From 37f8aea4aa8eea9e47f0dfd531fca7eed9b5ec69 Mon Sep 17 00:00:00 2001 From: Accusys Date: Fri, 22 May 2026 04:50:07 +0800 Subject: [PATCH] feat: GET file/:uuid/trace/:tid/representative-face endpoint --- src/api/trace_agent_api.rs | 182 +++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/src/api/trace_agent_api.rs b/src/api/trace_agent_api.rs index 193877a..21536f6 100644 --- a/src/api/trace_agent_api.rs +++ b/src/api/trace_agent_api.rs @@ -16,6 +16,10 @@ pub fn trace_agent_routes() -> Router { "/api/v1/file/:file_uuid/trace/:trace_id/faces", get(list_trace_faces), ) + .route( + "/api/v1/file/:file_uuid/trace/:trace_id/representative-face", + get(get_representative_face), + ) } #[derive(Debug, Deserialize)] @@ -328,3 +332,181 @@ async fn list_trace_faces( faces, })) } + +#[derive(Debug, Serialize)] +struct RepFaceBbox { + x: i32, + y: i32, + width: i32, + height: i32, +} + +#[derive(Debug, Serialize)] +struct RepFaceResult { + frame_number: i64, + timestamp_secs: f64, + bbox: RepFaceBbox, + confidence: f64, + quality_score: f64, + blur_score: f64, +} + +#[derive(Debug, Serialize)] +struct RepFaceResponse { + success: bool, + file_uuid: String, + trace_id: i32, + face_count: i64, + representative: RepFaceResult, +} + +async fn get_representative_face( + State(state): State, + Path((file_uuid, trace_id)): Path<(String, i32)>, +) -> Result, (StatusCode, Json)> { + use crate::core::db::schema; + + let fd_table = schema::table_name("face_detections"); + let video_table = schema::table_name("videos"); + + // Get fps for timestamp calculation + let fps: f64 = sqlx::query_scalar(&format!( + "SELECT COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1", + video_table + )) + .bind(&file_uuid) + .fetch_optional(state.db.pool()) + .await + .map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))) + })? + .unwrap_or(25.0); + + // Get face count for this trace + let face_count: (i64,) = sqlx::query_as(&format!( + "SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id = $2", + fd_table + )) + .bind(&file_uuid) + .bind(trace_id) + .fetch_one(state.db.pool()) + .await + .map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))) + })?; + + // Stage 1: SQL - top 10 candidates by area * confidence + #[derive(Debug)] + struct FaceCandidate { + frame: i64, + x: i32, + y: i32, + w: i32, + h: i32, + conf: f64, + score: f64, + } + + let rows = sqlx::query_as::<_, (i64, i32, i32, i32, i32, f64)>( + &format!( + "SELECT frame_number::bigint, x, y, width, height, confidence::float8 \ + FROM {} WHERE file_uuid = $1 AND trace_id = $2 AND confidence > 0.7 \ + AND ((metadata->>'qc_ok')::boolean IS NULL OR (metadata->>'qc_ok')::boolean = true) \ + ORDER BY (width::float8 * height::float8) * confidence::float8 DESC \ + LIMIT 10", + fd_table + ) + ) + .bind(&file_uuid) + .bind(trace_id) + .fetch_all(state.db.pool()) + .await + .map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))) + })?; + + if rows.is_empty() { + return Err((StatusCode::NOT_FOUND, Json(serde_json::json!({ + "error": "No suitable face found for this trace" + })))); + } + + let candidates: Vec = rows + .into_iter() + .map(|(frame, x, y, w, h, conf)| { + let score = (w as f64 * h as f64) * conf; + FaceCandidate { frame, x, y, w, h, conf, score } + }) + .collect(); + + // Stage 2: FFmpeg blurdetect on candidates + let video_path = sqlx::query_scalar::<_, String>(&format!( + "SELECT file_path FROM {} WHERE file_uuid = $1", + video_table + )) + .bind(&file_uuid) + .fetch_optional(state.db.pool()) + .await + .map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))) + })? + .ok_or_else(|| { + (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Video not found"}))) + })?; + + let mut best = candidates[0].frame; + let mut best_blur = f64::MAX; + let mut best_idx = 0usize; + + for (i, c) in candidates.iter().enumerate() { + let seek = c.frame as f64 / fps; + let output = tokio::process::Command::new("ffmpeg") + .args([ + "-ss", &format!("{:.2}", seek), + "-i", &video_path, + "-vframes", "1", + "-vf", &format!("crop={}:{}:{}:{},blurdetect", c.w, c.h, c.x, c.y), + "-f", "null", + "-", + ]) + .output() + .await; + + if let Ok(o) = output { + let stderr = String::from_utf8_lossy(&o.stderr); + for line in stderr.lines() { + if let Some(blur_str) = line.split("blur mean: ").nth(1) { + if let Ok(blur) = blur_str.trim().parse::() { + if blur < best_blur { + best_blur = blur; + best = c.frame; + best_idx = i; + } + } + } + } + } + } + + let chosen = &candidates[best_idx]; + + Ok(Json(RepFaceResponse { + success: true, + file_uuid, + trace_id, + face_count: face_count.0, + representative: RepFaceResult { + frame_number: chosen.frame, + timestamp_secs: chosen.frame as f64 / fps, + bbox: RepFaceBbox { + x: chosen.x, + y: chosen.y, + width: chosen.w, + height: chosen.h, + }, + confidence: chosen.conf, + quality_score: chosen.score, + blur_score: if best_blur == f64::MAX { 0.0 } else { best_blur }, + }, + })) +}