feat: save key_frame.jpg and key_face.jpg for each face trace during TKG build

- Extract full frame at best confidence frame using ffmpeg
- Crop face region using average bbox coordinates
- Save to .faces/{file_uuid}/{trace_id}/key_frame.jpg and key_face.jpg
- TKG node properties store full file paths for UI display
- Supports future extension for cut representative frames
This commit is contained in:
Accusys
2026-07-21 04:08:16 +08:00
parent 6f5d71763f
commit fcdeab82e6
+64 -1
View File
@@ -1218,6 +1218,69 @@ async fn build_face_track_nodes_from_qdrant(
"status": "pending", "status": "pending",
}); });
// Save key frame and key face JPGs
let output_dir = crate::core::config::OUTPUT_DIR.as_str();
let face_dir = std::path::PathBuf::from(output_dir)
.join(".faces").join(file_uuid).join(&external_id);
let _ = std::fs::create_dir_all(&face_dir);
let key_frame_path = face_dir.join("key_frame.jpg");
let key_face_path = face_dir.join("key_face.jpg");
// Get video path from database
let video_path: Option<String> = sqlx::query_scalar(&format!(
"SELECT file_path FROM {} WHERE file_uuid = $1",
crate::core::db::schema::table_name("videos")
))
.bind(file_uuid)
.fetch_optional(pool)
.await
.ok()
.flatten();
// Extract key frame using ffmpeg
if !key_frame_path.exists() {
if let Some(ref vp) = video_path {
let timestamp = agg.best_frame as f64 / 30.0;
let _ = std::process::Command::new("ffmpeg")
.args(["-y", "-v", "quiet", "-ss", &timestamp.to_string(), "-i", vp])
.args(["-frames:v", "1", "-q:v", "2"])
.arg(key_frame_path.to_str().unwrap_or(""))
.output();
}
}
// Crop key face from key frame using bbox
if !key_face_path.exists() && key_frame_path.exists() {
let bbox = &props["avg_bbox"];
let x = bbox["x"].as_i64().unwrap_or(0) as u32;
let y = bbox["y"].as_i64().unwrap_or(0) as u32;
let w = bbox["width"].as_i64().unwrap_or(100) as u32;
let h = bbox["height"].as_i64().unwrap_or(100) as u32;
let _ = std::process::Command::new("ffmpeg")
.args(["-y", "-v", "quiet", "-i", key_frame_path.to_str().unwrap_or("")])
.args(["-vf", &format!("crop={}:{}:{}:{}", w, h, x, y)])
.args(["-q:v", "2"])
.arg(key_face_path.to_str().unwrap_or(""))
.output();
}
// Update props with actual file paths
let final_props = serde_json::json!({
"trace_id": tid,
"frame_count": agg.frame_count,
"start_frame": agg.start_f,
"end_frame": agg.end_f,
"key_frame": if key_frame_path.exists() { key_frame_path.to_string_lossy().to_string() } else { serde_json::Value::Null.to_string() },
"key_face": if key_face_path.exists() { key_face_path.to_string_lossy().to_string() } else { serde_json::Value::Null.to_string() },
"avg_bbox": props["avg_bbox"].clone(),
"avg_yaw": props["avg_yaw"].clone(),
"avg_pitch": props["avg_pitch"].clone(),
"avg_roll": props["avg_roll"].clone(),
"pose_count": pose_count,
"status": "pending",
});
sqlx::query(&format!( sqlx::query(&format!(
r#" r#"
INSERT INTO {} (node_type, external_id, file_uuid, label, properties) INSERT INTO {} (node_type, external_id, file_uuid, label, properties)
@@ -1232,7 +1295,7 @@ async fn build_face_track_nodes_from_qdrant(
.bind(&external_id) .bind(&external_id)
.bind(file_uuid) .bind(file_uuid)
.bind(&label) .bind(&label)
.bind(serde_json::to_string(&props)?) .bind(serde_json::to_string(&final_props)?)
.execute(pool) .execute(pool)
.await?; .await?;