diff --git a/app.py b/app.py new file mode 100644 index 0000000..96c1f1a --- /dev/null +++ b/app.py @@ -0,0 +1,172 @@ +# app.py +"""Flask web UI for feedmill_recounter — port 9000.""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv +from flask import ( + Flask, render_template, request, redirect, + url_for, send_file, jsonify, +) + +from src.job import JobQueue +from src.model_registry import scan_models + +load_dotenv() + +app = Flask(__name__, template_folder="templates", static_folder="static") +app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me") +app.config["MAX_CONTENT_LENGTH"] = 2 * 1024 * 1024 * 1024 # 2GB + +MODELS_DIR = os.getenv("MODELS_DIR", "./models") +UPLOAD_DIR = os.getenv("UPLOAD_DIR", "./uploads") +OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./output") + +os.makedirs(UPLOAD_DIR, exist_ok=True) +os.makedirs(OUTPUT_DIR, exist_ok=True) + +job_queue = JobQueue(output_dir=OUTPUT_DIR) + + +@app.template_filter("basename") +def basename_filter(path): + """Extract filename from path for templates.""" + return os.path.basename(path) + + +@app.route("/") +def index(): + models = scan_models(MODELS_DIR) + return render_template("index.html", models=models, models_dir=MODELS_DIR) + + +@app.route("/upload", methods=["POST"]) +def upload(): + video = request.files.get("video") + if not video or not video.filename: + return "No video uploaded", 400 + + video_path = os.path.join(UPLOAD_DIR, video.filename) + video.save(video_path) + + selected_models = request.form.getlist("models") + models = scan_models(MODELS_DIR) + by_name = {m.filename: m for m in models} + + model_configs = [] + class_filters = {} + for name in selected_models: + if name in by_name: + model_configs.append(by_name[name]) + filter_val = request.form.get(f"filter_{name}", "") + if filter_val and filter_val == "all": + class_filters[name] = None + elif filter_val: + class_filters[name] = filter_val.split(",") + + if not model_configs: + return "No models selected", 400 + + job = job_queue.add_job( + video_path=video_path, + model_configs=model_configs, + class_filters=class_filters, + ) + + return redirect(url_for("status", job_id=job.job_id)) + + +@app.route("/status/") +def status(job_id): + job = job_queue.get_job(job_id) + if job is None: + return "Job not found", 404 + return render_template("status.html", job=job) + + +@app.route("/jobs") +def jobs_list(): + jobs = job_queue.list_jobs() + return render_template("jobs.html", jobs=jobs) + + +@app.route("/download//") +def download(job_id, filename): + job = job_queue.get_job(job_id) + if job is None: + return "Job not found", 404 + file_path = os.path.join(job.output_dir, filename) + if not os.path.isfile(file_path): + return "File not found", 404 + return send_file(file_path, as_attachment=True) + + +@app.route("/api/models") +def api_models(): + models = scan_models(MODELS_DIR) + return jsonify([ + { + "filename": m.filename, + "stem": m.stem, + "known_classes": m.known_classes, + } + for m in models + ]) + + +@app.route("/api/jobs") +def api_jobs(): + return jsonify([{ + "job_id": j.job_id, + "status": j.status.name, + "progress": j.progress, + "video_path": j.video_path, + "results": [ + { + "model": r.model_name, + "loading": r.loading_count, + "unloading": r.unloading_count, + "net": r.net_count, + } + for r in j.results + ], + } for j in job_queue.list_jobs()]) + + +@app.route("/api/jobs/") +def api_job_detail(job_id): + job = job_queue.get_job(job_id) + if job is None: + return jsonify({"error": "not found"}), 404 + return jsonify({ + "job_id": job.job_id, + "status": job.status.name, + "progress": job.progress, + "current_model": job.current_model, + "results": [ + { + "model": r.model_name, + "loading": r.loading_count, + "unloading": r.unloading_count, + "net": r.net_count, + "output": os.path.basename(r.output_path) if r.output_path else None, + } + for r in job.results + ], + "error": job.error, + }) + + +def main(): + host = os.getenv("WEB_HOST", "0.0.0.0") + port = int(os.getenv("WEB_PORT", "9000")) + debug = os.getenv("FLASK_DEBUG", "false").lower() == "true" + + print(f"Feedmill Recounter web UI: http://{host}:{port}") + app.run(host=host, port=port, debug=debug) + + +if __name__ == "__main__": + main() diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..b716a38 --- /dev/null +++ b/static/style.css @@ -0,0 +1,34 @@ +body { font-family: 'Segoe UI', sans-serif; margin: 0; padding: 20px; background: #1a1a2e; color: #e0e0e0; } +header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; padding-bottom: 10px; border-bottom: 2px solid #00d4ff; } +header h1 { margin: 0; color: #00d4ff; } +nav a { color: #00d4ff; margin-left: 20px; text-decoration: none; } +nav a:hover { text-decoration: underline; } +.form-group { margin-bottom: 20px; } +label { display: block; margin-bottom: 5px; font-weight: bold; } +input[type="file"] { padding: 8px; margin-top: 5px; } +button { background: #00d4ff; color: #1a1a2e; border: none; padding: 12px 24px; font-size: 16px; cursor: pointer; border-radius: 4px; font-weight: bold; } +button:hover { background: #00b8d9; } +button:disabled { background: #555; cursor: not-allowed; } +.model-list { display: flex; flex-direction: column; gap: 10px; } +.model-item { background: #16213e; padding: 12px; border-radius: 4px; border: 1px solid #0f3460; } +.model-item label { display: inline; font-weight: normal; } +.classes { color: #aaa; margin-left: 10px; font-size: 0.9em; } +.classes.unknown { color: #ff6b6b; } +.filter-group { margin-top: 8px; margin-left: 25px; } +.filter-group label { display: inline; font-size: 0.9em; } +.filter-group select { padding: 4px; margin-top: 4px; } +.job-info { background: #16213e; padding: 20px; border-radius: 4px; margin-bottom: 20px; border: 1px solid #0f3460; } +.status-pending { color: #ffa726; } +.status-running { color: #42a5f5; } +.status-completed { color: #66bb6a; } +.status-failed { color: #ef5350; } +.status-cancelled { color: #bdbdbd; } +.results-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; } +.results-table th, .results-table td { padding: 10px; text-align: left; border-bottom: 1px solid #333; } +.results-table th { background: #0f3460; color: #00d4ff; } +.results-table tr:hover { background: #1a1a3e; } +.download-btn { background: #66bb6a; color: #1a1a2e; padding: 6px 12px; text-decoration: none; border-radius: 4px; font-size: 0.9em; } +.download-btn:hover { background: #4caf50; } +.error { color: #ef5350; } +.warning { color: #ffa726; } +.auto-refresh { background: #16213e; padding: 12px; border-radius: 4px; border: 1px solid #0f3460; } diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..472dc83 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,21 @@ + + + + + + {% block title %}Feedmill Recounter{% endblock %} + + + +
+

