feat: Flask web UI on port 9000 with upload, job status, API endpoints

This commit is contained in:
jetson committed 2026-09-16 15:04:49 +07:00
1 parent f38c9708f6
commit eb77e5be8a
6 files changed
+373

No files matched your search

+172
View File
@@ -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/<job_id>")
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/<job_id>/<filename>")
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/<job_id>")
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()
+34
View File
@@ -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; }
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Feedmill Recounter{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header>
<h1>Feedmill Recounter</h1>
<nav>
<a href="/">Upload</a>
<a href="/jobs">Jobs</a>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Upload - Feedmill Recounter{% endblock %}
{% block content %}
<h2>Upload Video & Select Models</h2>
<form action="/upload" method="post" enctype="multipart/form-data">
<div class="form-group">
<label for="video">Video File:</label>
<input type="file" name="video" id="video" accept="video/*" required>
</div>
<div class="form-group">
<label>Models to Run:</label>
{% if models %}
<div class="model-list">
{% for model in models %}
<div class="model-item">
<input type="checkbox" name="models" value="{{ model.filename }}" id="m-{{ loop.index }}">
<label for="m-{{ loop.index }}">
<strong>{{ model.filename }}</strong>
{% if model.known_classes %}
<span class="classes">[{{ model.known_classes | join(', ') }}]</span>
{% else %}
<span class="classes unknown">[no class info]</span>
{% endif %}
</label>
<div class="filter-group">
<label>Class filter:</label>
<select name="filter_{{ model.filename }}">
<option value="default">Use model defaults</option>
<option value="sack">sack only</option>
<option value="box">box only</option>
<option value="sack,box">sack + box</option>
<option value="all">all classes</option>
</select>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="warning">No models found in {{ models_dir }}. Place model files in the models/ directory.</p>
{% endif %}
</div>
<div class="form-group">
<button type="submit" {% if not models %}disabled{% endif %}>Start Processing</button>
</div>
</form>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Jobs - Feedmill Recounter{% endblock %}
{% block content %}
<h2>All Jobs</h2>
{% if jobs %}
<table class="results-table">
<thead>
<tr>
<th>Job ID</th>
<th>Status</th>
<th>Progress</th>
<th>Models</th>
<th>Created</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
<td><a href="/status/{{ job.job_id }}">{{ job.job_id }}</a></td>
<td class="status-{{ job.status|lower }}">{{ job.status.name }}</td>
<td>{{ "%.0f"|format(job.progress * 100) }}%</td>
<td>{{ job.model_configs|length }} model(s)</td>
<td>{{ "%.1f"|format(job.created_at) }}</td>
<td>
<a href="/status/{{ job.job_id }}">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No jobs yet. <a href="/">Upload a video</a></p>
{% endif %}
{% endblock %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Job {{ job.job_id }} - Feedmill Recounter{% endblock %}
{% block content %}
<h2>Job: {{ job.job_id }}</h2>
<div class="job-info">
<p><strong>Status:</strong> <span class="status-{{ job.status|lower }}">{{ job.status }}</span></p>
<p><strong>Video:</strong> {{ job.video_path }}</p>
<p><strong>Progress:</strong> {{ "%.0f"|format(job.progress * 100) }}%</p>
{% if job.current_model %}
<p><strong>Current Model:</strong> {{ job.current_model }}</p>
{% endif %}
{% if job.error %}
<p class="error"><strong>Error:</strong> {{ job.error }}</p>
{% endif %}
</div>
{% if job.results %}
<h3>Results</h3>
<table class="results-table">
<thead>
<tr>
<th>Model</th>
<th>Loading</th>
<th>Unloading</th>
<th>Net</th>
<th>Batches</th>
<th>Frames</th>
<th>Duration</th>
<th>Output</th>
</tr>
</thead>
<tbody>
{% for r in job.results %}
<tr>
<td>{{ r.model_name }}</td>
<td>{{ r.loading_count }}</td>
<td>{{ r.unloading_count }}</td>
<td>{{ r.net_count }}</td>
<td>{{ r.batch_count }}</td>
<td>{{ r.frame_count }}</td>
<td>{{ "%.1f"|format(r.duration_seconds) }}s</td>
<td>
{% if r.output_path %}
<a href="/download/{{ job.job_id }}/{{ r.output_path | basename }}"
class="download-btn">Download</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if job.status == "RUNNING" or job.status == "PENDING" %}
<div class="auto-refresh">
<p>Status will auto-refresh...</p>
</div>
<script>
setTimeout(function() { location.reload(); }, 2000);
</script>
{% endif %}
{% endblock %}