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