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",
|
"/api/v1/file/:file_uuid/trace/:trace_id/thumbnail",
|
||||||
get(get_trace_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)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -542,3 +546,192 @@ async fn get_trace_thumbnail(
|
|||||||
.body(Body::from(bytes))
|
.body(Body::from(bytes))
|
||||||
.unwrap())
|
.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