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 =
"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]
pub async fn generate_osm_near_assets(
radius_m: Option<f64>,
max_samples: Option<usize>,
min_lat: f64,
min_lng: f64,
max_lat: f64,
max_lng: f64,
keep_threshold_m: Option<f64>,
path: String,
overpass_url: Option<String>,
state: State<'_, AppState>,
) -> Result<usize, String> {
// Tighter defaults — only roads the assets are *on*, not nearby:
// radius_m = 60 m (capture buffer for GPS drift + side offset)
// keep_threshold = 30 m (post-filter: an asset must be within 30 m of
// the road centerline to count as "on" the road)
// min_votes = 2 (a road must collect at least this many asset
// hits to be kept; one stray asset isn't enough)
let radius = radius_m.unwrap_or(60.0);
let keep_threshold_m: f64 = 30.0;
let min_votes: usize = 2;
let cap = max_samples.unwrap_or(800).max(1);
// Pull all live asset centres. The point bbox columns are populated at
// import time so the SELECT is cheap.
let coords: Vec<(f64, f64)> = sqlx::query_as::<_, (f64, f64)>(
"SELECT lat, lng FROM assets
) -> Result<GenerateOsmResult, String> {
// New flow: Overpass query is the visible bbox (gives every way in view,
// including highways that earlier around: + min_votes=2 dropped). The
// returned ways are then filtered against a unified reference set built
// from per-video metadata polyline points + live asset coords. A way is
// kept if any of its segments comes within `keep_threshold_m` of any
// reference point. This covers highways with sparse assets via the
// metadata polyline (the GPS track always touches the highway it drove)
// and urban side streets via the dense asset cloud.
let threshold_m = keep_threshold_m.unwrap_or(50.0).max(1.0);
if !(min_lat < max_lat) || !(min_lng < max_lng) {
return Err("invalid bbox: min must be < max for both lat and lng".into());
}
// --- 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",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
if coords.is_empty() {
return Err("no live assets in DB to generate roads near".to_string());
}
// Stride-sample for the around: query body. We use the *full* asset
// list further down for vote counting, so the sample is purely a
// query-size optimisation.
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(',');
// Pull every metadata polyline point (DB-persisted, stored as
// [[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;
}
videos_used += 1;
for p in track {
ref_points.push((p[0], p[1]));
}
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!(
"[out:json][timeout:180];\
(way[\"highway\"~\"{classes}\"](around:{r},{pts}););\
(way[\"highway\"~\"{classes}\"]({mn_lat},{mn_lng},{mx_lat},{mx_lng}););\
out body geom;",
classes = OSM_HIGHWAY_CLASSES,
r = radius as i64,
pts = latlng,
mn_lat = min_lat,
mn_lng = min_lng,
mx_lat = max_lat,
mx_lng = max_lng,
);
let url = overpass_url
.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
// keep_threshold_m of the centerline. The around: query alone returns
// every road within `radius` m of any asset — including parallel/cross
// streets that just happen to be close. The vote step picks out the
// road(s) the assets actually follow.
let kept = filter_features_by_asset_votes(
// Post-filter: keep a way if any of its segments comes within
// `threshold_m` of any reference point (metadata polyline points or
// asset coords). Permissive — needs only ONE hit, vs the old
// min_votes=2 which dropped sparse-asset highways.
let kept = filter_features_by_reference_points(
features,
&coords,
keep_threshold_m,
min_votes,
&ref_points,
threshold_m,
);
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())?)
.await
.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 {
@@ -3905,12 +3951,28 @@ fn point_segment_distance_m(
(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>,
asset_coords: &[(f64, f64)],
ref_points: &[(f64, f64)],
threshold_m: f64,
min_votes: usize,
) -> 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();
for feat in features {
let pts: Vec<[f64; 2]> = feat
@@ -3931,53 +3993,46 @@ fn filter_features_by_asset_votes(
if pts.len() < 2 {
continue;
}
// Road bbox + buffer in degrees (so we can skip far-away assets cheaply).
let mut mn_lng = f64::INFINITY;
let mut mx_lng = f64::NEG_INFINITY;
let mut mn_lat = f64::INFINITY;
let mut mx_lat = f64::NEG_INFINITY;
for &[lng, lat] in &pts {
if lng < mn_lng { mn_lng = lng; }
if lng > mx_lng { mx_lng = lng; }
if lat < mn_lat { mn_lat = lat; }
if lat > mx_lat { mx_lat = lat; }
}
let lat0 = ((mn_lat + mx_lat) * 0.5).to_radians();
let m_per_lng = (111_320.0_f64 * lat0.cos()).max(1.0);
let buf_lat = threshold_m / 111_320.0;
let buf_lng = threshold_m / m_per_lng;
let mut votes: usize = 0;
for &(a_lat, a_lng) in asset_coords {
if a_lng < mn_lng - buf_lng
|| 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;
let mut keep = false;
// Walk segments at sub-cell intervals so a long segment crossing a
// populated cell can't slip through the grid.
let step_deg = cell_deg * 0.5;
'segs: for w in pts.windows(2) {
let a = w[0];
let b = w[1];
let dx = b[0] - a[0];
let dy = b[1] - a[1];
let len = (dx * dx + dy * dy).sqrt();
let steps = ((len / step_deg).ceil() as usize).max(1);
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 mx = (p.0 - lng) * m_per_lng;
let my = (p.1 - lat) * 111_320.0;
let d = (mx * mx + my * my).sqrt();
if d <= threshold_m {
keep = true;
break 'segs;
}
}
}
}
}
}
}
if votes >= min_votes {
if keep {
out.push(feat);
}
}

