map updates
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user