Central Verification Dashboard

This commit is contained in:
2026-06-16 14:32:21 +05:30
commit 7cb85d5ebc
37 changed files with 9665 additions and 0 deletions

254
server/src/ingest.rs Normal file
View File

@@ -0,0 +1,254 @@
use crate::models::ExportDoc;
use chrono::{DateTime, NaiveDateTime, Utc};
use sqlx::PgPool;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
const VIDEO_EXTS: &[&str] = &["mp4", "mkv", "avi", "mov", "m4v", "webm"];
fn is_video(p: &Path) -> bool {
p.extension()
.and_then(|e| e.to_str())
.map(|e| VIDEO_EXTS.contains(&e.to_lowercase().as_str()))
.unwrap_or(false)
}
/// Find the sibling export-JSON for a video. Supports `<file>_annotations.json`
/// (video-annotator's default export name), `<file>.json`, and `<stem>.json`.
fn sibling_json(video: &Path) -> Option<PathBuf> {
let dir = video.parent()?;
let file_name = video.file_name()?.to_str()?;
let stem = video.file_stem()?.to_str()?;
let candidates = [
format!("{file_name}_annotations.json"),
format!("{file_name}.json"),
format!("{stem}.json"),
format!("{stem}_annotations.json"),
];
for c in candidates {
let p = dir.join(c);
if p.is_file() {
return Some(p);
}
}
None
}
fn parse_ts(s: &str) -> Option<DateTime<Utc>> {
if s.is_empty() {
return None;
}
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Some(dt.with_timezone(&Utc));
}
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.ok()
.map(|n| n.and_utc())
}
/// Scan `dir`, upsert every video (and its annotations) into the project.
/// Idempotent: re-running updates rows; never downgrades a 'verified' video.
pub async fn ingest_project(db: &PgPool, project_id: i32, dir: &Path) -> anyhow::Result<usize> {
if !dir.is_dir() {
anyhow::bail!("ingest dir not found: {}", dir.display());
}
let mut count = 0usize;
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !entry.file_type().is_file() || !is_video(path) {
continue;
}
let rel_path = path
.strip_prefix(dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
// Never clobber a verified push (Phase 3): a worker's pushed annotations +
// completer/verify-time are authoritative, so skip re-ingesting this video.
let existing_status: Option<String> =
sqlx::query_scalar("SELECT status FROM videos WHERE project_id=$1 AND rel_path=$2")
.bind(project_id)
.bind(&rel_path)
.fetch_optional(db)
.await?;
if existing_status.as_deref() == Some("verified") {
tracing::debug!("skip re-ingest of verified video: {rel_path}");
continue;
}
let json_path = sibling_json(path);
let has_json = json_path.is_some();
// Parse the sibling JSON if present.
let doc: Option<ExportDoc> = json_path.as_ref().and_then(|jp| {
std::fs::read_to_string(jp)
.ok()
.and_then(|t| serde_json::from_str::<ExportDoc>(&t).ok())
});
let (width, height, fps, frame_count, time_ms) = match doc.as_ref().and_then(|d| d.video.as_ref()) {
Some(v) => (
v.width.map(|x| x as i32),
v.height.map(|x| x as i32),
v.fps,
v.frame_count.map(|x| x as i32),
v.annotation_time_ms,
),
None => (None, None, None, None, 0i64),
};
// Aggregate annotation_count, primary annotator, annotated_at.
let mut ann_count: i32 = 0;
let mut by_freq: HashMap<String, i32> = HashMap::new();
let mut latest: Option<DateTime<Utc>> = None;
if let Some(d) = doc.as_ref() {
for a in &d.fixed_annotations {
ann_count += 1;
if !a.annotated_by.is_empty() {
*by_freq.entry(a.annotated_by.clone()).or_default() += 1;
}
if let Some(t) = parse_ts(&a.created_at) {
latest = Some(latest.map_or(t, |l| l.max(t)));
}
}
for a in &d.range_annotations {
ann_count += 1;
if !a.annotated_by.is_empty() {
*by_freq.entry(a.annotated_by.clone()).or_default() += 1;
}
if let Some(t) = parse_ts(&a.created_at) {
latest = Some(latest.map_or(t, |l| l.max(t)));
}
}
}
let primary_annotator = by_freq
.into_iter()
.max_by_key(|(_, c)| *c)
.map(|(u, _)| u)
.unwrap_or_default();
let status = if has_json && ann_count > 0 { "annotated" } else { "pending" };
let raw_json: Option<serde_json::Value> = json_path.as_ref().and_then(|jp| {
std::fs::read_to_string(jp).ok().and_then(|t| serde_json::from_str(&t).ok())
});
// Upsert the video. Keep 'verified' status sticky (Phase 3 pushes).
let video_id: i32 = sqlx::query_scalar(
r#"
INSERT INTO videos
(project_id, file_name, rel_path, has_json, width, height, fps, frame_count,
annotation_count, annotation_time_ms, primary_annotator, status, annotated_at, raw_json,
imported_count)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
ON CONFLICT (project_id, rel_path) DO UPDATE SET
file_name=EXCLUDED.file_name,
has_json=EXCLUDED.has_json,
width=EXCLUDED.width, height=EXCLUDED.height, fps=EXCLUDED.fps,
frame_count=EXCLUDED.frame_count,
annotation_count=EXCLUDED.annotation_count,
annotation_time_ms=EXCLUDED.annotation_time_ms,
primary_annotator=EXCLUDED.primary_annotator,
status = CASE WHEN videos.status='verified' THEN videos.status ELSE EXCLUDED.status END,
annotated_at=EXCLUDED.annotated_at,
raw_json=EXCLUDED.raw_json,
-- imported baseline = the NAS-ingested count (skipped for verified videos
-- via the early continue above, so a pushed pass's baseline is preserved).
imported_count=EXCLUDED.imported_count,
ingested_at=now()
RETURNING id
"#,
)
.bind(project_id)
.bind(&file_name)
.bind(&rel_path)
.bind(has_json)
.bind(width)
.bind(height)
.bind(fps)
.bind(frame_count)
.bind(ann_count)
.bind(time_ms)
.bind(&primary_annotator)
.bind(status)
.bind(latest)
.bind(raw_json)
.bind(ann_count)
.fetch_one(db)
.await?;
// Replace detailed annotation rows for this video.
if let Some(d) = doc.as_ref() {
replace_annotations(db, video_id, d).await?;
} else {
sqlx::query("DELETE FROM annotations WHERE video_id=$1")
.bind(video_id)
.execute(db)
.await?;
}
count += 1;
}
Ok(count)
}
/// Replace all annotation rows for a video with the contents of an export doc.
/// Shared by ingest (NAS JSON) and the Phase 3 worker push. Returns the row count
/// written (each range annotation contributes 12 rows: start, and end if present).
pub async fn replace_annotations(
db: &PgPool,
video_id: i32,
d: &ExportDoc,
) -> anyhow::Result<i32> {
sqlx::query("DELETE FROM annotations WHERE video_id=$1")
.bind(video_id)
.execute(db)
.await?;
let mut written = 0i32;
for a in &d.fixed_annotations {
insert_ann(db, video_id, a.frame_number, &a.label, &a.side, &a.shape_type,
&a.vertices, &a.annotated_by, &a.review_status, &a.remark, &a.subclass, &a.created_at).await?;
written += 1;
}
for a in &d.range_annotations {
insert_ann(db, video_id, a.start_frame, &a.label, &a.side, &a.shape_type,
&a.start_vertices, &a.annotated_by, &a.review_status, &a.remark, &a.subclass, &a.created_at).await?;
written += 1;
if let (Some(ef), Some(ev)) = (a.end_frame, a.end_vertices.as_ref()) {
insert_ann(db, video_id, ef, &a.label, &a.side, &a.shape_type,
ev, &a.annotated_by, &a.review_status, &a.remark, &a.subclass, &a.created_at).await?;
written += 1;
}
}
Ok(written)
}
#[allow(clippy::too_many_arguments)]
async fn insert_ann(
db: &PgPool, video_id: i32, frame: i64, label: &str, side: &str, shape: &str,
vertices: &serde_json::Value, by: &str, review: &str, remark: &str, subclass: &str, created_at: &str,
) -> anyhow::Result<()> {
sqlx::query(
r#"INSERT INTO annotations
(video_id, frame_number, label, side, shape_type, vertices, annotated_by, review_status, remark, subclass, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)"#,
)
.bind(video_id)
.bind(frame as i32)
.bind(label)
.bind(side)
.bind(if shape.is_empty() { "bbox" } else { shape })
.bind(vertices)
.bind(by)
.bind(if review.is_empty() { "none" } else { review })
.bind(remark)
.bind(subclass)
.bind(created_at)
.execute(db)
.await?;
Ok(())
}