osm generate code fix

This commit is contained in:
2026-05-04 21:02:20 +05:30
parent ffb27eb17f
commit 6eb854444d
2 changed files with 227 additions and 128 deletions

View File

@@ -3711,57 +3711,98 @@ pub struct BboxOut {
const OSM_HIGHWAY_CLASSES: &str = const OSM_HIGHWAY_CLASSES: &str =
"motorway|trunk|primary|secondary|tertiary|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link|residential|unclassified|service"; "motorway|trunk|primary|secondary|tertiary|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link|residential|unclassified|service";
#[derive(Debug, Serialize)]
pub struct GenerateOsmResult {
pub ways_kept: usize,
pub source: String,
pub points_used: usize,
pub videos_used: usize,
}
#[tauri::command] #[tauri::command]
pub async fn generate_osm_near_assets( pub async fn generate_osm_near_assets(
radius_m: Option<f64>, min_lat: f64,
max_samples: Option<usize>, min_lng: f64,
max_lat: f64,
max_lng: f64,
keep_threshold_m: Option<f64>,
path: String, path: String,
overpass_url: Option<String>, overpass_url: Option<String>,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<usize, String> { ) -> Result<GenerateOsmResult, String> {
// Tighter defaults — only roads the assets are *on*, not nearby: // New flow: Overpass query is the visible bbox (gives every way in view,
// radius_m = 60 m (capture buffer for GPS drift + side offset) // including highways that earlier around: + min_votes=2 dropped). The
// keep_threshold = 30 m (post-filter: an asset must be within 30 m of // returned ways are then filtered against a unified reference set built
// the road centerline to count as "on" the road) // from per-video metadata polyline points + live asset coords. A way is
// min_votes = 2 (a road must collect at least this many asset // kept if any of its segments comes within `keep_threshold_m` of any
// hits to be kept; one stray asset isn't enough) // reference point. This covers highways with sparse assets via the
let radius = radius_m.unwrap_or(60.0); // metadata polyline (the GPS track always touches the highway it drove)
let keep_threshold_m: f64 = 30.0; // and urban side streets via the dense asset cloud.
let min_votes: usize = 2; let threshold_m = keep_threshold_m.unwrap_or(50.0).max(1.0);
let cap = max_samples.unwrap_or(800).max(1); if !(min_lat < max_lat) || !(min_lng < max_lng) {
// Pull all live asset centres. The point bbox columns are populated at return Err("invalid bbox: min must be < max for both lat and lng".into());
// import time so the SELECT is cheap. }
let coords: Vec<(f64, f64)> = sqlx::query_as::<_, (f64, f64)>(
"SELECT lat, lng FROM assets // --- Build the reference-point set (lng, lat). ---
// Pull asset centres for the post-filter votes.
let asset_coords: Vec<(f64, f64)> = sqlx::query_as::<_, (f64, f64)>(
"SELECT lng, lat FROM assets
WHERE deleted = 0 AND lat IS NOT NULL AND lng IS NOT NULL", WHERE deleted = 0 AND lat IS NOT NULL AND lng IS NOT NULL",
) )
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if coords.is_empty() { // Pull every metadata polyline point (DB-persisted, stored as
return Err("no live assets in DB to generate roads near".to_string()); // [[lng, lat], ...] to match deck.gl convention).
let metadata_rows: Vec<(String,)> = sqlx::query_as(
"SELECT track_json FROM video_metadata",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let mut ref_points: Vec<(f64, f64)> = Vec::new();
let mut videos_used = 0usize;
for (json,) in &metadata_rows {
let track: Vec<[f64; 2]> = match serde_json::from_str(json) {
Ok(t) => t,
Err(_) => continue,
};
if track.is_empty() {
continue;
} }
// Stride-sample for the around: query body. We use the *full* asset videos_used += 1;
// list further down for vote counting, so the sample is purely a for p in track {
// query-size optimisation. ref_points.push((p[0], p[1]));
let stride = (coords.len().div_ceil(cap)).max(1);
let sampled: Vec<&(f64, f64)> = coords.iter().step_by(stride).collect();
// Build a `(lat,lng,lat,lng,...)` list. Overpass `around:r,...` returns
// ways with any node within `r` metres of any of the given points.
let mut latlng = String::with_capacity(sampled.len() * 22);
for (i, (lat, lng)) in sampled.iter().enumerate() {
if i > 0 {
latlng.push(',');
} }
latlng.push_str(&format!("{},{}", lat, lng));
} }
for c in &asset_coords {
ref_points.push(*c);
}
if ref_points.is_empty() {
return Err(
"nothing to filter against. Import per-video metadata or assets first \
so the generator knows which Overpass-returned roads to keep."
.into(),
);
}
let source = if videos_used > 0 && !asset_coords.is_empty() {
"metadata+assets"
} else if videos_used > 0 {
"metadata"
} else {
"assets"
};
// --- Overpass: bbox query, no around: list. ---
let query = format!( let query = format!(
"[out:json][timeout:180];\ "[out:json][timeout:180];\
(way[\"highway\"~\"{classes}\"](around:{r},{pts}););\ (way[\"highway\"~\"{classes}\"]({mn_lat},{mn_lng},{mx_lat},{mx_lng}););\
out body geom;", out body geom;",
classes = OSM_HIGHWAY_CLASSES, classes = OSM_HIGHWAY_CLASSES,
r = radius as i64, mn_lat = min_lat,
pts = latlng, mn_lng = min_lng,
mx_lat = max_lat,
mx_lng = max_lng,
); );
let url = overpass_url let url = overpass_url
.unwrap_or_else(|| "https://overpass-api.de/api/interpreter".to_string()); .unwrap_or_else(|| "https://overpass-api.de/api/interpreter".to_string());
@@ -3843,16 +3884,14 @@ pub async fn generate_osm_near_assets(
})); }));
} }
// Post-filter: keep only roads where >= min_votes assets sit within // Post-filter: keep a way if any of its segments comes within
// keep_threshold_m of the centerline. The around: query alone returns // `threshold_m` of any reference point (metadata polyline points or
// every road within `radius` m of any asset — including parallel/cross // asset coords). Permissive — needs only ONE hit, vs the old
// streets that just happen to be close. The vote step picks out the // min_votes=2 which dropped sparse-asset highways.
// road(s) the assets actually follow. let kept = filter_features_by_reference_points(
let kept = filter_features_by_asset_votes(
features, features,
&coords, &ref_points,
keep_threshold_m, threshold_m,
min_votes,
); );
let fc = serde_json::json!({ let fc = serde_json::json!({
@@ -3863,7 +3902,14 @@ pub async fn generate_osm_near_assets(
tokio::fs::write(&path, serde_json::to_string_pretty(&fc).map_err(|e| e.to_string())?) tokio::fs::write(&path, serde_json::to_string_pretty(&fc).map_err(|e| e.to_string())?)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(count) Ok(GenerateOsmResult {
ways_kept: count,
source: source.to_string(),
// Reuse points_used to surface the size of the reference set the
// post-filter checked against (asset coords + every metadata vertex).
points_used: ref_points.len(),
videos_used,
})
} }
fn kept_count(fc: &serde_json::Value) -> usize { fn kept_count(fc: &serde_json::Value) -> usize {
@@ -3905,12 +3951,28 @@ fn point_segment_distance_m(
(ex * ex + ey * ey).sqrt() (ex * ex + ey * ey).sqrt()
} }
fn filter_features_by_asset_votes( /// Permissive filter: keep a way if any of its segments comes within
/// `threshold_m` of any reference point. Reference points are passed as
/// `(lng, lat)`. Single hit is enough — designed for the bbox+filter flow
/// where we want every road touched by either metadata polylines or assets,
/// including highways with sparse asset coverage.
///
/// Builds a spatial-hash grid keyed by `cell_deg = threshold_m / 111320`,
/// then for each way walks its bbox cell + a 1-ring neighbourhood; at most
/// one exact distance check per nearby reference point. ~O(W × P_local).
fn filter_features_by_reference_points(
features: Vec<serde_json::Value>, features: Vec<serde_json::Value>,
asset_coords: &[(f64, f64)], ref_points: &[(f64, f64)],
threshold_m: f64, threshold_m: f64,
min_votes: usize,
) -> Vec<serde_json::Value> { ) -> Vec<serde_json::Value> {
use std::collections::HashMap;
let cell_deg = (threshold_m / 111_320.0).max(1e-7);
let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
for (i, (lng, lat)) in ref_points.iter().enumerate() {
let gx = (lng / cell_deg).floor() as i64;
let gy = (lat / cell_deg).floor() as i64;
grid.entry((gx, gy)).or_default().push(i);
}
let mut out: Vec<serde_json::Value> = Vec::new(); let mut out: Vec<serde_json::Value> = Vec::new();
for feat in features { for feat in features {
let pts: Vec<[f64; 2]> = feat let pts: Vec<[f64; 2]> = feat
@@ -3931,53 +3993,46 @@ fn filter_features_by_asset_votes(
if pts.len() < 2 { if pts.len() < 2 {
continue; continue;
} }
// Road bbox + buffer in degrees (so we can skip far-away assets cheaply). let mut keep = false;
let mut mn_lng = f64::INFINITY; // Walk segments at sub-cell intervals so a long segment crossing a
let mut mx_lng = f64::NEG_INFINITY; // populated cell can't slip through the grid.
let mut mn_lat = f64::INFINITY; let step_deg = cell_deg * 0.5;
let mut mx_lat = f64::NEG_INFINITY; 'segs: for w in pts.windows(2) {
for &[lng, lat] in &pts { let a = w[0];
if lng < mn_lng { mn_lng = lng; } let b = w[1];
if lng > mx_lng { mx_lng = lng; } let dx = b[0] - a[0];
if lat < mn_lat { mn_lat = lat; } let dy = b[1] - a[1];
if lat > mx_lat { mx_lat = lat; } let len = (dx * dx + dy * dy).sqrt();
} let steps = ((len / step_deg).ceil() as usize).max(1);
let lat0 = ((mn_lat + mx_lat) * 0.5).to_radians(); for s in 0..=steps {
let t = s as f64 / steps as f64;
let lng = a[0] + dx * t;
let lat = a[1] + dy * t;
let gx = (lng / cell_deg).floor() as i64;
let gy = (lat / cell_deg).floor() as i64;
for dy_c in -1..=1 {
for dx_c in -1..=1 {
if let Some(idxs) = grid.get(&(gx + dx_c, gy + dy_c)) {
for &idx in idxs {
let p = ref_points[idx];
// Cheap equirectangular distance — accurate
// enough for 50 m thresholding.
let lat0 = ((lat + p.1) * 0.5).to_radians();
let m_per_lng = (111_320.0_f64 * lat0.cos()).max(1.0); let m_per_lng = (111_320.0_f64 * lat0.cos()).max(1.0);
let buf_lat = threshold_m / 111_320.0; let mx = (p.0 - lng) * m_per_lng;
let buf_lng = threshold_m / m_per_lng; let my = (p.1 - lat) * 111_320.0;
let d = (mx * mx + my * my).sqrt();
let mut votes: usize = 0; if d <= threshold_m {
for &(a_lat, a_lng) in asset_coords { keep = true;
if a_lng < mn_lng - buf_lng break 'segs;
|| a_lng > mx_lng + buf_lng
|| a_lat < mn_lat - buf_lat
|| a_lat > mx_lat + buf_lat
{
continue;
}
let mut hit = false;
for w in pts.windows(2) {
let d = point_segment_distance_m(
a_lng, a_lat,
w[0][0], w[0][1],
w[1][0], w[1][1],
);
if d < threshold_m {
hit = true;
break;
}
}
if hit {
votes += 1;
if votes >= min_votes {
// Early exit — we already have enough votes; no need to
// count exactly how many more.
break;
} }
} }
} }
if votes >= min_votes { }
}
}
}
if keep {
out.push(feat); out.push(feat);
} }
} }

View File

@@ -207,7 +207,14 @@ function AppShell({
range_assets: number; range_assets: number;
osm_roads: number; osm_roads: number;
scope_features: number; scope_features: number;
}>({ fixed_assets: 0, range_assets: 0, osm_roads: 0, scope_features: 0 }); video_metadata: number;
}>({
fixed_assets: 0,
range_assets: 0,
osm_roads: 0,
scope_features: 0,
video_metadata: 0,
});
const [recentActions, setRecentActions] = useState<ActionRow[]>([]); const [recentActions, setRecentActions] = useState<ActionRow[]>([]);
const [status, setStatus] = useState<string>(""); const [status, setStatus] = useState<string>("");
const [busy, setBusy] = useState<boolean>(false); const [busy, setBusy] = useState<boolean>(false);
@@ -1580,6 +1587,7 @@ function AppShell({
range_assets: number; range_assets: number;
osm_roads: number; osm_roads: number;
scope_features: number; scope_features: number;
video_metadata: number;
}>("data_source_counts"); }>("data_source_counts");
setDataCounts(c); setDataCounts(c);
} catch { } catch {
@@ -1830,46 +1838,82 @@ function AppShell({
folder.endsWith("/") || folder.endsWith("\\") ? "" : "/"; folder.endsWith("/") || folder.endsWith("\\") ? "" : "/";
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const hasAssets = dataCounts.fixed_assets + dataCounts.range_assets > 0; const hasAssets = dataCounts.fixed_assets + dataCounts.range_assets > 0;
const hasMetadata = (dataCounts.video_metadata ?? 0) > 0
|| Object.keys(metadataByVideo).length > 0;
if (!hasAssets && !hasMetadata) {
setStatus(
"Import per-video metadata or assets first — the generator filters Overpass results against them.",
);
return;
}
// We need a bbox to query Overpass against. Prefer the current visible
// viewport (gives the user direct control over scope); fall back to the
// bbox of all loaded assets if the viewport isn't valid yet (e.g. user
// never panned the map). Either way the bbox is what Overpass filters
// on, and the post-filter then narrows by metadata + asset proximity.
let b = boundsRef.current;
if (!b) {
try {
const ab = await invoke<{
min_lat: number;
min_lng: number;
max_lat: number;
max_lng: number;
} | null>("assets_bbox");
if (ab) {
b = {
south: ab.min_lat,
north: ab.max_lat,
west: ab.min_lng,
east: ab.max_lng,
};
}
} catch {
/* fall through to error below */
}
}
if (!b) {
setStatus(
"No visible map bbox and no asset bbox available — pan/zoom the map or import assets first.",
);
return;
}
setBusy(true); setBusy(true);
setOsmFetchActive(true); setOsmFetchActive(true);
try { try {
if (hasAssets) { const sourceHint = hasMetadata && hasAssets
// Tight per-asset filter: fetch a small candidate set with ? "metadata + assets"
// around:60m, then keep only roads where ≥2 assets sit within 30 m : hasMetadata
// of the centerline. That way the output is the road(s) the ? "metadata"
// assets are actually on, not every street that happens to be : "assets";
// within bbox or radius.
setStatus("Fetching OSM near assets from Overpass…");
const path = `${folder}${sep}osm_on_asset_roads_${ts}.geojson`;
const n = await invoke<number>("generate_osm_near_assets", {
radiusM: 60,
maxSamples: 800,
path,
});
setStatus( setStatus(
`Generated ${n} road(s) carrying assets → ${path}. Use Import → OSM roads to load.`, `Fetching OSM for visible bbox from Overpass, then filtering by ${sourceHint}`,
); );
} else if (boundsRef.current) { const path = `${folder}${sep}osm_on_asset_roads_${ts}.geojson`;
const b = boundsRef.current; const r = await invoke<{
const path = `${folder}${sep}osm_${b.south.toFixed(4)}_${b.west.toFixed(4)}_${b.north.toFixed(4)}_${b.east.toFixed(4)}_${ts}.geojson`; ways_kept: number;
setStatus("Fetching OSM for visible viewport from Overpass…"); source: string;
const n = await invoke<number>("generate_osm_for_bbox", { points_used: number;
videos_used: number;
}>("generate_osm_near_assets", {
minLat: b.south, minLat: b.south,
minLng: b.west, minLng: b.west,
maxLat: b.north, maxLat: b.north,
maxLng: b.east, maxLng: b.east,
keepThresholdM: 50,
path, path,
}); });
const refDesc =
r.videos_used > 0
? `${r.points_used} ref points (${r.videos_used} video track(s) + assets)`
: `${r.points_used} asset ref points`;
setStatus( setStatus(
`Generated ${n} OSM way(s) from viewport${path}. Use Import → OSM roads to load.`, `Generated ${r.ways_kept} road(s) [bbox + ${r.source}, ${refDesc}]${path}. Use Import → OSM roads to load.`,
); );
} else { return;
setStatus(
"No assets imported and no map viewport set. Import assets or pan/zoom the map first.",
);
}
} catch (e) { } catch (e) {
setStatus(`Generate OSM failed: ${e}`); setStatus(`Generate OSM failed: ${e}`);
return;
} finally { } finally {
setOsmFetchActive(false); setOsmFetchActive(false);
setBusy(false); setBusy(false);