This commit is contained in:
Ryan Shpeherd
2026-09-15 12:39:47 -04:00
parent 639c29c2c4
commit 1611485a66
38 changed files with 313 additions and 4728 deletions
+227 -469
View File
@@ -1,67 +1,38 @@
"""
VideoDetect Review UI — Flask Application
Lightweight web interface for annotating low-confidence videos.
Lightweight web interface for annotating pending REVIEW tasks.
Accessible via http://<server-ip>:5000 — no auth, no SSL (per TC-06).
The UI queries the generic API (http://api:3000/api/v1/...) for all data
and posts review results back through the same API. No direct DB access.
"""
import csv
import io
import json
import os
from datetime import datetime, timezone
from typing import Optional
import pymysql
import pymysql.cursors
from flask import Flask, Response, g, jsonify, render_template_string, request, send_file
import requests
from flask import Flask, jsonify, render_template_string, request, send_file
app = Flask(__name__)
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
def _db_config() -> dict:
return dict(
host=os.environ.get("DB_HOST", "mariadb"),
port=int(os.environ.get("DB_PORT", 3306)),
database=os.environ.get("DB_NAME", "videodetect"),
user=os.environ.get("DB_USER", "videodetect"),
password=os.environ.get("DB_PASSWORD", "videodetect123"),
cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
charset="utf8mb4",
)
API_BASE = os.environ.get("API_BASE", "http://api:3000/api/v1")
def get_db():
if "db" not in g:
g.db = pymysql.connect(**_db_config())
return g.db
def api_get(path: str, params: dict = None) -> dict:
"""Perform a GET request to the API and return JSON."""
url = f"{API_BASE}/{path}"
resp = requests.get(url, params=params, timeout=10)
resp.raise_for_status()
return resp.json()
@app.teardown_appcontext
def close_db(exc=None):
db = g.pop("db", None)
if db:
db.close()
def _query(sql: str, params=(), one: bool = False):
db = get_db()
with db.cursor() as cur:
cur.execute(sql, params)
return cur.fetchone() if one else cur.fetchall()
def _execute(sql: str, params=()):
db = get_db()
with db.cursor() as cur:
cur.execute(sql, params)
rowcount = cur.rowcount
db.commit()
return rowcount
def api_post(path: str, data: dict = None) -> dict:
"""Perform a POST request to the API and return JSON."""
url = f"{API_BASE}/{path}"
resp = requests.post(url, json=data or {}, timeout=10)
resp.raise_for_status()
return resp.json()
# ---------------------------------------------------------------------------
@@ -70,330 +41,172 @@ def _execute(sql: str, params=()):
@app.route("/")
def index():
"""Index view: list of pending REVIEW tasks with pagination."""
page = request.args.get("page", 1, type=int)
per_page = int(os.environ.get("PER_PAGE", 20))
sort = request.args.get("sort", "created_at")
order = request.args.get("order", "desc").upper()
conf_min = request.args.get("conf_min", type=float)
conf_max = request.args.get("conf_max", type=float)
model_filter = request.args.get("model_version")
allowed_sort = {"created_at", "confidence_score", "file_path"}
sort_col = sort if sort in allowed_sort else "created_at"
order_dir = "DESC" if order == "DESC" else "ASC"
try:
data = api_get("tasks", {
"type": "REVIEW",
"status": "PENDING",
"page": page,
"per_page": per_page,
})
except requests.RequestException as exc:
return jsonify({"error": f"API unavailable: {exc}"}), 503
where_clauses = ["rq.annotated = FALSE"]
params: list = []
if conf_min is not None:
where_clauses.append("rq.confidence_score >= %s")
params.append(conf_min)
if conf_max is not None:
where_clauses.append("rq.confidence_score <= %s")
params.append(conf_max)
if model_filter:
where_clauses.append("v.model_version = %s")
params.append(model_filter)
tasks = data.get("tasks", [])
total = data.get("total", 0)
total_pages = max(1, (total + per_page - 1) // per_page)
where = " AND ".join(where_clauses)
count_row = _query(
f"SELECT COUNT(*) AS cnt FROM review_queue rq JOIN videos v ON v.id = rq.video_id WHERE {where}",
params, one=True,
) or {"cnt": 0}
total = count_row["cnt"]
# Enrich each task with its associated video info from the API
enriched = []
for t in tasks:
vid_id = t.get("video_id")
video_path = "unknown"
resolution = None
try:
vdata = api_get(f"video/{vid_id}")
video_path = vdata.get("file_path", "unknown")
res_w = vdata.get("resolution_w")
res_h = vdata.get("resolution_h")
if res_w and res_h:
resolution = f"{res_w}x{res_h}"
except requests.RequestException:
pass
offset = (page - 1) * per_page
videos = _query(
f"""SELECT rq.id, rq.video_id, v.file_path, rq.confidence_score,
rq.routing_decision, v.model_version, v.frame_count, rq.created_at
FROM review_queue rq
JOIN videos v ON v.id = rq.video_id
WHERE {where}
ORDER BY {sort_col} {order_dir}
LIMIT %s OFFSET %s""",
params + [per_page, offset],
)
enriched.append({
"id": t["id"],
"video_id": vid_id,
"file_path": video_path,
"task_type": t.get("task_type"),
"status": t.get("status"),
"created_at": t.get("assigned_at") or t.get("created_at"),
"resolution": resolution,
})
return render_template_string(
_INDEX_TEMPLATE,
videos=videos or [],
tasks=enriched,
page=page,
per_page=per_page,
total=total,
total_pages=max(1, (total + per_page - 1) // per_page),
sort=sort_col,
order=order_dir,
conf_min=conf_min,
conf_max=conf_max,
model_filter=model_filter or "",
total_pages=total_pages,
)
@app.route("/review/<int:video_id>")
def review_video(video_id):
row = _query(
"""SELECT rq.id, rq.video_id, v.file_path, rq.confidence_score,
rq.routing_decision, v.model_version, v.frame_count, v.duration,
rq.ground_truth, rq.notes, rq.annotated
FROM review_queue rq
JOIN videos v ON v.id = rq.video_id
WHERE rq.video_id = %s
LIMIT 1""",
(video_id,), one=True,
)
if not row:
return "Video not found in review queue", 404
top_k = int(os.environ.get("TOP_K_FRAMES", 5))
contributing_frames = _contributing_frames(video_id, row.get("duration"), row.get("frame_count"), top_k)
video = dict(row)
video["contributing_frames"] = contributing_frames
video["top_k_frames"] = top_k
video["video_duration"] = row.get("duration")
if isinstance(video.get("created_at"), datetime):
video["created_at"] = video["created_at"].isoformat()
return render_template_string(_REVIEW_TEMPLATE, video=video)
def _contributing_frames(video_id: int, duration: Optional[float], frame_count: Optional[int], top_k: int) -> list:
"""Reconstruct top-k contributing frame info from stored per-crop confidence scores."""
log = _query(
"""SELECT confidence_scores, frame_count FROM processing_logs
WHERE video_id = %s ORDER BY processed_at DESC LIMIT 1""",
(video_id,), one=True,
)
if not log or not log.get("confidence_scores"):
return []
@app.route("/review/<int:task_id>")
def review_task(task_id):
"""Detail view: show task info + embedded video player."""
try:
scores = json.loads(log["confidence_scores"])
data = api_get(f"task/{task_id}")
if not data or not data.get("id"):
return "Task not found", 404
video_id = data["video_id"]
vid_data = api_get(f"video/{video_id}")
file_path = vid_data.get("file_path", "unknown")
except requests.RequestException as exc:
return jsonify({"error": f"API unavailable: {exc}"}), 503
video_obj = dict(data)
video_obj["file_path"] = file_path
video_obj["video_duration"] = vid_data.get("duration")
video_obj["resolution"] = vid_data.get("resolution_w") and vid_data.get("resolution_h") \
and f"{vid_data['resolution_w']}x{vid_data['resolution_h']}"
# Parse results if already present
raw_results = data.get("results", {})
try:
video_obj["prev_results"] = json.loads(raw_results) if isinstance(raw_results, str) else (raw_results or {})
except (json.JSONDecodeError, TypeError):
return []
video_obj["prev_results"] = {}
fc = frame_count or log.get("frame_count") or len(scores)
dur = duration or 0.0
interval = (dur / fc) if fc else 30.0
indexed = sorted(enumerate(scores), key=lambda x: x[1], reverse=True)[:top_k]
return [{"timestamp": round(i * interval, 1), "confidence": round(c, 4)} for i, c in indexed]
return render_template_string(_REVIEW_TEMPLATE, video=video_obj)
# ---------------------------------------------------------------------------
# API routes
# API routes (Flask → NFS file serving + JSON wrappers)
# ---------------------------------------------------------------------------
@app.route("/api/review/queue")
def api_queue():
page = request.args.get("page", 1, type=int)
per_page = 20
offset = (page - 1) * per_page
@app.route("/video/<int:video_id>")
def serve_video(video_id):
"""Stream video from NFS-mounted /data/input; supports HTTP range requests."""
# Resolve video path via the API first
try:
vid_data = api_get(f"video/{video_id}")
file_path = vid_data.get("file_path", "")
except requests.RequestException:
return "Not found", 404
count_row = _query("SELECT COUNT(*) AS cnt FROM review_queue WHERE annotated = FALSE", one=True) or {"cnt": 0}
total = count_row["cnt"]
if not file_path or file_path == "unknown":
return "Video file path unknown", 404
videos = _query(
"""SELECT rq.id, rq.video_id, v.file_path, rq.confidence_score,
rq.routing_decision, v.model_version, v.frame_count, rq.created_at
FROM review_queue rq JOIN videos v ON v.id = rq.video_id
WHERE rq.annotated = FALSE
ORDER BY rq.created_at DESC LIMIT %s OFFSET %s""",
(per_page, offset),
)
# Try multiple base paths for video files (NFS mounts may vary)
nfs_input = os.environ.get("NAS_INPUT_PATH", "/data/input")
nfs_output = os.environ.get("NAS_OUTPUT_PATH", "/mnt/nas/output")
return jsonify({"videos": _serialise(videos), "total": total, "page": page, "per_page": per_page})
candidates = []
if os.path.isabs(file_path):
candidates.append(file_path)
candidates.append(os.path.join(nfs_input, file_path.lstrip('/')))
candidates.append(os.path.join(nfs_output, file_path.lstrip('/')))
else:
candidates.append(os.path.join(nfs_input, file_path))
candidates.append(os.path.join(nfs_output, file_path))
for full_path in candidates:
try:
return send_file(full_path, mimetype="video/mp4", conditional=True)
except FileNotFoundError:
continue
return "Video file not found on disk", 404
@app.route("/api/review/<int:video_id>")
def api_video_details(video_id):
row = _query(
"""SELECT rq.id, rq.video_id, v.file_path, rq.confidence_score,
rq.routing_decision, v.model_version, v.frame_count, v.duration,
rq.ground_truth, rq.notes
FROM review_queue rq JOIN videos v ON v.id = rq.video_id
WHERE rq.video_id = %s LIMIT 1""",
(video_id,), one=True,
)
if not row:
return jsonify({"error": "Video not found"}), 404
top_k = int(os.environ.get("TOP_K_FRAMES", 5))
result = _serialise(row)
result["contributing_frames"] = _contributing_frames(
video_id, row.get("duration"), row.get("frame_count"), top_k
)
result["video_duration"] = row.get("duration")
return jsonify(result)
@app.route("/api/review/<int:video_id>/label", methods=["POST"])
def api_label(video_id):
@app.route("/api/review/submit/<int:task_id>", methods=["POST"])
def api_submit_review(task_id):
"""Submit review verdict for a task via the generic complete endpoint."""
data = request.get_json(silent=True) or {}
ground_truth = data.get("ground_truth")
notes = data.get("notes", "")
verdict = data.get("verdict") # "false_positive", "correct", or other string
if ground_truth is None:
return jsonify({"error": "ground_truth is required"}), 400
if not verdict:
return jsonify({"error": "verdict is required"}), 400
now = datetime.now(timezone.utc).replace(tzinfo=None)
rows = _execute(
"""UPDATE review_queue
SET annotated = TRUE, ground_truth = %s, notes = %s, annotated_at = %s
WHERE video_id = %s AND annotated = FALSE""",
(bool(ground_truth), notes, now, video_id),
)
if rows == 0:
return jsonify({"error": "Video not found or already annotated"}), 404
return jsonify({"status": "annotated", "video_id": video_id, "ground_truth": ground_truth})
try:
result = api_post(f"task/{task_id}/complete", {
"results": {"reviewer_verdict": verdict},
})
return jsonify({"status": "completed", "task_id": task_id, "verdict": verdict})
except requests.RequestException as exc:
return jsonify({"error": f"API error: {exc}"}), 503
@app.route("/api/review/export")
def api_export():
fmt = request.args.get("format", "json").lower()
annotated_only = request.args.get("annotated", "true").lower() != "false"
date_from = request.args.get("date_from")
date_to = request.args.get("date_to")
clauses: list = []
params: list = []
if annotated_only:
clauses.append("rq.annotated = TRUE")
if date_from:
clauses.append("rq.annotated_at >= %s")
params.append(date_from)
if date_to:
clauses.append("rq.annotated_at < %s")
params.append(date_to)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
rows = _query(
f"""SELECT rq.video_id, v.file_path, rq.confidence_score, rq.routing_decision,
v.model_version, rq.ground_truth, rq.annotated_at, rq.notes,
pl.confidence_scores
FROM review_queue rq
JOIN videos v ON v.id = rq.video_id
LEFT JOIN (
SELECT video_id, confidence_scores,
ROW_NUMBER() OVER (PARTITION BY video_id ORDER BY processed_at DESC) rn
FROM processing_logs
) pl ON pl.video_id = rq.video_id AND pl.rn = 1
{where}
ORDER BY rq.annotated_at DESC""",
params,
)
records = _build_export_records(rows or [])
if fmt == "csv":
return Response(
_to_csv(records),
mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=review_export.csv"},
)
return Response(
json.dumps(records, default=str, indent=2),
mimetype="application/json",
headers={"Content-Disposition": "attachment; filename=review_export.json"},
)
@app.route("/api/review/skip/<int:task_id>", methods=["POST"])
def api_skip_review(task_id):
"""Skip (unassign + reset) a task so it returns to the PENDING queue."""
try:
result = api_post(f"task/{task_id}/unassign", {"reset": 1})
return jsonify({"status": "skipped", "task_id": task_id})
except requests.RequestException as exc:
return jsonify({"error": f"API error: {exc}"}), 503
@app.route("/api/review/stats")
def api_stats():
stats = _query(
"""SELECT
COUNT(*) AS total_in_queue,
SUM(annotated) AS annotated_count,
AVG(confidence_score) AS avg_confidence,
SUM(CASE WHEN annotated = TRUE AND DATE(annotated_at) = CURDATE() THEN 1 ELSE 0 END) AS annotated_today
FROM review_queue""",
one=True,
) or {}
dist = {"0.0-0.2": 0, "0.2-0.4": 0, "0.4-0.6": 0, "0.6-0.8": 0, "0.8-1.0": 0}
buckets = _query(
"""SELECT
CASE
WHEN confidence_score < 0.2 THEN '0.0-0.2'
WHEN confidence_score < 0.4 THEN '0.2-0.4'
WHEN confidence_score < 0.6 THEN '0.4-0.6'
WHEN confidence_score < 0.8 THEN '0.6-0.8'
ELSE '0.8-1.0'
END AS bucket,
COUNT(*) AS cnt
FROM review_queue GROUP BY bucket"""
) or []
for b in buckets:
dist[b["bucket"]] = b["cnt"]
return jsonify({
"total_in_queue": stats.get("total_in_queue", 0),
"annotated": int(stats.get("annotated_count") or 0),
"unannotated": int(stats.get("total_in_queue", 0)) - int(stats.get("annotated_count") or 0),
"annotated_today": int(stats.get("annotated_today") or 0),
"avg_confidence": round(float(stats.get("avg_confidence") or 0.0), 4),
"confidence_distribution": dist,
})
@app.route("/video/<int:video_id>")
def serve_video(video_id):
"""Stream video from /data/input; supports HTTP range requests for seek."""
row = _query("SELECT file_path FROM videos WHERE id = %s", (video_id,), one=True)
if not row:
return "Not found", 404
"""Return a summary of REVIEW tasks (via two API calls)."""
try:
return send_file(row["file_path"], mimetype="video/mp4", conditional=True)
except FileNotFoundError:
return "Video file not found on disk", 404
pending = api_get("tasks", {"type": "REVIEW", "status": "PENDING"}) or {}
completed = api_get("tasks", {"type": "REVIEW", "status": "COMPLETED"}) or {}
failed = api_get("tasks", {"type": "REVIEW", "status": "FAILED"}) or {}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _serialise(obj):
"""Recursively convert datetime values to ISO strings for JSON output."""
if isinstance(obj, dict):
return {k: _serialise(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_serialise(i) for i in obj]
if hasattr(obj, "isoformat"):
return obj.isoformat()
return obj
def _build_export_records(rows) -> list:
records = []
for row in rows:
try:
scores = json.loads(row["confidence_scores"]) if row.get("confidence_scores") else []
except (json.JSONDecodeError, TypeError):
scores = []
at = row.get("annotated_at")
records.append({
"video_id": row["video_id"],
"file_path": row["file_path"],
"confidence_score": row["confidence_score"],
"routing_decision": row["routing_decision"],
"model_version": row["model_version"],
"ground_truth": bool(row["ground_truth"]) if row["ground_truth"] is not None else None,
"annotated_at": at.isoformat() if hasattr(at, "isoformat") else at,
"notes": row.get("notes"),
"contributing_frames": scores,
return jsonify({
"total_in_queue": pending.get("total", 0),
"completed": completed.get("total", 0),
"failed": failed.get("total", 0),
})
return records
def _to_csv(records: list) -> str:
if not records:
return ""
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=list(records[0].keys()))
writer.writeheader()
for r in records:
row = dict(r)
row["contributing_frames"] = json.dumps(row["contributing_frames"])
writer.writerow(row)
return output.getvalue()
except requests.RequestException as exc:
return jsonify({"error": f"API error: {exc}"}), 503
# ---------------------------------------------------------------------------
@@ -411,38 +224,23 @@ _INDEX_TEMPLATE = """
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;color:#333}
.header{background:#1a1a2e;color:#fff;padding:18px 40px;display:flex;justify-content:space-between;align-items:center}
.header h1{font-size:22px}
.header .meta{font-size:13px;opacity:.75}
.header h1{font-size:22px}.header .meta{font-size:13px;opacity:.75}
.container{max-width:1280px;margin:30px auto;padding:0 20px}
.filters{background:#fff;border-radius:8px;padding:16px 20px;margin-bottom:18px;box-shadow:0 1px 4px rgba(0,0,0,.08);display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end}
.filters label{font-size:12px;color:#666;display:flex;flex-direction:column;gap:4px}
.filters input,.filters select{padding:6px 10px;border:1px solid #ddd;border-radius:5px;font-size:13px}
.filters button{padding:7px 16px;background:#007bff;color:#fff;border:none;border-radius:5px;cursor:pointer;font-size:13px}
.filters button:hover{background:#0056b3}
.stats{background:#fff;border-radius:8px;padding:16px 20px;margin-bottom:18px;box-shadow:0 1px 4px rgba(0,0,0,.08);display:flex;gap:24px;flex-wrap:wrap}
.stat{text-align:center}.stat .num{font-size:24px;font-weight:700;color:#007bff}.stat .lbl{font-size:11px;color:#888;text-transform:uppercase}
.card{background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08)}
table{width:100%;border-collapse:collapse}
th{background:#f8f9fa;padding:12px 14px;text-align:left;font-size:12px;text-transform:uppercase;color:#666;border-bottom:2px solid #e9ecef}
th a{color:#666;text-decoration:none}
th a:hover{color:#333}
td{padding:12px 14px;border-bottom:1px solid #e9ecef;font-size:13px}
tr:hover{background:#fafafa}
.badge{display:inline-block;padding:3px 9px;border-radius:10px;font-size:11px;font-weight:600}
.badge-review{background:#fff3cd;color:#856404}
.badge-match{background:#d4edda;color:#155724}
.badge-skip{background:#f8d7da;color:#721c24}
.badge{display:inline-block;padding:3px 9px;border-radius:10px;font-size:11px;font-weight:600;background:#fff3cd;color:#856404}
.btn{padding:6px 14px;background:#007bff;color:#fff;text-decoration:none;border-radius:5px;font-size:12px;border:none;cursor:pointer}
.btn:hover{background:#0056b3}
.conf-high{color:#28a745;font-weight:600}
.conf-mid{color:#ffc107;font-weight:600}
.conf-low{color:#dc3545;font-weight:600}
.pagination{display:flex;justify-content:center;gap:6px;margin-top:20px}
.pagination a,.pagination span{padding:7px 13px;border-radius:5px;font-size:13px;text-decoration:none}
.pagination a{background:#fff;color:#007bff;border:1px solid #dee2e6}
.pagination a:hover{background:#e9ecef}
.pagination a{background:#fff;color:#007bff;border:1px solid #dee2e6}.pagination a:hover{background:#e9ecef}
.pagination .cur{background:#007bff;color:#fff}
.empty{text-align:center;padding:60px;color:#999}
.export-btn{padding:7px 16px;background:#28a745;color:#fff;border:none;border-radius:5px;cursor:pointer;font-size:13px;text-decoration:none;display:inline-block}
.export-btn:hover{background:#1e7e34}
</style>
</head>
<body>
@@ -451,71 +249,38 @@ _INDEX_TEMPLATE = """
<span class="meta">Review Queue &mdash; {{ total }} pending &mdash; Page {{ page }}/{{ total_pages }}</span>
</div>
<div class="container">
<form class="filters" method="get">
<label>Confidence min
<input type="number" name="conf_min" step="0.01" min="0" max="1" value="{{ conf_min or '' }}" placeholder="0.0">
</label>
<label>Confidence max
<input type="number" name="conf_max" step="0.01" min="0" max="1" value="{{ conf_max or '' }}" placeholder="1.0">
</label>
<label>Model version
<input type="text" name="model_version" value="{{ model_filter }}" placeholder="any">
</label>
<label>Sort by
<select name="sort">
<option value="created_at" {% if sort == 'created_at' %}selected{% endif %}>Date added</option>
<option value="confidence_score" {% if sort == 'confidence_score' %}selected{% endif %}>Confidence</option>
<option value="file_path" {% if sort == 'file_path' %}selected{% endif %}>File path</option>
</select>
</label>
<label>Order
<select name="order">
<option value="desc" {% if order == 'DESC' %}selected{% endif %}>Desc</option>
<option value="asc" {% if order == 'ASC' %}selected{% endif %}>Asc</option>
</select>
</label>
<button type="submit">Filter</button>
<a href="/api/review/export?format=csv" class="export-btn">Export CSV</a>
<a href="/api/review/export?format=json" class="export-btn">Export JSON</a>
</form>
{% if videos %}
<div class="stats">
<div class="stat"><div class="num">{{ total }}</div><div class="lbl">In Queue</div></div>
<div class="stat"><div class="num">{{ per_page }}</div><div class="lbl">Per Page</div></div>
</div>
{% if tasks %}
<div class="card">
<table>
<thead><tr>
<th><a href="?sort=created_at&order={% if sort=='created_at' and order=='ASC' %}desc{% else %}asc{% endif %}">ID</a></th>
<th>File Path</th>
<th><a href="?sort=confidence_score&order={% if sort=='confidence_score' and order=='ASC' %}desc{% else %}asc{% endif %}">Confidence</a></th>
<th>Routing</th>
<th>Model</th>
<th>Frames</th>
<th><a href="?sort=created_at&order={% if sort=='created_at' and order=='ASC' %}desc{% else %}asc{% endif %}">Added</a></th>
<th></th>
<th>ID</th><th>File Path</th><th>Resolution</th><th>Task Type</th><th>Status</th><th>Date</th><th></th>
</tr></thead>
<tbody>
{% for v in videos %}
{% for t in tasks %}
<tr>
<td>{{ v.video_id }}</td>
<td style="max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ v.file_path }}">{{ v.file_path }}</td>
<td class="{% if v.confidence_score >= 0.75 %}conf-high{% elif v.confidence_score >= 0.45 %}conf-mid{% else %}conf-low{% endif %}">
{{ "%.3f"|format(v.confidence_score) }}
</td>
<td><span class="badge badge-{{ v.routing_decision|lower }}">{{ v.routing_decision }}</span></td>
<td>{{ v.model_version or '&mdash;' }}</td>
<td>{{ v.frame_count or '&mdash;' }}</td>
<td>{{ v.created_at|string|truncate(10, True, '') }}</td>
<td><a href="/review/{{ v.video_id }}" class="btn">Annotate</a></td>
<td>{{ t.id }}</td>
<td style="max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ t.file_path }}">{{ t.file_path }}</td>
<td>{{ t.resolution or '&mdash;' }}</td>
<td><span class="badge">{{ t.task_type }}</span></td>
<td>{{ t.status }}</td>
<td>{{ t.created_at|string|truncate(19, True, '') if t.created_at else '&mdash;' }}</td>
<td><a href="/review/{{ t.id }}" class="btn">Review</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="pagination">
{% if page > 1 %}<a href="?page={{ page-1 }}&sort={{ sort }}&order={{ order }}">&laquo; Prev</a>{% endif %}
{% if page > 1 %}<a href="?page={{ page-1 }}">&laquo; Prev</a>{% endif %}
<span class="cur">{{ page }}</span>
{% if page < total_pages %}<a href="?page={{ page+1 }}&sort={{ sort }}&order={{ order }}">Next &raquo;</a>{% endif %}
{% if page < total_pages %}<a href="?page={{ page+1 }}">Next &raquo;</a>{% endif %}
</div>
{% else %}
<div class="empty"><h2>No videos pending review</h2><p>All REVIEW-routed videos have been annotated.</p></div>
<div class="empty"><h2>No videos pending review</h2><p>All REVIEW tasks have been completed.</p></div>
{% endif %}
</div>
</body></html>
@@ -527,14 +292,12 @@ _REVIEW_TEMPLATE = """
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VideoDetect — Review #{{ video.video_id }}</title>
<title>VideoDetect — Review #{{ video.id }}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;color:#333}
.header{background:#1a1a2e;color:#fff;padding:18px 40px;display:flex;justify-content:space-between;align-items:center}
.header h1{font-size:22px}
.header a{color:#adb5bd;text-decoration:none}
.header a:hover{color:#fff}
.header h1{font-size:22px}.header a{color:#adb5bd;text-decoration:none}.header a:hover{color:#fff}
.container{max-width:960px;margin:30px auto;padding:0 20px}
.card{background:#fff;border-radius:8px;padding:22px;margin-bottom:18px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
.card h2{font-size:16px;margin-bottom:14px;color:#1a1a2e}
@@ -543,19 +306,14 @@ _REVIEW_TEMPLATE = """
.info-value{font-size:15px;font-weight:600}
.video-wrap{width:100%;background:#000;border-radius:6px;overflow:hidden}
video{width:100%;display:block}
.frames{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-top:4px}
.frame{background:#e9ecef;border-radius:6px;padding:10px;text-align:center;font-size:12px;color:#666}
.frame .conf{font-size:13px;font-weight:600;color:#007bff;margin-top:4px}
.label-row{display:flex;gap:12px;margin:14px 0 10px}
.label-btn{flex:1;padding:14px;border:2px solid #dee2e6;border-radius:8px;background:#fff;cursor:pointer;font-size:15px;font-weight:600;transition:all .15s}
.label-btn:hover{border-color:#007bff}
.sel-true{border-color:#28a745!important;background:#d4edda;color:#155724}
.sel-false{border-color:#dc3545!important;background:#f8d7da;color:#721c24}
textarea{width:100%;padding:10px;border:1px solid #dee2e6;border-radius:6px;font-size:13px;resize:vertical;min-height:72px;margin-top:8px}
.submit{width:100%;padding:13px;background:#007bff;color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;margin-top:12px}
.submit:hover{background:#0056b3}
.submit:disabled{background:#ccc;cursor:not-allowed}
.already{color:#28a745;font-weight:600;margin-top:8px}
.actions{display:flex;gap:12px;margin-top:14px}
.action-btn{flex:1;padding:14px;border:2px solid #dee2e6;border-radius:8px;background:#fff;cursor:pointer;font-size:15px;font-weight:600;transition:all .15s;text-align:center}
.action-btn:hover{border-color:#007bff;transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,.1)}
.action-btn.false-positive:hover{border-color:#dc3545;color:#dc3545;background:#fff5f5}
.action-btn.correct:hover{border-color:#28a745;color:#28a745;background:#f0fff4}
.skip-btn:hover{border-color:#6c757d;color:#6c757d;background:#f8f9fa}
.already{color:#28a745;font-weight:600;margin-top:8px;padding:14px;background:#d4edda;border-radius:8px;text-align:center}
#flash{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:8px;color:#fff;font-weight:600;z-index:999;display:none}
</style>
</head>
<body>
@@ -564,73 +322,74 @@ _REVIEW_TEMPLATE = """
<a href="/">&laquo; Back to queue</a>
</div>
<div class="container">
{% if video.prev_results and video.prev_results.review_verdict %}
<div class="already">Previously reviewed: {{ video.prev_results.review_verdict }}</div>
{% endif %}
<div class="card">
<h2>Video information</h2>
<h2>Task information</h2>
<div class="grid">
<div><div class="info-label">Video ID</div><div class="info-value">{{ video.video_id }}</div></div>
<div><div class="info-label">Confidence</div><div class="info-value">{{ "%.4f"|format(video.confidence_score) }}</div></div>
<div><div class="info-label">Routing</div><div class="info-value">{{ video.routing_decision }}</div></div>
<div><div class="info-label">Model</div><div class="info-value">{{ video.model_version or '&mdash;' }}</div></div>
<div><div class="info-label">Frames</div><div class="info-value">{{ video.frame_count or '&mdash;' }}</div></div>
<div><div class="info-label">Task ID</div><div class="info-value">{{ video.id }}</div></div>
<div><div class="info-label">Task Type</div><div class="info-value">{{ video.task_type }}</div></div>
<div><div class="info-label">Status</div><div class="info-value">{{ video.status }}</div></div>
<div><div class="info-label">Resolution</div><div class="info-value">{{ video.resolution or '&mdash;' }}</div></div>
<div><div class="info-label">Duration</div><div class="info-value">{{ "%.1f"|format(video.video_duration) if video.video_duration else '&mdash;' }}s</div></div>
<div><div class="info-label">Assigned</div><div class="info-value">{{ video.assigned_at|string|truncate(19, True, '') if video.assigned_at else '—' }}</div></div>
</div>
<div style="margin-top:10px;font-size:12px;color:#888;word-break:break-all">{{ video.file_path }}</div>
</div>
<div class="card">
<h2>Video</h2>
<div class="video-wrap">
<video controls preload="metadata">
<video controls preload="metadata" id="review-video">
<source src="/video/{{ video.video_id }}" type="video/mp4">
Video playback not supported.
</video>
</div>
</div>
{% if video.contributing_frames %}
<div class="card">
<h2>Top contributing frames (by confidence)</h2>
<div class="frames">
{% for f in video.contributing_frames %}
<div class="frame">
<div>&#9201; {{ f.timestamp }}s</div>
<div class="conf">{{ "%.3f"|format(f.confidence) }}</div>
</div>
{% endfor %}
<h2>Review Actions</h2>
<div class="actions">
<button class="action-btn false-positive" onclick="submitVerdict('false_positive')">False Positive</button>
<button class="action-btn correct" onclick="submitVerdict('correct')">Correct</button>
<button class="action-btn skip-btn" onclick="skipTask()">Skip</button>
</div>
</div>
{% endif %}
<div class="card">
<h2>Annotate</h2>
{% if video.annotated %}
<p class="already">&#10003; Already annotated: <strong>{{ 'MATCH' if video.ground_truth else 'NO MATCH' }}</strong></p>
{% else %}
<p style="color:#666;margin-bottom:8px">Does this video contain the target demographic?</p>
<div class="label-row">
<button class="label-btn" id="btn-yes" onclick="selectLabel(true)">&#10003; Yes (MATCH)</button>
<button class="label-btn" id="btn-no" onclick="selectLabel(false)">&#10007; No (NO MATCH)</button>
</div>
<textarea id="notes" placeholder="Optional notes&hellip;"></textarea>
<button class="submit" id="submitBtn" disabled onclick="submitLabel()">Submit label</button>
{% endif %}
</div>
</div>
<script>
let selectedLabel = null;
function selectLabel(val) {
selectedLabel = val;
document.getElementById('btn-yes').className = 'label-btn' + (val ? ' sel-true' : '');
document.getElementById('btn-no').className = 'label-btn' + (!val ? ' sel-false' : '');
document.getElementById('submitBtn').disabled = false;
}
async function submitLabel() {
const btn = document.getElementById('submitBtn');
btn.disabled = true; btn.textContent = 'Saving\u2026';
const res = await fetch('/api/review/{{ video.video_id }}/label', {
async function submitVerdict(verdict) {
const resp = await fetch(`/api/review/submit/{{ video.id }}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ground_truth: selectedLabel, notes: document.getElementById('notes').value})
body: JSON.stringify({verdict})
});
if (res.ok) { window.location.href = '/'; }
else { btn.disabled = false; btn.textContent = 'Submit label'; alert('Error saving label.'); }
if (resp.ok) {
showFlash('Verdict submitted ✓', '#28a745');
setTimeout(() => location.href = '/', 1000);
} else {
const err = await resp.json();
alert(err.error || 'Submission failed');
}
}
async function skipTask() {
if (!confirm('Skip this task? It will be returned to the PENDING queue.')) return;
const resp = await fetch(`/api/review/skip/{{ video.id }}`, {method: 'POST'});
if (resp.ok) {
showFlash('Task skipped, returned to queue', '#6c757d');
setTimeout(() => location.href = '/', 1000);
} else {
alert('Skip failed');
}
}
function showFlash(msg, color) {
const el = document.getElementById('flash');
el.textContent = msg; el.style.background = color; el.style.display = 'block';
setTimeout(() => el.style.display = 'none', 2500);
}
</script>
</body></html>
@@ -638,11 +397,10 @@ async function submitLabel() {
# ---------------------------------------------------------------------------
# Entry point
# Main
# ---------------------------------------------------------------------------
if __name__ == "__main__":
host = os.environ.get("FLASK_HOST", "0.0.0.0")
port = int(os.environ.get("FLASK_PORT", "5000"))
app.run(host=host, port=port, debug=False)
port = int(os.environ.get("PORT", 5000))
debug = os.environ.get("FLASK_DEBUG", "0") == "1"
app.run(host="0.0.0.0", port=port, debug=debug)