- Fix swift_pose/swift_ocr Y-flip bugs (BUG-003~006) - Add heuristic_scene module + post-processing trigger (replaces Places365) - YOLOv5nu → YOLOv8s CoreML (+33% detections, +390% scene indicators) - Per-table SQL export (split 4.7GB single file → 478MB max per table) - Version/build check in deploy.sh (compare /health vs file_info.json) - Add file_uuid column to identities table + backfill - Identity pre-clean step in deploy (avoids UNIQUE conflicts on re-deploy) - Stranger_xxx naming fix with UUID context - Add DETECTOR_REGISTRY.md (25 detectors), DETECTOR_SELECTION_SOP.md - Update SPATIAL_COORDINATE_REGISTRY.md (P layer, 6-layer architecture) - New IDENTITY_LIFECYCLE.md - M4 response docs for deploy_script_fix and 111614 test report
281 lines
8.3 KiB
Rust
281 lines
8.3 KiB
Rust
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::FromRow;
|
|
|
|
// ==========================================
|
|
// 舊版結構體 (保留以向後兼容)
|
|
// ==========================================
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
|
pub struct PersonIdentity {
|
|
pub id: i32,
|
|
pub person_id: String,
|
|
pub face_identity_id: Option<i32>,
|
|
pub speaker_id: Option<String>,
|
|
pub file_uuid: String,
|
|
pub confidence: f64,
|
|
pub name: Option<String>,
|
|
pub metadata: serde_json::Value,
|
|
pub first_appearance_time: Option<f64>,
|
|
pub last_appearance_time: Option<f64>,
|
|
pub total_appearance_duration: f64,
|
|
pub appearance_count: i32,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
pub is_confirmed: bool,
|
|
}
|
|
|
|
// ==========================================
|
|
// 新版結構體 (V5 身份綁定系統)
|
|
// ==========================================
|
|
|
|
/// 人物身份 (Identity) - 統一管理演員、公眾人物、家人朋友等
|
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
|
pub struct Identity {
|
|
pub id: i32,
|
|
pub name: String,
|
|
pub embedding: Option<String>,
|
|
pub metadata: Option<serde_json::Value>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub uuid: Option<uuid::Uuid>,
|
|
pub identity_type: Option<String>,
|
|
pub source: Option<String>,
|
|
pub status: Option<String>,
|
|
pub face_embedding: Option<Vec<f32>>,
|
|
pub voice_embedding: Option<Vec<f32>>,
|
|
pub identity_embedding: Option<Vec<f32>>,
|
|
pub reference_data: Option<serde_json::Value>,
|
|
pub tmdb_id: Option<i32>,
|
|
pub tmdb_profile: Option<String>,
|
|
pub tmdb_poster: Option<String>,
|
|
pub file_uuid: Option<String>,
|
|
}
|
|
|
|
/// 身份綁定記錄 (Identity Binding)
|
|
/// 將機器 ID (face_x, speaker_y) 綁定到 Identity
|
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
|
pub struct IdentityBinding {
|
|
pub id: i64,
|
|
pub identity_id: i64,
|
|
pub binding_type: String, // 'face', 'speaker'
|
|
pub binding_value: String, // e.g. "face_1", "speaker_3"
|
|
pub source: String, // 'auto', 'manual'
|
|
pub confidence: f64,
|
|
pub is_active: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// 綁定請求 (用於 API)
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct BindIdentityRequest {
|
|
pub file_uuid: String,
|
|
pub face_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct UnbindIdentityRequest {
|
|
pub file_uuid: String,
|
|
pub face_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct MergeIdentitiesRequest {
|
|
pub into_uuid: String,
|
|
pub keep_history: Option<bool>,
|
|
}
|
|
|
|
/// 建議綁定請求 (由系統自動產生,人工確認)
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct SuggestedBinding {
|
|
pub binding_type: String,
|
|
pub binding_value: String,
|
|
pub suggested_identity_id: i64,
|
|
pub suggested_identity_name: String,
|
|
pub confidence: f64,
|
|
pub reason: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
|
pub struct PersonAppearance {
|
|
pub id: i32,
|
|
pub person_id: String,
|
|
pub file_uuid: String,
|
|
pub start_time: f64,
|
|
pub end_time: f64,
|
|
pub duration: f64,
|
|
pub face_detection_id: Option<i32>,
|
|
pub asrx_segment_start: Option<f64>,
|
|
pub asrx_segment_end: Option<f64>,
|
|
pub confidence: f64,
|
|
pub metadata: serde_json::Value,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
|
pub struct PersonMatch {
|
|
pub face_id: String,
|
|
pub speaker_id: String,
|
|
pub confidence: f64,
|
|
pub match_count: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PersonTimelineEntry {
|
|
pub start_time: f64,
|
|
pub end_time: f64,
|
|
pub duration: f64,
|
|
pub confidence: f64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PersonStatistics {
|
|
pub total_appearances: i32,
|
|
pub total_duration: f64,
|
|
pub first_appearance: Option<f64>,
|
|
pub last_appearance: Option<f64>,
|
|
pub average_confidence: f64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CreatePersonIdentityRequest {
|
|
pub file_uuid: String,
|
|
pub face_identity_id: Option<i32>,
|
|
pub speaker_id: Option<String>,
|
|
pub name: Option<String>,
|
|
pub metadata: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct UpdatePersonIdentityRequest {
|
|
pub name: Option<String>,
|
|
pub metadata: Option<serde_json::Value>,
|
|
pub is_confirmed: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PersonIdentityResponse {
|
|
pub person_id: String,
|
|
pub name: Option<String>,
|
|
pub face_identity_id: Option<i32>,
|
|
pub speaker_id: Option<String>,
|
|
pub confidence: f64,
|
|
pub appearance_count: i32,
|
|
pub total_appearance_duration: f64,
|
|
pub first_appearance_time: Option<f64>,
|
|
pub last_appearance_time: Option<f64>,
|
|
pub is_confirmed: bool,
|
|
}
|
|
|
|
impl From<PersonIdentity> for PersonIdentityResponse {
|
|
fn from(person: PersonIdentity) -> Self {
|
|
Self {
|
|
person_id: person.person_id,
|
|
name: person.name,
|
|
face_identity_id: person.face_identity_id,
|
|
speaker_id: person.speaker_id,
|
|
confidence: person.confidence,
|
|
appearance_count: person.appearance_count,
|
|
total_appearance_duration: person.total_appearance_duration,
|
|
first_appearance_time: person.first_appearance_time,
|
|
last_appearance_time: person.last_appearance_time,
|
|
is_confirmed: person.is_confirmed,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PersonTimelineResponse {
|
|
pub person_id: String,
|
|
pub name: Option<String>,
|
|
pub timeline: Vec<PersonTimelineEntry>,
|
|
pub statistics: PersonStatistics,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ChunkPersonInfo {
|
|
pub person_id: String,
|
|
pub name: Option<String>,
|
|
pub confidence: f64,
|
|
pub overlap_duration: f64,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_person_identity_serialization() {
|
|
let person = PersonIdentity {
|
|
id: 1,
|
|
person_id: "person_001".to_string(),
|
|
face_identity_id: Some(123),
|
|
speaker_id: Some("SPEAKER_00".to_string()),
|
|
file_uuid: "video_abc".to_string(),
|
|
confidence: 0.85,
|
|
name: Some("张三".to_string()),
|
|
metadata: serde_json::json!({"role": "host"}),
|
|
first_appearance_time: Some(10.5),
|
|
last_appearance_time: Some(350.2),
|
|
total_appearance_duration: 120.5,
|
|
appearance_count: 15,
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
is_confirmed: true,
|
|
};
|
|
|
|
let json = serde_json::to_string(&person).unwrap();
|
|
assert!(json.contains("person_001"));
|
|
assert!(json.contains("SPEAKER_00"));
|
|
assert!(json.contains("张三"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_person_appearance_serialization() {
|
|
let appearance = PersonAppearance {
|
|
id: 1,
|
|
person_id: "person_001".to_string(),
|
|
file_uuid: "video_abc".to_string(),
|
|
start_time: 10.5,
|
|
end_time: 25.3,
|
|
duration: 14.8,
|
|
face_detection_id: Some(456),
|
|
asrx_segment_start: Some(10.0),
|
|
asrx_segment_end: Some(26.0),
|
|
confidence: 0.92,
|
|
metadata: serde_json::json!({}),
|
|
created_at: Utc::now(),
|
|
};
|
|
|
|
let json = serde_json::to_string(&appearance).unwrap();
|
|
assert!(json.contains("person_001"));
|
|
assert!(json.contains("14.8"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_person_match() {
|
|
let match_result = PersonMatch {
|
|
face_id: "face_123".to_string(),
|
|
speaker_id: "SPEAKER_00".to_string(),
|
|
confidence: 0.85,
|
|
match_count: 15,
|
|
};
|
|
|
|
assert_eq!(match_result.face_id, "face_123");
|
|
assert!(match_result.confidence >= 0.0 && match_result.confidence <= 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_person_statistics() {
|
|
let stats = PersonStatistics {
|
|
total_appearances: 15,
|
|
total_duration: 120.5,
|
|
first_appearance: Some(10.5),
|
|
last_appearance: Some(350.2),
|
|
average_confidence: 0.88,
|
|
};
|
|
|
|
assert_eq!(stats.total_appearances, 15);
|
|
assert!(stats.total_duration > 0.0);
|
|
}
|
|
}
|