This commit is contained in:
Ryan Shpeherd
2026-08-10 18:36:57 -04:00
parent 0d6b5b9b9d
commit 7a53b60371
5 changed files with 790 additions and 301 deletions
+3 -1
View File
@@ -85,7 +85,9 @@ services:
- DB_PASSWORD=${DB_PASSWORD:-videodetect123} - DB_PASSWORD=${DB_PASSWORD:-videodetect123}
- FLASK_ENV=production - FLASK_ENV=production
volumes: volumes:
- ./ui:/app/ui - ./ui:/app
- ${NAS_INPUT_PATH:-/mnt/nas/input}:/data/input:ro
- ${NAS_OUTPUT_PATH:-/mnt/nas/output}:/data/output
networks: networks:
- videodetect-network - videodetect-network
depends_on: depends_on:
+114
View File
@@ -0,0 +1,114 @@
"""
Review queue export for backend scripts.
Provides CSV and JSON export of annotated review data, with optional
filtering by date range, model version, annotation status, and ground truth.
Can be called standalone or imported from other src/ modules.
"""
import csv
import io
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
def fetch_annotated(
db_connector,
annotated_only: bool = True,
model_version: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
ground_truth: Optional[bool] = None,
) -> List[Dict[str, Any]]:
"""Query the review_queue and return matching records as plain dicts."""
clauses: List[str] = []
params: List[Any] = []
if annotated_only:
clauses.append("rq.annotated = TRUE")
if model_version:
clauses.append("v.model_version = %s")
params.append(model_version)
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)
if ground_truth is not None:
clauses.append("rq.ground_truth = %s")
params.append(bool(ground_truth))
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
rows = db_connector.fetchall(
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 if params else None,
)
return [_normalise(row) for row in (rows or [])]
def _normalise(row: dict) -> Dict[str, Any]:
try:
scores = json.loads(row["confidence_scores"]) if row.get("confidence_scores") else []
except (json.JSONDecodeError, TypeError):
scores = []
at = row.get("annotated_at")
return {
"video_id": row["video_id"],
"file_path": row["file_path"],
"confidence_score": row["confidence_score"],
"routing_decision": row["routing_decision"],
"model_version": row.get("model_version"),
"ground_truth": bool(row["ground_truth"]) if row["ground_truth"] is not None else None,
"annotated_at": at.isoformat() if isinstance(at, datetime) else at,
"notes": row.get("notes"),
"contributing_frames": scores,
}
def export_json(records: List[Dict[str, Any]], output_path: str) -> str:
"""Write records to a UTF-8 JSON file; returns the path."""
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(records, default=str, indent=2), encoding="utf-8")
logger.info("Exported %d records to %s", len(records), path)
return str(path)
def export_csv(records: List[Dict[str, Any]], output_path: str) -> str:
"""Write records to a UTF-8 CSV file; returns the path."""
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
if not records:
path.write_text("", encoding="utf-8")
return str(path)
buf = io.StringIO()
writer = csv.DictWriter(buf, 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)
path.write_text(buf.getvalue(), encoding="utf-8")
logger.info("Exported %d records to %s", len(records), path)
return str(path)
+95
View File
@@ -0,0 +1,95 @@
import csv
import io
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
import review_export
def _make_db(rows):
db = MagicMock()
db.fetchall.return_value = rows
return db
class Story07ReviewExportTests(unittest.TestCase):
_ROWS = [
{
"video_id": 1, "file_path": "/data/input/a.mp4",
"confidence_score": 0.62, "routing_decision": "REVIEW",
"model_version": "v1.0", "ground_truth": True,
"annotated_at": None, "notes": "ok",
"confidence_scores": json.dumps([0.60, 0.62, 0.65]),
},
]
def test_fetch_annotated_passes_correct_where_clause(self):
db = _make_db(self._ROWS)
review_export.fetch_annotated(db, annotated_only=True, model_version="v1.0")
call_args = db.fetchall.call_args
sql = call_args[0][0]
self.assertIn("rq.annotated = TRUE", sql)
self.assertIn("v.model_version = %s", sql)
def test_fetch_annotated_normalises_confidence_scores(self):
db = _make_db(self._ROWS)
records = review_export.fetch_annotated(db)
self.assertIsInstance(records[0]["contributing_frames"], list)
self.assertEqual(records[0]["contributing_frames"], [0.60, 0.62, 0.65])
def test_fetch_annotated_handles_null_confidence_scores(self):
rows = [{**self._ROWS[0], "confidence_scores": None}]
db = _make_db(rows)
records = review_export.fetch_annotated(db)
self.assertEqual(records[0]["contributing_frames"], [])
def test_export_json_writes_valid_utf8_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
records = review_export.fetch_annotated(_make_db(self._ROWS))
path = review_export.export_json(records, f"{tmpdir}/out.json")
loaded = json.loads(Path(path).read_text(encoding="utf-8"))
self.assertEqual(len(loaded), 1)
self.assertEqual(loaded[0]["video_id"], 1)
self.assertTrue(loaded[0]["ground_truth"])
def test_export_csv_writes_valid_csv(self):
with tempfile.TemporaryDirectory() as tmpdir:
records = review_export.fetch_annotated(_make_db(self._ROWS))
path = review_export.export_csv(records, f"{tmpdir}/out.csv")
content = Path(path).read_text(encoding="utf-8")
reader = csv.DictReader(io.StringIO(content))
rows = list(reader)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["routing_decision"], "REVIEW")
# contributing_frames should be a JSON string in CSV
frames = json.loads(rows[0]["contributing_frames"])
self.assertEqual(frames, [0.60, 0.62, 0.65])
def test_export_csv_empty_returns_empty_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = review_export.export_csv([], f"{tmpdir}/empty.csv")
self.assertEqual(Path(path).read_text(), "")
def test_ground_truth_filter_appears_in_query(self):
db = _make_db([])
review_export.fetch_annotated(db, ground_truth=False)
sql = db.fetchall.call_args[0][0]
self.assertIn("rq.ground_truth = %s", sql)
class Story07AppSyntaxTest(unittest.TestCase):
def test_app_module_compiles(self):
"""Ensure ui/app.py has no syntax errors."""
app_path = Path(__file__).resolve().parents[1] / "ui" / "app.py"
source = app_path.read_text(encoding="utf-8")
compile(source, str(app_path), "exec")
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -6,10 +6,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY ui/requirements.txt /app/requirements.txt COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY ui/ /app/ui/ COPY . .
EXPOSE 5000 EXPOSE 5000
+576 -298
View File
@@ -1,370 +1,648 @@
""" """
VideoDetect Review UI - Flask Application VideoDetect Review UI Flask Application
Lightweight web interface for annotating low-confidence videos. Lightweight web interface for annotating low-confidence videos.
Accessible via http://<server-ip>:5000 Accessible via http://<server-ip>:5000 — no auth, no SSL (per TC-06).
No authentication or SSL (per TC-06).
""" """
import csv
import io
import json
import os import os
from flask import Flask, jsonify, request, render_template_string 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
app = Flask(__name__) app = Flask(__name__)
# In-memory storage for demo (replace with DB queries in production) # ---------------------------------------------------------------------------
review_queue = [] # Database
# ---------------------------------------------------------------------------
def _db_config() -> dict:
# ------ Routes ------ return dict(
host=os.environ.get("DB_HOST", "mariadb"),
@app.route("/") port=int(os.environ.get("DB_PORT", 3306)),
def index(): database=os.environ.get("DB_NAME", "videodetect"),
"""Queue page - list of videos awaiting review.""" user=os.environ.get("DB_USER", "videodetect"),
page = request.args.get("page", 1, type=int) password=os.environ.get("DB_PASSWORD", "videodetect123"),
per_page = 20 cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
# Filter unannotated charset="utf8mb4",
unannotated = [v for v in review_queue if not v.get("annotated")]
total = len(unannotated)
start = (page - 1) * per_page
end = start + per_page
videos = unannotated[start:end]
return render_template_string(
INDEX_TEMPLATE,
videos=videos,
page=page,
per_page=per_page,
total=total,
total_pages=(total + per_page - 1) // per_page,
) )
def get_db():
if "db" not in g:
g.db = pymysql.connect(**_db_config())
return g.db
@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
# ---------------------------------------------------------------------------
# HTML routes
# ---------------------------------------------------------------------------
@app.route("/")
def index():
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"
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)
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"]
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],
)
return render_template_string(
_INDEX_TEMPLATE,
videos=videos or [],
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 "",
)
@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 []
try:
scores = json.loads(log["confidence_scores"])
except (json.JSONDecodeError, TypeError):
return []
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]
# ---------------------------------------------------------------------------
# API routes
# ---------------------------------------------------------------------------
@app.route("/api/review/queue") @app.route("/api/review/queue")
def api_queue(): def api_queue():
"""API: List videos in review queue."""
page = request.args.get("page", 1, type=int) page = request.args.get("page", 1, type=int)
per_page = 20 per_page = 20
offset = (page - 1) * per_page
unannotated = [v for v in review_queue if not v.get("annotated")] count_row = _query("SELECT COUNT(*) AS cnt FROM review_queue WHERE annotated = FALSE", one=True) or {"cnt": 0}
total = len(unannotated) total = count_row["cnt"]
start = (page - 1) * per_page
end = start + per_page
return jsonify({ videos = _query(
"videos": unannotated[start:end], """SELECT rq.id, rq.video_id, v.file_path, rq.confidence_score,
"total": total, rq.routing_decision, v.model_version, v.frame_count, rq.created_at
"page": page, FROM review_queue rq JOIN videos v ON v.id = rq.video_id
"per_page": per_page, WHERE rq.annotated = FALSE
}) ORDER BY rq.created_at DESC LIMIT %s OFFSET %s""",
(per_page, offset),
)
return jsonify({"videos": _serialise(videos), "total": total, "page": page, "per_page": per_page})
@app.route("/api/review/<int:video_id>") @app.route("/api/review/<int:video_id>")
def api_video_details(video_id): def api_video_details(video_id):
"""API: Get video details for annotation.""" row = _query(
video = next((v for v in review_queue if v["id"] == video_id), None) """SELECT rq.id, rq.video_id, v.file_path, rq.confidence_score,
if not video: 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 return jsonify({"error": "Video not found"}), 404
return jsonify(video)
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"]) @app.route("/api/review/<int:video_id>/label", methods=["POST"])
def api_label(video_id): def api_label(video_id):
"""API: Submit annotation for a video.""" data = request.get_json(silent=True) or {}
data = request.get_json()
ground_truth = data.get("ground_truth") ground_truth = data.get("ground_truth")
notes = data.get("notes", "") notes = data.get("notes", "")
if ground_truth is None: if ground_truth is None:
return jsonify({"error": "ground_truth is required"}), 400 return jsonify({"error": "ground_truth is required"}), 400
video = next((v for v in review_queue if v["id"] == video_id), None) now = datetime.now(timezone.utc).replace(tzinfo=None)
if not video: rows = _execute(
return jsonify({"error": "Video not found"}), 404 """UPDATE review_queue
SET annotated = TRUE, ground_truth = %s, notes = %s, annotated_at = %s
video["annotated"] = True WHERE video_id = %s AND annotated = FALSE""",
video["ground_truth"] = ground_truth (bool(ground_truth), notes, now, video_id),
video["notes"] = notes )
video["annotated_at"] = "2026-08-03T10:30:00Z" 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}) return jsonify({"status": "annotated", "video_id": video_id, "ground_truth": ground_truth})
@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/stats") @app.route("/api/review/stats")
def api_stats(): def api_stats():
"""API: Review queue statistics.""" stats = _query(
total = len(review_queue) """SELECT
annotated = sum(1 for v in review_queue if v.get("annotated")) COUNT(*) AS total_in_queue,
unannotated = total - annotated SUM(annotated) AS annotated_count,
avg_conf = sum(v.get("confidence_score", 0) for v in review_queue) / max(total, 1) 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} 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}
for v in review_queue: buckets = _query(
c = v.get("confidence_score", 0) """SELECT
if c < 0.2: CASE
dist["0.0-0.2"] += 1 WHEN confidence_score < 0.2 THEN '0.0-0.2'
elif c < 0.4: WHEN confidence_score < 0.4 THEN '0.2-0.4'
dist["0.2-0.4"] += 1 WHEN confidence_score < 0.6 THEN '0.4-0.6'
elif c < 0.6: WHEN confidence_score < 0.8 THEN '0.6-0.8'
dist["0.4-0.6"] += 1 ELSE '0.8-1.0'
elif c < 0.8: END AS bucket,
dist["0.6-0.8"] += 1 COUNT(*) AS cnt
else: FROM review_queue GROUP BY bucket"""
dist["0.8-1.0"] += 1 ) or []
for b in buckets:
dist[b["bucket"]] = b["cnt"]
return jsonify({ return jsonify({
"total_in_queue": total, "total_in_queue": stats.get("total_in_queue", 0),
"annotated": annotated, "annotated": int(stats.get("annotated_count") or 0),
"unannotated": unannotated, "unannotated": int(stats.get("total_in_queue", 0)) - int(stats.get("annotated_count") or 0),
"avg_confidence": round(avg_conf, 4), "annotated_today": int(stats.get("annotated_today") or 0),
"avg_confidence": round(float(stats.get("avg_confidence") or 0.0), 4),
"confidence_distribution": dist, "confidence_distribution": dist,
}) })
# ------ HTML Templates ------ @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
try:
return send_file(row["file_path"], mimetype="video/mp4", conditional=True)
except FileNotFoundError:
return "Video file not found on disk", 404
INDEX_TEMPLATE = """
# ---------------------------------------------------------------------------
# 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 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()
# ---------------------------------------------------------------------------
# Templates
# ---------------------------------------------------------------------------
_INDEX_TEMPLATE = """
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VideoDetect - Review Queue</title> <title>VideoDetect Review Queue</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } *{margin:0;padding:0;box-sizing:border-box}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; } body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;color:#333}
.header { background: #1a1a2e; color: white; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; } .header{background:#1a1a2e;color:#fff;padding:18px 40px;display:flex;justify-content:space-between;align-items:center}
.header h1 { font-size: 24px; } .header h1{font-size:22px}
.header .stats { font-size: 14px; opacity: 0.8; } .header .meta{font-size:13px;opacity:.75}
.container { max-width: 1200px; margin: 40px auto; padding: 0 20px; } .container{max-width:1280px;margin:30px auto;padding:0 20px}
.table { width: 100%; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .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}
table { width: 100%; border-collapse: collapse; } .filters label{font-size:12px;color:#666;display:flex;flex-direction:column;gap:4px}
th { background: #f8f9fa; padding: 14px 16px; text-align: left; font-weight: 600; font-size: 13px; text-transform: uppercase; color: #666; border-bottom: 2px solid #e9ecef; } .filters input,.filters select{padding:6px 10px;border:1px solid #ddd;border-radius:5px;font-size:13px}
td { padding: 14px 16px; border-bottom: 1px solid #e9ecef; font-size: 14px; } .filters button{padding:7px 16px;background:#007bff;color:#fff;border:none;border-radius:5px;cursor:pointer;font-size:13px}
tr:hover { background: #f8f9fa; } .filters button:hover{background:#0056b3}
.badge { display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; } .card{background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08)}
.badge-match { background: #d4edda; color: #155724; } table{width:100%;border-collapse:collapse}
.badge-review { background: #fff3cd; color: #856404; } th{background:#f8f9fa;padding:12px 14px;text-align:left;font-size:12px;text-transform:uppercase;color:#666;border-bottom:2px solid #e9ecef}
.badge-skip { background: #f8d7da; color: #721c24; } th a{color:#666;text-decoration:none}
.btn { display: inline-block; padding: 8px 16px; background: #007bff; color: white; text-decoration: none; border-radius: 6px; font-size: 14px; border: none; cursor: pointer; } th a:hover{color:#333}
.btn:hover { background: #0056b3; } td{padding:12px 14px;border-bottom:1px solid #e9ecef;font-size:13px}
.pagination { display: flex; justify-content: center; gap: 8px; margin-top: 24px; } tr:hover{background:#fafafa}
.pagination a, .pagination span { padding: 8px 14px; border-radius: 6px; text-decoration: none; font-size: 14px; } .badge{display:inline-block;padding:3px 9px;border-radius:10px;font-size:11px;font-weight:600}
.pagination a { background: white; color: #007bff; border: 1px solid #dee2e6; } .badge-review{background:#fff3cd;color:#856404}
.pagination a:hover { background: #e9ecef; } .badge-match{background:#d4edda;color:#155724}
.pagination .current { background: #007bff; color: white; } .badge-skip{background:#f8d7da;color:#721c24}
.empty { text-align: center; padding: 60px 20px; color: #999; } .btn{padding:6px 14px;background:#007bff;color:#fff;text-decoration:none;border-radius:5px;font-size:12px;border:none;cursor:pointer}
.confidence { font-weight: 600; } .btn:hover{background:#0056b3}
.confidence-high { color: #28a745; } .conf-high{color:#28a745;font-weight:600}
.confidence-mid { color: #ffc107; } .conf-mid{color:#ffc107;font-weight:600}
.confidence-low { color: #dc3545; } .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 .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> </style>
</head> </head>
<body> <body>
<div class="header"> <div class="header">
<h1>VideoDetect</h1> <h1>VideoDetect</h1>
<div class="stats"> <span class="meta">Review Queue &mdash; {{ total }} pending &mdash; Page {{ page }}/{{ total_pages }}</span>
Review Queue: {{ total }} videos | Page {{ page }} of {{ total_pages }} </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="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>
</tr></thead>
<tbody>
{% for v in videos %}
<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>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="pagination">
{% if page > 1 %}<a href="?page={{ page-1 }}&sort={{ sort }}&order={{ order }}">&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 %}
</div>
{% else %}
<div class="empty"><h2>No videos pending review</h2><p>All REVIEW-routed videos have been annotated.</p></div>
{% endif %}
</div>
</body></html>
"""
_REVIEW_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VideoDetect — Review #{{ video.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}
.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}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.info-label{font-size:11px;text-transform:uppercase;color:#888;margin-bottom:3px}
.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}
</style>
</head>
<body>
<div class="header">
<h1>VideoDetect</h1>
<a href="/">&laquo; Back to queue</a>
</div>
<div class="container">
<div class="card">
<h2>Video 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">Duration</div><div class="info-value">{{ "%.1f"|format(video.video_duration) if video.video_duration else '&mdash;' }}s</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">
<source src="/video/{{ video.video_id }}" type="video/mp4">
Video playback not supported.
</video>
</div> </div>
</div> </div>
<div class="container"> {% if video.contributing_frames %}
{% if videos %} <div class="card">
<div class="table"> <h2>Top contributing frames (by confidence)</h2>
<table> <div class="frames">
<thead> {% for f in video.contributing_frames %}
<tr> <div class="frame">
<th>Video ID</th> <div>&#9201; {{ f.timestamp }}s</div>
<th>File Path</th> <div class="conf">{{ "%.3f"|format(f.confidence) }}</div>
<th>Confidence</th> </div>
<th>Routing</th> {% endfor %}
<th>Model</th>
<th>Frames</th>
<th>Added</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for video in videos %}
<tr>
<td>{{ video.id }}</td>
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ video.file_path }}</td>
<td class="confidence {% if video.confidence_score >= 0.75 %}confidence-high{% elif video.confidence_score >= 0.45 %}confidence-mid{% else %}confidence-low{% endif %}">
{{ "%.2f"|format(video.confidence_score) }}
</td>
<td><span class="badge badge-{{ video.routing_decision|lower }}">{{ video.routing_decision }}</span></td>
<td>{{ video.model_version }}</td>
<td>{{ video.frame_count }}</td>
<td>{{ video.created_at[:10] if video.created_at else 'N/A' }}</td>
<td><a href="/review/{{ video.id }}" class="btn">Annotate</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="pagination">
{% if page > 1 %}
<a href="?page={{ page - 1 }}">&laquo; Prev</a>
{% endif %}
<span class="current">{{ page }}</span>
{% if page < total_pages %}
<a href="?page={{ page + 1 }}">Next &raquo;</a>
{% endif %}
</div> </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 %} {% else %}
<div class="empty"> <p style="color:#666;margin-bottom:8px">Does this video contain the target demographic?</p>
<h2>No videos in review queue</h2> <div class="label-row">
<p>All videos have been processed and classified.</p> <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> </div>
<textarea id="notes" placeholder="Optional notes&hellip;"></textarea>
<button class="submit" id="submitBtn" disabled onclick="submitLabel()">Submit label</button>
{% endif %} {% endif %}
</div> </div>
</body> </div>
</html> <script>
""" let selectedLabel = null;
function selectLabel(val) {
REVIEW_TEMPLATE = """ selectedLabel = val;
<!DOCTYPE html> document.getElementById('btn-yes').className = 'label-btn' + (val ? ' sel-true' : '');
<html lang="en"> document.getElementById('btn-no').className = 'label-btn' + (!val ? ' sel-false' : '');
<head> document.getElementById('submitBtn').disabled = false;
<meta charset="UTF-8"> }
<meta name="viewport" content="width=device-width, initial-scale=1.0"> async function submitLabel() {
<title>VideoDetect - Review #{{ video.id }}</title> const btn = document.getElementById('submitBtn');
<style> btn.disabled = true; btn.textContent = 'Saving\u2026';
* { margin: 0; padding: 0; box-sizing: border-box; } const res = await fetch('/api/review/{{ video.video_id }}/label', {
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; } method: 'POST',
.header { background: #1a1a2e; color: white; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; } headers: {'Content-Type': 'application/json'},
.header h1 { font-size: 24px; } body: JSON.stringify({ground_truth: selectedLabel, notes: document.getElementById('notes').value})
.header a { color: #adb5bd; text-decoration: none; } });
.header a:hover { color: white; } if (res.ok) { window.location.href = '/'; }
.container { max-width: 1000px; margin: 40px auto; padding: 0 20px; } else { btn.disabled = false; btn.textContent = 'Submit label'; alert('Error saving label.'); }
.card { background: white; border-radius: 8px; padding: 24px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } }
.card h2 { font-size: 18px; margin-bottom: 16px; color: #1a1a2e; } </script>
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } </body></html>
.info-item { }
.info-label { font-size: 12px; text-transform: uppercase; color: #666; margin-bottom: 4px; }
.info-value { font-size: 16px; font-weight: 600; }
.video-player { width: 100%; aspect-ratio: 16/9; background: #000; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: white; }
.frames-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }
.frame-thumb { aspect-ratio: 1; background: #e9ecef; border-radius: 6px; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 12px; color: #666; }
.frame-thumb .confidence { font-size: 14px; font-weight: 600; color: #007bff; margin-top: 4px; }
.label-toggle { display: flex; gap: 12px; margin: 20px 0; }
.label-btn { flex: 1; padding: 16px; border: 2px solid #dee2e6; border-radius: 8px; background: white; cursor: pointer; font-size: 16px; font-weight: 600; text-align: center; transition: all 0.2s; }
.label-btn:hover { border-color: #007bff; }
.label-btn.selected-true { border-color: #28a745; background: #d4edda; color: #155724; }
.label-btn.selected-false { border-color: #dc3545; background: #f8d7da; color: #721c24; }
.notes { width: 100%; padding: 12px; border: 1px solid #dee2e6; border-radius: 6px; font-size: 14px; resize: vertical; min-height: 80px; }
.submit-btn { width: 100%; padding: 14px; background: #007bff; color: white; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; margin-top: 16px; }
.submit-btn:hover { background: #0056b3; }
.submit-btn:disabled { background: #ccc; cursor: not-allowed; }
</style>
</head>
<body>
<div class="header">
<h1>VideoDetect</h1>
<a href="/">&laquo; Back to Queue</a>
</div>
<div class="container">
<div class="card">
<h2>Video Information</h2>
<div class="info-grid">
<div class="info-item">
<div class="info-label">Video ID</div>
<div class="info-value">{{ video.id }}</div>
</div>
<div class="info-item">
<div class="info-label">Confidence</div>
<div class="info-value">{{ "%.2f"|format(video.confidence_score) }}</div>
</div>
<div class="info-item">
<div class="info-label">Routing</div>
<div class="info-value">{{ video.routing_decision }}</div>
</div>
<div class="info-item">
<div class="info-label">Model</div>
<div class="info-value">{{ video.model_version }}</div>
</div>
<div class="info-item">
<div class="info-label">Frames</div>
<div class="info-value">{{ video.frame_count }}</div>
</div>
<div class="info-item">
<div class="info-label">Duration</div>
<div class="info-value">{{ "%.1f"|format(video.video_duration) if video.video_duration else 'N/A' }}s</div>
</div>
</div>
</div>
<div class="card">
<h2>Video</h2>
<div class="video-player">
<video controls style="width: 100%; height: 100%;">
<source src="{{ video.file_path }}" type="video/mp4">
Video playback not supported in this browser.
</video>
</div>
</div>
<div class="card">
<h2>Contributing Frames (Top {{ video.top_k_frames|default(5) }})</h2>
<div class="frames-grid">
{% for frame in video.contributing_frames %}
<div class="frame-thumb">
<span>⏱ {{ "%.0f"|format(frame.timestamp) }}s</span>
<span class="confidence">{{ "%.2f"|format(frame.confidence) }}</span>
</div>
{% endfor %}
</div>
</div>
<div class="card">
<h2>Annotate</h2>
<p style="margin-bottom: 12px; color: #666;">Does this video contain the target demographic?</p>
<div class="label-toggle">
<button class="label-btn {% if video.ground_truth == True %}selected-true{% endif %}" onclick="selectLabel(true)">✓ Yes (MATCH)</button>
<button class="label-btn {% if video.ground_truth == False %}selected-false{% endif %}" onclick="selectLabel(false)">✗ No (NO_MATCH)</button>
</div>
<textarea class="notes" id="notes" placeholder="Optional notes...">{{ video.notes or '' }}</textarea>
<button class="submit-btn" id="submitBtn" onclick="submitLabel()" disabled>Submit Label</button>
</div>
</div>
<script>
let selectedLabel = null;
function selectLabel(value) {
selectedLabel = value;
document.querySelectorAll('.label-btn').forEach(btn => {
btn.classList.remove('selected-true', 'selected-false');
});
event.target.classList.add(value ? 'selected-true' : 'selected-false');
document.getElementById('submitBtn').disabled = false;
}
async function submitLabel() {
const videoId = {{ video.id }};
const notes = document.getElementById('notes').value;
const response = await fetch(`/api/review/${videoId}/label`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ground_truth: selectedLabel, notes: notes})
});
if (response.ok) {
alert('Label saved successfully!');
window.location.href = '/';
} else {
alert('Error saving label.');
}
}
</script>
</body>
</html>
""" """
# ------ Entry Point ------ # ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__": if __name__ == "__main__":
host = os.environ.get("FLASK_HOST", "0.0.0.0") host = os.environ.get("FLASK_HOST", "0.0.0.0")
port = int(os.environ.get("FLASK_PORT", "5000")) port = int(os.environ.get("FLASK_PORT", "5000"))
app.run(host=host, port=port, debug=False) app.run(host=host, port=port, debug=False)