map updates

This commit is contained in:
2026-07-08 15:06:57 +05:30
parent 5c129bbdfe
commit 78b1666402
9 changed files with 457 additions and 71 deletions

View File

@@ -390,18 +390,19 @@ pub async fn load_calibration(db: &sqlx::PgPool, project_id: i32, road_type: &st
v.as_ref().and_then(Calib::from_value)
}
fn round3(x: f64) -> f64 {
(x * 1000.0).round() / 1000.0
/// Meters → km with 4 decimals (the user's chainage convention: 22.4456).
fn km4(m: f64) -> f64 {
(m * 10.0).round() / 10000.0
}
fn round6(x: f64) -> f64 {
(x * 1e6).round() / 1e6
}
/// Stamp every annotation in an export doc with its frame-exact geo: `lat`, `lon`,
/// `sr_chainage_m` (measured) and `chainage_m` (client-calibrated) — range
/// annotations additionally get `end_*` values at their end frame. Server-
/// authoritative: applied on claim, re-applied on push and in both exports, so the
/// values always reflect the CURRENT calibration.
/// `sr_chainage` (measured) and `chainage` (client-calibrated), both in **km with
/// 4 decimals** — range annotations additionally get `end_*` values at their end
/// frame. Server-authoritative: applied on claim, re-applied on push and in both
/// exports, so the values always reflect the CURRENT calibration.
pub fn stamp_annotation_geo(doc: &mut Value, geo: &VideoGeo) {
let Some(obj) = doc.as_object_mut() else { return };
let stamp = |m: &mut serde_json::Map<String, Value>, frame_key: &str, prefix: &str| {
@@ -409,8 +410,11 @@ pub fn stamp_annotation_geo(doc: &mut Value, geo: &VideoGeo) {
let g = geo.at_frame(frame);
m.insert(format!("{prefix}lat"), serde_json::json!(round6(g.lat)));
m.insert(format!("{prefix}lon"), serde_json::json!(round6(g.lon)));
m.insert(format!("{prefix}sr_chainage_m"), serde_json::json!(g.sr_m.map(round3)));
m.insert(format!("{prefix}chainage_m"), serde_json::json!(g.chainage_m.map(round3)));
m.insert(format!("{prefix}sr_chainage"), serde_json::json!(g.sr_m.map(km4)));
m.insert(format!("{prefix}chainage"), serde_json::json!(g.chainage_m.map(km4)));
// Drop earlier meter-suffixed variants from previously-stamped docs.
m.remove(&format!("{prefix}sr_chainage_m"));
m.remove(&format!("{prefix}chainage_m"));
};
if let Some(arr) = obj.get_mut("fixed_annotations").and_then(|v| v.as_array_mut()) {
for a in arr {
@@ -1035,10 +1039,14 @@ mod tests {
});
stamp_annotation_geo(&mut doc, &geo);
let f = &doc["fixed_annotations"][0];
assert!(f["chainage_m"].as_f64().unwrap() > 15.0);
// km, 4 decimals: frame 30 = t 1 s ≈ 22.7 m ≈ 0.0204 km after calibration.
let ch = f["chainage"].as_f64().unwrap();
assert!(ch > 0.015 && ch < 0.025, "{ch}");
assert_eq!(ch, (ch * 10000.0).round() / 10000.0, "not 4-decimal km");
assert!(f["lat"].as_f64().unwrap().abs() < 1e-6);
assert!(f.get("chainage_m").is_none(), "old meter key must be gone");
let r = &doc["range_annotations"][0];
assert!(r["chainage_m"].as_f64().unwrap() < r["end_chainage_m"].as_f64().unwrap());
assert!(r["chainage"].as_f64().unwrap() < r["end_chainage"].as_f64().unwrap());
}
#[test]

View File

@@ -103,6 +103,22 @@ pub fn track_value(track: &[Pt]) -> Value {
)
}
/// Average + maximum speed over a track (m/s), from the full-resolution samples.
/// Max is measured over single hops (dt clamped ≥ 0.5 s so duplicate timestamps
/// can't fabricate teleports). None when the track has no elapsed time.
pub fn speed_stats(track: &[Pt]) -> (Option<f64>, Option<f64>) {
if track.len() < 2 {
return (None, None);
}
let elapsed = track.last().unwrap()[2] - track[0][2];
let avg = (elapsed > 0.0).then(|| polyline_len_m(track) / elapsed);
let max = track
.windows(2)
.map(|w| dist_m(w[0][0], w[0][1], w[1][0], w[1][1]) / (w[1][2] - w[0][2]).max(0.5))
.fold(None::<f64>, |acc, v| Some(acc.map_or(v, |a| a.max(v))));
(avg, max)
}
/// Cap a track at `max` points (uniform stride, always keeping the last point).
pub fn downsample(track: Vec<Pt>, max: usize) -> Vec<Pt> {
if track.len() <= max || max < 2 {
@@ -851,6 +867,21 @@ mod tests {
assert_eq!(d.last(), t.last());
}
#[test]
fn speed_stats_measures_avg_and_max() {
// ~11.13 m per second for 60 s → avg ≈ max ≈ 11.1 m/s (40 km/h).
let t = straight(60);
let (avg, max) = speed_stats(&t);
assert!((avg.unwrap() - 11.13).abs() < 0.1, "{avg:?}");
assert!((max.unwrap() - 11.13).abs() < 0.1, "{max:?}");
// One fast hop dominates max but barely moves avg.
let mut t2 = straight(60);
t2[30][1] += 0.0002; // extra ~22 m in that second
let (avg2, max2) = speed_stats(&t2);
assert!(max2.unwrap() > 25.0, "{max2:?}");
assert!(avg2.unwrap() < 13.0, "{avg2:?}");
}
#[test]
fn stitch_planning_rules() {
// Healthy 1 Hz rollover (1 s gap) → nothing missing.

View File

@@ -434,7 +434,8 @@ async fn map_data(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPath<
COALESCE((SELECT count(*) FROM video_remarks r WHERE r.video_id = videos.id), 0) AS remark_count,
COALESCE(gps_track_corrected, gps_track) AS track,
(gps_track_corrected IS NOT NULL) AS corrected,
gps_corrected_by, gps_corrected_note, gps_sample_count, gps_max_gap_s
gps_corrected_by, gps_corrected_note, gps_sample_count, gps_max_gap_s,
duration_s, gps_start_epoch, gps_end_epoch
FROM videos WHERE project_id=$1 {FOLDER_FILTER} ORDER BY file_name"#
);
let mut rows = sqlx::query_as::<_, MapVideo>(&q)
@@ -444,9 +445,13 @@ async fn map_data(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPath<
.map_err(err)?;
// Downsample tracks for transport — hundreds of videos × 5k points would be tens
// of MB; ~400 points per video keeps the payload in the low single-digit MB.
// Speed stats are computed BEFORE downsampling (max speed needs 1 Hz hops).
for r in rows.iter_mut() {
if let Some(t) = r.track.take() {
let pts = gps::parse_track(&t);
let (avg, max) = gps::speed_stats(&pts);
r.speed_avg_mps = avg;
r.speed_max_mps = max;
r.track = Some(gps::track_value(&gps::downsample(pts, MAP_MAX_PTS)));
}
}
@@ -613,7 +618,7 @@ async fn export_tracks_kml(
desc.push_str(&format!(" · road: {road_type}"));
}
if let (Some(a), Some(b)) = (ch_s, ch_e) {
desc.push_str(&format!(" · chainage {:.3}{:.3} km", a / 1000.0, b / 1000.0));
desc.push_str(&format!(" · chainage {:.4}{:.4}", a / 1000.0, b / 1000.0));
}
kml.push_str(&format!(
"<Placemark><name>{}</name><description>{}</description><styleUrl>#st-{}</styleUrl><LineString><tessellate>1</tessellate><coordinates>",
@@ -641,9 +646,16 @@ async fn export_tracks_kml(
.map_err(err)
}
/// Meters → the wire/display convention: kilometers with 4 decimals (0.1 m).
/// 151779.98 m → 151.78 km; 22445.6 m → 22.4456 km.
pub fn km4(m: Option<f64>) -> Option<f64> {
m.map(|v| (v * 10.0).round() / 10000.0)
}
/// Server-authoritative wire-format metadata: inject/overwrite `road_type` + chainage
/// keys at the top level of an export doc. Applied on claim (pull), push (store) and
/// both export endpoints, so a worker's old client can never drop them.
/// Chainage convention (user-fixed): plain keys, **km with 4 decimals** (22.4456).
pub fn stamp_road_meta(
doc: &mut serde_json::Value,
road_type: &str,
@@ -651,10 +663,14 @@ pub fn stamp_road_meta(
) {
if let Some(obj) = doc.as_object_mut() {
obj.insert("road_type".into(), json!(road_type));
obj.insert("chainage_start_m".into(), json!(chainage.0));
obj.insert("chainage_end_m".into(), json!(chainage.1));
obj.insert("sr_chainage_start_m".into(), json!(chainage.2));
obj.insert("sr_chainage_end_m".into(), json!(chainage.3));
obj.insert("chainage_start".into(), json!(km4(chainage.0)));
obj.insert("chainage_end".into(), json!(km4(chainage.1)));
obj.insert("sr_chainage_start".into(), json!(km4(chainage.2)));
obj.insert("sr_chainage_end".into(), json!(km4(chainage.3)));
// Drop earlier meter-suffixed variants so re-stamped docs don't carry both.
for k in ["chainage_start_m", "chainage_end_m", "sr_chainage_start_m", "sr_chainage_end_m"] {
obj.remove(k);
}
}
}

View File

@@ -214,6 +214,15 @@ pub struct MapVideo {
// Data quality from the raw scan: fix count + biggest dropout between fixes.
pub gps_sample_count: Option<i32>,
pub gps_max_gap_s: Option<f64>,
pub duration_s: Option<f64>,
// Recording window (absolute UTC epoch of first/last fix) — temporal ordering.
pub gps_start_epoch: Option<f64>,
pub gps_end_epoch: Option<f64>,
// Speed over the full-resolution track (m/s) — the map's speed mode/stats.
#[sqlx(default)]
pub speed_avg_mps: Option<f64>,
#[sqlx(default)]
pub speed_max_mps: Option<f64>,
}
/// `GET /api/projects/:id/road_types` — a label + how many videos carry it.