feat: GET file/:uuid/trace/:tid/representative-face endpoint
This commit is contained in:
@@ -16,6 +16,10 @@ pub fn trace_agent_routes() -> Router<crate::api::types::AppState> {
|
||||
"/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<crate::api::types::AppState>,
|
||||
Path((file_uuid, trace_id)): Path<(String, i32)>,
|
||||
) -> Result<Json<RepFaceResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
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<FaceCandidate> = 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::<f64>() {
|
||||
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 },
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user