Feedmill Recounter

+ +
+
+ {% block content %}{% endblock %} +
+ + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..e9d0360 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}Upload - Feedmill Recounter{% endblock %} +{% block content %} +

Upload Video & Select Models

+
+
+ + +
+ +
+ + {% if models %} +
+ {% for model in models %} +
+ + +
+ + +
+
+ {% endfor %} +
+ {% else %} +

No models found in {{ models_dir }}. Place model files in the models/ directory.

+ {% endif %} +
+ +
+ +
+
+{% endblock %} diff --git a/templates/jobs.html b/templates/jobs.html new file mode 100644 index 0000000..d727818 --- /dev/null +++ b/templates/jobs.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Jobs - Feedmill Recounter{% endblock %} +{% block content %} +

All Jobs

+{% if jobs %} + + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + + {% endfor %} + +
Job IDStatusProgressModelsCreatedAction
{{ job.job_id }}{{ job.status.name }}{{ "%.0f"|format(job.progress * 100) }}%{{ job.model_configs|length }} model(s){{ "%.1f"|format(job.created_at) }} + View +
+{% else %} +

No jobs yet. Upload a video

+{% endif %} +{% endblock %} diff --git a/templates/status.html b/templates/status.html new file mode 100644 index 0000000..987e480 --- /dev/null +++ b/templates/status.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}Job {{ job.job_id }} - Feedmill Recounter{% endblock %} +{% block content %} +

Job: {{ job.job_id }}

+ +
+

Status: {{ job.status }}

+

Video: {{ job.video_path }}

+

Progress: {{ "%.0f"|format(job.progress * 100) }}%

+ {% if job.current_model %} +

Current Model: {{ job.current_model }}

+ {% endif %} + {% if job.error %} +

Error: {{ job.error }}

+ {% endif %} +
+ +{% if job.results %} +

Results

+ + + + + + + + + + + + + + + {% for r in job.results %} + + + + + + + + + + + {% endfor %} + +
ModelLoadingUnloadingNetBatchesFramesDurationOutput
{{ r.model_name }}{{ r.loading_count }}{{ r.unloading_count }}{{ r.net_count }}{{ r.batch_count }}{{ r.frame_count }}{{ "%.1f"|format(r.duration_seconds) }}s + {% if r.output_path %} + Download + {% endif %} +
+{% endif %} + +{% if job.status == "RUNNING" or job.status == "PENDING" %} +
+

Status will auto-refresh...

+
+ +{% endif %} +{% endblock %}