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