- Remove session-ses_2f27.md (161KB raw session log) - Remove 49 ROOT_* duplicate files across REFERENCE/ - Remove 14 duplicate files between REFERENCE/ root and history/ - Remove asr_legacy.rs (dead code, replaced by asr.rs) - Remove src/core/worker/ (duplicate JobWorker) - Remove src/core/layers/ (empty directory) - Remove 4 .bak files in src/ - Remove 7 dead private methods in worker/processor.rs - Remove backup directory from git tracking
945 lines
28 KiB
Rust
945 lines
28 KiB
Rust
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<bool>,
|
|
pub enable_tracking: Option<bool>,
|
|
pub enable_clustering: Option<bool>,
|
|
pub database_path: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FaceRecognitionResponse {
|
|
pub success: bool,
|
|
pub message: String,
|
|
pub result: Option<FaceRecognitionResult>,
|
|
pub processing_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct FaceRegistrationRequest {
|
|
pub file_uuid: String,
|
|
pub name: String,
|
|
pub metadata: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FaceRegistrationApiResponse {
|
|
pub success: bool,
|
|
pub message: String,
|
|
pub result: Option<FaceRegistrationResult>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct FaceSearchRequest {
|
|
pub file_uuid: String,
|
|
pub embedding: Vec<f32>,
|
|
pub similarity_threshold: Option<f64>,
|
|
pub limit: Option<i32>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FaceSearchResponse {
|
|
pub success: bool,
|
|
pub message: String,
|
|
pub results: Vec<FaceSearchResult>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FaceSearchResult {
|
|
pub face_id: String,
|
|
pub name: Option<String>,
|
|
pub similarity: f64,
|
|
pub attributes: Option<serde_json::Value>,
|
|
pub metadata: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct FaceListQuery {
|
|
pub file_uuid: String,
|
|
pub page: Option<usize>,
|
|
pub page_size: Option<usize>,
|
|
pub active_only: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FaceListResponse {
|
|
pub success: bool,
|
|
pub message: String,
|
|
pub faces: Vec<FaceListItem>,
|
|
pub count: i64,
|
|
pub page: usize,
|
|
pub page_size: usize,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FaceListItem {
|
|
pub face_id: String,
|
|
pub name: Option<String>,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
pub is_active: bool,
|
|
pub metadata: Option<serde_json::Value>,
|
|
}
|
|
|
|
pub fn face_recognition_routes() -> Router<crate::api::server::AppState> {
|
|
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<crate::api::server::AppState>,
|
|
Json(request): Json<FaceRecognitionRequest>,
|
|
) -> Result<Json<FaceRecognitionResponse>, (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<crate::api::server::AppState>,
|
|
mut multipart: Multipart,
|
|
) -> Result<Json<FaceRegistrationApiResponse>, (StatusCode, String)> {
|
|
let mut image_path: Option<String> = None;
|
|
let mut name: Option<String> = None;
|
|
let mut metadata: Option<serde_json::Value> = 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::<Vec<_>>()
|
|
.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<crate::api::server::AppState>,
|
|
Json(request): Json<FaceSearchRequest>,
|
|
) -> Result<Json<FaceSearchResponse>, (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::<Vec<_>>()
|
|
.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<FaceSearchResult> = match sqlx::query_as::<
|
|
_,
|
|
(
|
|
String,
|
|
Option<String>,
|
|
f64,
|
|
Option<serde_json::Value>,
|
|
Option<serde_json::Value>,
|
|
),
|
|
>(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<crate::api::server::AppState>,
|
|
Query(query): Query<FaceListQuery>,
|
|
) -> Result<Json<FaceListResponse>, (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<FaceListItem> = match sqlx::query_as::<
|
|
_,
|
|
(
|
|
String,
|
|
Option<String>,
|
|
chrono::DateTime<chrono::Utc>,
|
|
chrono::DateTime<chrono::Utc>,
|
|
bool,
|
|
Option<serde_json::Value>,
|
|
),
|
|
>(&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<crate::api::server::AppState>,
|
|
Path((file_uuid, face_id)): Path<(String, String)>,
|
|
) -> Result<Json<serde_json::Value>, (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<String>,
|
|
Option<String>,
|
|
Option<serde_json::Value>,
|
|
Option<serde_json::Value>,
|
|
chrono::DateTime<chrono::Utc>,
|
|
chrono::DateTime<chrono::Utc>,
|
|
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<crate::api::server::AppState>,
|
|
Path((file_uuid, face_id)): Path<(String, String)>,
|
|
) -> Result<Json<serde_json::Value>, (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<String>)> = 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<crate::api::server::AppState>,
|
|
Path(file_uuid): Path<String>,
|
|
) -> Result<Json<serde_json::Value>, (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<f64>,
|
|
chrono::DateTime<chrono::Utc>,
|
|
)> = 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::<usize>();
|
|
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::<Vec<_>>()
|
|
.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::<Vec<_>>()
|
|
.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(())
|
|
}
|