From 2d3017d3c1290dbfeab07b9f12563b95df4cc697 Mon Sep 17 00:00:00 2001 From: Accusys Date: Fri, 22 May 2026 05:34:25 +0800 Subject: [PATCH] feat: GET file/:uuid/identities/:a/co-occur-with/:b endpoint --- src/api/trace_agent_api.rs | 193 +++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/src/api/trace_agent_api.rs b/src/api/trace_agent_api.rs index 67cbf91..269fb42 100644 --- a/src/api/trace_agent_api.rs +++ b/src/api/trace_agent_api.rs @@ -25,6 +25,10 @@ pub fn trace_agent_routes() -> Router { "/api/v1/file/:file_uuid/trace/:trace_id/thumbnail", get(get_trace_thumbnail), ) + .route( + "/api/v1/file/:file_uuid/identities/:identity_uuid_a/co-occur-with/:identity_uuid_b", + get(get_cooccurrence), + ) } #[derive(Debug, Deserialize)] @@ -542,3 +546,192 @@ async fn get_trace_thumbnail( .body(Body::from(bytes)) .unwrap()) } + +#[derive(Debug, Serialize)] +struct CoOccurIdentity { + identity_uuid: String, + name: String, + trace_id: i32, +} + +#[derive(Debug, Serialize)] +struct CoOccurRepFace { + frame_number: i64, + bbox: RepFaceBbox, + confidence: f64, + thumbnail_url: String, +} + +#[derive(Debug, Serialize)] +struct CoOccurrence { + frame_number: i64, + timestamp_secs: f64, + total_cooccurrence_frames: i64, + representative_face_a: Option, + representative_face_b: Option, +} + +#[derive(Debug, Serialize)] +struct CoOccurResponse { + success: bool, + file_uuid: String, + identity_a: CoOccurIdentity, + identity_b: CoOccurIdentity, + first_cooccurrence: CoOccurrence, +} + +async fn get_cooccurrence( + State(state): State, + Path((file_uuid, identity_uuid_a, identity_uuid_b)): Path<(String, String, String)>, +) -> Result, (StatusCode, Json)> { + use crate::core::db::schema; + let id_table = schema::table_name("identities"); + let fd_table = schema::table_name("face_detections"); + + // Stage 1: Get identity names and IDs + let id_a = sqlx::query_as::<_, (i32, String)>(&format!( + "SELECT id, name FROM {} WHERE uuid::text = $1 OR REPLACE(uuid::text, '-', '') = $1", + id_table + )) + .bind(&identity_uuid_a) + .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": "Identity A not found"}))) + })?; + + let id_b = sqlx::query_as::<_, (i32, String)>(&format!( + "SELECT id, name FROM {} WHERE uuid::text = $1 OR REPLACE(uuid::text, '-', '') = $1", + id_table + )) + .bind(&identity_uuid_b) + .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": "Identity B not found"}))) + })?; + + // Stage 2: Find first frame where both identity_ids appear + let cooccur: Option<(i64,)> = sqlx::query_as( + &format!( + "SELECT MIN(fd.frame_number)::bigint FROM {} fd \ + WHERE fd.file_uuid = $1 AND fd.identity_id = $2 \ + AND fd.frame_number IN ( \ + SELECT frame_number FROM {} \ + WHERE file_uuid = $1 AND identity_id = $3 \ + )", + fd_table, fd_table + ) + ) + .bind(&file_uuid) + .bind(id_a.0) + .bind(id_b.0) + .fetch_optional(state.db.pool()) + .await + .map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))) + })?; + + let (first_frame,) = cooccur.ok_or_else(|| { + (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "These two identities never appear together in this file"}))) + })?; + + // Get fps for timestamp + let video_table = schema::table_name("videos"); + 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); + + // Stage 3: Get trace_ids for both at this frame + let trace_a: Option<(i32,)> = sqlx::query_as( + &format!("SELECT trace_id FROM {} WHERE file_uuid = $1 AND frame_number = $2 AND identity_id = $3 AND trace_id IS NOT NULL LIMIT 1", fd_table) + ) + .bind(&file_uuid).bind(first_frame).bind(id_a.0) + .fetch_optional(state.db.pool()).await + .map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))) + })?; + + let trace_b: Option<(i32,)> = sqlx::query_as( + &format!("SELECT trace_id FROM {} WHERE file_uuid = $1 AND frame_number = $2 AND identity_id = $3 AND trace_id IS NOT NULL LIMIT 1", fd_table) + ) + .bind(&file_uuid).bind(first_frame).bind(id_b.0) + .fetch_optional(state.db.pool()).await + .map_err(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))) + })?; + + // Stage 4: Get representative faces for both traces (reusing select_rep_face) + let rep_a = if let Some((tid,)) = trace_a { + select_rep_face(state.db.pool(), &file_uuid, tid, |e| { + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))) + }).await.ok().map(|sel| CoOccurRepFace { + frame_number: sel.frame, + bbox: RepFaceBbox { x: sel.x, y: sel.y, width: sel.w, height: sel.h }, + confidence: sel.conf, + thumbnail_url: format!("/api/v1/file/{}/trace/{}/thumbnail", file_uuid, tid), + }) + } else { None }; + + let rep_b = if let Some((tid,)) = trace_b { + select_rep_face(state.db.pool(), &file_uuid, tid, |e| { + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))) + }).await.ok().map(|sel| CoOccurRepFace { + frame_number: sel.frame, + bbox: RepFaceBbox { x: sel.x, y: sel.y, width: sel.w, height: sel.h }, + confidence: sel.conf, + thumbnail_url: format!("/api/v1/file/{}/trace/{}/thumbnail", file_uuid, tid), + }) + } else { None }; + + // Total co-occurrence frames (from TKG if available, otherwise from face_detections) + let total_cooccurrence_frames: i64 = sqlx::query_scalar( + &format!( + "SELECT COUNT(DISTINCT fd.frame_number)::bigint FROM {} fd \ + WHERE fd.file_uuid = $1 AND fd.identity_id = $2 \ + AND fd.frame_number IN ( \ + SELECT frame_number FROM {} \ + WHERE file_uuid = $1 AND identity_id = $3 \ + )", + fd_table, fd_table + ) + ) + .bind(&file_uuid).bind(id_a.0).bind(id_b.0) + .fetch_one(state.db.pool()).await + .unwrap_or(0); + + Ok(Json(CoOccurResponse { + success: true, + file_uuid, + identity_a: CoOccurIdentity { + identity_uuid: identity_uuid_a, + name: id_a.1, + trace_id: trace_a.map(|t| t.0).unwrap_or(0), + }, + identity_b: CoOccurIdentity { + identity_uuid: identity_uuid_b, + name: id_b.1, + trace_id: trace_b.map(|t| t.0).unwrap_or(0), + }, + first_cooccurrence: CoOccurrence { + frame_number: first_frame, + timestamp_secs: first_frame as f64 / fps, + total_cooccurrence_frames, + representative_face_a: rep_a, + representative_face_b: rep_b, + }, + })) +}