feat: GET file/:uuid/trace/:tid/thumbnail endpoint
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
|
body::Body,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::{header, StatusCode},
|
||||||
response::Json,
|
response::{IntoResponse, Json, Response},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
@@ -20,6 +21,10 @@ pub fn trace_agent_routes() -> Router<crate::api::types::AppState> {
|
|||||||
"/api/v1/file/:file_uuid/trace/:trace_id/representative-face",
|
"/api/v1/file/:file_uuid/trace/:trace_id/representative-face",
|
||||||
get(get_representative_face),
|
get(get_representative_face),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/file/:file_uuid/trace/:trace_id/thumbnail",
|
||||||
|
get(get_trace_thumbnail),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -360,99 +365,84 @@ struct RepFaceResponse {
|
|||||||
representative: RepFaceResult,
|
representative: RepFaceResult,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_representative_face(
|
struct RepFaceSelection {
|
||||||
State(state): State<crate::api::types::AppState>,
|
frame: i64,
|
||||||
Path((file_uuid, trace_id)): Path<(String, i32)>,
|
x: i32,
|
||||||
) -> Result<Json<RepFaceResponse>, (StatusCode, Json<serde_json::Value>)> {
|
y: i32,
|
||||||
use crate::core::db::schema;
|
w: i32,
|
||||||
|
h: i32,
|
||||||
|
conf: f64,
|
||||||
|
blur: f64,
|
||||||
|
score: f64,
|
||||||
|
video_path: String,
|
||||||
|
fps: f64,
|
||||||
|
face_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn select_rep_face<F, T>(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
file_uuid: &str,
|
||||||
|
trace_id: i32,
|
||||||
|
err_fn: F,
|
||||||
|
) -> Result<RepFaceSelection, T>
|
||||||
|
where
|
||||||
|
F: Fn(anyhow::Error) -> T,
|
||||||
|
{
|
||||||
|
use crate::core::db::schema;
|
||||||
let fd_table = schema::table_name("face_detections");
|
let fd_table = schema::table_name("face_detections");
|
||||||
let video_table = schema::table_name("videos");
|
let video_table = schema::table_name("videos");
|
||||||
|
|
||||||
// Get fps for timestamp calculation
|
|
||||||
let fps: f64 = sqlx::query_scalar(&format!(
|
let fps: f64 = sqlx::query_scalar(&format!(
|
||||||
"SELECT COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1",
|
"SELECT COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1", video_table
|
||||||
video_table
|
|
||||||
))
|
))
|
||||||
.bind(&file_uuid)
|
.bind(file_uuid)
|
||||||
.fetch_optional(state.db.pool())
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
|
||||||
})?
|
|
||||||
.unwrap_or(25.0);
|
.unwrap_or(25.0);
|
||||||
|
|
||||||
// Get face count for this trace
|
|
||||||
let face_count: (i64,) = sqlx::query_as(&format!(
|
let face_count: (i64,) = sqlx::query_as(&format!(
|
||||||
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id = $2",
|
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id = $2", fd_table
|
||||||
|
))
|
||||||
|
.bind(file_uuid)
|
||||||
|
.bind(trace_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?;
|
||||||
|
|
||||||
|
struct Candidate { frame: i64, x: i32, y: i32, w: i32, h: i32, conf: f64, score: f64 }
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, (i64, i32, i32, i32, i32, f64)>(&format!(
|
||||||
|
"SELECT frame_number::bigint, x, y, width, height, confidence::float8 \
|
||||||
|
FROM {} WHERE file_uuid = $1 AND trace_id = $2 AND confidence > 0.7 \
|
||||||
|
AND ((metadata->>'qc_ok')::boolean IS NULL OR (metadata->>'qc_ok')::boolean = true) \
|
||||||
|
ORDER BY (width::float8 * height::float8) * confidence::float8 DESC LIMIT 10",
|
||||||
fd_table
|
fd_table
|
||||||
))
|
))
|
||||||
.bind(&file_uuid)
|
.bind(file_uuid).bind(trace_id)
|
||||||
.bind(trace_id)
|
.fetch_all(pool)
|
||||||
.fetch_one(state.db.pool())
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?;
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Stage 1: SQL - top 10 candidates by area * confidence
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct FaceCandidate {
|
|
||||||
frame: i64,
|
|
||||||
x: i32,
|
|
||||||
y: i32,
|
|
||||||
w: i32,
|
|
||||||
h: i32,
|
|
||||||
conf: f64,
|
|
||||||
score: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, (i64, i32, i32, i32, i32, f64)>(
|
|
||||||
&format!(
|
|
||||||
"SELECT frame_number::bigint, x, y, width, height, confidence::float8 \
|
|
||||||
FROM {} WHERE file_uuid = $1 AND trace_id = $2 AND confidence > 0.7 \
|
|
||||||
AND ((metadata->>'qc_ok')::boolean IS NULL OR (metadata->>'qc_ok')::boolean = true) \
|
|
||||||
ORDER BY (width::float8 * height::float8) * confidence::float8 DESC \
|
|
||||||
LIMIT 10",
|
|
||||||
fd_table
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.bind(&file_uuid)
|
|
||||||
.bind(trace_id)
|
|
||||||
.fetch_all(state.db.pool())
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if rows.is_empty() {
|
if rows.is_empty() {
|
||||||
return Err((StatusCode::NOT_FOUND, Json(serde_json::json!({
|
return Err(err_fn(anyhow::anyhow!("No suitable face found")));
|
||||||
"error": "No suitable face found for this trace"
|
|
||||||
}))));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let candidates: Vec<FaceCandidate> = rows
|
let candidates: Vec<Candidate> = rows.into_iter()
|
||||||
.into_iter()
|
|
||||||
.map(|(frame, x, y, w, h, conf)| {
|
.map(|(frame, x, y, w, h, conf)| {
|
||||||
let score = (w as f64 * h as f64) * conf;
|
let score = (w as f64 * h as f64) * conf;
|
||||||
FaceCandidate { frame, x, y, w, h, conf, score }
|
Candidate { frame, x, y, w, h, conf, score }
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Stage 2: FFmpeg blurdetect on candidates
|
let video_path: String = sqlx::query_scalar(&format!(
|
||||||
let video_path = sqlx::query_scalar::<_, String>(&format!(
|
"SELECT file_path FROM {} WHERE file_uuid = $1", video_table
|
||||||
"SELECT file_path FROM {} WHERE file_uuid = $1",
|
|
||||||
video_table
|
|
||||||
))
|
))
|
||||||
.bind(&file_uuid)
|
.bind(file_uuid)
|
||||||
.fetch_optional(state.db.pool())
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| err_fn(anyhow::anyhow!("{}", e)))?
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
.ok_or_else(|| err_fn(anyhow::anyhow!("Video not found")))?;
|
||||||
})?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Video not found"})))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut best = candidates[0].frame;
|
let mut best = candidates[0].frame;
|
||||||
let mut best_blur = f64::MAX;
|
let mut best_blur = f64::MAX;
|
||||||
@@ -460,28 +450,17 @@ async fn get_representative_face(
|
|||||||
|
|
||||||
for (i, c) in candidates.iter().enumerate() {
|
for (i, c) in candidates.iter().enumerate() {
|
||||||
let seek = c.frame as f64 / fps;
|
let seek = c.frame as f64 / fps;
|
||||||
let output = tokio::process::Command::new("ffmpeg")
|
if let Ok(output) = tokio::process::Command::new("ffmpeg")
|
||||||
.args([
|
.args(["-ss", &format!("{:.2}", seek), "-i", &video_path,
|
||||||
"-ss", &format!("{:.2}", seek),
|
"-vframes", "1", "-vf", &format!("crop={}:{}:{}:{},blurdetect", c.w, c.h, c.x, c.y),
|
||||||
"-i", &video_path,
|
"-f", "null", "-"])
|
||||||
"-vframes", "1",
|
.output().await
|
||||||
"-vf", &format!("crop={}:{}:{}:{},blurdetect", c.w, c.h, c.x, c.y),
|
{
|
||||||
"-f", "null",
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
"-",
|
|
||||||
])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Ok(o) = output {
|
|
||||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
|
||||||
for line in stderr.lines() {
|
for line in stderr.lines() {
|
||||||
if let Some(blur_str) = line.split("blur mean: ").nth(1) {
|
if let Some(blur_str) = line.split("blur mean: ").nth(1) {
|
||||||
if let Ok(blur) = blur_str.trim().parse::<f64>() {
|
if let Ok(blur) = blur_str.trim().parse::<f64>() {
|
||||||
if blur < best_blur {
|
if blur < best_blur { best_blur = blur; best = c.frame; best_idx = i; }
|
||||||
best_blur = blur;
|
|
||||||
best = c.frame;
|
|
||||||
best_idx = i;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -489,24 +468,77 @@ async fn get_representative_face(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let chosen = &candidates[best_idx];
|
let chosen = &candidates[best_idx];
|
||||||
|
Ok(RepFaceSelection {
|
||||||
|
frame: chosen.frame, x: chosen.x, y: chosen.y, w: chosen.w, h: chosen.h,
|
||||||
|
conf: chosen.conf, blur: best_blur, score: chosen.score,
|
||||||
|
video_path, fps, face_count: face_count.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_representative_face(
|
||||||
|
State(state): State<crate::api::types::AppState>,
|
||||||
|
Path((file_uuid, trace_id)): Path<(String, i32)>,
|
||||||
|
) -> Result<Json<RepFaceResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
let sel = select_rep_face(state.db.pool(), &file_uuid, trace_id, |e| {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||||
|
}).await?;
|
||||||
|
|
||||||
Ok(Json(RepFaceResponse {
|
Ok(Json(RepFaceResponse {
|
||||||
success: true,
|
success: true,
|
||||||
file_uuid,
|
file_uuid,
|
||||||
trace_id,
|
trace_id,
|
||||||
face_count: face_count.0,
|
face_count: sel.face_count,
|
||||||
representative: RepFaceResult {
|
representative: RepFaceResult {
|
||||||
frame_number: chosen.frame,
|
frame_number: sel.frame,
|
||||||
timestamp_secs: chosen.frame as f64 / fps,
|
timestamp_secs: sel.frame as f64 / sel.fps,
|
||||||
bbox: RepFaceBbox {
|
bbox: RepFaceBbox { x: sel.x, y: sel.y, width: sel.w, height: sel.h },
|
||||||
x: chosen.x,
|
confidence: sel.conf,
|
||||||
y: chosen.y,
|
quality_score: sel.score,
|
||||||
width: chosen.w,
|
blur_score: sel.blur,
|
||||||
height: chosen.h,
|
|
||||||
},
|
|
||||||
confidence: chosen.conf,
|
|
||||||
quality_score: chosen.score,
|
|
||||||
blur_score: if best_blur == f64::MAX { 0.0 } else { best_blur },
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_trace_thumbnail(
|
||||||
|
State(state): State<crate::api::types::AppState>,
|
||||||
|
Path((file_uuid, trace_id)): Path<(String, i32)>,
|
||||||
|
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
let sel = select_rep_face(state.db.pool(), &file_uuid, trace_id, |e| {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||||
|
}).await?;
|
||||||
|
|
||||||
|
let seek = sel.frame as f64 / sel.fps;
|
||||||
|
let tmp = std::env::temp_dir().join(format!("trace_{}_{}.jpg", file_uuid, trace_id));
|
||||||
|
|
||||||
|
let status = tokio::process::Command::new("ffmpeg")
|
||||||
|
.args([
|
||||||
|
"-ss", &format!("{:.2}", seek),
|
||||||
|
"-i", &sel.video_path,
|
||||||
|
"-vframes", "1",
|
||||||
|
"-vf", &format!("crop={}:{}:{}:{},scale=320:320", sel.w, sel.h, sel.x, sel.y),
|
||||||
|
"-q:v", "2",
|
||||||
|
"-y", &tmp.to_string_lossy().to_string(),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !status.status.success() {
|
||||||
|
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "FFmpeg failed"}))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = tokio::fs::read(&tmp).await.map_err(|e| {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let _ = tokio::fs::remove_file(&tmp).await;
|
||||||
|
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, "image/jpeg")
|
||||||
|
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
||||||
|
.body(Body::from(bytes))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user