bbox mode
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user