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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user