View File

@@ -207,7 +207,14 @@ function AppShell({
range_assets: number;
osm_roads: 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 [status, setStatus] = useState<string>("");
const [busy, setBusy] = useState<boolean>(false);
@@ -1580,6 +1587,7 @@ function AppShell({
range_assets: number;
osm_roads: number;
scope_features: number;
video_metadata: number;
}>("data_source_counts");
setDataCounts(c);
} catch {
@@ -1830,46 +1838,82 @@ function AppShell({
folder.endsWith("/") || folder.endsWith("\\") ? "" : "/";
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
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);
setOsmFetchActive(true);
try {
if (hasAssets) {
// Tight per-asset filter: fetch a small candidate set with
// around:60m, then keep only roads where ≥2 assets sit within 30 m
// of the centerline. That way the output is the road(s) the
// assets are actually on, not every street that happens to be
// 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(
`Generated ${n} road(s) carrying assets → ${path}. Use Import → OSM roads to load.`,
);
} else if (boundsRef.current) {
const b = boundsRef.current;
const path = `${folder}${sep}osm_${b.south.toFixed(4)}_${b.west.toFixed(4)}_${b.north.toFixed(4)}_${b.east.toFixed(4)}_${ts}.geojson`;
setStatus("Fetching OSM for visible viewport from Overpass…");
const n = await invoke<number>("generate_osm_for_bbox", {
minLat: b.south,
minLng: b.west,
maxLat: b.north,
maxLng: b.east,
path,
});
setStatus(
`Generated ${n} OSM way(s) from viewport → ${path}. Use Import → OSM roads to load.`,
);
} else {
setStatus(
"No assets imported and no map viewport set. Import assets or pan/zoom the map first.",
);
}
const sourceHint = hasMetadata && hasAssets
? "metadata + assets"
: hasMetadata
? "metadata"
: "assets";
setStatus(
`Fetching OSM for visible bbox from Overpass, then filtering by ${sourceHint}`,
);
const path = `${folder}${sep}osm_on_asset_roads_${ts}.geojson`;
const r = await invoke<{
ways_kept: number;
source: string;
points_used: number;
videos_used: number;
}>("generate_osm_near_assets", {
minLat: b.south,
minLng: b.west,
maxLat: b.north,
maxLng: b.east,
keepThresholdM: 50,
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(
`Generated ${r.ways_kept} road(s) [bbox + ${r.source}, ${refDesc}] → ${path}. Use Import → OSM roads to load.`,
);
return;
} catch (e) {
setStatus(`Generate OSM failed: ${e}`);
return;
} finally {
setOsmFetchActive(false);
setBusy(false);