""" VideoDetect Review UI — Flask Application Lightweight web interface for annotating low-confidence videos. Accessible via http://:5000 — no auth, no SSL (per TC-06). """ 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 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", ) 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/") 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") def api_queue(): page = request.args.get("page", 1, type=int) per_page = 20 offset = (page - 1) * per_page count_row = _query("SELECT COUNT(*) AS cnt FROM review_queue WHERE annotated = FALSE", one=True) or {"cnt": 0} total = count_row["cnt"] 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), ) return jsonify({"videos": _serialise(videos), "total": total, "page": page, "per_page": per_page}) @app.route("/api/review/") 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//label", methods=["POST"]) def api_label(video_id): data = request.get_json(silent=True) or {} ground_truth = data.get("ground_truth") notes = data.get("notes", "") if ground_truth is None: return jsonify({"error": "ground_truth 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}) @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") 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/") 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 # --------------------------------------------------------------------------- # 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 = """ VideoDetect — Review Queue

VideoDetect

Review Queue — {{ total }} pending — Page {{ page }}/{{ total_pages }}
Export CSV Export JSON
{% if videos %}
{% for v in videos %} {% endfor %}
ID File Path Confidence Routing Model Frames Added
{{ v.video_id }} {{ v.file_path }} {{ "%.3f"|format(v.confidence_score) }} {{ v.routing_decision }} {{ v.model_version or '—' }} {{ v.frame_count or '—' }} {{ v.created_at|string|truncate(10, True, '') }} Annotate
{% else %}

No videos pending review

All REVIEW-routed videos have been annotated.

{% endif %}
""" _REVIEW_TEMPLATE = """ VideoDetect — Review #{{ video.video_id }}

VideoDetect

« Back to queue

Video information

Video ID
{{ video.video_id }}
Confidence
{{ "%.4f"|format(video.confidence_score) }}
Routing
{{ video.routing_decision }}
Model
{{ video.model_version or '—' }}
Frames
{{ video.frame_count or '—' }}
Duration
{{ "%.1f"|format(video.video_duration) if video.video_duration else '—' }}s
{{ video.file_path }}

Video

{% if video.contributing_frames %}

Top contributing frames (by confidence)

{% for f in video.contributing_frames %}
⏱ {{ f.timestamp }}s
{{ "%.3f"|format(f.confidence) }}
{% endfor %}
{% endif %}

Annotate

{% if video.annotated %}

✓ Already annotated: {{ 'MATCH' if video.ground_truth else 'NO MATCH' }}

{% else %}

Does this video contain the target demographic?

{% endif %}
""" # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- 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)