use axum::{ extract::{Multipart, Path, Query, State}, http::StatusCode, response::Json, routing::{get, post}, Router, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::core::db::{schema, Database, PostgresDb}; use crate::core::processor::face_recognition::{ process_face_recognition, register_face, FaceRecognitionResult, FaceRegistrationResult, }; #[derive(Debug, Deserialize)] pub struct FaceRecognitionRequest { pub file_uuid: String, pub enable_recognition: Option, pub enable_tracking: Option, pub enable_clustering: Option, pub database_path: Option, } #[derive(Debug, Serialize)] pub struct FaceRecognitionResponse { pub success: bool, pub message: String, pub result: Option, pub processing_id: String, } #[derive(Debug, Deserialize)] pub struct FaceRegistrationRequest { pub file_uuid: String, pub name: String, pub metadata: Option, } #[derive(Debug, Serialize)] pub struct FaceRegistrationApiResponse { pub success: bool, pub message: String, pub result: Option, } #[derive(Debug, Deserialize)] pub struct FaceSearchRequest { pub file_uuid: String, pub embedding: Vec, pub similarity_threshold: Option, pub limit: Option, } #[derive(Debug, Serialize)] pub struct FaceSearchResponse { pub success: bool, pub message: String, pub results: Vec, } #[derive(Debug, Serialize)] pub struct FaceSearchResult { pub face_id: String, pub name: Option, pub similarity: f64, pub attributes: Option, pub metadata: Option, } #[derive(Debug, Deserialize)] pub struct FaceListQuery { pub file_uuid: String, pub page: Option, pub page_size: Option, pub active_only: Option, } #[derive(Debug, Serialize)] pub struct FaceListResponse { pub success: bool, pub message: String, pub faces: Vec, pub count: i64, pub page: usize, pub page_size: usize, } #[derive(Debug, Serialize)] pub struct FaceListItem { pub face_id: String, pub name: Option, pub created_at: String, pub updated_at: String, pub is_active: bool, pub metadata: Option, } pub fn face_recognition_routes() -> Router { Router::new() .route("/api/v1/face/recognize", post(recognize_faces)) .route("/api/v1/face/register", post(register_face_api)) .route("/api/v1/face/search", post(search_faces)) .route("/api/v1/face/list", get(list_faces)) .route( "/api/v1/files/:file_uuid/faces/:face_id", get(get_face_details), ) .route( "/api/v1/files/:file_uuid/faces/:face_id", axum::routing::delete(delete_face), ) .route( "/api/v1/face/results/:file_uuid", get(get_recognition_results), ) } async fn recognize_faces( State(_state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { let processing_id = Uuid::new_v4().to_string(); tracing::info!( "[FACE_RECOGNITION] Starting recognition for video: {}, processing_id: {}", request.file_uuid, processing_id ); // Get video path from database let db = match PostgresDb::init().await { Ok(db) => db, Err(e) => { return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to database: {}", e), )) } }; let video_record = match db.get_video_by_uuid(&request.file_uuid).await { Ok(Some(record)) => record, Ok(None) => { return Err(( StatusCode::NOT_FOUND, format!("Video not found: {}", request.file_uuid), )) } Err(e) => { return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch video: {}", e), )) } }; let video_path = video_record.file_path; let output_path = format!( "{}/face_recognition_{}.json", crate::core::config::OUTPUT_DIR.as_str(), processing_id ); // Process face recognition let result = match process_face_recognition( &video_path, &output_path, Some(&processing_id), request.enable_recognition.unwrap_or(true), request.enable_tracking.unwrap_or(true), request.enable_clustering.unwrap_or(true), ) .await { Ok(result) => result, Err(e) => { return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Face recognition failed: {}", e), )) } }; // Store results in database if let Err(e) = store_recognition_results(&db, &request.file_uuid, &result).await { tracing::warn!("Failed to store recognition results: {}", e); } Ok(Json(FaceRecognitionResponse { success: true, message: format!("Face recognition completed for {}", request.file_uuid), result: Some(result), processing_id, })) } async fn register_face_api( State(_state): State, mut multipart: Multipart, ) -> Result, (StatusCode, String)> { let mut image_path: Option = None; let mut name: Option = None; let mut metadata: Option = None; // Parse multipart form data while let Some(field) = multipart.next_field().await.map_err(|e| { ( StatusCode::BAD_REQUEST, format!("Failed to parse form data: {}", e), ) })? { let field_name = field.name().unwrap_or("").to_string(); match field_name.as_str() { "image" => { // Save uploaded image let file_name = format!("face_registration_{}.jpg", Uuid::new_v4()); let file_path = format!("/tmp/{}", file_name); let data = field.bytes().await.map_err(|e| { ( StatusCode::BAD_REQUEST, format!("Failed to read image data: {}", e), ) })?; tokio::fs::write(&file_path, &data).await.map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save image: {}", e), ) })?; image_path = Some(file_path); } "name" => { let value = field.text().await.map_err(|e| { ( StatusCode::BAD_REQUEST, format!("Failed to read name: {}", e), ) })?; name = Some(value); } "metadata" => { let value = field.text().await.map_err(|e| { ( StatusCode::BAD_REQUEST, format!("Failed to read metadata: {}", e), ) })?; metadata = Some(serde_json::from_str(&value).map_err(|e| { ( StatusCode::BAD_REQUEST, format!("Invalid JSON metadata: {}", e), ) })?); } _ => {} } } // Validate required fields let image_path = image_path.ok_or((StatusCode::BAD_REQUEST, "Image is required".to_string()))?; let name = name.ok_or((StatusCode::BAD_REQUEST, "Name is required".to_string()))?; // Register face let result = match register_face(&image_path, &name, metadata.clone()).await { Ok(result) => result, Err(e) => { // Clean up temporary file let _ = tokio::fs::remove_file(&image_path).await; return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Face registration failed: {}", e), )); } }; // Clean up temporary file let _ = tokio::fs::remove_file(&image_path).await; // Store in PostgreSQL face_identities table if result.success && !result.embedding.is_empty() { let db = match PostgresDb::init().await { Ok(db) => db, Err(e) => { tracing::warn!("[FACE_REGISTRATION] Failed to connect to DB: {}", e); // Return success even if DB write fails (embedding is still in JSON output) return Ok(Json(FaceRegistrationApiResponse { success: result.success, message: format!("{} (Warning: DB write failed: {})", result.message, e), result: Some(result), })); } }; // Convert embedding to PostgreSQL vector format let embedding_str = format!( "[{}]", result .embedding .iter() .map(|v| v.to_string()) .collect::>() .join(",") ); // Insert into face_identities let face_identities_table = schema::table_name("face_identities"); let attrs_json = serde_json::to_string(&result.attributes).unwrap_or_else(|_| "{}".to_string()); // Use public.vector type to work across schemas let vector_type = if schema::SCHEMA_PREFIX.as_str().is_empty() { "vector".to_string() } else { "public.vector".to_string() }; let insert_query = format!( r#" INSERT INTO {} (face_id, name, embedding, attributes, metadata, is_active) VALUES ($1, $2, $3::{}, $4::jsonb, $5, TRUE) ON CONFLICT (face_id) DO UPDATE SET name = EXCLUDED.name, embedding = EXCLUDED.embedding, attributes = EXCLUDED.attributes, metadata = COALESCE(EXCLUDED.metadata, {}.metadata), updated_at = CURRENT_TIMESTAMP, is_active = TRUE "#, face_identities_table, vector_type, face_identities_table ); match sqlx::query(&insert_query) .bind(&result.face_id) .bind(&name) .bind(&embedding_str) .bind(&attrs_json) .bind(serde_json::to_string(&metadata.unwrap_or(serde_json::json!({}))).unwrap()) .execute(db.pool()) .await { Ok(_) => { tracing::info!( "[FACE_REGISTRATION] Stored face '{}' (face_id={}) in DB", name, result.face_id ); } Err(e) => { tracing::warn!("[FACE_REGISTRATION] Failed to store face in DB: {}", e); } } } Ok(Json(FaceRegistrationApiResponse { success: result.success, message: result.message.clone(), result: Some(result), })) } async fn search_faces( State(_state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { let db = match PostgresDb::init().await { Ok(db) => db, Err(e) => { return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to database: {}", e), )) } }; // Convert embedding to PostgreSQL vector format let embedding_str = format!( "[{}]", request .embedding .iter() .map(|v| v.to_string()) .collect::>() .join(",") ); let similarity_threshold = request.similarity_threshold.unwrap_or(0.6); let limit = request.limit.unwrap_or(10); // Search for similar faces let face_identities_table = schema::table_name("face_identities"); let vector_type = if schema::SCHEMA_PREFIX.as_str().is_empty() { "vector".to_string() } else { "public.vector".to_string() }; let query = format!( r#" SELECT face_id, name, 1 - (embedding <=> $1::{}) as similarity, attributes, metadata FROM {} WHERE is_active = TRUE AND embedding IS NOT NULL AND 1 - (embedding <=> $1::{}) >= $2 ORDER BY embedding <=> $1::{} LIMIT $3 "#, vector_type, face_identities_table, vector_type, vector_type ); let results: Vec = match sqlx::query_as::< _, ( String, Option, f64, Option, Option, ), >(query.as_str()) .bind(&embedding_str) .bind(similarity_threshold) .bind(limit) .fetch_all(db.pool()) .await { Ok(rows) => rows .into_iter() .map( |(face_id, name, similarity, attributes, metadata)| FaceSearchResult { face_id, name, similarity, attributes, metadata, }, ) .collect(), Err(e) => { return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to search faces: {}", e), )) } }; Ok(Json(FaceSearchResponse { success: true, message: format!("Found {} similar faces", results.len()), results, })) } async fn list_faces( State(_state): State, Query(query): Query, ) -> Result, (StatusCode, String)> { let db = match PostgresDb::init().await { Ok(db) => db, Err(e) => { return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to database: {}", e), )) } }; let page = query.page.unwrap_or(1); let page_size = query.page_size.unwrap_or(20); let offset = ((page - 1) as i64) * (page_size as i64); let active_only = query.active_only.unwrap_or(true); // Build query let mut where_clause = "WHERE 1=1".to_string(); if active_only { where_clause.push_str(" AND is_active = TRUE"); } let face_identities_table = schema::table_name("face_identities"); let count_query = format!( "SELECT COUNT(*) FROM {} {}", face_identities_table, where_clause ); let list_query = format!( "SELECT face_id, name, created_at, updated_at, is_active, metadata FROM {} {} ORDER BY created_at DESC LIMIT $1 OFFSET $2", face_identities_table, where_clause ); // Get total count let total: i64 = match sqlx::query_scalar(&count_query).fetch_one(db.pool()).await { Ok(count) => count, Err(e) => { return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to count faces: {}", e), )) } }; // Get face list let faces: Vec = match sqlx::query_as::< _, ( String, Option, chrono::DateTime, chrono::DateTime, bool, Option, ), >(&list_query) .bind(page_size as i32) .bind(offset) .fetch_all(db.pool()) .await { Ok(rows) => rows .into_iter() .map( |(face_id, name, created_at, updated_at, is_active, metadata)| FaceListItem { face_id, name, created_at: created_at.to_rfc3339(), updated_at: updated_at.to_rfc3339(), is_active, metadata, }, ) .collect(), Err(e) => { return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to list faces: {}", e), )) } }; Ok(Json(FaceListResponse { success: true, message: format!("Found {} faces", total), faces, count: total, page, page_size, })) } async fn get_face_details( State(_state): State, Path((file_uuid, face_id)): Path<(String, String)>, ) -> Result, (StatusCode, String)> { let db = match PostgresDb::init().await { Ok(db) => db, Err(e) => { return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to database: {}", e), )) } }; let face_identities_table = schema::table_name("face_identities"); let query = format!( r#" SELECT face_id, name, embedding, attributes, metadata, created_at, updated_at, is_active FROM {} WHERE face_id = $1 AND file_uuid = $2 "#, face_identities_table ); let face: Option<( String, Option, Option, Option, Option, chrono::DateTime, chrono::DateTime, bool, )> = match sqlx::query_as(&query) .bind(&face_id) .bind(&file_uuid) .fetch_optional(db.pool()) .await { Ok(face) => face, Err(e) => { return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch face details: {}", e), )) } }; match face { Some(( face_id, name, embedding, attributes, metadata, created_at, updated_at, is_active, )) => { let response = serde_json::json!({ "success": true, "face_id": face_id, "name": name, "has_embedding": embedding.is_some(), "attributes": attributes, "metadata": metadata, "created_at": created_at.to_rfc3339(), "updated_at": updated_at.to_rfc3339(), "is_active": is_active }); Ok(Json(response)) } None => Err(( StatusCode::NOT_FOUND, format!("Face not found: {}", face_id), )), } } async fn delete_face( State(_state): State, Path((file_uuid, face_id)): Path<(String, String)>, ) -> Result, (StatusCode, String)> { let db = match PostgresDb::init().await { Ok(db) => db, Err(e) => { return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to database: {}", e), )) } }; // Soft delete by marking as inactive let face_identities_table = schema::table_name("face_identities"); let query = format!( r#" UPDATE {} SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP WHERE face_id = $1 AND file_uuid = $2 AND is_active = TRUE RETURNING face_id, name "#, face_identities_table ); let deleted: Option<(String, Option)> = match sqlx::query_as(&query) .bind(&face_id) .bind(&file_uuid) .fetch_optional(db.pool()) .await { Ok(result) => result, Err(e) => { return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to delete face: {}", e), )) } }; match deleted { Some((deleted_id, name)) => { let response = serde_json::json!({ "success": true, "message": format!("Face '{}' deleted successfully", name.clone().unwrap_or_else(|| deleted_id.clone())), "face_id": deleted_id.clone() }); Ok(Json(response)) } None => Err(( StatusCode::NOT_FOUND, format!("Face not found or already deleted: {}", face_id), )), } } async fn get_recognition_results( State(_state): State, Path(file_uuid): Path, ) -> Result, (StatusCode, String)> { let db = match PostgresDb::init().await { Ok(db) => db, Err(e) => { return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to connect to database: {}", e), )) } }; let query = r#" SELECT file_uuid, frame_count, fps, total_faces, recognized_faces, clusters_count, result_data, processing_time_secs, created_at FROM face_recognition_results WHERE file_uuid = $1 ORDER BY created_at DESC LIMIT 1 "#; let result: Option<( String, i64, f64, i32, i32, i32, serde_json::Value, Option, chrono::DateTime, )> = match sqlx::query_as(query) .bind(&file_uuid) .fetch_optional(db.pool()) .await { Ok(result) => result, Err(e) => { return Err(( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch recognition results: {}", e), )) } }; match result { Some(( file_uuid, frame_count, fps, total_faces, recognized_faces, clusters_count, result_data, processing_time_secs, created_at, )) => { let response = serde_json::json!({ "success": true, "file_uuid": file_uuid, "frame_count": frame_count, "fps": fps, "total_faces": total_faces, "recognized_faces": recognized_faces, "clusters_count": clusters_count, "result_data": result_data, "processing_time_secs": processing_time_secs, "created_at": created_at.to_rfc3339() }); Ok(Json(response)) } None => Err(( StatusCode::NOT_FOUND, format!("No recognition results found for video: {}", file_uuid), )), } } async fn store_recognition_results( db: &PostgresDb, file_uuid: &str, result: &FaceRecognitionResult, ) -> Result<(), anyhow::Error> { let total_faces = result.frames.iter().map(|f| f.faces.len()).sum::(); let recognized_faces = result .frames .iter() .flat_map(|f| &f.faces) .filter(|face| face.identity.is_some()) .count(); let query = r#" INSERT INTO face_recognition_results ( file_uuid, frame_count, fps, total_faces, recognized_faces, clusters_count, result_data ) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (file_uuid) DO UPDATE SET frame_count = EXCLUDED.frame_count, fps = EXCLUDED.fps, total_faces = EXCLUDED.total_faces, recognized_faces = EXCLUDED.recognized_faces, clusters_count = EXCLUDED.clusters_count, result_data = EXCLUDED.result_data, updated_at = CURRENT_TIMESTAMP "#; sqlx::query(query) .bind(file_uuid) .bind(result.frame_count as i64) .bind(result.fps) .bind(total_faces as i32) .bind(recognized_faces as i32) .bind(result.face_clusters.len() as i32) .bind(serde_json::to_value(result)?) .execute(db.pool()) .await?; // Store individual face detections for frame in &result.frames { for face in &frame.faces { if let Some(embedding) = &face.embedding { let embedding_str = format!( "[{}]", embedding .iter() .map(|v| v.to_string()) .collect::>() .join(",") ); let insert_query = r#" INSERT INTO face_detections ( file_uuid, frame_number, timestamp_secs, face_id, x, y, width, height, confidence, embedding, attributes, identity_confidence, cluster_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::vector, $11, $12, $13) ON CONFLICT (file_uuid, frame_number, x, y, width, height) DO UPDATE SET face_id = EXCLUDED.face_id, confidence = EXCLUDED.confidence, embedding = EXCLUDED.embedding, attributes = EXCLUDED.attributes, identity_confidence = EXCLUDED.identity_confidence, cluster_id = EXCLUDED.cluster_id "#; let identity_confidence = face.identity.as_ref().map(|id| id.confidence as f64); let cluster_id = result .face_clusters .iter() .find(|c| { c.face_ids .contains(&face.face_id.clone().unwrap_or_default()) }) .map(|c| c.cluster_id.clone()); sqlx::query(insert_query) .bind(file_uuid) .bind(frame.frame as i64) .bind(frame.timestamp) .bind(face.face_id.as_deref()) .bind(face.x) .bind(face.y) .bind(face.width) .bind(face.height) .bind(face.confidence as f64) .bind(&embedding_str) .bind(serde_json::to_value(&face.attributes)?) .bind(identity_confidence) .bind(cluster_id) .execute(db.pool()) .await?; } } } // Store face clusters for cluster in &result.face_clusters { let centroid = &cluster.centroid; let centroid_str = format!( "[{}]", centroid .iter() .map(|v: &f32| v.to_string()) .collect::>() .join(",") ); let cluster_query = r#" INSERT INTO face_clusters ( cluster_id, file_uuid, centroid, size, representative_face_id, metadata ) VALUES ($1, $2, $3::vector, $4, $5, $6) ON CONFLICT (cluster_id) DO UPDATE SET centroid = EXCLUDED.centroid, size = EXCLUDED.size, representative_face_id = EXCLUDED.representative_face_id, metadata = EXCLUDED.metadata "#; sqlx::query(cluster_query) .bind(&cluster.cluster_id) .bind(file_uuid) .bind(¢roid_str) .bind(cluster.size as i32) .bind(cluster.representative_face_id.as_deref()) .bind(&cluster.metadata) .execute(db.pool()) .await?; } Ok(()) }