fix: ASRX duplication, TKG edges, trace ingest, and add pipeline progress publishing

- ASRX handler no longer stores duplicate 'asr' pre_chunks
- Pre_chunks storage made idempotent (delete-before-insert)
- Rule 1 + trace_ingest changed to query 'asrx' not 'asr'
- Trace chunks removed (dynamic from TKG/Qdrant)
- TKG scroll_face_points fixed: trace_id >= 1 (not == 1)
- TKG AsrxSegmentEntry: start/end -> start_time/end_time (match ASRX JSON)
- Unregister error handling: log instead of silent discard
- Add publish_pipeline_progress calls at each pipeline stage
  (processors, rule1, face_trace, identity_agent, TKG, rule2, completion)
This commit is contained in:
Accusys
2026-07-02 10:43:46 +08:00
parent d791d138f2
commit 3eabd45882
65 changed files with 9477 additions and 3852 deletions
+10 -2
View File
@@ -24,10 +24,18 @@ pub struct AppearanceFrame {
pub struct AppearancePerson {
pub person_id: u64,
pub bbox: BBox,
pub facing: String,
pub body_parts: Vec<BodyPart>,
pub dominant_colors: Vec<Vec<f64>>,
pub hsv_histogram: Vec<Vec<f64>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BodyPart {
pub name: String,
pub bbox: BBox,
pub hsv_histogram: Vec<Vec<f64>>,
pub dominant_colors: Vec<Vec<f64>>,
pub upper_body: Option<Vec<Vec<f64>>>,
pub lower_body: Option<Vec<Vec<f64>>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
+45 -3
View File
@@ -2,12 +2,47 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use super::executor::PythonExecutor;
use super::AsrStatus;
#[derive(Debug, Serialize, Deserialize)]
pub struct AsrResult {
#[serde(default)]
pub status: Option<AsrStatus>,
pub language: Option<String>,
pub language_probability: Option<f64>,
pub segments: Vec<AsrSegment>,
#[serde(default)]
pub segment_count: usize,
}
impl AsrResult {
pub fn compute_status(&mut self) {
self.segment_count = self.segments.len();
// Only compute status if Python didn't provide one
if self.status.is_none() {
self.status = Some(AsrStatus::from_segments(self.segment_count));
}
}
pub fn no_audio_track() -> Self {
AsrResult {
status: Some(AsrStatus::NoAudioTrack),
language: None,
language_probability: None,
segments: vec![],
segment_count: 0,
}
}
pub fn silent_audio() -> Self {
AsrResult {
status: Some(AsrStatus::SilentAudio),
language: None,
language_probability: None,
segments: vec![],
segment_count: 0,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
@@ -44,12 +79,19 @@ pub async fn process_asr(
let json_str = std::fs::read_to_string(output_path).context("Failed to read ASR output")?;
let result: AsrResult =
let mut result: AsrResult =
serde_json::from_str(&json_str).context("Failed to parse ASR output")?;
result.compute_status();
tracing::info!(
"[ASR] Result: {} segments, language: {:?}",
result.segments.len(),
"[ASR] Result: status={}, {} segments, language: {:?}",
result
.status
.as_ref()
.map(|s| s.to_string())
.unwrap_or_default(),
result.segment_count,
result.language
);
+44 -2
View File
@@ -6,15 +6,47 @@ use tokio::process::Command;
use tokio::time::timeout;
use super::executor::PythonExecutor;
use super::AsrStatus;
const ASRX_TIMEOUT: Duration = Duration::from_secs(7200);
#[derive(Debug, Serialize, Deserialize)]
pub struct AsrxResult {
#[serde(default)]
pub status: Option<AsrStatus>,
pub language: Option<String>,
pub segments: Vec<AsrxSegment>,
#[serde(skip_serializing)]
pub embeddings: Option<Vec<Vec<f32>>>,
#[serde(default)]
pub segment_count: usize,
}
impl AsrxResult {
pub fn compute_status(&mut self) {
self.segment_count = self.segments.len();
self.status = Some(AsrStatus::from_segments(self.segment_count));
}
pub fn no_audio_track() -> Self {
AsrxResult {
status: Some(AsrStatus::NoAudioTrack),
language: None,
segments: vec![],
embeddings: None,
segment_count: 0,
}
}
pub fn silent_audio() -> Self {
AsrxResult {
status: Some(AsrStatus::SilentAudio),
language: None,
segments: vec![],
embeddings: None,
segment_count: 0,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
@@ -157,10 +189,20 @@ pub async fn process_asrx(
let json_str = std::fs::read_to_string(output_path).context("Failed to read ASRX output")?;
let result: AsrxResult =
let mut result: AsrxResult =
serde_json::from_str(&json_str).context("Failed to parse ASRX output")?;
tracing::info!("[ASRX] Result: {} segments", result.segments.len());
result.compute_status();
tracing::info!(
"[ASRX] Result: status={}, {} segments",
result
.status
.as_ref()
.map(|s| s.to_string())
.unwrap_or_default(),
result.segment_count
);
Ok(result)
}
+12
View File
@@ -174,6 +174,12 @@ impl PythonExecutor {
(0..total_frames).step_by(interval as usize).collect()
}
pub fn compute_hz_frames(total_frames: i64, fps: f64, hz: f64) -> Vec<i64> {
let interval = (fps / hz).round() as i64;
let interval = interval.max(1);
(0..total_frames).step_by(interval as usize).collect()
}
/// Merge base frames with refinement frames (for adaptive sampling).
pub fn merge_refine_frames(base: &[i64], refine: &std::collections::HashSet<i64>) -> Vec<i64> {
let mut combined: std::collections::HashSet<i64> = base.iter().cloned().collect();
@@ -303,6 +309,9 @@ impl PythonExecutor {
cmd.env("DATABASE_SCHEMA", &*DATABASE_SCHEMA);
cmd.env("MOMENTRY_DB_SCHEMA", &*DATABASE_SCHEMA);
cmd.env("MOMENTRY_REDIS_PREFIX", &*REDIS_KEY_PREFIX);
if let Some(u) = uuid {
cmd.env("UUID", u);
}
cmd.arg(&script_path);
for arg in args {
@@ -441,6 +450,9 @@ impl PythonExecutor {
cmd.env("DATABASE_SCHEMA", &*DATABASE_SCHEMA);
cmd.env("MOMENTRY_DB_SCHEMA", &*DATABASE_SCHEMA);
cmd.env("MOMENTRY_REDIS_PREFIX", &*REDIS_KEY_PREFIX);
if let Some(u) = uuid {
cmd.env("UUID", u);
}
cmd.arg(&script_path);
for arg in args {
+66 -7
View File
@@ -3,14 +3,39 @@ use serde::{Deserialize, Serialize};
use std::time::Duration;
use super::executor::PythonExecutor;
use super::FaceStatus;
const FACE_TIMEOUT: Duration = Duration::from_secs(7200);
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FaceResult {
#[serde(default)]
pub status: Option<FaceStatus>,
pub frame_count: u64,
pub fps: f64,
pub frames: Vec<FaceFrame>,
#[serde(default)]
pub total_faces: usize,
}
impl FaceResult {
pub fn compute_status(&mut self) {
self.total_faces = self.frames.iter().map(|f| f.faces.len()).sum();
// Only compute status if Python didn't provide one
if self.status.is_none() {
self.status = Some(FaceStatus::from_face_count(self.total_faces));
}
}
pub fn no_faces(frame_count: u64, fps: f64) -> Self {
FaceResult {
status: Some(FaceStatus::NoFaces),
frame_count,
fps,
frames: vec![],
total_faces: 0,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -46,6 +71,33 @@ pub async fn process_face(
uuid: Option<&str>,
frames: Option<&[i64]>,
) -> Result<FaceResult> {
// Check if face.json already exists (from SwiftFacePose)
if std::path::Path::new(output_path).exists() {
tracing::info!(
"[FACE] Output exists from SwiftFacePose, loading: {}",
output_path
);
let json_str =
std::fs::read_to_string(output_path).context("Failed to read existing FACE output")?;
let mut result: FaceResult =
serde_json::from_str(&json_str).context("Failed to parse existing FACE output")?;
result.compute_status();
tracing::info!(
"[FACE] Loaded from SwiftFacePose: status={}, {} frames, {} total faces",
result
.status
.as_ref()
.map(|s| s.to_string())
.unwrap_or_default(),
result.frames.len(),
result.total_faces
);
return Ok(result);
}
let executor = PythonExecutor::new()?;
let script_path = executor.script_path("face_processor.py");
@@ -53,11 +105,7 @@ pub async fn process_face(
if !script_path.exists() {
tracing::warn!("[FACE] Script not found, returning empty result");
return Ok(FaceResult {
frame_count: 0,
fps: 0.0,
frames: vec![],
});
return Ok(FaceResult::no_faces(0, 0.0));
}
executor
@@ -74,10 +122,21 @@ pub async fn process_face(
let json_str = std::fs::read_to_string(output_path).context("Failed to read FACE output")?;
let result: FaceResult =
let mut result: FaceResult =
serde_json::from_str(&json_str).context("Failed to parse FACE output")?;
tracing::info!("[FACE] Result: {} frames", result.frames.len());
result.compute_status();
tracing::info!(
"[FACE] Result: status={}, {} frames, {} total faces",
result
.status
.as_ref()
.map(|s| s.to_string())
.unwrap_or_default(),
result.frames.len(),
result.total_faces
);
Ok(result)
}
+8 -3
View File
@@ -64,12 +64,17 @@ pub async fn process_face_cluster(
.await
.with_context(|| format!("Failed to run face clustering script"))?;
let json_str = std::fs::read_to_string(output_path).context("Failed to read FACE_CLUSTER output")?;
let json_str =
std::fs::read_to_string(output_path).context("Failed to read FACE_CLUSTER output")?;
let result: FaceClusterResult =
serde_json::from_str(&json_str).context("Failed to parse FACE_CLUSTER output")?;
tracing::info!("[FACE_CLUSTER] Result: {} clusters, {} frames", result.clusters.len(), result.frames.len());
tracing::info!(
"[FACE_CLUSTER] Result: {} clusters, {} frames",
result.clusters.len(),
result.frames.len()
);
Ok(result)
}
}
+1 -1
View File
@@ -82,4 +82,4 @@ pub async fn process_hand(
tracing::info!("[HAND] Result: {} frames", result.frames.len());
Ok(result)
}
}
+15 -16
View File
@@ -148,24 +148,23 @@ pub async fn build_heuristic_scene_meta(
}
}
// Get face counts grouped by frame
let fd_table = schema::table_name("face_detections");
let face_rows: Vec<(i64, i64)> = sqlx::query_as(&format!(
"SELECT frame_number, COUNT(*) as fc \
FROM {} \
WHERE file_uuid = $1 AND frame_number IS NOT NULL \
GROUP BY frame_number \
ORDER BY frame_number",
fd_table
))
.bind(file_uuid)
.fetch_all(pool)
.await
.unwrap_or_default();
// Get face counts from Qdrant _faces
use crate::core::db::qdrant_db::QdrantDb;
use serde_json::json;
let qdrant = QdrantDb::new();
let face_filter = json!({
"must": [
{"key": "file_uuid", "match": {"value": file_uuid}},
{"key": "trace_id", "match": {"value": 1}}
]
});
let points = qdrant.scroll_all_points("_faces", face_filter, 500).await.unwrap_or_default();
let mut frame_face_counts: HashMap<i64, i64> = HashMap::new();
for (frame, count) in &face_rows {
frame_face_counts.insert(*frame, *count);
for point in &points {
let frame = point["payload"]["frame"].as_i64().unwrap_or(0);
*frame_face_counts.entry(frame).or_default() += 1;
}
// Process each segment
+140 -4
View File
@@ -17,8 +17,146 @@ pub mod scene_classification;
pub mod tkg;
pub mod yolo;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AsrStatus {
NoAudioTrack,
SilentAudio,
HasTranscript,
Processing,
}
impl std::fmt::Display for AsrStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AsrStatus::NoAudioTrack => write!(f, "no_audio_track"),
AsrStatus::SilentAudio => write!(f, "silent_audio"),
AsrStatus::HasTranscript => write!(f, "has_transcript"),
AsrStatus::Processing => write!(f, "processing"),
}
}
}
impl AsrStatus {
pub fn css_class(&self) -> &'static str {
match self {
AsrStatus::NoAudioTrack => "card-asr--no_audio_track",
AsrStatus::SilentAudio => "card-asr--silent_audio",
AsrStatus::HasTranscript => "card-asr--has_transcript",
AsrStatus::Processing => "card-asr--processing",
}
}
pub fn display_text(&self, segment_count: usize) -> String {
match self {
AsrStatus::NoAudioTrack => "無音軌".to_string(),
AsrStatus::SilentAudio => "無語音".to_string(),
AsrStatus::HasTranscript => format!("{} 段語音", segment_count),
AsrStatus::Processing => "處理中".to_string(),
}
}
pub fn from_segments(segment_count: usize) -> Self {
if segment_count > 0 {
AsrStatus::HasTranscript
} else {
AsrStatus::SilentAudio
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FaceStatus {
NoFaces,
HasFaces,
Processing,
}
impl std::fmt::Display for FaceStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FaceStatus::NoFaces => write!(f, "no_faces"),
FaceStatus::HasFaces => write!(f, "has_faces"),
FaceStatus::Processing => write!(f, "processing"),
}
}
}
impl FaceStatus {
pub fn css_class(&self) -> &'static str {
match self {
FaceStatus::NoFaces => "card-face--no_faces",
FaceStatus::HasFaces => "card-face--has_faces",
FaceStatus::Processing => "card-face--processing",
}
}
pub fn display_text(&self, face_count: usize) -> String {
match self {
FaceStatus::NoFaces => "無人脸".to_string(),
FaceStatus::HasFaces => format!("{} 張人脸", face_count),
FaceStatus::Processing => "處理中".to_string(),
}
}
pub fn from_face_count(face_count: usize) -> Self {
if face_count > 0 {
FaceStatus::HasFaces
} else {
FaceStatus::NoFaces
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TraceStatus {
NoTraces,
HasTraces,
Processing,
}
impl std::fmt::Display for TraceStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TraceStatus::NoTraces => write!(f, "no_traces"),
TraceStatus::HasTraces => write!(f, "has_traces"),
TraceStatus::Processing => write!(f, "processing"),
}
}
}
impl TraceStatus {
pub fn css_class(&self) -> &'static str {
match self {
TraceStatus::NoTraces => "card-trace--no_traces",
TraceStatus::HasTraces => "card-trace--has_traces",
TraceStatus::Processing => "card-trace--processing",
}
}
pub fn display_text(&self, trace_count: usize) -> String {
match self {
TraceStatus::NoTraces => "無人脸轨迹".to_string(),
TraceStatus::HasTraces => format!("{} 条人脸轨迹", trace_count),
TraceStatus::Processing => "處理中".to_string(),
}
}
pub fn from_trace_count(trace_count: usize) -> Self {
if trace_count > 0 {
TraceStatus::HasTraces
} else {
TraceStatus::NoTraces
}
}
}
pub use appearance::{
process_appearance, AppearanceFrame, AppearancePerson, AppearanceResult, BBox,
process_appearance, AppearanceFrame, AppearancePerson, AppearanceResult, BBox, BodyPart,
};
pub use asr::{process_asr, AsrResult, AsrSegment};
pub use asrx::{process_asrx, AsrxResult, AsrxSegment};
@@ -39,9 +177,7 @@ pub use face_recognition::{
FaceRecognitionFrame, FaceRecognitionResult, FaceRegistrationResult, RecognizedFace,
RecognizedFaceDetection,
};
pub use hand::{
process_hand, HandFrame, HandLandmark, HandResult, PersonHand,
};
pub use hand::{process_hand, HandFrame, HandLandmark, HandResult, PersonHand};
pub use heuristic_scene::{
build_heuristic_scene_meta, generate_scene_meta, CrowdSize, HeuristicSceneMeta,
SceneSegmentMeta,
+144
View File
@@ -48,6 +48,150 @@ pub async fn process_pose(
uuid: Option<&str>,
frames: Option<&[i64]>,
) -> Result<PoseResult> {
// Check if pose.json already exists (from swift_face_pose)
if std::path::Path::new(output_path).exists() {
tracing::info!(
"[POSE] Output exists from swift_face_pose, checking if needs interpolation: {}",
output_path
);
let json_str =
std::fs::read_to_string(output_path).context("Failed to read existing POSE output")?;
let existing_result: PoseResult =
serde_json::from_str(&json_str).context("Failed to parse existing POSE output")?;
// Get total video frames to check if interpolation needed
let total_video_frames = {
// Use ffprobe to get frame count from container metadata
let output = std::process::Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=nb_frames",
"-of",
"csv=p=0",
video_path,
])
.output()
.context("Failed to run ffprobe")?;
if output.status.success() {
let frame_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Handle "N/A" case for some videos
if frame_str == "N/A" {
// Fallback to duration * fps
let dur_output = std::process::Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
video_path,
])
.output()
.context("Failed to run ffprobe for duration")?;
let fps_output = std::process::Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"stream=r_frame_rate",
"-of",
"csv=p=0",
video_path,
])
.output()
.context("Failed to run ffprobe for fps")?;
if dur_output.status.success() && fps_output.status.success() {
let dur_str = String::from_utf8_lossy(&dur_output.stdout)
.trim()
.to_string();
let fps_str = String::from_utf8_lossy(&fps_output.stdout)
.trim()
.to_string();
let duration: f64 = dur_str.parse().ok().unwrap_or(0.0);
// Parse fps like "30000/1001" or "30"
let fps: f64 = if fps_str.contains('/') {
let parts: Vec<&str> = fps_str.split('/').collect();
if parts.len() == 2 {
let num: f64 = parts[0].parse().ok().unwrap_or(30.0);
let den: f64 = parts[1].parse().ok().unwrap_or(1.0);
num / den
} else {
30.0
}
} else {
fps_str.parse().ok().unwrap_or(30.0)
};
(duration * fps) as u64
} else {
0
}
} else {
frame_str.parse::<u64>().ok().unwrap_or(0)
}
} else {
0
}
};
// When 8Hz sampling frames are provided, skip interpolation entirely.
// Swift already outputs at sample_interval=3 (~8Hz), no need to fill all frames.
if frames.is_some() {
tracing::info!(
"[POSE] 8Hz mode: returning {} existing frames without interpolation",
existing_result.frames.len()
);
return Ok(existing_result);
}
// If pose frames < video frames, need interpolation
if existing_result.frames.len() < total_video_frames as usize && total_video_frames > 0 {
tracing::info!(
"[POSE] Interpolation needed: {} pose frames < {} video frames",
existing_result.frames.len(),
total_video_frames
);
// Call Python pose_processor.py for interpolation
let executor = PythonExecutor::new()?;
let script_path = executor.script_path("pose_processor.py");
if script_path.exists() {
executor
.run_with_frames(
"pose_processor.py",
&[video_path, output_path],
uuid,
"POSE",
Some(POSE_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 interpolated POSE output")?;
let result: PoseResult = serde_json::from_str(&json_str)
.context("Failed to parse interpolated POSE output")?;
tracing::info!(
"[POSE] Interpolation completed: {} frames",
result.frames.len()
);
return Ok(result);
}
} else {
tracing::info!(
"[POSE] No interpolation needed, loaded {} frames",
existing_result.frames.len()
);
return Ok(existing_result);
}
}
let executor = PythonExecutor::new()?;
let script_path = executor.script_path("pose_processor.py");
+704 -484
View File
File diff suppressed because it is too large Load Diff