diff --git a/docker-compose.yml b/docker-compose.yml index b0db14c..2483c42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,7 +85,9 @@ services: - DB_PASSWORD=${DB_PASSWORD:-videodetect123} - FLASK_ENV=production volumes: - - ./ui:/app/ui + - ./ui:/app + - ${NAS_INPUT_PATH:-/mnt/nas/input}:/data/input:ro + - ${NAS_OUTPUT_PATH:-/mnt/nas/output}:/data/output networks: - videodetect-network depends_on: diff --git a/src/review_export.py b/src/review_export.py new file mode 100644 index 0000000..eed6b77 --- /dev/null +++ b/src/review_export.py @@ -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) diff --git a/tests/test_story_07.py b/tests/test_story_07.py new file mode 100644 index 0000000..a915326 --- /dev/null +++ b/tests/test_story_07.py @@ -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() diff --git a/ui/Dockerfile b/ui/Dockerfile index acd986e..0ef18af 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -6,10 +6,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && 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 -COPY ui/ /app/ui/ +COPY . . EXPOSE 5000 diff --git a/ui/app.py b/ui/app.py index 7ff4ae8..9dca4a1 100644 --- a/ui/app.py +++ b/ui/app.py @@ -1,370 +1,648 @@ """ -VideoDetect Review UI - Flask Application +VideoDetect Review UI — Flask Application Lightweight web interface for annotating low-confidence videos. -Accessible via http://:5000 -No authentication or SSL (per TC-06). +Accessible via http://:5000 — no auth, no SSL (per TC-06). """ +import csv +import io +import json 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__) -# In-memory storage for demo (replace with DB queries in production) -review_queue = [] +# --------------------------------------------------------------------------- +# Database +# --------------------------------------------------------------------------- - -# ------ Routes ------ - -@app.route("/") -def index(): - """Queue page - list of videos awaiting review.""" - page = request.args.get("page", 1, type=int) - per_page = 20 - - # Filter unannotated - 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 _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(): - """API: List videos in review queue.""" page = request.args.get("page", 1, type=int) per_page = 20 + offset = (page - 1) * per_page - unannotated = [v for v in review_queue if not v.get("annotated")] - total = len(unannotated) - start = (page - 1) * per_page - end = start + per_page + count_row = _query("SELECT COUNT(*) AS cnt FROM review_queue WHERE annotated = FALSE", one=True) or {"cnt": 0} + total = count_row["cnt"] - return jsonify({ - "videos": unannotated[start:end], - "total": total, - "page": page, - "per_page": per_page, - }) + 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): - """API: Get video details for annotation.""" - video = next((v for v in review_queue if v["id"] == video_id), None) - if not video: + 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 - 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//label", methods=["POST"]) def api_label(video_id): - """API: Submit annotation for a video.""" - data = request.get_json() + 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 - video = next((v for v in review_queue if v["id"] == video_id), None) - if not video: - return jsonify({"error": "Video not found"}), 404 - - video["annotated"] = True - video["ground_truth"] = ground_truth - video["notes"] = notes - video["annotated_at"] = "2026-08-03T10:30:00Z" - + 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(): - """API: Review queue statistics.""" - total = len(review_queue) - annotated = sum(1 for v in review_queue if v.get("annotated")) - unannotated = total - annotated - avg_conf = sum(v.get("confidence_score", 0) for v in review_queue) / max(total, 1) + 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} - for v in review_queue: - c = v.get("confidence_score", 0) - if c < 0.2: - dist["0.0-0.2"] += 1 - elif c < 0.4: - dist["0.2-0.4"] += 1 - elif c < 0.6: - dist["0.4-0.6"] += 1 - elif c < 0.8: - dist["0.6-0.8"] += 1 - else: - dist["0.8-1.0"] += 1 + 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": total, - "annotated": annotated, - "unannotated": unannotated, - "avg_confidence": round(avg_conf, 4), + "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, }) -# ------ HTML Templates ------ +@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 -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 = """ - VideoDetect - Review Queue + VideoDetect — Review Queue -
-

VideoDetect

-
- Review Queue: {{ total }} videos | Page {{ page }} of {{ total_pages }} +
+

VideoDetect

+ Review Queue — {{ total }} pending — Page {{ page }}/{{ total_pages }} +
+
+
+ + + + + + + Export CSV + Export JSON +
+ {% if videos %} +
+ + + + + + + + + + + + + {% for v in videos %} + + + + + + + + + + + {% endfor %} + +
IDFile PathConfidenceRoutingModelFramesAdded
{{ 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 videos %} -
- - - - - - - - - - - - - - - {% for video in videos %} - - - - - - - - - - - {% endfor %} - -
Video IDFile PathConfidenceRoutingModelFramesAddedAction
{{ video.id }}{{ video.file_path }} - {{ "%.2f"|format(video.confidence_score) }} - {{ video.routing_decision }}{{ video.model_version }}{{ video.frame_count }}{{ video.created_at[:10] if video.created_at else 'N/A' }}Annotate
-
-