bbox mode

This commit is contained in:
2026-05-19 18:31:44 +05:30
parent ac5deafbbf
commit 717d4986b6
6 changed files with 1648 additions and 102 deletions

View File

@@ -355,6 +355,99 @@ async fn insert_range_row(
Ok(())
}
/// Parse a single bbox prefix from a row and write it into asset_bboxes for
/// the given slot. Accepts numeric or string values and silently skips slots
/// where any of the four coordinate fields are missing / unparseable. Class
/// is asset-level (assets.bbox_class) and handled separately by the caller.
async fn maybe_insert_bbox_from_row(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
asset_id: i64,
row: &serde_json::Value,
prefix: &str,
slot: &str,
) -> Result<(), String> {
fn parse_f(v: Option<&serde_json::Value>) -> Option<f64> {
match v? {
serde_json::Value::Number(n) => n.as_f64().filter(|x| x.is_finite()),
serde_json::Value::String(s) => {
let t = s.trim();
if t.is_empty() {
None
} else {
t.parse::<f64>().ok().filter(|x| x.is_finite())
}
}
_ => None,
}
}
let x1 = parse_f(row.get(&format!("{prefix}bbox_x1")));
let y1 = parse_f(row.get(&format!("{prefix}bbox_y1")));
let x2 = parse_f(row.get(&format!("{prefix}bbox_x2")));
let y2 = parse_f(row.get(&format!("{prefix}bbox_y2")));
let (Some(x1), Some(y1), Some(x2), Some(y2)) = (x1, y1, x2, y2) else {
return Ok(());
};
let (nx1, nx2) = if x1 <= x2 { (x1, x2) } else { (x2, x1) };
let (ny1, ny2) = if y1 <= y2 { (y1, y2) } else { (y2, y1) };
if (nx2 - nx1).abs() < 1.0 || (ny2 - ny1).abs() < 1.0 {
return Ok(());
}
sqlx::query(
"INSERT INTO asset_bboxes(asset_id, slot, x1, y1, x2, y2)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(asset_id, slot) DO UPDATE SET
x1 = excluded.x1, y1 = excluded.y1,
x2 = excluded.x2, y2 = excluded.y2",
)
.bind(asset_id)
.bind(slot)
.bind(nx1)
.bind(ny1)
.bind(nx2)
.bind(ny2)
.execute(&mut **tx)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
/// Resolve the asset id for a row_id we just inserted, then attempt to ingest
/// any bbox fields. For fixed rows we look for the flat `bbox_*` keys; for
/// range rows we additionally check `start_bbox_*` / `mid_bbox_*` /
/// `end_bbox_*` so each image-slot can carry its own bbox. The (asset-level)
/// `bbox_class` field, if present anywhere in the row, is stored on assets.
async fn ingest_bboxes_for_row(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
row_id: &str,
raw: &serde_json::Value,
is_range: bool,
) -> Result<(), String> {
let asset_id: Option<i64> =
sqlx::query_scalar("SELECT id FROM assets WHERE row_id = ?")
.bind(row_id)
.fetch_optional(&mut **tx)
.await
.map_err(|e| e.to_string())?;
let Some(asset_id) = asset_id else {
return Ok(());
};
if is_range {
// Flat bbox_* maps to mid by convention; explicit per-slot prefixes
// override that when present.
maybe_insert_bbox_from_row(tx, asset_id, raw, "", "mid").await?;
maybe_insert_bbox_from_row(tx, asset_id, raw, "start_", "start").await?;
maybe_insert_bbox_from_row(tx, asset_id, raw, "mid_", "mid").await?;
maybe_insert_bbox_from_row(tx, asset_id, raw, "end_", "end").await?;
} else {
maybe_insert_bbox_from_row(tx, asset_id, raw, "", "fixed").await?;
}
// Class lives on assets.asset_name, populated by the regular import path.
// No bbox_class write here — `set_asset_bbox` API stays purely geometric
// and class renames flow through the existing rename_assets command.
let _ = asset_id;
Ok(())
}
async fn stamp_orig_positions(pool: &SqlitePool) -> Result<(), String> {
sqlx::query(
"UPDATE assets
@@ -377,13 +470,20 @@ pub async fn import_fixed_assets(
state: State<'_, AppState>,
) -> Result<usize, String> {
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let parsed: Vec<FixedAssetInput> =
// Deserialize as raw JSON first so we can still see bbox_* keys that
// aren't on FixedAssetInput. Each row is then converted to the typed
// shape for INSERT and re-used for bbox ingest.
let raws: Vec<serde_json::Value> =
serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let mut count = 0usize;
for input in parsed {
for raw in raws {
let input: FixedAssetInput =
serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?;
let row_id = input.row_id.clone();
insert_fixed_row(&mut tx, &input).await?;
ingest_bboxes_for_row(&mut tx, &row_id, &raw, false).await?;
count += 1;
}
tx.commit().await.map_err(|e| e.to_string())?;
@@ -398,13 +498,17 @@ pub async fn import_range_assets(
state: State<'_, AppState>,
) -> Result<usize, String> {
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let parsed: Vec<RangeAssetInput> =
let raws: Vec<serde_json::Value> =
serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let mut count = 0usize;
for input in parsed {
for raw in raws {
let input: RangeAssetInput =
serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?;
let row_id = input.row_id.clone();
insert_range_row(&mut tx, &input).await?;
ingest_bboxes_for_row(&mut tx, &row_id, &raw, true).await?;
count += 1;
}
tx.commit().await.map_err(|e| e.to_string())?;
@@ -485,13 +589,17 @@ pub async fn import_assets_auto(
&& coord_field_valid(row.get("end_coord_lng"));
if is_range {
let input: RangeAssetInput =
serde_json::from_value(row).map_err(|e| e.to_string())?;
serde_json::from_value(row.clone()).map_err(|e| e.to_string())?;
let row_id = input.row_id.clone();
insert_range_row(&mut tx, &input).await?;
ingest_bboxes_for_row(&mut tx, &row_id, &row, true).await?;
range_count += 1;
} else {
let input: FixedAssetInput =
serde_json::from_value(row).map_err(|e| e.to_string())?;
serde_json::from_value(row.clone()).map_err(|e| e.to_string())?;
let row_id = input.row_id.clone();
insert_fixed_row(&mut tx, &input).await?;
ingest_bboxes_for_row(&mut tx, &row_id, &row, false).await?;
fixed_count += 1;
}
}
@@ -2924,6 +3032,128 @@ pub async fn list_video_names(
Ok(rows.into_iter().map(|(n,)| n).collect())
}
#[derive(Debug, Serialize)]
pub struct VideoEntry {
pub video_name: String,
pub video_number: i64,
pub completed: bool,
pub completed_at: Option<String>,
pub completed_by: Option<String>,
}
/// Sort key for stable numbering: parse the first `YYYY_MMDD_HHMMSS` pattern
/// out of the video name (timestamps captured by the on-vehicle recorder).
/// Names without a timestamp sort after timestamped ones, lexically.
fn video_sort_key(name: &str) -> String {
let bytes = name.as_bytes();
// Window-scan for the pattern: 4 digits, _, 4 digits, _, 6 digits.
let mut i = 0;
while i + 16 <= bytes.len() {
let w = &bytes[i..i + 16];
let is_digits = |b: &[u8]| b.iter().all(|c| c.is_ascii_digit());
if w[4] == b'_'
&& w[9] == b'_'
&& is_digits(&w[0..4])
&& is_digits(&w[5..9])
&& is_digits(&w[10..16])
{
// 14-char numeric key sorts chronologically.
let mut key = String::with_capacity(14 + 1 + name.len());
key.push_str(std::str::from_utf8(&w[0..4]).unwrap_or(""));
key.push_str(std::str::from_utf8(&w[5..9]).unwrap_or(""));
key.push_str(std::str::from_utf8(&w[10..16]).unwrap_or(""));
key.push('_');
key.push_str(name);
return key;
}
i += 1;
}
// No timestamp: prefix with sentinel that sorts after any real timestamp.
let mut key = String::with_capacity(15 + name.len());
key.push_str("99999999999999_");
key.push_str(name);
key
}
/// Return every distinct video name (from assets + video_metadata + the
/// status table) annotated with a deterministic 1-based number and its
/// completion state. Sort: parsed timestamp asc, then video_name asc. The
/// numbering is recomputed on every call, so adding a new video shifts later
/// numbers consistently for every client sharing the DB.
#[tauri::command]
pub async fn list_videos(
state: State<'_, AppState>,
) -> Result<Vec<VideoEntry>, String> {
let names: Vec<(String,)> = sqlx::query_as(
"SELECT DISTINCT video_name FROM (
SELECT video_name FROM assets WHERE deleted = 0
UNION
SELECT video_name FROM video_metadata
UNION
SELECT video_name FROM video_status
) WHERE TRIM(COALESCE(video_name, '')) <> ''",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let mut all: Vec<String> = names.into_iter().map(|(n,)| n).collect();
all.sort_by(|a, b| video_sort_key(a).cmp(&video_sort_key(b)));
let status_rows: Vec<(String, i64, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT video_name, completed, completed_at, completed_by FROM video_status",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let mut status: std::collections::HashMap<
String,
(i64, Option<String>, Option<String>),
> = std::collections::HashMap::new();
for (n, c, at, by) in status_rows {
status.insert(n, (c, at, by));
}
let mut out: Vec<VideoEntry> = Vec::with_capacity(all.len());
for (idx, n) in all.into_iter().enumerate() {
let (completed, completed_at, completed_by) =
status.remove(&n).unwrap_or((0, None, None));
out.push(VideoEntry {
video_number: (idx + 1) as i64,
video_name: n,
completed: completed != 0,
completed_at,
completed_by,
});
}
Ok(out)
}
#[tauri::command]
pub async fn set_video_completed(
video_name: String,
completed: bool,
state: State<'_, AppState>,
) -> Result<(), String> {
let user = user_id();
sqlx::query(
"INSERT INTO video_status(video_name, completed, completed_at, completed_by)
VALUES (?, ?, CASE WHEN ? THEN datetime('now') ELSE NULL END, ?)
ON CONFLICT(video_name) DO UPDATE SET
completed = excluded.completed,
completed_at = CASE WHEN excluded.completed = 1 THEN datetime('now') ELSE NULL END,
completed_by = CASE WHEN excluded.completed = 1 THEN excluded.completed_by ELSE NULL END",
)
.bind(&video_name)
.bind(if completed { 1i64 } else { 0 })
.bind(if completed { 1i64 } else { 0 })
.bind(&user)
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn restore_assets(
ids: Vec<i64>,
@@ -6739,3 +6969,137 @@ pub async fn quality_report(
missing_side_total,
})
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct AssetBbox {
pub asset_id: i64,
pub slot: String,
pub x1: f64,
pub y1: f64,
pub x2: f64,
pub y2: f64,
// The class label is the asset's name. Reported here so the frontend
// doesn't have to plumb assets[asset_id].asset_name separately, but the
// source of truth is `assets.asset_name` — rename via rename_assets.
pub class: Option<String>,
}
fn normalize_slot(slot: &str) -> Result<&'static str, String> {
match slot {
"fixed" => Ok("fixed"),
"start" => Ok("start"),
"mid" => Ok("mid"),
"end" => Ok("end"),
other => Err(format!("invalid bbox slot: {other}")),
}
}
#[tauri::command]
pub async fn get_asset_bboxes(
asset_id: i64,
state: State<'_, AppState>,
) -> Result<Vec<AssetBbox>, String> {
sqlx::query_as::<_, AssetBbox>(
"SELECT b.asset_id, b.slot, b.x1, b.y1, b.x2, b.y2,
a.asset_name AS class
FROM asset_bboxes b JOIN assets a ON a.id = b.asset_id
WHERE b.asset_id = ? ORDER BY b.slot",
)
.bind(asset_id)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn set_asset_bbox(
asset_id: i64,
slot: String,
x1: f64,
y1: f64,
x2: f64,
y2: f64,
state: State<'_, AppState>,
) -> Result<(), String> {
let slot = normalize_slot(&slot)?;
// Normalize so x1 <= x2 and y1 <= y2 so render math doesn't have to.
let (nx1, nx2) = if x1 <= x2 { (x1, x2) } else { (x2, x1) };
let (ny1, ny2) = if y1 <= y2 { (y1, y2) } else { (y2, y1) };
if (nx2 - nx1).abs() < 1.0 || (ny2 - ny1).abs() < 1.0 {
return Err("bbox too small (need ≥ 1 px in each axis)".into());
}
sqlx::query(
"INSERT INTO asset_bboxes(asset_id, slot, x1, y1, x2, y2)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(asset_id, slot) DO UPDATE SET
x1 = excluded.x1, y1 = excluded.y1,
x2 = excluded.x2, y2 = excluded.y2",
)
.bind(asset_id)
.bind(slot)
.bind(nx1)
.bind(ny1)
.bind(nx2)
.bind(ny2)
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn clear_asset_bbox(
asset_id: i64,
slot: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let slot = normalize_slot(&slot)?;
sqlx::query("DELETE FROM asset_bboxes WHERE asset_id = ? AND slot = ?")
.bind(asset_id)
.bind(slot)
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn export_bboxes_to_json(
path: String,
state: State<'_, AppState>,
) -> Result<usize, String> {
// Sidecar shape: one entry per (row_id, slot). The original JSON's bbox_*
// keys stay shallow — we emit a flat `slot` field so consumers don't have
// to know the asset_type ahead of time. Class is asset-level so every
// entry for the same row_id carries the same value.
let rows: Vec<(String, String, String, f64, f64, f64, f64, Option<String>)> =
sqlx::query_as(
"SELECT a.row_id, a.video_name, b.slot, b.x1, b.y1, b.x2, b.y2, a.asset_name
FROM asset_bboxes b JOIN assets a ON a.id = b.asset_id
ORDER BY a.row_id, b.slot",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let entries: Vec<serde_json::Value> = rows
.into_iter()
.map(|(row_id, video_name, slot, x1, y1, x2, y2, class)| {
serde_json::json!({
"row_id": row_id,
"video_name": video_name,
"slot": slot,
"bbox_x1": x1,
"bbox_y1": y1,
"bbox_x2": x2,
"bbox_y2": y2,
"class": class,
})
})
.collect();
let n = entries.len();
let out = serde_json::json!({ "bboxes": entries });
let body = serde_json::to_vec_pretty(&out).map_err(|e| e.to_string())?;
tokio::fs::write(&path, body).await.map_err(|e| e.to_string())?;
Ok(n)
}