feat: GET file/:uuid/identities/:a/co-occur-with/:b endpoint
This commit is contained in:
@@ -25,6 +25,10 @@ pub fn trace_agent_routes() -> Router<crate::api::types::AppState> {
|
||||
"/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<CoOccurRepFace>,
|
||||
representative_face_b: Option<CoOccurRepFace>,
|
||||
}
|
||||
|
||||
#[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<crate::api::types::AppState>,
|
||||
Path((file_uuid, identity_uuid_a, identity_uuid_b)): Path<(String, String, String)>,
|
||||
) -> Result<Json<CoOccurResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
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,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user