feat: progressive multi-round face matching + pending person API

- Identity agent: per-face max matching, multi-round with derived
  seeds from high-confidence faces, angle diversity filter (cosine sim < 0.90)
- Pending person API: POST /file/:file_uuid/pending-person
  + GET /file/:file_uuid/pending-persons with status=pending, source=manual
- Update API docs (07_identity.md)
This commit is contained in:
Accusys
2026-06-24 03:42:04 +08:00
parent 766a1d9a6d
commit 14e886cc08
31 changed files with 5882 additions and 742 deletions
+162
View File
@@ -7,6 +7,7 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use std::process::Command;
use crate::core::db::ResourceRecord;
@@ -45,6 +46,10 @@ pub fn identity_routes() -> Router<crate::api::types::AppState> {
"/api/v1/identity/:identity_uuid/profile-image",
post(upload_profile_image).get(get_profile_image),
)
.route(
"/api/v1/identity/:identity_uuid/profile-image/from-face",
post(set_profile_from_face),
)
.route(
"/api/v1/identity/:identity_uuid/status",
get(get_identity_status),
@@ -1279,6 +1284,163 @@ async fn get_profile_image(
Err(StatusCode::NOT_FOUND)
}
#[derive(Debug, Deserialize)]
pub struct SetProfileFromFaceRequest {
pub file_uuid: String,
pub face_id: Option<String>,
pub id: Option<i64>,
}
async fn set_profile_from_face(
State(state): State<crate::api::types::AppState>,
Path(identity_uuid): Path<String>,
Json(req): Json<SetProfileFromFaceRequest>,
) -> Result<Json<ProfileImageResponse>, (StatusCode, Json<serde_json::Value>)> {
use crate::core::db::schema;
let fd_table = schema::table_name("face_detections");
let videos_table = schema::table_name("videos");
let uuid_clean = identity_uuid.replace('-', "");
let face_identifier = match (&req.face_id, req.id) {
(Some(fid), _) => fid.clone(),
(None, Some(id)) => id.to_string(),
(None, None) => {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"success": false, "message": "Either face_id or id is required"})),
));
}
};
let use_id_field = req.id.is_some();
let row: Option<(i64, i32, i32, i32, i32, f64)> = if use_id_field {
sqlx::query_as(&format!(
"SELECT frame_number, x, y, width, height, confidence FROM {} WHERE file_uuid = $1 AND id = $2",
fd_table
))
.bind(&req.file_uuid)
.bind(req.id.unwrap())
.fetch_optional(state.db.pool())
.await
} else {
sqlx::query_as(&format!(
"SELECT frame_number, x, y, width, height, confidence FROM {} WHERE file_uuid = $1 AND face_id = $2",
fd_table
))
.bind(&req.file_uuid)
.bind(&face_identifier)
.fetch_optional(state.db.pool())
.await
}
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"success": false, "message": format!("DB error: {}", e)})),
)
})?;
let (frame_number, x, y, width, height, confidence) = row.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({"success": false, "message": "Face not found"})),
)
})?;
let video_row: Option<(String, Option<i32>, Option<i32>)> = sqlx::query_as(&format!(
"SELECT file_path, width, height FROM {} WHERE file_uuid = $1",
videos_table
))
.bind(&req.file_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"success": false, "message": format!("DB error: {}", e)})),
)
})?;
let (file_path, video_width, video_height) = video_row.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({"success": false, "message": "Video file not found"})),
)
})?;
let vw = video_width.unwrap_or(1920);
let vh = video_height.unwrap_or(1080);
crate::core::thumbnail::validator::validate_crop(x, y, width, height, vw, vh).map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"success": false, "message": format!("Crop validation failed: {}", e)})),
)
})?;
let select = format!("select=eq(n\\,{})", frame_number);
let vf = format!("{},crop={}:{}:{}:{}", select, width, height, x, y);
let output = Command::new("ffmpeg")
.args([
"-i",
&file_path,
"-vf",
&vf,
"-frames:v",
"1",
"-f",
"image2pipe",
"-vcodec",
"mjpeg",
"-",
])
.output()
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"success": false, "message": format!("FFmpeg failed: {}", e)})),
)
})?;
if !output.status.success() {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"success": false, "message": "FFmpeg extraction failed"})),
));
}
crate::core::thumbnail::validator::validate_jpeg(&output.stdout).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"success": false, "message": format!("JPEG validation failed: {}", e)})),
)
})?;
let dir = crate::core::identity::storage::identity_dir(&uuid_clean);
std::fs::create_dir_all(&dir).map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": format!("Failed to create dir: {}", e)})))
})?;
let file_name = "profile.jpg";
let file_path = dir.join(file_name);
std::fs::write(&file_path, &output.stdout).map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": format!("Failed to write file: {}", e)})))
})?;
let pool = state.db.pool().clone();
let uuid_clone = uuid_clean.clone();
let _ = crate::core::identity::storage::save_identity_file_by_pool(&pool, &uuid_clone).await;
Ok(Json(ProfileImageResponse {
success: true,
identity_uuid: uuid_clean,
path: file_path.to_string_lossy().to_string(),
message: format!("Profile image set from face {} (frame {}, confidence {:.2})", face_identifier, frame_number, confidence),
}))
}
async fn get_identity_json(
State(state): State<crate::api::types::AppState>,
Path(identity_uuid): Path<String>,