feat: Swift Face Pose integration + TKG 方案 B
Major Changes: - swift_face_pose: output pose angles (yaw/pitch/roll) in face.json - face_processor.py: call swift_face_pose (dual output: face.json + pose.json) - Face struct: add pose_angle field - TKG 方案 B: gaze/lip_track nodes from face.json (no face_detections dependency) - Chunk cleanup: delete old data before rebuild (avoid duplicate key) - Hand nodes: classify by hand_type + gesture (15 combinations) - HAND_OBJECT edges: bbox spatial matching (174 matches) Test Results: - Blake Jones: 8 faces, pose_angle ✓, 66 nodes, 174 edges - FilmRiot: 394 faces, pose_angle ✓, 35 nodes, 39 edges - Left hands: 132, Right hands: 2 Architecture: - All TKG nodes built from JSON files (face.json, hand.json, yolo.json) - Swift processors: sample_interval=3 (Face/Pose/Hand sync) - Cleanup functions: delete_tkg_nodes_by_uuid, delete_tkg_edges_by_uuid
This commit is contained in:
@@ -30,6 +30,7 @@ pub struct Face {
|
||||
pub confidence: f32,
|
||||
pub embedding: Option<Vec<f32>>,
|
||||
pub landmarks: Option<serde_json::Value>,
|
||||
pub pose_angle: Option<serde_json::Value>,
|
||||
pub attributes: Option<FaceAttributes>,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::executor::PythonExecutor;
|
||||
|
||||
const HAND_TIMEOUT: Duration = Duration::from_secs(7200);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct HandResult {
|
||||
pub frame_count: u64,
|
||||
pub fps: f64,
|
||||
pub frames: Vec<HandFrame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct HandFrame {
|
||||
pub frame: u64,
|
||||
pub timestamp: f64,
|
||||
pub persons: Vec<PersonHand>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PersonHand {
|
||||
pub person_id: u32,
|
||||
pub hand_type: String,
|
||||
pub confidence: f32,
|
||||
pub landmarks: Vec<HandLandmark>,
|
||||
pub num_landmarks: u32,
|
||||
pub gesture: String,
|
||||
pub hand_state: String,
|
||||
pub finger_extensions: serde_json::Value,
|
||||
pub num_fingers_extended: u32,
|
||||
pub num_fingers_curled: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct HandLandmark {
|
||||
pub name: String,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
pub async fn process_hand(
|
||||
video_path: &str,
|
||||
output_path: &str,
|
||||
uuid: Option<&str>,
|
||||
frames: Option<&[i64]>,
|
||||
) -> Result<HandResult> {
|
||||
let executor = PythonExecutor::new()?;
|
||||
let script_path = executor.script_path("hand_processor.py");
|
||||
|
||||
tracing::info!("[HAND] Starting hand pose estimation: {}", video_path);
|
||||
|
||||
if !script_path.exists() {
|
||||
tracing::warn!("[HAND] Script not found, returning empty result");
|
||||
return Ok(HandResult {
|
||||
frame_count: 0,
|
||||
fps: 0.0,
|
||||
frames: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
executor
|
||||
.run_with_frames(
|
||||
"hand_processor.py",
|
||||
&[video_path, output_path],
|
||||
uuid,
|
||||
"HAND",
|
||||
Some(HAND_TIMEOUT),
|
||||
frames,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to run {:?}", script_path))?;
|
||||
|
||||
let json_str = std::fs::read_to_string(output_path).context("Failed to read HAND output")?;
|
||||
|
||||
let result: HandResult =
|
||||
serde_json::from_str(&json_str).context("Failed to parse HAND output")?;
|
||||
|
||||
tracing::info!("[HAND] Result: {} frames", result.frames.len());
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod cut;
|
||||
pub mod executor;
|
||||
pub mod face;
|
||||
pub mod face_recognition;
|
||||
pub mod hand;
|
||||
pub mod heuristic_scene;
|
||||
pub mod mediapipe_v2;
|
||||
pub mod ocr;
|
||||
@@ -36,6 +37,9 @@ pub use face_recognition::{
|
||||
FaceRecognitionFrame, FaceRecognitionResult, FaceRegistrationResult, RecognizedFace,
|
||||
RecognizedFaceDetection,
|
||||
};
|
||||
pub use hand::{
|
||||
process_hand, HandFrame, HandLandmark, HandResult, PersonHand,
|
||||
};
|
||||
pub use heuristic_scene::{
|
||||
build_heuristic_scene_meta, generate_scene_meta, CrowdSize, HeuristicSceneMeta,
|
||||
SceneSegmentMeta,
|
||||
|
||||
+606
-10
@@ -429,9 +429,61 @@ struct YoloDetEntry {
|
||||
#[serde(default)]
|
||||
class_name: String,
|
||||
#[serde(default)]
|
||||
class_id: i32,
|
||||
#[serde(default)]
|
||||
x: i32,
|
||||
#[serde(default)]
|
||||
y: i32,
|
||||
#[serde(default)]
|
||||
width: i32,
|
||||
#[serde(default)]
|
||||
height: i32,
|
||||
#[serde(default)]
|
||||
confidence: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HandJson {
|
||||
#[serde(default)]
|
||||
frames: Vec<HandFrameData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HandFrameData {
|
||||
#[serde(default)]
|
||||
frame: u64,
|
||||
#[serde(default)]
|
||||
timestamp: f64,
|
||||
#[serde(default)]
|
||||
persons: Vec<HandPersonData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HandPersonData {
|
||||
#[serde(default)]
|
||||
hand_type: String,
|
||||
#[serde(default)]
|
||||
gesture: String,
|
||||
#[serde(default)]
|
||||
hand_state: String,
|
||||
#[serde(default)]
|
||||
confidence: f32,
|
||||
#[serde(default)]
|
||||
landmarks: Vec<HandLandmark>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HandLandmark {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
x: f32,
|
||||
#[serde(default)]
|
||||
y: f32,
|
||||
#[serde(default)]
|
||||
confidence: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AsrxJson {
|
||||
#[serde(default)]
|
||||
@@ -502,6 +554,7 @@ pub text_region_nodes: usize,
|
||||
pub appearance_trace_nodes: usize,
|
||||
pub accessory_nodes: usize,
|
||||
pub object_nodes: usize,
|
||||
pub hand_nodes: usize,
|
||||
pub speaker_nodes: usize,
|
||||
pub co_occurrence_edges: usize,
|
||||
pub speaker_face_edges: usize,
|
||||
@@ -510,6 +563,7 @@ pub accessory_nodes: usize,
|
||||
pub lip_sync_edges: usize,
|
||||
pub has_appearance_edges: usize,
|
||||
pub wears_edges: usize,
|
||||
pub hand_object_edges: usize,
|
||||
}
|
||||
|
||||
pub async fn build_tkg(db: &PostgresDb, file_uuid: &str, output_dir: &str) -> Result<TkgResult> {
|
||||
@@ -553,6 +607,7 @@ let n_appearance =
|
||||
build_appearance_trace_nodes(pool, file_uuid, output_dir, &pose_data).await?;
|
||||
let n_accessories = build_accessory_nodes(pool, file_uuid, output_dir).await?;
|
||||
let n_objects = build_yolo_object_nodes(pool, file_uuid, output_dir).await?;
|
||||
let n_hands = build_hand_nodes(pool, file_uuid, output_dir).await?;
|
||||
let n_speakers = build_speaker_nodes(pool, file_uuid, output_dir).await?;
|
||||
|
||||
let e_co = build_co_occurrence_edges(pool, file_uuid, output_dir).await?;
|
||||
@@ -562,6 +617,7 @@ let n_accessories = build_accessory_nodes(pool, file_uuid, output_dir).await?;
|
||||
let e_ls = build_lip_sync_edges(pool, file_uuid, output_dir, &pose_data).await?;
|
||||
let e_ha = build_has_appearance_edges(pool, file_uuid).await?;
|
||||
let e_w = build_wears_edges(pool, file_uuid).await?;
|
||||
let e_ho = build_hand_object_edges(pool, file_uuid, output_dir).await?;
|
||||
|
||||
Ok(TkgResult {
|
||||
face_track_nodes: n_face,
|
||||
@@ -571,6 +627,7 @@ text_region_nodes: n_text,
|
||||
appearance_trace_nodes: n_appearance,
|
||||
accessory_nodes: n_accessories,
|
||||
object_nodes: n_objects,
|
||||
hand_nodes: n_hands,
|
||||
speaker_nodes: n_speakers,
|
||||
co_occurrence_edges: e_co,
|
||||
speaker_face_edges: e_sf,
|
||||
@@ -579,6 +636,7 @@ object_nodes: n_objects,
|
||||
lip_sync_edges: e_ls,
|
||||
has_appearance_edges: e_ha,
|
||||
wears_edges: e_w,
|
||||
hand_object_edges: e_ho,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -604,9 +662,9 @@ async fn build_face_track_nodes(
|
||||
.await;
|
||||
}
|
||||
|
||||
// Fallback to PostgreSQL
|
||||
tracing::info!("[TKG-Phase2] No Qdrant embeddings, falling back to PostgreSQL");
|
||||
build_face_track_nodes_from_pg(pool, file_uuid, pose_data).await
|
||||
// Fallback to face.json (Phase 2.5: Direct from face.json)
|
||||
tracing::info!("[TKG-Phase2.5] No Qdrant embeddings, trying face.json");
|
||||
build_face_track_nodes_from_face_json(pool, file_uuid, pose_data).await
|
||||
}
|
||||
|
||||
async fn build_face_track_nodes_from_qdrant(
|
||||
@@ -844,6 +902,121 @@ async fn build_face_track_nodes_from_pg(
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn build_face_track_nodes_from_face_json(
|
||||
pool: &PgPool,
|
||||
file_uuid: &str,
|
||||
pose_data: &[FacePose],
|
||||
) -> Result<usize> {
|
||||
let face_json_path = Path::new(&*crate::core::config::OUTPUT_DIR)
|
||||
.join(format!("{}.face.json", file_uuid));
|
||||
|
||||
if !face_json_path.exists() {
|
||||
tracing::info!("[TKG-Phase2.5] No face.json for {}", file_uuid);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&face_json_path)
|
||||
.with_context(|| format!("Failed to read {:?}", face_json_path))?;
|
||||
let face_result: crate::core::processor::face::FaceResult = serde_json::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse {:?}", face_json_path))?;
|
||||
|
||||
// Extract faces with embeddings
|
||||
let faces_with_embeddings: Vec<(u64, f64, crate::core::processor::face::Face)> = face_result
|
||||
.frames
|
||||
.iter()
|
||||
.flat_map(|frame| {
|
||||
frame.faces.iter().filter_map(|face| {
|
||||
if let Some(ref embedding) = face.embedding {
|
||||
Some((frame.frame, frame.timestamp, face.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if faces_with_embeddings.is_empty() {
|
||||
tracing::info!("[TKG-Phase2.5] No embeddings in face.json for {}", file_uuid);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[TKG-Phase2.5] Found {} faces with embeddings in face.json",
|
||||
faces_with_embeddings.len()
|
||||
);
|
||||
|
||||
// Simple clustering: assign trace_id based on consecutive similar embeddings
|
||||
let nodes_table = t("tkg_nodes");
|
||||
let mut trace_id = 1_i64;
|
||||
let mut traces: Vec<(i64, Vec<(u64, f64, f64, f64, f64, f64)>)> = vec![];
|
||||
|
||||
for (frame, timestamp, face) in &faces_with_embeddings {
|
||||
// For simplicity, assign all faces to one trace (proper clustering requires DBSCAN)
|
||||
if traces.is_empty() {
|
||||
traces.push((trace_id, vec![]));
|
||||
}
|
||||
traces[0].1.push((
|
||||
*frame,
|
||||
*timestamp,
|
||||
face.x as f64,
|
||||
face.y as f64,
|
||||
face.width as f64,
|
||||
face.height as f64,
|
||||
));
|
||||
}
|
||||
|
||||
let mut count = 0;
|
||||
for (tid, frames) in &traces {
|
||||
let external_id = format!("face_track_{}", tid);
|
||||
let label = format!("Face Trace {}", tid);
|
||||
|
||||
let frame_count = frames.len() as i64;
|
||||
let start_f = frames.iter().map(|(f, _, _, _, _, _)| *f as i64).min().unwrap_or(0);
|
||||
let end_f = frames.iter().map(|(f, _, _, _, _, _)| *f as i64).max().unwrap_or(0);
|
||||
let avg_x = frames.iter().map(|(_, _, x, _, _, _)| *x).sum::<f64>() / frame_count as f64;
|
||||
let avg_y = frames.iter().map(|(_, _, _, y, _, _)| *y).sum::<f64>() / frame_count as f64;
|
||||
let avg_w = frames.iter().map(|(_, _, _, _, w, _)| *w).sum::<f64>() / frame_count as f64;
|
||||
let avg_h = frames.iter().map(|(_, _, _, _, _, h)| *h).sum::<f64>() / frame_count as f64;
|
||||
|
||||
let props = serde_json::json!({
|
||||
"trace_id": tid,
|
||||
"frame_count": frame_count,
|
||||
"start_frame": start_f,
|
||||
"end_frame": end_f,
|
||||
"avg_bbox": {
|
||||
"x": avg_x.round() as i64,
|
||||
"y": avg_y.round() as i64,
|
||||
"width": avg_w.round() as i64,
|
||||
"height": avg_h.round() as i64,
|
||||
},
|
||||
});
|
||||
|
||||
sqlx::query(&format!(
|
||||
r#"
|
||||
INSERT INTO {} (node_type, external_id, file_uuid, label, properties)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
ON CONFLICT (file_uuid, node_type, external_id)
|
||||
DO UPDATE SET
|
||||
properties = COALESCE(EXCLUDED.properties, tkg_nodes.properties),
|
||||
label = COALESCE(NULLIF(EXCLUDED.label, ''), tkg_nodes.label)
|
||||
"#,
|
||||
nodes_table
|
||||
))
|
||||
.bind("face_track")
|
||||
.bind(&external_id)
|
||||
.bind(file_uuid)
|
||||
.bind(&label)
|
||||
.bind(serde_json::to_string(&props)?)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
count += 1;
|
||||
}
|
||||
|
||||
tracing::info!("[TKG-Phase2.5] Built {} face_track nodes from face.json", count);
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn build_yolo_object_nodes(
|
||||
pool: &PgPool,
|
||||
file_uuid: &str,
|
||||
@@ -900,6 +1073,64 @@ for det in dets {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn build_hand_nodes(
|
||||
pool: &PgPool,
|
||||
file_uuid: &str,
|
||||
output_dir: &str,
|
||||
) -> Result<usize> {
|
||||
let hand_path = Path::new(output_dir).join(format!("{}.hand.json", file_uuid));
|
||||
if !hand_path.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&hand_path)
|
||||
.with_context(|| format!("Failed to read {:?}", hand_path))?;
|
||||
let hand: HandJson = serde_json::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse {:?}", hand_path))?;
|
||||
|
||||
let mut hand_gesture_counts: HashMap<(String, String), i64> = HashMap::new();
|
||||
for fdata in &hand.frames {
|
||||
for person in &fdata.persons {
|
||||
let key = (person.hand_type.clone(), person.gesture.clone());
|
||||
*hand_gesture_counts.entry(key).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let nodes_table = t("tkg_nodes");
|
||||
let mut count = 0;
|
||||
for ((hand_type, gesture), cnt) in &hand_gesture_counts {
|
||||
let external_id = format!("{}/{}", hand_type, gesture);
|
||||
let props = serde_json::json!({
|
||||
"total_detections": cnt,
|
||||
"hand_type": hand_type,
|
||||
"gesture": gesture,
|
||||
});
|
||||
|
||||
sqlx::query(&format!(
|
||||
r#"
|
||||
INSERT INTO {} (node_type, external_id, file_uuid, label, properties)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
ON CONFLICT (file_uuid, node_type, external_id)
|
||||
DO UPDATE SET
|
||||
properties = COALESCE(EXCLUDED.properties, tkg_nodes.properties)
|
||||
"#,
|
||||
nodes_table
|
||||
))
|
||||
.bind("hand")
|
||||
.bind(&external_id)
|
||||
.bind(file_uuid)
|
||||
.bind(&external_id)
|
||||
.bind(serde_json::to_string(&props)?)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
count += 1;
|
||||
}
|
||||
|
||||
tracing::info!("[TKG] Hand nodes: {} hand_type/gesture combinations", count);
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn build_speaker_nodes(pool: &PgPool, file_uuid: &str, output_dir: &str) -> Result<usize> {
|
||||
let asrx_path = Path::new(output_dir).join(format!("{}.asrx.json", file_uuid));
|
||||
if !asrx_path.exists() {
|
||||
@@ -1928,7 +2159,16 @@ async fn build_gaze_track_nodes(
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!("[TKG-Phase2.5] No Qdrant embeddings, falling back to PostgreSQL");
|
||||
tracing::info!("[TKG-Phase2.5] No Qdrant embeddings, trying face.json");
|
||||
|
||||
// Try face.json first (方案 B)
|
||||
let count = build_gaze_track_nodes_from_face_json(pool, file_uuid, pose_data).await?;
|
||||
if count > 0 {
|
||||
return Ok(count);
|
||||
}
|
||||
|
||||
// Fallback to PostgreSQL
|
||||
tracing::info!("[TKG-Phase2.5] No face.json gaze data, falling back to PostgreSQL");
|
||||
build_gaze_track_nodes_from_pg(pool, file_uuid, pose_data).await
|
||||
}
|
||||
|
||||
@@ -2084,6 +2324,107 @@ let mut frame_count = 0i64;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn build_gaze_track_nodes_from_face_json(
|
||||
pool: &PgPool,
|
||||
file_uuid: &str,
|
||||
pose_data: &[FacePose],
|
||||
) -> Result<usize> {
|
||||
let face_json_path = Path::new(&*crate::core::config::OUTPUT_DIR)
|
||||
.join(format!("{}.face.json", file_uuid));
|
||||
|
||||
if !face_json_path.exists() {
|
||||
tracing::info!("[TKG-Phase2.5] No face.json for gaze_track");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&face_json_path)?;
|
||||
let face_result: crate::core::processor::face::FaceResult = serde_json::from_str(&content)?;
|
||||
|
||||
// Group faces by trace_id (assuming trace_id = 1 for all faces in face.json)
|
||||
let mut frames_data: Vec<(u64, f64, f64, f64, f64)> = vec![];
|
||||
|
||||
for frame in &face_result.frames {
|
||||
for face in &frame.faces {
|
||||
frames_data.push((
|
||||
frame.frame,
|
||||
face.x as f64,
|
||||
face.y as f64,
|
||||
face.width as f64,
|
||||
face.height as f64,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if frames_data.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Compute gaze stats for trace_id = 1
|
||||
let trace_id = 1_i64;
|
||||
let external_id = format!("gaze_{}", trace_id);
|
||||
|
||||
let mut frame_count = 0i64;
|
||||
let mut first_frame = i64::MAX;
|
||||
let mut last_frame = i64::MIN;
|
||||
let mut yaw_sum = 0.0f64;
|
||||
let mut pitch_sum = 0.0f64;
|
||||
let mut roll_sum = 0.0f64;
|
||||
let mut gaze_dir_counts: HashMap<&str, i64> = HashMap::new();
|
||||
|
||||
for (frame, x, y, w, h) in &frames_data {
|
||||
if let Some((yaw, pitch, roll)) = get_pose_for_face(*frame as i64, *x, *y, *w, *h, pose_data) {
|
||||
frame_count += 1;
|
||||
first_frame = first_frame.min(*frame as i64);
|
||||
last_frame = last_frame.max(*frame as i64);
|
||||
yaw_sum += yaw;
|
||||
pitch_sum += pitch;
|
||||
roll_sum += roll;
|
||||
|
||||
let gaze_dir = GazeDirection::from_yaw_pitch(yaw, pitch);
|
||||
*gaze_dir_counts.entry(gaze_dir.as_str()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if frame_count == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let avg_yaw = yaw_sum / frame_count as f64;
|
||||
let avg_pitch = pitch_sum / frame_count as f64;
|
||||
let avg_roll = roll_sum / frame_count as f64;
|
||||
let dominant_gaze = gaze_dir_counts.iter().max_by_key(|(_, &c)| c).map(|(&d, _)| d).unwrap_or("unknown");
|
||||
let (gaze_dx, gaze_dy) = compute_gaze_vector(avg_yaw, avg_pitch);
|
||||
|
||||
let props = serde_json::json!({
|
||||
"trace_id": trace_id,
|
||||
"frame_count": frame_count,
|
||||
"start_frame": first_frame,
|
||||
"end_frame": last_frame,
|
||||
"avg_yaw": (avg_yaw * 1000.0).round() / 1000.0,
|
||||
"avg_pitch": (avg_pitch * 1000.0).round() / 1000.0,
|
||||
"avg_roll": (avg_roll * 1000.0).round() / 1000.0,
|
||||
"dominant_gaze": dominant_gaze,
|
||||
"gaze_dx": (gaze_dx * 1000.0).round() / 1000.0,
|
||||
"gaze_dy": (gaze_dy * 1000.0).round() / 1000.0,
|
||||
});
|
||||
|
||||
let nodes_table = t("tkg_nodes");
|
||||
sqlx::query(&format!(
|
||||
"INSERT INTO {} (file_uuid, external_id, label, node_type, properties, created_at) \
|
||||
VALUES ($1, $2, $3, 'gaze_track', $4, NOW())",
|
||||
nodes_table
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.bind(&external_id)
|
||||
.bind(&format!("Gaze Trace {}", trace_id))
|
||||
.bind(&props)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
tracing::info!("[TKG-Phase2.5] Built {} gaze_track node from face.json", 1);
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
async fn build_gaze_track_nodes_from_pg(
|
||||
pool: &PgPool,
|
||||
file_uuid: &str,
|
||||
@@ -2408,7 +2749,16 @@ async fn build_lip_track_nodes(
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!("[TKG-Phase2.5] No Qdrant embeddings, falling back to PostgreSQL");
|
||||
tracing::info!("[TKG-Phase2.5] No Qdrant embeddings, trying face.json");
|
||||
|
||||
// Try face.json first (方案 B)
|
||||
let count = build_lip_track_nodes_from_face_json(pool, file_uuid, pose_data).await?;
|
||||
if count > 0 {
|
||||
return Ok(count);
|
||||
}
|
||||
|
||||
// Fallback to PostgreSQL
|
||||
tracing::info!("[TKG-Phase2.5] No face.json lip data, falling back to PostgreSQL");
|
||||
build_lip_track_nodes_from_pg(pool, file_uuid, output_dir, pose_data).await
|
||||
}
|
||||
|
||||
@@ -2624,6 +2974,109 @@ let frame_count = frames.len() as i64;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn build_lip_track_nodes_from_face_json(
|
||||
pool: &PgPool,
|
||||
file_uuid: &str,
|
||||
pose_data: &[FacePose],
|
||||
) -> Result<usize> {
|
||||
let face_json_path = Path::new(&*crate::core::config::OUTPUT_DIR)
|
||||
.join(format!("{}.face.json", file_uuid));
|
||||
|
||||
if !face_json_path.exists() {
|
||||
tracing::info!("[TKG-Phase2.5] No face.json for lip_track");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&face_json_path)?;
|
||||
let face_result: crate::core::processor::face::FaceResult = serde_json::from_str(&content)?;
|
||||
|
||||
// Group faces by trace_id (assuming trace_id = 1 for all faces in face.json)
|
||||
let mut frames_data: Vec<(u64, f64, f64, f64, f64, Option<serde_json::Value>)> = vec![];
|
||||
|
||||
for frame in &face_result.frames {
|
||||
for face in &frame.faces {
|
||||
frames_data.push((
|
||||
frame.frame,
|
||||
face.x as f64,
|
||||
face.y as f64,
|
||||
face.width as f64,
|
||||
face.height as f64,
|
||||
face.landmarks.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if frames_data.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Compute lip stats for trace_id = 1
|
||||
let trace_id = 1_i64;
|
||||
let external_id = format!("lip_{}", trace_id);
|
||||
|
||||
let mut frame_count = 0i64;
|
||||
let mut first_frame = i64::MAX;
|
||||
let mut last_frame = i64::MIN;
|
||||
let mut lip_area_sum = 0.0f64;
|
||||
let mut lip_openness_sum = 0.0f64;
|
||||
let mut speaking_frames = 0i64;
|
||||
|
||||
for (frame, x, y, w, h, landmarks) in &frames_data {
|
||||
if let Some((yaw, pitch, roll)) = get_pose_for_face(*frame as i64, *x, *y, *w, *h, pose_data) {
|
||||
frame_count += 1;
|
||||
first_frame = first_frame.min(*frame as i64);
|
||||
last_frame = last_frame.max(*frame as i64);
|
||||
|
||||
// Compute lip area and openness from landmarks
|
||||
let lip_area = compute_lip_area(landmarks.as_ref());
|
||||
let lip_openness = if lip_area > 0.0 { lip_area / (w * h) } else { 0.0 };
|
||||
|
||||
lip_area_sum += lip_area;
|
||||
lip_openness_sum += lip_openness;
|
||||
|
||||
// Speaking detection (lip openness > threshold)
|
||||
if lip_openness > 0.02 {
|
||||
speaking_frames += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if frame_count == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let avg_lip_area = lip_area_sum / frame_count as f64;
|
||||
let avg_lip_openness = lip_openness_sum / frame_count as f64;
|
||||
let speaking_ratio = speaking_frames as f64 / frame_count as f64;
|
||||
|
||||
let props = serde_json::json!({
|
||||
"trace_id": trace_id,
|
||||
"frame_count": frame_count,
|
||||
"start_frame": first_frame,
|
||||
"end_frame": last_frame,
|
||||
"avg_lip_area": (avg_lip_area * 1000.0).round() / 1000.0,
|
||||
"avg_lip_openness": (avg_lip_openness * 1000.0).round() / 1000.0,
|
||||
"speaking_frames": speaking_frames,
|
||||
"speaking_ratio": (speaking_ratio * 100.0).round() / 100.0,
|
||||
});
|
||||
|
||||
let nodes_table = t("tkg_nodes");
|
||||
sqlx::query(&format!(
|
||||
"INSERT INTO {} (file_uuid, external_id, label, node_type, properties, created_at) \
|
||||
VALUES ($1, $2, $3, 'lip_track', $4, NOW())",
|
||||
nodes_table
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.bind(&external_id)
|
||||
.bind(&format!("Lip Trace {}", trace_id))
|
||||
.bind(&props)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
tracing::info!("[TKG-Phase2.5] Built {} lip_track node from face.json", 1);
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
async fn build_lip_track_nodes_from_pg(
|
||||
pool: &PgPool,
|
||||
file_uuid: &str,
|
||||
@@ -2936,13 +3389,13 @@ async fn build_lip_sync_edges(
|
||||
let edges_table = t("tkg_edges");
|
||||
|
||||
// Get lip traces
|
||||
let lip_tracks: Vec<(i64, String, i64, i64, i64, f64)> = sqlx::query_as(&format!(
|
||||
let lip_tracks: Vec<(i64, String, Option<i64>, Option<i64>, Option<i64>, Option<f64>)> = sqlx::query_as(&format!(
|
||||
r#"
|
||||
SELECT id::bigint, external_id,
|
||||
(properties->>'start_frame')::bigint,
|
||||
(properties->>'end_frame')::bigint,
|
||||
(properties->>'speaking_frames')::bigint,
|
||||
(properties->>'avg_openness')::float8
|
||||
(properties->>'avg_lip_openness')::float8
|
||||
FROM {} WHERE file_uuid = $1 AND node_type = 'lip_track'
|
||||
"#,
|
||||
nodes_table
|
||||
@@ -2952,7 +3405,7 @@ async fn build_lip_sync_edges(
|
||||
.await?;
|
||||
|
||||
// Get text traces
|
||||
let text_regions: Vec<(i64, String, i64, i64, Option<String>)> = sqlx::query_as(&format!(
|
||||
let text_regions: Vec<(i64, String, Option<i64>, Option<i64>, Option<String>)> = sqlx::query_as(&format!(
|
||||
r#"
|
||||
SELECT id::bigint, external_id,
|
||||
(properties->>'start_frame')::bigint,
|
||||
@@ -2969,8 +3422,15 @@ async fn build_lip_sync_edges(
|
||||
let mut edge_count = 0;
|
||||
let mut node_id_cache: HashMap<String, i64> = HashMap::new();
|
||||
|
||||
for (lip_id, lip_ext, lip_start, lip_end, lip_speaking, lip_openness) in &lip_tracks {
|
||||
for (text_id, text_ext, text_start, text_end, speaker_id) in &text_regions {
|
||||
for (lip_id, lip_ext, lip_start_opt, lip_end_opt, lip_speaking_opt, lip_openness_opt) in &lip_tracks {
|
||||
let lip_start = lip_start_opt.unwrap_or(0);
|
||||
let lip_end = lip_end_opt.unwrap_or(0);
|
||||
let lip_speaking = lip_speaking_opt.unwrap_or(0);
|
||||
let lip_openness = lip_openness_opt.unwrap_or(0.0);
|
||||
|
||||
for (text_id, text_ext, text_start_opt, text_end_opt, speaker_id) in &text_regions {
|
||||
let text_start = text_start_opt.unwrap_or(0);
|
||||
let text_end = text_end_opt.unwrap_or(0);
|
||||
// Check time overlap
|
||||
let overlap_start = lip_start.max(text_start);
|
||||
let overlap_end = lip_end.min(text_end);
|
||||
@@ -3334,6 +3794,142 @@ async fn build_wears_edges(pool: &PgPool, file_uuid: &str) -> Result<usize> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn build_hand_object_edges(pool: &PgPool, file_uuid: &str, output_dir: &str) -> Result<usize> {
|
||||
let hand_path = Path::new(output_dir).join(format!("{}.hand.json", file_uuid));
|
||||
let yolo_path = Path::new(output_dir).join(format!("{}.yolo.json", file_uuid));
|
||||
|
||||
if !hand_path.exists() || !yolo_path.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let hand_content = std::fs::read_to_string(&hand_path)
|
||||
.with_context(|| format!("Failed to read {:?}", hand_path))?;
|
||||
let hand: HandJson = serde_json::from_str(&hand_content)
|
||||
.with_context(|| format!("Failed to parse {:?}", hand_path))?;
|
||||
|
||||
let yolo_content = std::fs::read_to_string(&yolo_path)
|
||||
.with_context(|| format!("Failed to read {:?}", yolo_path))?;
|
||||
let yolo: YoloJson = serde_json::from_str(&yolo_content)
|
||||
.with_context(|| format!("Failed to parse {:?}", yolo_path))?;
|
||||
|
||||
let yolo_frames: HashMap<u64, &Vec<YoloDetEntry>> = yolo.frames
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let objs = if !f.objects.is_empty() { &f.objects } else { &f.detections };
|
||||
if !objs.is_empty() {
|
||||
Some((f.frame as u64, objs))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let edges_table = t("tkg_edges");
|
||||
let nodes_table = t("tkg_nodes");
|
||||
let mut count = 0;
|
||||
|
||||
for hf in &hand.frames {
|
||||
let yolo_objs = yolo_frames.get(&(hf.frame as u64));
|
||||
if yolo_objs.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for person in &hf.persons {
|
||||
if person.hand_state != "holding" || person.landmarks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate hand bbox from landmarks
|
||||
let xs: Vec<f32> = person.landmarks.iter().map(|lm| lm.x).collect();
|
||||
let ys: Vec<f32> = person.landmarks.iter().map(|lm| lm.y).collect();
|
||||
|
||||
let hand_x = xs.iter().cloned().fold(f32::MAX, f32::min) as f64;
|
||||
let hand_y = ys.iter().cloned().fold(f32::MAX, f32::min) as f64;
|
||||
let hand_x_max = xs.iter().cloned().fold(f32::MIN, f32::max) as f64;
|
||||
let hand_y_max = ys.iter().cloned().fold(f32::MIN, f32::max) as f64;
|
||||
let hand_w = hand_x_max - hand_x;
|
||||
let hand_h = hand_y_max - hand_y;
|
||||
|
||||
// Match with YOLO objects by bbox overlap
|
||||
for obj in yolo_objs.unwrap().iter() {
|
||||
let obj_x = obj.x as f64;
|
||||
let obj_y = obj.y as f64;
|
||||
let obj_w = obj.width as f64;
|
||||
let obj_h = obj.height as f64;
|
||||
|
||||
// Calculate overlap (IoU-like)
|
||||
let x1 = hand_x.max(obj_x);
|
||||
let y1 = hand_y.max(obj_y);
|
||||
let x2 = (hand_x + hand_w).min(obj_x + obj_w);
|
||||
let y2 = (hand_y + hand_h).min(obj_y + obj_h);
|
||||
|
||||
if x1 < x2 && y1 < y2 {
|
||||
let overlap_area = (x2 - x1) * (y2 - y1);
|
||||
let hand_area = hand_w * hand_h;
|
||||
let obj_area = obj_w * obj_h;
|
||||
|
||||
let overlap_ratio = overlap_area / hand_area.min(obj_area);
|
||||
|
||||
if overlap_ratio > 0.1 {
|
||||
// Create HAND_OBJECT edge
|
||||
let hand_node_id = format!("{}/{}", person.hand_type, person.gesture);
|
||||
|
||||
// Query hand node id
|
||||
let hand_node_row: Option<(i64,)> = sqlx::query_as(&format!(
|
||||
"SELECT id FROM {} WHERE node_type = $1 AND external_id = $2 AND file_uuid = $3",
|
||||
nodes_table
|
||||
))
|
||||
.bind("hand")
|
||||
.bind(&hand_node_id)
|
||||
.bind(file_uuid)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
// Query object node id
|
||||
let object_node_row: Option<(i64,)> = sqlx::query_as(&format!(
|
||||
"SELECT id FROM {} WHERE node_type = $1 AND external_id = $2 AND file_uuid = $3",
|
||||
nodes_table
|
||||
))
|
||||
.bind("object")
|
||||
.bind(&obj.class_name)
|
||||
.bind(file_uuid)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let (Some((hand_id,)), Some((obj_id,))) = (hand_node_row, object_node_row) {
|
||||
sqlx::query(&format!(
|
||||
r#"
|
||||
INSERT INTO {} (edge_type, source_node_id, target_node_id, file_uuid, properties)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
ON CONFLICT (file_uuid, edge_type, source_node_id, target_node_id)
|
||||
DO NOTHING
|
||||
"#,
|
||||
edges_table
|
||||
))
|
||||
.bind("hand_object")
|
||||
.bind(hand_id)
|
||||
.bind(obj_id)
|
||||
.bind(file_uuid)
|
||||
.bind(serde_json::json!({
|
||||
"frame": hf.frame,
|
||||
"overlap_ratio": overlap_ratio,
|
||||
"hand_state": "holding",
|
||||
}).to_string())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("[TKG] HAND_OBJECT edges: {} matches", count);
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn match_trace_by_bbox(
|
||||
pool: &PgPool,
|
||||
file_uuid: &str,
|
||||
|
||||
Reference in New Issue
Block a user