Files
VideoDetect/ui/app.py
T
2026-09-15 13:12:21 -04:00

407 lines
17 KiB
Python

"""
VideoDetect Review UI — Flask Application
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 json
import os
import requests
from flask import Flask, jsonify, render_template_string, request, send_file
app = Flask(__name__)
API_BASE = os.environ.get("API_BASE", "http://api:3000/api/v1")
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()
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()
# ---------------------------------------------------------------------------
# HTML routes
# ---------------------------------------------------------------------------
@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))
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
tasks = data.get("tasks", [])
total = data.get("total", 0)
total_pages = max(1, (total + per_page - 1) // per_page)
# 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
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,
tasks=enriched,
page=page,
per_page=per_page,
total=total,
total_pages=total_pages,
)
@app.route("/review/<int:task_id>")
def review_task(task_id):
"""Detail view: show task info + embedded video player."""
try:
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):
video_obj["prev_results"] = {}
return render_template_string(_REVIEW_TEMPLATE, video=video_obj)
# ---------------------------------------------------------------------------
# API routes (Flask → NFS file serving + JSON wrappers)
# ---------------------------------------------------------------------------
@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
if not file_path or file_path == "unknown":
return "Video file path unknown", 404
# 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")
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, conditional=True)
except FileNotFoundError:
continue
return "Video file not found on disk", 404
@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 {}
verdict = data.get("verdict") # "false_positive", "correct", or other string
if not verdict:
return jsonify({"error": "verdict is required"}), 400
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/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():
"""Return a summary of REVIEW tasks (via two API calls)."""
try:
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 {}
return jsonify({
"total_in_queue": pending.get("total", 0),
"completed": completed.get("total", 0),
"failed": failed.get("total", 0),
})
except requests.RequestException as exc:
return jsonify({"error": f"API error: {exc}"}), 503
# ---------------------------------------------------------------------------
# Templates
# ---------------------------------------------------------------------------
_INDEX_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VideoDetect — Review Queue</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 .meta{font-size:13px;opacity:.75}
.container{max-width:1280px;margin:30px auto;padding:0 20px}
.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}
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;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}
.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}
</style>
</head>
<body>
<div class="header">
<h1>VideoDetect</h1>
<span class="meta">Review Queue &mdash; {{ total }} pending &mdash; Page {{ page }}/{{ total_pages }}</span>
</div>
<div class="container">
<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>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 t in tasks %}
<tr>
<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 '&mdash;' }}</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 '&mdash;' }}</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 }}">&laquo; Prev</a>{% endif %}
<span class="cur">{{ page }}</span>
{% if page < total_pages %}<a href="?page={{ page+1 }}">Next &raquo;</a>{% endif %}
</div>
{% else %}
<div class="empty"><h2>No videos pending review</h2><p>All REVIEW tasks have been completed.</p></div>
{% endif %}
</div>
</body></html>
"""
_REVIEW_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VideoDetect — Review #{{ video.id }}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5;color:#333}
.header{background:#1a1a2e;color:#fff;padding:18px 40px;display:flex;justify-content:space-between;align-items:center}
.header h1{font-size:22px}.header a{color:#adb5bd;text-decoration:none}.header a:hover{color:#fff}
.container{max-width:960px;margin:30px auto;padding:0 20px}
.card{background:#fff;border-radius:8px;padding:22px;margin-bottom:18px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
.card h2{font-size:16px;margin-bottom:14px;color:#1a1a2e}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.info-label{font-size:11px;text-transform:uppercase;color:#888;margin-bottom:3px}
.info-value{font-size:15px;font-weight:600}
.video-wrap{width:100%;background:#000;border-radius:6px;overflow:hidden}
video{width:100%;display:block}
.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>
<div class="header">
<h1>VideoDetect</h1>
<a href="/">&laquo; 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>Task information</h2>
<div class="grid">
<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 '&mdash;' }}</div></div>
<div><div class="info-label">Duration</div><div class="info-value">{{ "%.1f"|format(video.video_duration) if video.video_duration else '&mdash;' }}s</div></div>
<div><div 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" id="review-video">
<source src="/video/{{ video.video_id }}">
Video playback not supported.
</video>
</div>
</div>
<div class="card">
<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>
</div>
<script>
async function submitVerdict(verdict) {
const resp = await fetch(`/api/review/submit/{{ video.id }}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({verdict})
});
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>
"""
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if __name__ == "__main__":
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)