feat: add pose and appearance endpoints for face detail display

- Add GET /api/v1/file/:file_uuid/pose endpoint
- Add GET /api/v1/file/:file_uuid/appearance endpoint
- Keypoint deduplication (highest confidence)
- Add face-groups endpoint for Studio integration
- Add profile routes (trace-profile, file-profile)
- Update API documentation
This commit is contained in:
Accusys
2026-07-19 20:43:35 +08:00
parent 455c6b81e6
commit d0b7bfa56b
7 changed files with 824 additions and 2 deletions
+197
View File
@@ -1346,3 +1346,200 @@ async fn media_proxy_handler(
pub fn media_proxy_routes() -> Router<crate::api::types::AppState> {
Router::new().route("/api/v1/media-proxy", get(media_proxy_handler))
}
// ── Pose & Appearance Endpoints ──
#[derive(Debug, serde::Deserialize)]
struct PoseQuery {
frame: i64,
}
#[derive(Debug, Clone, serde::Serialize)]
struct PoseKeypoint {
name: String,
x: f64,
y: f64,
confidence: f64,
}
#[derive(Debug, serde::Serialize)]
struct PoseResponse {
frame: i64,
keypoints: Vec<PoseKeypoint>,
pose_class: String,
confidence: Option<f64>,
}
#[derive(Debug, serde::Deserialize)]
struct AppearanceQuery {
frame: i64,
}
#[derive(Debug, serde::Serialize)]
struct AppearanceResponse {
frame: i64,
dominant_colors: Vec<serde_json::Value>,
hsv_histogram: Vec<Vec<f64>>,
}
async fn get_pose_handler(
State(_state): State<crate::api::types::AppState>,
Path(file_uuid): Path<String>,
Query(params): Query<PoseQuery>,
) -> Result<axum::Json<PoseResponse>, StatusCode> {
use crate::core::config::OUTPUT_DIR;
use std::collections::HashMap as StdHashMap;
let output_dir = OUTPUT_DIR.as_str();
let pose_path = std::path::Path::new(output_dir)
.join(format!("{}.pose.json", file_uuid));
if !pose_path.exists() {
tracing::error!("[get_pose] File not found: {}", pose_path.display());
return Err(StatusCode::NOT_FOUND);
}
let content = std::fs::read_to_string(&pose_path).map_err(|e| {
tracing::error!("[get_pose] Failed to read pose.json: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let data: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
tracing::error!("[get_pose] Failed to parse pose.json: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let frames = data.get("frames").and_then(|v| v.as_array()).ok_or(StatusCode::NOT_FOUND)?;
let frame_data = frames
.iter()
.find(|f| f.get("frame").and_then(|v| v.as_i64()) == Some(params.frame))
.ok_or(StatusCode::NOT_FOUND)?;
let persons = frame_data
.get("persons")
.and_then(|v| v.as_array())
.ok_or(StatusCode::NOT_FOUND)?;
if persons.is_empty() {
return Err(StatusCode::NOT_FOUND);
}
let person = &persons[0];
let keypoints_raw = person
.get("keypoints")
.and_then(|v| v.as_array())
.ok_or(StatusCode::NOT_FOUND)?;
let mut unique_kps: StdHashMap<String, PoseKeypoint> = StdHashMap::new();
for kp in keypoints_raw {
let name = kp.get("name").and_then(|v| v.as_str()).unwrap_or("");
let x = kp.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = kp.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
let conf = kp.get("confidence").and_then(|v| v.as_f64()).unwrap_or(0.0);
if let Some(existing) = unique_kps.get(name) {
if conf > existing.confidence {
unique_kps.insert(name.to_string(), PoseKeypoint {
name: name.to_string(),
x,
y,
confidence: conf,
});
}
} else {
unique_kps.insert(name.to_string(), PoseKeypoint {
name: name.to_string(),
x,
y,
confidence: conf,
});
}
}
let coco_names = [
"nose", "left_eye", "right_eye", "left_ear", "right_ear",
"left_shoulder", "right_shoulder", "left_elbow", "right_elbow",
"left_wrist", "right_wrist", "left_hip", "right_hip",
"left_knee", "right_knee", "left_ankle", "right_ankle",
];
let keypoints: Vec<PoseKeypoint> = coco_names
.iter()
.map(|name| {
unique_kps.get(*name).cloned().unwrap_or_else(|| PoseKeypoint {
name: name.to_string(),
x: 0.0,
y: 0.0,
confidence: 0.0,
})
})
.collect();
Ok(axum::Json(PoseResponse {
frame: params.frame,
keypoints,
pose_class: "unknown".to_string(),
confidence: None,
}))
}
async fn get_appearance_handler(
State(_state): State<crate::api::types::AppState>,
Path(file_uuid): Path<String>,
Query(params): Query<AppearanceQuery>,
) -> Result<axum::Json<AppearanceResponse>, StatusCode> {
use crate::core::config::OUTPUT_DIR;
let output_dir = OUTPUT_DIR.as_str();
let appearance_path = std::path::Path::new(output_dir)
.join(format!("{}.appearance.json", file_uuid));
if !appearance_path.exists() {
tracing::error!("[get_appearance] File not found: {}", appearance_path.display());
return Err(StatusCode::NOT_FOUND);
}
let content = std::fs::read_to_string(&appearance_path).map_err(|e| {
tracing::error!("[get_appearance] Failed to read appearance.json: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let data: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
tracing::error!("[get_appearance] Failed to parse appearance.json: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let frames = data.get("frames").and_then(|v| v.as_array()).ok_or(StatusCode::NOT_FOUND)?;
let frame_data = frames
.iter()
.find(|f| f.get("frame").and_then(|v| v.as_i64()) == Some(params.frame))
.ok_or(StatusCode::NOT_FOUND)?;
let hsv_histogram = frame_data
.get("hsv_histogram")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|channel| {
channel.as_array().map(|bins| {
bins.iter().filter_map(|v| v.as_f64()).collect()
})
})
.collect()
})
.unwrap_or_default();
Ok(axum::Json(AppearanceResponse {
frame: params.frame,
dominant_colors: vec![],
hsv_histogram,
}))
}
pub fn pose_appearance_routes() -> Router<crate::api::types::AppState> {
Router::new()
.route("/api/v1/file/:file_uuid/pose", get(get_pose_handler))
.route("/api/v1/file/:file_uuid/appearance", get(get_appearance_handler))
}
+1
View File
@@ -14,6 +14,7 @@ pub mod media_api;
pub mod middleware;
pub mod pipeline;
pub mod processing;
pub mod profile;
pub mod scan;
pub mod search;
pub mod server;
+83 -1
View File
@@ -1,10 +1,11 @@
use axum::{
Extension, Json,
extract::{Query, State},
extract::{Path, Query, State},
http::StatusCode,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::collections::HashMap;
use crate::core::db::schema;
use crate::api::middleware::UserAuth;
@@ -375,6 +376,86 @@ pub async fn update_file_profile_handler(
}
}
// ─── Face Groups ───
#[derive(Debug, Serialize, Deserialize)]
pub struct FaceGroup {
pub group_id: i32,
pub name: String,
pub trace_ids: Vec<i64>,
pub trace_count: i32,
pub representative_trace: i64,
pub editable: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FaceGroupsResponse {
pub success: bool,
pub file_uuid: String,
pub face_groups: Vec<FaceGroup>,
pub total_groups: i32,
pub unassigned_traces: Vec<i64>,
}
pub async fn get_face_groups_handler(
State(state): State<AppState>,
Extension(_auth): Extension<UserAuth>,
Path(file_uuid): Path<String>,
) -> Result<Json<FaceGroupsResponse>, StatusCode> {
let tkg_table = schema::table_name("tkg_nodes");
let rows: Vec<(String, serde_json::Value)> = sqlx::query_as(&format!(
"SELECT label, properties FROM {} \
WHERE file_uuid = $1 AND node_type = 'face_track' \
ORDER BY (properties->>'trace_id')::int",
tkg_table
))
.bind(&file_uuid)
.fetch_all(state.db.pool())
.await
.map_err(|e| {
tracing::error!("[FaceGroups] DB error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let mut groups: HashMap<String, Vec<i64>> = HashMap::new();
let mut unassigned: Vec<i64> = Vec::new();
for (label, properties) in rows {
let trace_id = properties
.get("trace_id")
.and_then(|v| v.as_i64())
.unwrap_or(0);
if label.starts_with("Face Trace ") || label.starts_with("Trace ") {
unassigned.push(trace_id);
} else {
groups.entry(label).or_default().push(trace_id);
}
}
let face_groups: Vec<FaceGroup> = groups
.into_iter()
.enumerate()
.map(|(idx, (name, trace_ids))| FaceGroup {
group_id: (idx + 1) as i32,
name,
trace_count: trace_ids.len() as i32,
representative_trace: trace_ids.first().copied().unwrap_or(0),
trace_ids,
editable: true,
})
.collect();
Ok(Json(FaceGroupsResponse {
success: true,
file_uuid,
total_groups: face_groups.len() as i32,
unassigned_traces: unassigned,
face_groups,
}))
}
// ─── Routes ───
pub fn profile_routes() -> axum::Router<AppState> {
@@ -388,4 +469,5 @@ pub fn profile_routes() -> axum::Router<AppState> {
)
.route("/api/v1/file-profile", get(get_file_profile_handler))
.route("/api/v1/file-profile", put(update_file_profile_handler))
.route("/api/v1/file/:file_uuid/face-groups", get(get_face_groups_handler))
}
+3
View File
@@ -26,6 +26,7 @@ use super::media_api;
use super::middleware::unified_auth;
use super::pipeline;
use super::processing;
use super::profile;
use super::scan;
use super::search::search_routes;
use super::tmdb_api;
@@ -119,12 +120,14 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> {
.merge(identity_agent_api::identity_agent_routes())
.merge(media_api::bbox_routes())
.merge(media_api::media_proxy_routes())
.merge(media_api::pose_appearance_routes())
.merge(trace_agent_api::trace_agent_routes())
.merge(search_routes())
.merge(llm_search::llm_smart_routes())
.merge(universal_search_routes())
.merge(pipeline::pipeline_routes())
.merge(checkin_api::checkin_routes())
.merge(profile::profile_routes())
.layer(axum::middleware::from_fn_with_state(
state.api_state.clone(),
unified_auth,