407 lines
17 KiB
Python
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, mimetype="video/mp4", 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 — {{ total }} pending — 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 '—' }}</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 }}">« Prev</a>{% endif %}
|
|
<span class="cur">{{ page }}</span>
|
|
{% 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 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="/">« 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 '—' }}</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" id="review-video">
|
|
<source src="/video/{{ video.video_id }}" type="video/mp4">
|
|
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)
|