371 lines
15 KiB
Python
371 lines
15 KiB
Python
"""
|
|
VideoDetect Review UI - Flask Application
|
|
|
|
Lightweight web interface for annotating low-confidence videos.
|
|
Accessible via http://<server-ip>:5000
|
|
No authentication or SSL (per TC-06).
|
|
"""
|
|
|
|
import os
|
|
from flask import Flask, jsonify, request, render_template_string
|
|
|
|
app = Flask(__name__)
|
|
|
|
# In-memory storage for demo (replace with DB queries in production)
|
|
review_queue = []
|
|
|
|
|
|
# ------ Routes ------
|
|
|
|
@app.route("/")
|
|
def index():
|
|
"""Queue page - list of videos awaiting review."""
|
|
page = request.args.get("page", 1, type=int)
|
|
per_page = 20
|
|
|
|
# Filter unannotated
|
|
unannotated = [v for v in review_queue if not v.get("annotated")]
|
|
total = len(unannotated)
|
|
start = (page - 1) * per_page
|
|
end = start + per_page
|
|
videos = unannotated[start:end]
|
|
|
|
return render_template_string(
|
|
INDEX_TEMPLATE,
|
|
videos=videos,
|
|
page=page,
|
|
per_page=per_page,
|
|
total=total,
|
|
total_pages=(total + per_page - 1) // per_page,
|
|
)
|
|
|
|
|
|
@app.route("/api/review/queue")
|
|
def api_queue():
|
|
"""API: List videos in review queue."""
|
|
page = request.args.get("page", 1, type=int)
|
|
per_page = 20
|
|
|
|
unannotated = [v for v in review_queue if not v.get("annotated")]
|
|
total = len(unannotated)
|
|
start = (page - 1) * per_page
|
|
end = start + per_page
|
|
|
|
return jsonify({
|
|
"videos": unannotated[start:end],
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": per_page,
|
|
})
|
|
|
|
|
|
@app.route("/api/review/<int:video_id>")
|
|
def api_video_details(video_id):
|
|
"""API: Get video details for annotation."""
|
|
video = next((v for v in review_queue if v["id"] == video_id), None)
|
|
if not video:
|
|
return jsonify({"error": "Video not found"}), 404
|
|
return jsonify(video)
|
|
|
|
|
|
@app.route("/api/review/<int:video_id>/label", methods=["POST"])
|
|
def api_label(video_id):
|
|
"""API: Submit annotation for a video."""
|
|
data = request.get_json()
|
|
ground_truth = data.get("ground_truth")
|
|
notes = data.get("notes", "")
|
|
|
|
if ground_truth is None:
|
|
return jsonify({"error": "ground_truth is required"}), 400
|
|
|
|
video = next((v for v in review_queue if v["id"] == video_id), None)
|
|
if not video:
|
|
return jsonify({"error": "Video not found"}), 404
|
|
|
|
video["annotated"] = True
|
|
video["ground_truth"] = ground_truth
|
|
video["notes"] = notes
|
|
video["annotated_at"] = "2026-08-03T10:30:00Z"
|
|
|
|
return jsonify({"status": "annotated", "video_id": video_id, "ground_truth": ground_truth})
|
|
|
|
|
|
@app.route("/api/review/stats")
|
|
def api_stats():
|
|
"""API: Review queue statistics."""
|
|
total = len(review_queue)
|
|
annotated = sum(1 for v in review_queue if v.get("annotated"))
|
|
unannotated = total - annotated
|
|
avg_conf = sum(v.get("confidence_score", 0) for v in review_queue) / max(total, 1)
|
|
|
|
dist = {"0.0-0.2": 0, "0.2-0.4": 0, "0.4-0.6": 0, "0.6-0.8": 0, "0.8-1.0": 0}
|
|
for v in review_queue:
|
|
c = v.get("confidence_score", 0)
|
|
if c < 0.2:
|
|
dist["0.0-0.2"] += 1
|
|
elif c < 0.4:
|
|
dist["0.2-0.4"] += 1
|
|
elif c < 0.6:
|
|
dist["0.4-0.6"] += 1
|
|
elif c < 0.8:
|
|
dist["0.6-0.8"] += 1
|
|
else:
|
|
dist["0.8-1.0"] += 1
|
|
|
|
return jsonify({
|
|
"total_in_queue": total,
|
|
"annotated": annotated,
|
|
"unannotated": unannotated,
|
|
"avg_confidence": round(avg_conf, 4),
|
|
"confidence_distribution": dist,
|
|
})
|
|
|
|
|
|
# ------ HTML 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: white; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
|
.header h1 { font-size: 24px; }
|
|
.header .stats { font-size: 14px; opacity: 0.8; }
|
|
.container { max-width: 1200px; margin: 40px 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); }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
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; }
|
|
td { padding: 14px 16px; border-bottom: 1px solid #e9ecef; font-size: 14px; }
|
|
tr:hover { background: #f8f9fa; }
|
|
.badge { display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
|
.badge-match { background: #d4edda; color: #155724; }
|
|
.badge-review { background: #fff3cd; color: #856404; }
|
|
.badge-skip { background: #f8d7da; color: #721c24; }
|
|
.btn { display: inline-block; padding: 8px 16px; background: #007bff; color: white; text-decoration: none; border-radius: 6px; font-size: 14px; border: none; cursor: pointer; }
|
|
.btn:hover { background: #0056b3; }
|
|
.pagination { display: flex; justify-content: center; gap: 8px; margin-top: 24px; }
|
|
.pagination a, .pagination span { padding: 8px 14px; border-radius: 6px; text-decoration: none; font-size: 14px; }
|
|
.pagination a { background: white; color: #007bff; border: 1px solid #dee2e6; }
|
|
.pagination a:hover { background: #e9ecef; }
|
|
.pagination .current { background: #007bff; color: white; }
|
|
.empty { text-align: center; padding: 60px 20px; color: #999; }
|
|
.confidence { font-weight: 600; }
|
|
.confidence-high { color: #28a745; }
|
|
.confidence-mid { color: #ffc107; }
|
|
.confidence-low { color: #dc3545; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>VideoDetect</h1>
|
|
<div class="stats">
|
|
Review Queue: {{ total }} videos | Page {{ page }} of {{ total_pages }}
|
|
</div>
|
|
</div>
|
|
<div class="container">
|
|
{% if videos %}
|
|
<div class="table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Video ID</th>
|
|
<th>File Path</th>
|
|
<th>Confidence</th>
|
|
<th>Routing</th>
|
|
<th>Model</th>
|
|
<th>Frames</th>
|
|
<th>Added</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for video in videos %}
|
|
<tr>
|
|
<td>{{ video.id }}</td>
|
|
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ video.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 %}">
|
|
{{ "%.2f"|format(video.confidence_score) }}
|
|
</td>
|
|
<td><span class="badge badge-{{ video.routing_decision|lower }}">{{ video.routing_decision }}</span></td>
|
|
<td>{{ video.model_version }}</td>
|
|
<td>{{ video.frame_count }}</td>
|
|
<td>{{ video.created_at[:10] if video.created_at else 'N/A' }}</td>
|
|
<td><a href="/review/{{ video.id }}" class="btn">Annotate</a></td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="pagination">
|
|
{% if page > 1 %}
|
|
<a href="?page={{ page - 1 }}">« Prev</a>
|
|
{% endif %}
|
|
<span class="current">{{ page }}</span>
|
|
{% if page < total_pages %}
|
|
<a href="?page={{ page + 1 }}">Next »</a>
|
|
{% endif %}
|
|
</div>
|
|
{% else %}
|
|
<div class="empty">
|
|
<h2>No videos in review queue</h2>
|
|
<p>All videos have been processed and classified.</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: white; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
|
.header h1 { font-size: 24px; }
|
|
.header a { color: #adb5bd; text-decoration: none; }
|
|
.header a:hover { color: white; }
|
|
.container { max-width: 1000px; margin: 40px 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 h2 { font-size: 18px; margin-bottom: 16px; color: #1a1a2e; }
|
|
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
|
.info-item { }
|
|
.info-label { font-size: 12px; text-transform: uppercase; color: #666; margin-bottom: 4px; }
|
|
.info-value { font-size: 16px; font-weight: 600; }
|
|
.video-player { width: 100%; aspect-ratio: 16/9; background: #000; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: white; }
|
|
.frames-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }
|
|
.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-thumb .confidence { font-size: 14px; font-weight: 600; color: #007bff; margin-top: 4px; }
|
|
.label-toggle { display: flex; gap: 12px; margin: 20px 0; }
|
|
.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:hover { border-color: #007bff; }
|
|
.label-btn.selected-true { border-color: #28a745; background: #d4edda; color: #155724; }
|
|
.label-btn.selected-false { border-color: #dc3545; background: #f8d7da; color: #721c24; }
|
|
.notes { width: 100%; padding: 12px; border: 1px solid #dee2e6; border-radius: 6px; font-size: 14px; resize: vertical; min-height: 80px; }
|
|
.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-btn:hover { background: #0056b3; }
|
|
.submit-btn:disabled { background: #ccc; cursor: not-allowed; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>VideoDetect</h1>
|
|
<a href="/">« Back to Queue</a>
|
|
</div>
|
|
<div class="container">
|
|
<div class="card">
|
|
<h2>Video Information</h2>
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<div class="info-label">Video ID</div>
|
|
<div class="info-value">{{ video.id }}</div>
|
|
</div>
|
|
<div class="info-item">
|
|
<div class="info-label">Confidence</div>
|
|
<div class="info-value">{{ "%.2f"|format(video.confidence_score) }}</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">
|
|
<h2>Video</h2>
|
|
<div class="video-player">
|
|
<video controls style="width: 100%; height: 100%;">
|
|
<source src="{{ video.file_path }}" type="video/mp4">
|
|
Video playback not supported in this browser.
|
|
</video>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Contributing Frames (Top {{ video.top_k_frames|default(5) }})</h2>
|
|
<div class="frames-grid">
|
|
{% for frame in video.contributing_frames %}
|
|
<div class="frame-thumb">
|
|
<span>⏱ {{ "%.0f"|format(frame.timestamp) }}s</span>
|
|
<span class="confidence">{{ "%.2f"|format(frame.confidence) }}</span>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Annotate</h2>
|
|
<p style="margin-bottom: 12px; color: #666;">Does this video contain the target demographic?</p>
|
|
<div class="label-toggle">
|
|
<button class="label-btn {% if video.ground_truth == True %}selected-true{% endif %}" onclick="selectLabel(true)">✓ Yes (MATCH)</button>
|
|
<button class="label-btn {% if video.ground_truth == False %}selected-false{% endif %}" onclick="selectLabel(false)">✗ No (NO_MATCH)</button>
|
|
</div>
|
|
<textarea class="notes" id="notes" placeholder="Optional notes...">{{ video.notes or '' }}</textarea>
|
|
<button class="submit-btn" id="submitBtn" onclick="submitLabel()" disabled>Submit Label</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let selectedLabel = null;
|
|
|
|
function selectLabel(value) {
|
|
selectedLabel = value;
|
|
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;
|
|
}
|
|
|
|
async function submitLabel() {
|
|
const videoId = {{ video.id }};
|
|
const notes = document.getElementById('notes').value;
|
|
|
|
const response = await fetch(`/api/review/${videoId}/label`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ground_truth: selectedLabel, notes: notes})
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert('Label saved successfully!');
|
|
window.location.href = '/';
|
|
} else {
|
|
alert('Error saving label.');
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
# ------ Entry Point ------
|
|
|
|
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)
|