Release v1.0.0 candidate

This commit is contained in:
Accusys
2026-05-08 00:48:15 +08:00
parent 26d9c33419
commit 573714788f
17 changed files with 5040 additions and 895 deletions

View File

@@ -1,8 +1,8 @@
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
response::Json,
routing::post,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
@@ -10,10 +10,15 @@ use serde::{Deserialize, Serialize};
use crate::core::db::PostgresDb;
pub fn trace_agent_routes() -> Router<crate::api::server::AppState> {
Router::new().route(
"/api/v1/file/:file_uuid/face_trace/sortby",
post(list_traces_sorted),
)
Router::new()
.route(
"/api/v1/file/:file_uuid/face_trace/sortby",
post(list_traces_sorted),
)
.route(
"/api/v1/file/:file_uuid/trace/:trace_id/faces",
get(list_trace_faces),
)
}
#[derive(Debug, Deserialize)]
@@ -21,6 +26,8 @@ struct TracesRequest {
min_faces: Option<i64>,
sort_by: Option<String>,
limit: Option<i64>,
min_confidence: Option<f64>,
max_confidence: Option<f64>,
}
#[derive(Debug, Serialize)]
@@ -53,14 +60,15 @@ async fn list_traces_sorted(
let min_faces = req.min_faces.unwrap_or(1);
let sort = req.sort_by.as_deref().unwrap_or("first_appearance");
let limit = req.limit.unwrap_or(500);
let min_confidence = req.min_confidence.unwrap_or(0.0);
let max_confidence = req.max_confidence.unwrap_or(1.0);
let order_clause = match sort {
"face_count" => "face_count DESC",
"duration" => "duration_sec DESC",
"duration" => "(MAX(frame_number) - MIN(frame_number)) DESC",
_ => "first_frame ASC",
};
// Get actual video FPS
let fps: f64 =
sqlx::query_scalar("SELECT COALESCE(fps, 24.0) FROM dev.videos WHERE file_uuid = $1")
.bind(&file_uuid)
@@ -84,6 +92,7 @@ async fn list_traces_sorted(
AVG(confidence) AS avg_confidence
FROM dev.face_detections
WHERE file_uuid = $1 AND trace_id IS NOT NULL
AND confidence >= $4 AND confidence <= $5
GROUP BY trace_id
HAVING COUNT(*) >= $2
ORDER BY {}
@@ -103,6 +112,8 @@ async fn list_traces_sorted(
.bind(&file_uuid)
.bind(min_faces)
.bind(limit)
.bind(min_confidence)
.bind(max_confidence)
.fetch_all(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -138,3 +149,146 @@ async fn list_traces_sorted(
traces,
}))
}
// ── Individual face detections for a trace ──
#[derive(Debug, Deserialize)]
struct TraceFacesQuery {
limit: Option<i64>,
offset: Option<i64>,
interpolate: Option<bool>,
}
#[derive(Debug, Serialize)]
struct TraceFaceItem {
id: i32,
start_frame: i32,
start_time: f64,
x: Option<i32>,
y: Option<i32>,
width: Option<i32>,
height: Option<i32>,
confidence: f64,
interpolated: bool,
}
#[derive(Debug, Serialize)]
struct TraceFacesResponse {
success: bool,
file_uuid: String,
trace_id: i32,
total: i64,
faces: Vec<TraceFaceItem>,
}
fn lerp_i32(a: Option<i32>, b: Option<i32>, t: f64) -> Option<i32> {
match (a, b) {
(Some(av), Some(bv)) => Some((av as f64 + (bv - av) as f64 * t).round() as i32),
_ => None,
}
}
async fn list_trace_faces(
State(state): State<crate::api::server::AppState>,
Path((file_uuid, trace_id)): Path<(String, i32)>,
Query(q): Query<TraceFacesQuery>,
) -> Result<Json<TraceFacesResponse>, (StatusCode, String)> {
let limit = q.limit.unwrap_or(200).min(1000);
let offset = q.offset.unwrap_or(0);
let interpolate = q.interpolate.unwrap_or(false);
let fps: f64 =
sqlx::query_scalar("SELECT COALESCE(fps, 24.0) FROM dev.videos WHERE file_uuid = $1")
.bind(&file_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.unwrap_or(24.0);
let total_detected: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM dev.face_detections WHERE file_uuid = $1 AND trace_id = $2"
)
.bind(&file_uuid)
.bind(trace_id)
.fetch_one(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let rows: Vec<(i32, i32, Option<i32>, Option<i32>, Option<i32>, Option<i32>, f32)> =
sqlx::query_as(
"SELECT id, frame_number, x, y, width, height, confidence
FROM dev.face_detections
WHERE file_uuid = $1 AND trace_id = $2
ORDER BY frame_number ASC
LIMIT $3 OFFSET $4"
)
.bind(&file_uuid)
.bind(trace_id)
.bind(limit)
.bind(offset)
.fetch_all(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut faces: Vec<TraceFaceItem> = Vec::new();
for (i, (id, frame, x, y, w, h, conf)) in rows.iter().enumerate() {
let cur = (x, y, w, h);
// Add interpolated frames between previous and current detection
if interpolate && i > 0 {
let prev = &rows[i - 1];
let prev_frame = prev.1;
let gap = frame - prev_frame;
if gap > 1 {
for mid in 1..gap {
let t = mid as f64 / gap as f64;
let mid_x = lerp_i32(prev.2, *x, t);
let mid_y = lerp_i32(prev.3, *y, t);
let mid_w = lerp_i32(prev.4, *w, t);
let mid_h = lerp_i32(prev.5, *h, t);
let mid_frame = prev_frame + mid;
faces.push(TraceFaceItem {
id: 0,
start_frame: mid_frame,
start_time: (mid_frame as f64 / fps * 10.0).round() / 10.0,
x: mid_x,
y: mid_y,
width: mid_w,
height: mid_h,
confidence: 0.0,
interpolated: true,
});
}
}
}
// Add the real detection
let frame_val = *frame;
faces.push(TraceFaceItem {
id: *id,
start_frame: frame_val,
start_time: (frame_val as f64 / fps * 10.0).round() / 10.0,
x: *x,
y: *y,
width: *w,
height: *h,
confidence: *conf as f64,
interpolated: false,
});
}
let total = if interpolate && faces.len() as i64 > total_detected {
faces.len() as i64
} else {
total_detected
};
Ok(Json(TraceFacesResponse {
success: true,
file_uuid,
trace_id,
total,
faces,
}))
}