M4 handover: coordinate fixes, detector registry, deploy v2, YOLOv8s, identity lifecycle
- 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
This commit is contained in:
@@ -72,6 +72,7 @@ fn get_uptime_ms() -> u64 {
|
||||
struct HealthResponse {
|
||||
status: String,
|
||||
version: String,
|
||||
build_git_hash: String,
|
||||
uptime_ms: u64,
|
||||
}
|
||||
|
||||
@@ -369,6 +370,7 @@ pub struct AppState {
|
||||
struct DetailedHealthResponse {
|
||||
status: String,
|
||||
version: String,
|
||||
build_git_hash: String,
|
||||
uptime_ms: u64,
|
||||
services: ServiceHealth,
|
||||
}
|
||||
@@ -408,6 +410,7 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: status.to_string(),
|
||||
version: env!("BUILD_VERSION").to_string(),
|
||||
build_git_hash: env!("BUILD_GIT_HASH").to_string(),
|
||||
uptime_ms: get_uptime_ms(),
|
||||
})
|
||||
}
|
||||
@@ -431,6 +434,7 @@ async fn health_detailed(State(state): State<AppState>) -> Json<DetailedHealthRe
|
||||
Json(DetailedHealthResponse {
|
||||
status: overall_status.to_string(),
|
||||
version: env!("BUILD_VERSION").to_string(),
|
||||
build_git_hash: env!("BUILD_GIT_HASH").to_string(),
|
||||
uptime_ms: get_uptime_ms(),
|
||||
services: ServiceHealth {
|
||||
postgres,
|
||||
|
||||
@@ -12,6 +12,10 @@ use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn dir_size(path: &Path) -> u64 {
|
||||
path.read_dir().map(|d| d.filter_map(|e| e.ok()).filter_map(|e| e.metadata().ok()).map(|m| m.len()).sum()).unwrap_or(0)
|
||||
}
|
||||
|
||||
const DEMO_DIR: &str = "/Users/accusys/momentry/var/sftpgo/data/demo";
|
||||
const OUTPUT_DIR: &str = "/Users/accusys/momentry/output_dev";
|
||||
const RELEASE_DIR: &str = "/Users/accusys/momentry_core_0.1/release/files";
|
||||
@@ -353,77 +357,133 @@ async fn cmd_package(db: &PostgresDb, uuid: &str) -> Result<()> {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"status": "completed",
|
||||
"momentry_version": env!("CARGO_PKG_VERSION"),
|
||||
"momentry_build": env!("BUILD_GIT_HASH"),
|
||||
});
|
||||
fs::write(outdir.join("file_info.json"), serde_json::to_string_pretty(&info)?)?;
|
||||
|
||||
// Export data.sql
|
||||
let sql_path = outdir.join("data.sql");
|
||||
// Export per-table .sql files (avoid single 4.7GB psql load)
|
||||
let sql_dir = outdir.join("sql");
|
||||
fs::create_dir_all(&sql_dir)?;
|
||||
let tables = [
|
||||
("dev.videos", "file_uuid"),
|
||||
("dev.chunk", "file_uuid"),
|
||||
("dev.chunk_vectors", "uuid"),
|
||||
("dev.face_detections", "file_uuid"),
|
||||
("dev.tkg_nodes", "file_uuid"),
|
||||
("dev.tkg_edges", "file_uuid"),
|
||||
];
|
||||
|
||||
{
|
||||
let mut f = fs::File::create(&sql_path)?;
|
||||
writeln!(f, "-- Release package: {}", uuid)?;
|
||||
writeln!(f, "BEGIN;")?;
|
||||
writeln!(f)?;
|
||||
let mut import_order = vec!["master.sql"];
|
||||
|
||||
for (tbl, col) in &tables {
|
||||
writeln!(f, "-- {} WHERE {} = '{}'", tbl, col, uuid)?;
|
||||
// Get columns
|
||||
let parts: Vec<&str> = tbl.split('.').collect();
|
||||
let cols = psql_exec(&format!(
|
||||
"SELECT string_agg(column_name, ', ' ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='{}' AND table_name='{}' AND is_updatable='YES'",
|
||||
parts[0], parts[1]
|
||||
))?;
|
||||
|
||||
// COPY
|
||||
let data = psql_exec(&format!(
|
||||
"COPY (SELECT * FROM {} WHERE {} = '{}') TO STDOUT WITH CSV HEADER",
|
||||
tbl, col, uuid
|
||||
))?;
|
||||
|
||||
if !data.is_empty() {
|
||||
writeln!(f, "COPY {} ({}) FROM STDIN WITH CSV HEADER;", tbl, cols)?;
|
||||
writeln!(f, "{}", data)?;
|
||||
writeln!(f, "\\.")?;
|
||||
writeln!(f)?;
|
||||
}
|
||||
}
|
||||
// Export identities referenced by this file
|
||||
writeln!(f, "-- dev.identities (referenced by face_detections)")?;
|
||||
let cols = psql_exec("SELECT string_agg(column_name, ', ' ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='dev' AND table_name='identities' AND is_updatable='YES'")?;
|
||||
fn write_table_sql(outdir: &Path, tbl: &str, col: &str, uuid: &str, psql_exec: &dyn Fn(&str) -> Result<String>) -> Result<()> {
|
||||
let safe_name = tbl.replace('.', "_");
|
||||
let path = outdir.join(format!("{}.sql", safe_name));
|
||||
let parts: Vec<&str> = tbl.split('.').collect();
|
||||
let cols = psql_exec(&format!(
|
||||
"SELECT string_agg(column_name, ', ' ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='{}' AND table_name='{}' AND is_updatable='YES'",
|
||||
parts[0], parts[1]
|
||||
))?;
|
||||
let data = psql_exec(&format!(
|
||||
"COPY (SELECT DISTINCT i.* FROM dev.identities i INNER JOIN dev.face_detections fd ON fd.identity_id = i.id WHERE fd.file_uuid = '{}') TO STDOUT WITH CSV HEADER", uuid
|
||||
"COPY (SELECT * FROM {} WHERE {} = '{}') TO STDOUT WITH CSV HEADER",
|
||||
tbl, col, uuid
|
||||
))?;
|
||||
if !data.is_empty() {
|
||||
let mut f = fs::File::create(&path)?;
|
||||
writeln!(f, "-- {} WHERE {} = '{}'", tbl, col, uuid)?;
|
||||
writeln!(f, "COPY {} ({}) FROM STDIN WITH CSV HEADER;", tbl, cols)?;
|
||||
writeln!(f, "{}", data)?;
|
||||
writeln!(f, "\\.")?;
|
||||
let sz = fs::metadata(&path)?.len();
|
||||
println!(" sql/{} ({} MB)", safe_name, sz / 1024 / 1024);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
for (tbl, col) in &tables {
|
||||
write_table_sql(&sql_dir, tbl, col, uuid, &|q| psql_exec(q))?;
|
||||
}
|
||||
|
||||
// Export identities with file_uuid (direct column, no JOIN needed)
|
||||
// FILE LOCAL: file_uuid = '{uuid}'
|
||||
// GLOBAL (cross-file): tmdb identities + user-defined (exclude inactive auto)
|
||||
let idents_name = "dev_identities";
|
||||
let idents_path = sql_dir.join(format!("{}.sql", idents_name));
|
||||
{
|
||||
let idents_query = format!(
|
||||
"COPY (SELECT * FROM dev.identities WHERE file_uuid = '{}' OR (file_uuid IS NULL AND source IN ('tmdb', 'merged', 'user_defined'))) TO STDOUT WITH CSV HEADER", uuid
|
||||
);
|
||||
let cols = psql_exec(&format!(
|
||||
"SELECT string_agg(column_name, ', ' ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='dev' AND table_name='identities' AND is_updatable='YES'"
|
||||
))?;
|
||||
let data = psql_exec(&idents_query)?;
|
||||
if !data.is_empty() {
|
||||
let mut f = fs::File::create(&idents_path)?;
|
||||
writeln!(f, "-- dev.identities WHERE file_uuid = '{}' OR global (tmdb/merged/user_defined)", uuid)?;
|
||||
writeln!(f, "COPY dev.identities ({}) FROM STDIN WITH CSV HEADER;", cols)?;
|
||||
writeln!(f, "{}", data)?;
|
||||
writeln!(f, "\\.")?;
|
||||
writeln!(f)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Export identity_bindings for identities referenced by this file
|
||||
writeln!(f, "-- dev.identity_bindings (for identities in face_detections)")?;
|
||||
let cols = psql_exec("SELECT string_agg(column_name, ', ' ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='dev' AND table_name='identity_bindings' AND is_updatable='YES'")?;
|
||||
let data = psql_exec(&format!(
|
||||
"COPY (SELECT DISTINCT ib.* FROM dev.identity_bindings ib INNER JOIN dev.face_detections fd ON fd.identity_id = ib.identity_id WHERE fd.file_uuid = '{}') TO STDOUT WITH CSV HEADER", uuid
|
||||
// Export identity_bindings with custom query
|
||||
let binds_name = "dev_identity_bindings";
|
||||
let binds_path = sql_dir.join(format!("{}.sql", binds_name));
|
||||
{
|
||||
let binds_query = format!(
|
||||
"COPY (SELECT DISTINCT ib.* FROM dev.identity_bindings ib INNER JOIN dev.face_detections fd ON fd.identity_id = ib.identity_id AND fd.trace_id IS NOT NULL WHERE fd.file_uuid = '{}' AND ib.identity_value IN (SELECT DISTINCT trace_id::text FROM dev.face_detections WHERE file_uuid = '{}' AND trace_id IS NOT NULL)) TO STDOUT WITH CSV HEADER", uuid, uuid
|
||||
);
|
||||
let cols = psql_exec(&format!(
|
||||
"SELECT string_agg(column_name, ', ' ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='dev' AND table_name='identity_bindings' AND is_updatable='YES'"
|
||||
))?;
|
||||
let data = psql_exec(&binds_query)?;
|
||||
if !data.is_empty() {
|
||||
let mut f = fs::File::create(&binds_path)?;
|
||||
writeln!(f, "-- dev.identity_bindings (from face_detections JOIN)")?;
|
||||
writeln!(f, "COPY dev.identity_bindings ({}) FROM STDIN WITH CSV HEADER;", cols)?;
|
||||
writeln!(f, "{}", data)?;
|
||||
writeln!(f, "\\.")?;
|
||||
writeln!(f)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Write master.sql (import order, runs BEGIN/COMMIT around all)
|
||||
let master_path = sql_dir.join("master.sql");
|
||||
{
|
||||
let mut f = fs::File::create(&master_path)?;
|
||||
writeln!(f, "BEGIN;")?;
|
||||
writeln!(f)?;
|
||||
|
||||
writeln!(f, "\\i sql/dev_videos.sql")?;
|
||||
writeln!(f, "\\i sql/dev_chunk.sql")?;
|
||||
writeln!(f, "\\i sql/dev_chunk_vectors.sql")?;
|
||||
writeln!(f, "\\i sql/dev_face_detections.sql")?;
|
||||
writeln!(f, "\\i sql/dev_identities.sql")?;
|
||||
writeln!(f, "\\i sql/dev_identity_bindings.sql")?;
|
||||
writeln!(f, "\\i sql/dev_tkg_nodes.sql")?;
|
||||
writeln!(f, "\\i sql/dev_tkg_edges.sql")?;
|
||||
|
||||
writeln!(f, "COMMIT;")?;
|
||||
}
|
||||
|
||||
let sql_size = fs::metadata(&sql_path)?.len();
|
||||
println!(" data.sql ({} MB)", sql_size / 1024 / 1024);
|
||||
// Write legacy data.sql that sources master via psql -f (backward compat)
|
||||
let sql_path = outdir.join("data.sql");
|
||||
{
|
||||
let mut f = fs::File::create(&sql_path)?;
|
||||
writeln!(f, "-- Release package: {} — see sql/ for per-table files", uuid)?;
|
||||
writeln!(f, "BEGIN;")?;
|
||||
writeln!(f, "\\i sql/dev_videos.sql")?;
|
||||
writeln!(f, "\\i sql/dev_chunk.sql")?;
|
||||
writeln!(f, "\\i sql/dev_chunk_vectors.sql")?;
|
||||
writeln!(f, "\\i sql/dev_face_detections.sql")?;
|
||||
writeln!(f, "\\i sql/dev_identities.sql")?;
|
||||
writeln!(f, "\\i sql/dev_identity_bindings.sql")?;
|
||||
writeln!(f, "\\i sql/dev_tkg_nodes.sql")?;
|
||||
writeln!(f, "\\i sql/dev_tkg_edges.sql")?;
|
||||
writeln!(f, "COMMIT;")?;
|
||||
}
|
||||
|
||||
let sql_dir_sz = dir_size(&sql_dir);
|
||||
println!(" sql/ directory ({} MB total)", sql_dir_sz / 1024 / 1024);
|
||||
|
||||
// Copy video file
|
||||
if !file_path.is_empty() {
|
||||
@@ -487,6 +547,39 @@ async fn cmd_package(db: &PostgresDb, uuid: &str) -> Result<()> {
|
||||
}
|
||||
let tsize = fs::metadata(&tarball)?.len();
|
||||
println!("\n Package: {} ({} MB)", tarball.display(), tsize / 1024 / 1024);
|
||||
|
||||
// Sanity check: warn if any sql file is suspiciously large
|
||||
println!(" Checking sql/ file sizes...");
|
||||
for entry in fs::read_dir(&sql_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("sql") && path.is_file() {
|
||||
let sz = fs::metadata(&path)?.len() as f64 / 1024.0 / 1024.0;
|
||||
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
|
||||
match name {
|
||||
"dev_videos" | "master" if sz > 1.0 =>
|
||||
println!(" ⚠️ {} is {} MB, expected < 1 MB", name, sz as u64),
|
||||
"dev_chunk" if sz > 2.0 =>
|
||||
println!(" ⚠️ {} is {} MB, expected < 2 MB for ~2.4K chunks", name, sz as u64),
|
||||
"dev_identities" if sz > 1.0 =>
|
||||
println!(" ⚠️ {} is {} MB, expected < 1 MB for ~428 identities", name, sz as u64),
|
||||
"dev_identity_bindings" if sz > 5.0 =>
|
||||
println!(" ⚠️ {} is {} MB, expected < 5 MB for ~7.6K bindings", name, sz as u64),
|
||||
"dev_tkg_nodes" if sz > 10.0 =>
|
||||
println!(" ⚠️ {} is {} MB, expected < 10 MB for ~6.4K nodes", name, sz as u64),
|
||||
"dev_tkg_edges" if sz > 20.0 =>
|
||||
println!(" ⚠️ {} is {} MB, expected < 20 MB for ~21K edges", name, sz as u64),
|
||||
"dev_face_detections" if sz > 1000.0 =>
|
||||
println!(" ⚠️ {} is {} MB, expected < 1000 MB for ~70K faces (512D emb)", name, sz as u64),
|
||||
"dev_chunk_vectors" if sz > 200.0 =>
|
||||
println!(" ⚠️ {} is {} MB, expected < 200 MB for ~2.4K chunks (768D emb)", name, sz as u64),
|
||||
_ => {}
|
||||
}
|
||||
if sz > 2000.0 {
|
||||
println!(" ⚠️ {} is {:.0} MB — unusually large, verify query", name, sz);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -646,7 +739,9 @@ fn cmd_stats() -> Result<()> {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
dotenv::from_filename(".env.development").ok();
|
||||
if dotenv::from_filename("/Users/accusys/momentry_core_0.1/.env.development").is_err() {
|
||||
let _ = dotenv::from_filename(".env.development");
|
||||
}
|
||||
let cli = Cli::parse();
|
||||
let db = PostgresDb::new(&config::DATABASE_URL).await?;
|
||||
|
||||
|
||||
@@ -482,7 +482,7 @@ impl ProcessorType {
|
||||
pub fn all() -> Vec<ProcessorType> {
|
||||
vec![
|
||||
ProcessorType::Cut,
|
||||
ProcessorType::Scene,
|
||||
// Scene (Places365) removed — replaced by heuristic_scene_metadata post-processor
|
||||
ProcessorType::Asr,
|
||||
ProcessorType::Asrx,
|
||||
ProcessorType::Yolo,
|
||||
|
||||
@@ -34,9 +34,21 @@ pub struct PersonIdentity {
|
||||
pub struct Identity {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub embedding: Option<String>, // Vector embedding stored as text/json
|
||||
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)
|
||||
|
||||
292
src/core/processor/heuristic_scene.rs
Normal file
292
src/core/processor/heuristic_scene.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use std::path::Path;
|
||||
use tracing::info;
|
||||
|
||||
/// Heuristic scene metadata derived from YOLO + Face + luminance data.
|
||||
/// Runs as a post-processing trigger, not a standalone processor.
|
||||
/// Replaces the removed Places365 Scene classifier.
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HeuristicSceneMeta {
|
||||
pub file_uuid: String,
|
||||
pub segments: Vec<SceneSegmentMeta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SceneSegmentMeta {
|
||||
pub segment_index: u32,
|
||||
pub start_frame: i64,
|
||||
pub end_frame: i64,
|
||||
pub start_time: f64,
|
||||
pub end_time: f64,
|
||||
pub indoor_score: f64,
|
||||
pub outdoor_score: f64,
|
||||
pub crowd_size: CrowdSize,
|
||||
pub max_face_count: i64,
|
||||
pub dominant_objects: Vec<String>,
|
||||
pub likely_vehicle_transport: bool,
|
||||
pub avg_brightness: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CrowdSize {
|
||||
Empty,
|
||||
Single,
|
||||
Duo,
|
||||
SmallGroup,
|
||||
Crowd,
|
||||
}
|
||||
|
||||
/// Indoor-indicative YOLO classes (COCO labels)
|
||||
const INDOOR_CLASSES: &[&str] = &[
|
||||
"chair", "couch", "bed", "dining table", "toilet", "tv", "laptop",
|
||||
"microwave", "oven", "refrigerator", "sink", "book", "clock",
|
||||
"vase", "potted plant",
|
||||
];
|
||||
|
||||
/// Vehicle-indicative classes (person + vehicle = transport scene)
|
||||
const VEHICLE_CLASSES: &[&str] = &[
|
||||
"car", "truck", "bus", "train", "boat", "aeroplane", "bicycle", "motorbike",
|
||||
];
|
||||
|
||||
/// Outdoor-indicative YOLO classes
|
||||
const OUTDOOR_CLASSES: &[&str] = &[
|
||||
"car", "truck", "bus", "train", "boat", "airplane",
|
||||
"traffic light", "fire hydrant", "stop sign", "parking meter",
|
||||
"bench", "bird", "cat", "dog", "horse", "sheep", "cow", "elephant",
|
||||
"bear", "zebra", "giraffe", "tree",
|
||||
];
|
||||
|
||||
/// Build heuristic scene metadata from disk files (yolo.json + DB face data).
|
||||
/// segment_boundaries: [(start_frame, end_frame, start_time, end_time), ...]
|
||||
/// — from CUT detections.
|
||||
pub async fn build_heuristic_scene_meta(
|
||||
pool: &PgPool,
|
||||
file_uuid: &str,
|
||||
segment_boundaries: &[(i64, i64, f64, f64)],
|
||||
) -> Result<HeuristicSceneMeta> {
|
||||
if segment_boundaries.is_empty() {
|
||||
return Ok(HeuristicSceneMeta {
|
||||
file_uuid: file_uuid.to_string(),
|
||||
segments: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Build frame→class_counts map from yolo.json
|
||||
let yolo_path = Path::new(crate::core::config::OUTPUT_DIR.as_str())
|
||||
.join(format!("{}.yolo.json", file_uuid));
|
||||
let mut frame_objects: HashMap<i64, Vec<String>> = HashMap::new();
|
||||
if yolo_path.exists() {
|
||||
if let Ok(yolo_str) = tokio::fs::read_to_string(&yolo_path).await {
|
||||
#[derive(Deserialize)]
|
||||
struct YoloJson {
|
||||
frames: Vec<YoloFrameJson>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct YoloFrameJson {
|
||||
frame: i64,
|
||||
objects: Vec<YoloObjectJson>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct YoloObjectJson {
|
||||
class_name: String,
|
||||
}
|
||||
if let Ok(yolo) = serde_json::from_str::<YoloJson>(&yolo_str) {
|
||||
for frm in &yolo.frames {
|
||||
let classes: Vec<String> =
|
||||
frm.objects.iter().map(|o| o.class_name.clone()).collect();
|
||||
if !classes.is_empty() {
|
||||
frame_objects.insert(frm.frame, classes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get face counts grouped by frame
|
||||
let face_rows: Vec<(i64, i64)> = sqlx::query_as(
|
||||
"SELECT frame_number, COUNT(*) as fc \
|
||||
FROM dev.face_detections \
|
||||
WHERE file_uuid = $1 AND frame_number IS NOT NULL \
|
||||
GROUP BY frame_number \
|
||||
ORDER BY frame_number",
|
||||
)
|
||||
.bind(file_uuid)
|
||||
.fetch_all(pool)
|
||||
.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);
|
||||
}
|
||||
|
||||
// Process each segment
|
||||
let mut segments = Vec::new();
|
||||
for (idx, &(start_f, end_f, start_t, end_t)) in segment_boundaries.iter().enumerate() {
|
||||
let mut class_counts: HashMap<String, u64> = HashMap::new();
|
||||
let mut class_frame_presence: HashMap<String, u64> = HashMap::new();
|
||||
let mut indoor_objects = 0u64;
|
||||
let mut outdoor_objects = 0u64;
|
||||
let mut max_faces: i64 = 0;
|
||||
let mut frame_count = 0u64;
|
||||
|
||||
for frame in start_f..=end_f {
|
||||
frame_count += 1;
|
||||
if let Some(objects) = frame_objects.get(&frame) {
|
||||
let mut seen_this_frame: HashSet<String> = HashSet::new();
|
||||
for cls in objects {
|
||||
*class_counts.entry(cls.clone()).or_default() += 1;
|
||||
if seen_this_frame.insert(cls.clone()) {
|
||||
*class_frame_presence.entry(cls.clone()).or_default() += 1;
|
||||
}
|
||||
if INDOOR_CLASSES.contains(&cls.as_str()) {
|
||||
indoor_objects += 1;
|
||||
} else if OUTDOOR_CLASSES.contains(&cls.as_str()) {
|
||||
outdoor_objects += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(&fc) = frame_face_counts.get(&frame) {
|
||||
max_faces = max_faces.max(fc);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize by frame count (prevents static-scene FP inflation)
|
||||
let indoor_ratio = indoor_objects as f64 / frame_count.max(1) as f64;
|
||||
let outdoor_ratio = outdoor_objects as f64 / frame_count.max(1) as f64;
|
||||
let total_indicator = indoor_ratio + outdoor_ratio;
|
||||
let (indoor_score, outdoor_score) = if total_indicator > 0.0 {
|
||||
(indoor_ratio / total_indicator, outdoor_ratio / total_indicator)
|
||||
} else {
|
||||
(0.5, 0.5)
|
||||
};
|
||||
|
||||
// Determine crowd size
|
||||
let crowd_size = match max_faces {
|
||||
0 => CrowdSize::Empty,
|
||||
1 => CrowdSize::Single,
|
||||
2 | 3 => CrowdSize::Duo,
|
||||
4..=10 => CrowdSize::SmallGroup,
|
||||
_ => CrowdSize::Crowd,
|
||||
};
|
||||
|
||||
// Vehicle transport detection: check BEFORE class_frame_presence is consumed
|
||||
let person_frames = class_frame_presence.get("person").copied().unwrap_or(0);
|
||||
let vehicle_frames: u64 = VEHICLE_CLASSES
|
||||
.iter()
|
||||
.map(|c| class_frame_presence.get(*c).copied().unwrap_or(0))
|
||||
.sum();
|
||||
let person_ratio = person_frames as f64 / frame_count.max(1) as f64;
|
||||
let likely_vehicle = person_ratio > 0.5 && vehicle_frames > 0
|
||||
&& outdoor_score > 0.3;
|
||||
|
||||
// Dominant objects: rank by frame presence (not total count)
|
||||
let mut sorted: Vec<_> = class_frame_presence.into_iter().collect();
|
||||
sorted.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
let dominant_objects: Vec<String> = sorted
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|(cls, _)| cls.clone())
|
||||
.collect();
|
||||
|
||||
segments.push(SceneSegmentMeta {
|
||||
segment_index: idx as u32 + 1,
|
||||
start_frame: start_f,
|
||||
end_frame: end_f,
|
||||
start_time: start_t,
|
||||
end_time: end_t,
|
||||
indoor_score,
|
||||
outdoor_score,
|
||||
crowd_size,
|
||||
max_face_count: max_faces,
|
||||
dominant_objects,
|
||||
likely_vehicle_transport: likely_vehicle,
|
||||
avg_brightness: None, // Future: from frame luminance analysis
|
||||
});
|
||||
}
|
||||
|
||||
info!(
|
||||
"[SCENE-META] {} segments generated for {}",
|
||||
segments.len(),
|
||||
file_uuid
|
||||
);
|
||||
|
||||
Ok(HeuristicSceneMeta {
|
||||
file_uuid: file_uuid.to_string(),
|
||||
segments,
|
||||
})
|
||||
}
|
||||
|
||||
/// Full pipeline entry point: reads CUT data, generates heuristic metadata, writes JSON.
|
||||
/// Called from job_worker post-processing trigger.
|
||||
pub async fn generate_scene_meta(db: &crate::core::db::PostgresDb, file_uuid: &str) -> Result<usize> {
|
||||
let pool = db.pool();
|
||||
|
||||
// Read CUT segment boundaries from cut.json
|
||||
let cut_path = Path::new(crate::core::config::OUTPUT_DIR.as_str())
|
||||
.join(format!("{}.cut.json", file_uuid));
|
||||
let segments: Vec<(i64, i64, f64, f64)> = if cut_path.exists() {
|
||||
let cut_str = tokio::fs::read_to_string(&cut_path)
|
||||
.await
|
||||
.context("Failed to read cut.json")?;
|
||||
#[derive(Deserialize)]
|
||||
struct CutJson {
|
||||
scenes: Vec<CutSceneJson>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct CutSceneJson {
|
||||
start_frame: i64,
|
||||
end_frame: i64,
|
||||
start_time: f64,
|
||||
end_time: f64,
|
||||
}
|
||||
let cut: CutJson = serde_json::from_str(&cut_str)
|
||||
.context("Failed to parse cut.json")?;
|
||||
cut.scenes
|
||||
.into_iter()
|
||||
.map(|s| (s.start_frame, s.end_frame, s.start_time, s.end_time))
|
||||
.collect()
|
||||
} else {
|
||||
// Fallback: query DB for video duration, make one segment
|
||||
let (total_frames, duration): (Option<i64>, Option<f64>) = sqlx::query_as(
|
||||
"SELECT total_frames, duration FROM dev.videos WHERE file_uuid = $1",
|
||||
)
|
||||
.bind(file_uuid)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.context("Failed to query video info")?
|
||||
.unwrap_or((Some(0), Some(0.0)));
|
||||
let tf = total_frames.unwrap_or(0);
|
||||
let dur = duration.unwrap_or(0.0);
|
||||
if tf > 0 {
|
||||
vec![(0, tf, 0.0, dur)]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
if segments.is_empty() {
|
||||
info!("[SCENE-META] No segments for {}", file_uuid);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let meta = build_heuristic_scene_meta(pool, file_uuid, &segments).await?;
|
||||
let n = meta.segments.len();
|
||||
|
||||
// Write scene_meta.json
|
||||
let out_path = Path::new(crate::core::config::OUTPUT_DIR.as_str())
|
||||
.join(format!("{}.scene_meta.json", file_uuid));
|
||||
let json_str = serde_json::to_string_pretty(&meta)?;
|
||||
tokio::fs::write(&out_path, json_str)
|
||||
.await
|
||||
.context("Failed to write scene_meta.json")?;
|
||||
|
||||
Ok(n)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod cut;
|
||||
pub mod executor;
|
||||
pub mod face;
|
||||
pub mod face_recognition;
|
||||
pub mod heuristic_scene;
|
||||
pub mod ocr;
|
||||
pub mod pose;
|
||||
pub mod scene_classification;
|
||||
@@ -23,6 +24,9 @@ pub use face_recognition::{
|
||||
FaceRecognitionFrame, FaceRecognitionResult, FaceRegistrationResult, RecognizedFace,
|
||||
RecognizedFaceDetection,
|
||||
};
|
||||
pub use heuristic_scene::{
|
||||
build_heuristic_scene_meta, generate_scene_meta, CrowdSize, HeuristicSceneMeta, SceneSegmentMeta,
|
||||
};
|
||||
pub use ocr::{process_ocr, OcrFrame, OcrResult, OcrText};
|
||||
pub use pose::{process_pose, Bbox, Keypoint, PersonPose, PoseFrame, PoseResult};
|
||||
pub use scene_classification::{
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::core::db::{
|
||||
use crate::core::embedding::Embedder;
|
||||
use crate::worker::config::WorkerConfig;
|
||||
use crate::worker::processor::{ProcessorPool, ProcessorTask};
|
||||
use crate::core::processor::heuristic_scene::generate_scene_meta;
|
||||
use crate::worker::resources::SystemResources;
|
||||
|
||||
pub struct JobWorker {
|
||||
@@ -861,6 +862,26 @@ impl JobWorker {
|
||||
});
|
||||
}
|
||||
|
||||
// 🚀 P2.7 Trigger: Heuristic Scene Metadata (Face + YOLO → scene attributes)
|
||||
// Replaces removed Places365 Scene classifier.
|
||||
if has_face && has_yolo {
|
||||
info!("📝 Face + YOLO complete, generating heuristic scene metadata...");
|
||||
let db_clone = self.db.clone();
|
||||
let uuid_clone = uuid.to_string();
|
||||
tokio::spawn(async move {
|
||||
match generate_scene_meta(&db_clone, &uuid_clone).await {
|
||||
Ok(n) => info!(
|
||||
"✅ Heuristic scene metadata: {} segments for {}",
|
||||
n, uuid_clone
|
||||
),
|
||||
Err(e) => error!(
|
||||
"❌ Heuristic scene metadata failed for {}: {}",
|
||||
uuid_clone, e
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 🚀 P3 Trigger: Identity Agent (Face + ASRX)
|
||||
if has_face && has_asrx {
|
||||
info!("📝 Prerequisites met for Identity Agent. Starting analysis...");
|
||||
|
||||
Reference in New Issue
Block a user