feat: Flask web UI on port 9000 with upload, job status, API endpoints
This commit is contained in:
1 parent
f38c9708f6
commit
eb77e5be8a
6 files changed
+373
No files matched your search
@@ -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()
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
Reference in new issue
Block a user