Story 7
This commit is contained in:
+3
-1
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 — {{ total }} pending — Page {{ page }}/{{ total_pages }}</span>
|
||||||
Review Queue: {{ total }} videos | Page {{ page }} of {{ total_pages }}
|
</div>
|
||||||
</div>
|
<div class="container">
|
||||||
</div>
|
<form class="filters" method="get">
|
||||||
<div class="container">
|
<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 %}
|
{% if videos %}
|
||||||
<div class="table">
|
<div class="card">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead><tr>
|
||||||
<tr>
|
<th><a href="?sort=created_at&order={% if sort=='created_at' and order=='ASC' %}desc{% else %}asc{% endif %}">ID</a></th>
|
||||||
<th>Video ID</th>
|
|
||||||
<th>File Path</th>
|
<th>File Path</th>
|
||||||
<th>Confidence</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>Routing</th>
|
||||||
<th>Model</th>
|
<th>Model</th>
|
||||||
<th>Frames</th>
|
<th>Frames</th>
|
||||||
<th>Added</th>
|
<th><a href="?sort=created_at&order={% if sort=='created_at' and order=='ASC' %}desc{% else %}asc{% endif %}">Added</a></th>
|
||||||
<th>Action</th>
|
<th></th>
|
||||||
</tr>
|
</tr></thead>
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for video in videos %}
|
{% for v in videos %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ video.id }}</td>
|
<td>{{ v.video_id }}</td>
|
||||||
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ video.file_path }}</td>
|
<td style="max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ v.file_path }}">{{ v.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 %}">
|
<td class="{% if v.confidence_score >= 0.75 %}conf-high{% elif v.confidence_score >= 0.45 %}conf-mid{% else %}conf-low{% endif %}">
|
||||||
{{ "%.2f"|format(video.confidence_score) }}
|
{{ "%.3f"|format(v.confidence_score) }}
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge badge-{{ video.routing_decision|lower }}">{{ video.routing_decision }}</span></td>
|
<td><span class="badge badge-{{ v.routing_decision|lower }}">{{ v.routing_decision }}</span></td>
|
||||||
<td>{{ video.model_version }}</td>
|
<td>{{ v.model_version or '—' }}</td>
|
||||||
<td>{{ video.frame_count }}</td>
|
<td>{{ v.frame_count or '—' }}</td>
|
||||||
<td>{{ video.created_at[:10] if video.created_at else 'N/A' }}</td>
|
<td>{{ v.created_at|string|truncate(10, True, '') }}</td>
|
||||||
<td><a href="/review/{{ video.id }}" class="btn">Annotate</a></td>
|
<td><a href="/review/{{ v.video_id }}" class="btn">Annotate</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{% if page > 1 %}
|
{% if page > 1 %}<a href="?page={{ page-1 }}&sort={{ sort }}&order={{ order }}">« Prev</a>{% endif %}
|
||||||
<a href="?page={{ page - 1 }}">« Prev</a>
|
<span class="cur">{{ page }}</span>
|
||||||
{% endif %}
|
{% if page < total_pages %}<a href="?page={{ page+1 }}&sort={{ sort }}&order={{ order }}">Next »</a>{% endif %}
|
||||||
<span class="current">{{ page }}</span>
|
|
||||||
{% if page < total_pages %}
|
|
||||||
<a href="?page={{ page + 1 }}">Next »</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty">
|
<div class="empty"><h2>No videos pending review</h2><p>All REVIEW-routed videos have been annotated.</p></div>
|
||||||
<h2>No videos in review queue</h2>
|
|
||||||
<p>All videos have been processed and classified.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body></html>
|
||||||
</html>
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
REVIEW_TEMPLATE = """
|
_REVIEW_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 #{{ video.id }}</title>
|
<title>VideoDetect — Review #{{ video.video_id }}</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 a { color: #adb5bd; text-decoration: none; }
|
.header a{color:#adb5bd;text-decoration:none}
|
||||||
.header a:hover { color: white; }
|
.header a:hover{color:#fff}
|
||||||
.container { max-width: 1000px; margin: 40px auto; padding: 0 20px; }
|
.container{max-width:960px;margin:30px auto;padding:0 20px}
|
||||||
.card { background: white; border-radius: 8px; padding: 24px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
.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: 18px; margin-bottom: 16px; color: #1a1a2e; }
|
.card h2{font-size:16px;margin-bottom:14px;color:#1a1a2e}
|
||||||
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
|
||||||
.info-item { }
|
.info-label{font-size:11px;text-transform:uppercase;color:#888;margin-bottom:3px}
|
||||||
.info-label { font-size: 12px; text-transform: uppercase; color: #666; margin-bottom: 4px; }
|
.info-value{font-size:15px;font-weight:600}
|
||||||
.info-value { font-size: 16px; font-weight: 600; }
|
.video-wrap{width:100%;background:#000;border-radius:6px;overflow:hidden}
|
||||||
.video-player { width: 100%; aspect-ratio: 16/9; background: #000; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: white; }
|
video{width:100%;display:block}
|
||||||
.frames-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }
|
.frames{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-top:4px}
|
||||||
.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{background:#e9ecef;border-radius:6px;padding:10px;text-align:center;font-size:12px;color:#666}
|
||||||
.frame-thumb .confidence { font-size: 14px; font-weight: 600; color: #007bff; margin-top: 4px; }
|
.frame .conf{font-size:13px;font-weight:600;color:#007bff;margin-top:4px}
|
||||||
.label-toggle { display: flex; gap: 12px; margin: 20px 0; }
|
.label-row{display:flex;gap:12px;margin:14px 0 10px}
|
||||||
.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{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; }
|
.label-btn:hover{border-color:#007bff}
|
||||||
.label-btn.selected-true { border-color: #28a745; background: #d4edda; color: #155724; }
|
.sel-true{border-color:#28a745!important;background:#d4edda;color:#155724}
|
||||||
.label-btn.selected-false { border-color: #dc3545; background: #f8d7da; color: #721c24; }
|
.sel-false{border-color:#dc3545!important;background:#f8d7da;color:#721c24}
|
||||||
.notes { width: 100%; padding: 12px; border: 1px solid #dee2e6; border-radius: 6px; font-size: 14px; resize: vertical; min-height: 80px; }
|
textarea{width:100%;padding:10px;border:1px solid #dee2e6;border-radius:6px;font-size:13px;resize:vertical;min-height:72px;margin-top:8px}
|
||||||
.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{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-btn:hover { background: #0056b3; }
|
.submit:hover{background:#0056b3}
|
||||||
.submit-btn:disabled { background: #ccc; cursor: not-allowed; }
|
.submit:disabled{background:#ccc;cursor:not-allowed}
|
||||||
|
.already{color:#28a745;font-weight:600;margin-top:8px}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>VideoDetect</h1>
|
<h1>VideoDetect</h1>
|
||||||
<a href="/">« Back to Queue</a>
|
<a href="/">« Back to queue</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Video Information</h2>
|
<h2>Video information</h2>
|
||||||
<div class="info-grid">
|
<div class="grid">
|
||||||
<div class="info-item">
|
<div><div class="info-label">Video ID</div><div class="info-value">{{ video.video_id }}</div></div>
|
||||||
<div class="info-label">Video ID</div>
|
<div><div class="info-label">Confidence</div><div class="info-value">{{ "%.4f"|format(video.confidence_score) }}</div></div>
|
||||||
<div class="info-value">{{ video.id }}</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 '—' }}</div></div>
|
||||||
|
<div><div class="info-label">Frames</div><div class="info-value">{{ video.frame_count or '—' }}</div></div>
|
||||||
|
<div><div class="info-label">Duration</div><div class="info-value">{{ "%.1f"|format(video.video_duration) if video.video_duration else '—' }}s</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div style="margin-top:10px;font-size:12px;color:#888;word-break:break-all">{{ video.file_path }}</div>
|
||||||
<div class="info-label">Confidence</div>
|
|
||||||
<div class="info-value">{{ "%.2f"|format(video.confidence_score) }}</div>
|
|
||||||
</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">
|
<div class="card">
|
||||||
<h2>Video</h2>
|
<h2>Video</h2>
|
||||||
<div class="video-player">
|
<div class="video-wrap">
|
||||||
<video controls style="width: 100%; height: 100%;">
|
<video controls preload="metadata">
|
||||||
<source src="{{ video.file_path }}" type="video/mp4">
|
<source src="/video/{{ video.video_id }}" type="video/mp4">
|
||||||
Video playback not supported in this browser.
|
Video playback not supported.
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if video.contributing_frames %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Contributing Frames (Top {{ video.top_k_frames|default(5) }})</h2>
|
<h2>Top contributing frames (by confidence)</h2>
|
||||||
<div class="frames-grid">
|
<div class="frames">
|
||||||
{% for frame in video.contributing_frames %}
|
{% for f in video.contributing_frames %}
|
||||||
<div class="frame-thumb">
|
<div class="frame">
|
||||||
<span>⏱ {{ "%.0f"|format(frame.timestamp) }}s</span>
|
<div>⏱ {{ f.timestamp }}s</div>
|
||||||
<span class="confidence">{{ "%.2f"|format(frame.confidence) }}</span>
|
<div class="conf">{{ "%.3f"|format(f.confidence) }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Annotate</h2>
|
<h2>Annotate</h2>
|
||||||
<p style="margin-bottom: 12px; color: #666;">Does this video contain the target demographic?</p>
|
{% if video.annotated %}
|
||||||
<div class="label-toggle">
|
<p class="already">✓ Already annotated: <strong>{{ 'MATCH' if video.ground_truth else 'NO MATCH' }}</strong></p>
|
||||||
<button class="label-btn {% if video.ground_truth == True %}selected-true{% endif %}" onclick="selectLabel(true)">✓ Yes (MATCH)</button>
|
{% else %}
|
||||||
<button class="label-btn {% if video.ground_truth == False %}selected-false{% endif %}" onclick="selectLabel(false)">✗ No (NO_MATCH)</button>
|
<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)">✓ Yes (MATCH)</button>
|
||||||
|
<button class="label-btn" id="btn-no" onclick="selectLabel(false)">✗ No (NO MATCH)</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea class="notes" id="notes" placeholder="Optional notes...">{{ video.notes or '' }}</textarea>
|
<textarea id="notes" placeholder="Optional notes…"></textarea>
|
||||||
<button class="submit-btn" id="submitBtn" onclick="submitLabel()" disabled>Submit Label</button>
|
<button class="submit" id="submitBtn" disabled onclick="submitLabel()">Submit label</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
<script>
|
let selectedLabel = null;
|
||||||
let selectedLabel = null;
|
function selectLabel(val) {
|
||||||
|
selectedLabel = val;
|
||||||
function selectLabel(value) {
|
document.getElementById('btn-yes').className = 'label-btn' + (val ? ' sel-true' : '');
|
||||||
selectedLabel = value;
|
document.getElementById('btn-no').className = 'label-btn' + (!val ? ' sel-false' : '');
|
||||||
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;
|
document.getElementById('submitBtn').disabled = false;
|
||||||
}
|
}
|
||||||
|
async function submitLabel() {
|
||||||
async function submitLabel() {
|
const btn = document.getElementById('submitBtn');
|
||||||
const videoId = {{ video.id }};
|
btn.disabled = true; btn.textContent = 'Saving\u2026';
|
||||||
const notes = document.getElementById('notes').value;
|
const res = await fetch('/api/review/{{ video.video_id }}/label', {
|
||||||
|
|
||||||
const response = await fetch(`/api/review/${videoId}/label`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({ground_truth: selectedLabel, notes: notes})
|
body: JSON.stringify({ground_truth: selectedLabel, notes: document.getElementById('notes').value})
|
||||||
});
|
});
|
||||||
|
if (res.ok) { window.location.href = '/'; }
|
||||||
if (response.ok) {
|
else { btn.disabled = false; btn.textContent = 'Submit label'; alert('Error saving label.'); }
|
||||||
alert('Label saved successfully!');
|
}
|
||||||
window.location.href = '/';
|
</script>
|
||||||
} else {
|
</body></html>
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user