Compare commits

...

9 Commits

Author SHA1 Message Date
cf4ae6e146 chainage panel update 2026-07-08 18:02:01 +05:30
8238bcf981 compose update 2026-07-08 17:16:56 +05:30
e7ab0f3368 chainage compute and map view 2026-07-08 17:03:24 +05:30
78b1666402 map updates 2026-07-08 15:06:57 +05:30
5c129bbdfe port changed 2026-07-07 22:45:34 +05:30
308d6bc129 map and chainage calculation 2026-07-07 22:42:47 +05:30
05ce1f2cdb video_name missing in export 2026-07-06 19:47:31 +05:30
4d71e08aa4 detailed data for verifications 2026-07-02 18:48:15 +05:30
bf019f86c3 minor bugs 2026-06-23 20:17:32 +05:30
18 changed files with 5444 additions and 110 deletions

View File

@@ -110,6 +110,10 @@ each video's `rel_path` carries its subfolder, which the dashboard shows in a **
> In-app NFS mounting needs `nfs-common` (in the image) and the server container's `cap_add: SYS_ADMIN`
> + `security_opt: apparmor:unconfined` (in `docker-compose.yml`; use `privileged: true` if your host's
> `mount.nfs` still refuses). `local`-only deployments need none of this.
>
> The server service also sets `init: true` — it spawns exiftool/mount.nfs children as PID 1, and
> without an init their orphans become unreapable zombies that block `docker stop`
> ("PID … is zombie and can not be killed"). Keep it if you copy the service elsewhere.
### Roles
@@ -166,6 +170,71 @@ pass back. All Phase 3 routes require `Authorization: Bearer <token>`.
- `POST /api/videos/:id/push` — body = export doc + `verify_time_ms`; replace annotations,
mark `verified`, record verifier + time, clear claim
### Map / GPS / road_type / chainage
The server image bundles exiftool; a serial background sweep extracts each video's GPS
track (`gps_status`: `pending → ok | none | error`). The raw track is immutable —
a kept "Viterbi" correction lives beside it and is preferred by the map + chainage.
`road_type` + the four chainage values (`chainage_start`, `chainage_end`,
`sr_chainage_start`, `sr_chainage_end`) are injected into every claim/export doc and
re-stamped on push (server-authoritative). **Every annotation** additionally gets its
**frame-exact** `lat`/`lon`/`chainage`/`sr_chainage` (range annotations also `end_*`):
frame 15 of a 30 fps video is t = 0.5 s — halfway between two 1 Hz GPS samples — so it
gets the halfway position/chainage, not a second/video boundary.
**Chainage unit convention (everywhere — JSON + UI): kilometres with 4 decimals**,
e.g. `22.4456` (`chainage` = client-calibrated, `sr_chainage` = GPS-measured).
Distances are WGS84-ellipsoidal (no spherical bias); calibration anchors persist in
`chainage_calibrations` so this works at any time after a recompute.
- `GET /api/projects/:id/map` — per-video map rows: track (≤400 pts), status,
road_type, chainage, `gps_status` (member/admin)
- `GET /api/projects/:id/tracks.kml?token=` — all tracks as KML LineStrings colored by
status + calibration points (Google Earth)
- `POST /api/videos/:id/gps/rescan` — re-queue a video for the exiftool sweep (admin)
- `POST /api/projects/:id/gps/rescan_all` — re-queue every video (admin; background)
- `POST /api/videos/:id/gps/correct` `{action: preview|keep|discard}` — spike smoothing +
missing-start stitch from the previous file (by GPS
timestamp, duration-guarded); the scan sweep applies
the same pipeline automatically as 'auto' (raw never
changes; discard blocks re-auto-correction) (admin)
- `GET/POST /api/projects/:id/road_types` — list / replace label set (`{names}`, admin)
- `POST /api/projects/:id/road_type_assign` `{video_ids, road_type}` — bulk-assign ('' clears, admin)
- `GET/POST /api/projects/:id/chainage/points` — client calibration points (POST admin:
`{lat, lon, chainage_m, note?, road_type?}`)
- `POST /api/projects/:id/chainage/points/bulk` `{points: […]}` — transactional bulk insert;
the dashboard's **KML import** uses this (placemark
names like `12+400` or `12.4` become chainage) (admin)
- `DELETE /api/chainage/points/:id` — remove a point (admin)
- `POST /api/chainage/points/:id` `{note}` — edit a point's note in place; position/chainage
stay immutable (admin). Points carry an `origin` field:
`''` = manual, `'quick'` = generated by the quick assign
(replaced wholesale on the next quick run — identity is
the column, so renaming the note is safe)
- `GET /api/projects/:id/chainage/calibrations` — the saved calibrations, one per road-type group
(`road_type, route_len_m, anchor_count, computed_by,
computed_at, has_summary`) — every recompute persists one,
so any user sees what's already calibrated (member/admin)
- `GET /api/projects/:id/chainage/summary?road_type=` — the SAVED full result of the group's last
recompute (route, spans, per-video chainage, warnings),
null if never computed; the dashboard auto-loads it so a
newly signed-in admin sees the existing compute
(member/admin)
- `POST /api/projects/:id/chainage/recompute` `{video_ids, road_type}` — order the videos into a
route, snap the points, distribute the client-vs-GPS error
piecewise-linearly; writes `chainage_*` (client) +
`sr_chainage_*` (measured, immutable) + `route_seq` (admin).
Summary reports `majority_flipped` + per-video
`against_flow` (opposite to the route's dominant direction —
the only direction flag the UI shows). Warnings include
mixed directions (≥25% against the majority → probably both
carriageways in one group) and ambiguous untagged points
(used by this route but also within 150 m of another
road-type group's videos — tag them)
- `POST /api/projects/:id/chainage/quick` `{video_ids, start_m, end_m, road_type}` — the
two-point case: points at the route's two geometric ends
(notes `quick: chain start/end`, tagged with the group) +
same recompute (admin)
### Env
| var | default | meaning |

View File

@@ -8,10 +8,13 @@
"name": "central-dashboard",
"version": "0.1.0",
"dependencies": {
"fflate": "^0.8.3",
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
@@ -1241,6 +1244,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1467,6 +1487,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1524,6 +1550,12 @@
"node": ">=6"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",

View File

@@ -9,10 +9,13 @@
"preview": "vite preview"
},
"dependencies": {
"fflate": "^0.8.3",
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",

View File

@@ -1,13 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import {
api, exportProjectUrl, exportVideoUrl, fmtDuration, fmtWhen, setAuthToken,
type Activity, type Member, type ProjectSummary, type Stats, type UserRow, type VideoRow,
api, exportProjectUrl, exportVideoUrl, fmtChainageRange, fmtDuration, fmtWhen, passDeltas,
setAuthToken, statusLabel,
type Activity, type Member, type ProjectSummary, type RoadType, type Stats, type UserRow, type VideoRow,
} from "./api";
import { RemarkCell } from "./RemarkCell";
import { AdminPanel } from "./AdminPanel";
import { MembersPicker } from "./MembersPicker";
import { FoldersPicker } from "./FoldersPicker";
import { SignIn } from "./SignIn";
import { MapView } from "./MapView";
import { ChainagePanel } from "./ChainagePanel";
const TOKEN_KEY = "central-token";
const POLL_MS = 10_000;
@@ -88,6 +91,10 @@ export function App() {
const [selected, setSelected] = useState<Set<number>>(new Set());
const [members, setMembers] = useState<Member[]>([]);
const [assignee, setAssignee] = useState("");
// Videos panel: table ↔ map toggle + road types (labels for assignment/calibration).
const [videoView, setVideoView] = useState<"table" | "map">("table");
const [roadTypes, setRoadTypes] = useState<RoadType[]>([]);
const [rtAssign, setRtAssign] = useState("");
// All ml_support users (admin only) — the pool the members picker chooses from.
const [users, setUsers] = useState<UserRow[]>([]);
const mlUsers = users.filter((u) => u.role === "ml_support");
@@ -102,14 +109,16 @@ export function App() {
const loadProject = useCallback(async (id: number) => {
try {
const [s, v, a, m] = await Promise.all([
const [s, v, a, m, rt] = await Promise.all([
api.stats(id), api.videos(id), api.activity(id),
api.members(id).catch(() => [] as Member[]),
api.roadTypes(id).catch(() => [] as RoadType[]),
]);
setStats(s);
setVideos(v);
setActivity(a);
setMembers(m);
setRoadTypes(rt);
setError(null);
} catch (e) {
setError(String(e));
@@ -142,6 +151,29 @@ export function App() {
}
}, [isAdmin, selected, assignee, projectId, loadProject]);
// Bulk-assign the picked road type to the selected videos (admin; "" clears).
const applyRoadType = useCallback(async () => {
if (!isAdmin || selected.size === 0 || projectId == null) return;
setBusy(true);
try {
await api.assignRoadType(projectId, [...selected], rtAssign);
setSelected(new Set());
await loadProject(projectId);
setError(null);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}, [isAdmin, selected, rtAssign, projectId, loadProject]);
// A deleted road-type label can leave a stale dropdown selection: the select
// LOOKS like "(clear road type)" but still sends the dead label, which the server
// rejects — making un-assign impossible. Snap back to '' whenever that happens.
useEffect(() => {
if (rtAssign !== "" && !roadTypes.some((r) => r.name === rtAssign)) setRtAssign("");
}, [roadTypes, rtAssign]);
useEffect(() => { if (auth) void loadProjects(); }, [auth, loadProjects]);
useEffect(() => {
@@ -387,22 +419,23 @@ export function App() {
</div>
<div className="grid2">
{/* Verification leaderboard — assigned users ranked by completion speed */}
{/* Verification leaderboard — assigned users ranked by annotation throughput
(most annotations in the least time), not by video count. */}
<section className="panel">
<h3>Verification leaderboard <span className="dim">· fastest first</span></h3>
<h3>Verification leaderboard <span className="dim">· most annotations / min</span></h3>
<table>
<thead>
<tr><th>Rank</th><th>User</th><th>Verified</th><th>Speed (avg/video)</th><th>Annotations</th><th>Total time</th></tr>
<tr><th>Rank</th><th>User</th><th>Annotations</th><th>Anno/min</th><th>Verified</th><th>Total time</th></tr>
</thead>
<tbody>
{stats?.verifiers.map((r) => (
<tr key={r.user} className={r.rank === 1 ? "rank-top" : undefined}>
<td>{r.rank > 0 ? (r.rank === 1 ? "🥇 1" : `#${r.rank}`) : <span className="dim"></span>}</td>
<td>{r.user}</td>
<td>{r.videos > 0 ? r.videos : <span className="dim">0</span>}</td>
<td>{r.videos > 0 ? fmtDuration(r.avg_time_ms) : <span className="dim"></span>}</td>
<td>{r.annotations > 0 ? r.annotations : <span className="dim">0</span>}</td>
<td>{r.videos > 0 ? fmtDuration(r.total_time_ms) : <span className="dim"></span>}</td>
<td>{r.annotations_per_min > 0 ? r.annotations_per_min.toFixed(1) : <span className="dim"></span>}</td>
<td>{r.videos > 0 ? r.videos : <span className="dim">0</span>}</td>
<td>{r.total_time_ms > 0 ? fmtDuration(r.total_time_ms) : <span className="dim"></span>}</td>
</tr>
))}
{(!stats || stats.verifiers.length === 0) && (
@@ -437,15 +470,21 @@ export function App() {
</section>
</div>
{/* Per-video table */}
{/* Per-video table / map */}
<section className="panel">
<div className="panel-head">
<h3>Videos ({videos.length})</h3>
<h3>
Videos ({videos.length})
<span className="viewtabs videos-toggle">
<button className={videoView === "table" ? "active" : ""} onClick={() => setVideoView("table")}>Table</button>
<button className={videoView === "map" ? "active" : ""} onClick={() => setVideoView("map")}>Map</button>
</span>
</h3>
<div className="panel-actions">
{isAdmin && projectId != null && (
<FoldersPicker projectId={projectId} onChange={() => loadProject(projectId)} />
)}
{isAdmin && (
{isAdmin && videoView === "table" && (
<span className="assign-bar">
<span className="dim">{selected.size} selected </span>
<select value={assignee} onChange={(e) => setAssignee(e.target.value)} title="Assign to a project member (or unassign)">
@@ -453,7 +492,14 @@ export function App() {
{members.map((m) => <option key={m.username} value={m.username}>{m.username}</option>)}
</select>
<button className="btn" disabled={busy || selected.size === 0 || (assignee !== "" && members.length === 0)} onClick={assignSelected}>Assign</button>
{members.length === 0 && <span className="dim"> add members in Admin first</span>}
<select value={rtAssign} onChange={(e) => setRtAssign(e.target.value)} title="Set the road type of the selected videos (or clear it)">
<option value="">(clear road type)</option>
{roadTypes.map((r) => <option key={r.name} value={r.name}>{r.name}</option>)}
</select>
<button className="btn" disabled={busy || selected.size === 0} onClick={applyRoadType}
title={rtAssign ? "Apply the picked road type to the selected videos" : "Remove the road type from the selected videos"}>
{rtAssign ? "Set road" : "Clear road"}
</button>
</span>
)}
{projectId != null && videos.some((v) => v.annotation_count > 0) && (
@@ -463,13 +509,18 @@ export function App() {
)}
</div>
</div>
{videoView === "map" && projectId != null && (
<MapView projectId={projectId} isAdmin={isAdmin} onChanged={() => loadProject(projectId)} />
)}
{videoView === "table" && (
<div className="table-scroll">
<table>
<thead>
<tr>
{isAdmin && <th><input type="checkbox" title="select all"
checked={selected.size > 0 && selected.size === videos.length}
onChange={(e) => setSelected(e.target.checked ? new Set(videos.map((v) => v.id)) : new Set())} /></th>}
<th>#</th><th>Folder</th><th>File</th><th>Status</th><th>Annotator</th><th>Annotations</th>
<th>#</th><th>Folder</th><th>File</th><th>Status</th><th>Road · Chainage</th><th>Annotator</th><th>Annotations</th>
<th>Review</th><th>Verify time</th><th>Resolution</th><th>Annotated</th><th>Remarks</th>
</tr>
</thead>
@@ -481,7 +532,7 @@ export function App() {
<td className="dim mono" title={v.rel_path}>{subfolder(v.rel_path) || "—"}</td>
<td className="mono">{v.file_name}</td>
<td>
<span className={`badge ${v.workflow_status}`}>{v.workflow_status}</span>
<span className={`badge ${v.workflow_status}`}>{statusLabel(v.workflow_status)}</span>
{v.ignored && <span className="badge ignored-tag" title={v.ignored_by ? `ignored by ${v.ignored_by}` : "ignored"}>ignored</span>}
{isAdmin && (
<button className="ignore-btn" onClick={() => toggleIgnore(v)}
@@ -499,20 +550,48 @@ export function App() {
<div className="claim-line" title={`last push ${fmtWhen(v.completed_at)}`}> <b>{v.verifiers || v.completed_by}</b> · {fmtWhen(v.completed_at)}</div>
) : null}
</td>
<td>
{v.road_type ? <span className="badge road-tag">{v.road_type}</span> : <span className="dim"></span>}
<div className="claim-line" title={
v.sr_chainage_start_m != null
? `sr (GPS-measured): ${fmtChainageRange(v.sr_chainage_start_m, v.sr_chainage_end_m)}`
: v.gps_status === "ok" ? "GPS scanned — chainage not calibrated yet"
: v.gps_status === "error" ? `GPS scan failed: ${v.gps_error ?? ""}`
: v.gps_status === "none" ? "no GPS metadata in this video"
: "GPS scan pending"
}>
{v.chainage_start_m != null
? fmtChainageRange(v.chainage_start_m, v.chainage_end_m)
: <span className="dim">{v.gps_status === "ok" ? "gps ✓" : v.gps_status === "pending" ? "gps …" : `gps ${v.gps_status}`}</span>}
</div>
<GpsPoints v={v} />
</td>
<td>{v.verifiers || v.primary_annotator || (v.assigned_to ? <span className="dim">assigned: {v.assigned_to}</span> : "—")}</td>
<td>
{v.annotation_count > 0
? <a href={exportVideoUrl(v.id)} download title="Download this video's annotations (JSON)">{v.annotation_count} </a>
: 0}
{v.imported_count > 0 && (
<div className="imported-line" title="annotations present when verification started (imported baseline)">
<span className="dim">imported {v.imported_count}</span>
{v.annotation_count < v.imported_count &&
<span className="ann-del" title={`${v.imported_count - v.annotation_count} deleted by the user`}> · {v.imported_count - v.annotation_count}</span>}
{v.annotation_count > v.imported_count &&
<span className="ann-add" title={`${v.annotation_count - v.imported_count} added by the user`}> · +{v.annotation_count - v.imported_count}</span>}
</div>
)}
{(v.imported_count > 0 || (v.passes?.length ?? 0) > 0) && (() => {
const deltas = passDeltas(v.imported_count, v.passes ?? []);
return (
<div className="imported-line" title="imported baseline → per-pass changes (verify / re-verify)">
<span className="dim">imported {v.imported_count}</span>
{deltas.length > 0
? deltas.map((d, i) => (
<span key={i} className={d.delta >= 0 ? "ann-add" : "ann-del"}
title={`${d.label} by ${d.username}${d.total} total`}>
{" · "}{d.label} {d.delta >= 0 ? "+" : ""}{d.delta}
</span>
))
: <>
{v.annotation_count < v.imported_count &&
<span className="ann-del" title={`${v.imported_count - v.annotation_count} deleted by the user`}> · {v.imported_count - v.annotation_count}</span>}
{v.annotation_count > v.imported_count &&
<span className="ann-add" title={`${v.annotation_count - v.imported_count} added by the user`}> · +{v.annotation_count - v.imported_count}</span>}
</>}
</div>
);
})()}
</td>
<td>{v.review_count > 0
? <span className="review-flag" title={`${v.review_count} annotation(s) flagged for review`}>🚩 {v.review_count}</span>
@@ -530,10 +609,22 @@ export function App() {
</td>
</tr>
))}
{videos.length === 0 && <tr><td colSpan={isAdmin ? 12 : 11} className="dim">no videos ingested</td></tr>}
{videos.length === 0 && <tr><td colSpan={isAdmin ? 13 : 12} className="dim">no videos ingested</td></tr>}
</tbody>
</table>
</div>
)}
</section>
{/* Chainage calibration + road-type labels (admin) */}
{isAdmin && projectId != null && (
<ChainagePanel
projectId={projectId}
videos={videos}
roadTypes={roadTypes}
onChanged={() => loadProject(projectId)}
/>
)}
</>
)}
</div>
@@ -558,6 +649,56 @@ function groupByClient(projects: ProjectSummary[]): [string, ProjectSummary[]][]
return order.map((k) => [k, map.get(k)!]);
}
/** GPS fix count vs the ~1 fix/s expected for the video's duration. A mismatch gets
* a ⚠ that expands (on click) into a plain-language explanation of how the missing/
* extra data is handled — interpolation, start-stitching, end clamping. */
function GpsPoints({ v }: { v: VideoRow }) {
const [open, setOpen] = useState(false);
if (v.gps_status !== "ok" || v.gps_sample_count == null) return null;
const durS = v.duration_s ?? (v.frame_count && v.fps ? v.frame_count / v.fps : null);
const expected = durS ? Math.round(durS) : null;
const diff = expected != null ? v.gps_sample_count - expected : 0;
const off = expected != null && Math.abs(diff) > Math.max(3, expected * 0.05);
const bigGap = (v.gps_max_gap_s ?? 0) > 5;
return (
<div className="claim-line gps-pts">
{v.gps_sample_count} gps pts
{expected != null && <span className="dim"> / ~{expected}</span>}
{(off || bigGap) && (
<button
className="gps-warn"
onClick={() => setOpen((o) => !o)}
title="Fix count doesn't match the video duration — click to see how the gaps are handled"
>
</button>
)}
{open && (off || bigGap) && (
<div className="gps-explain" onClick={() => setOpen(false)}>
{diff < 0 || (!off && bigGap) ? (
<>
Expected ~{expected ?? "?"} fixes (1 per second of video), found {v.gps_sample_count}
{bigGap && <> largest dropout <b>{v.gps_max_gap_s!.toFixed(0)} s</b></>}.
Positions inside a dropout are <b>time-interpolated</b>: each frame is placed
proportionally on a straight line between the two nearest real fixes (constant
speed assumed), and its chainage follows the same interpolation. A missing{" "}
<b>start</b> is stitched from the previous video via GPS timestamps when possible;
a missing <b>end</b> clamps to the last fix. The longer the dropout, the rougher
the approximation.
</>
) : diff > 0 ? (
<>
Found {v.gps_sample_count} fixes but only ~{expected} expected the camera wrote
more than one fix per second (or duplicated samples). Extra fixes are used as-is;
nothing is approximated.
</>
) : null}
</div>
)}
</div>
);
}
function Card({ label, value, tone }: { label: string; value: number; tone?: string }) {
return (
<div className={`card ${tone ?? ""}`}>

View File

@@ -0,0 +1,697 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { unzipSync } from "fflate";
import {
api, fmtChainage, fmtChainageRange, fmtWhen,
type CalibrationRow, type CalibrationSummary, type ChainagePoint, type RoadType, type VideoRow,
} from "./api";
/** The import format clients should follow: one Point placemark per survey point,
* chainage in the name ("12+400" = 12 km + 400 m, or decimal km). */
const SAMPLE_KML = `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
<name>chainage points — sample</name>
<!-- One Placemark per survey point.
name = the chainage at that point: "12+400" (12 km + 400 m) or "12.4" (km)
coordinates = lon,lat[,alt] (KML order: longitude first!)
The dashboard reads the name (or description) and the Point coordinates.
LineStrings/paths are ignored — only Point placemarks import. -->
<Placemark><name>0+000</name><Point><coordinates>78.185718,21.105570,0</coordinates></Point></Placemark>
<Placemark><name>5+000</name><Point><coordinates>78.158000,21.092045,0</coordinates></Point></Placemark>
<Placemark><name>10+000</name><Point><coordinates>78.123885,21.091447,0</coordinates></Point></Placemark>
</Document></kml>
`;
function downloadSampleKml() {
const url = URL.createObjectURL(new Blob([SAMPLE_KML], { type: "application/vnd.google-earth.kml+xml" }));
const a = document.createElement("a");
a.href = url;
a.download = "chainage_points_sample.kml";
a.click();
URL.revokeObjectURL(url);
}
/** One placemark parsed out of an uploaded KML, editable before import. */
interface KmlRow {
name: string;
lat: number;
lon: number;
/** Editable chainage in km (prefilled from the placemark name/description). */
km: string;
include: boolean;
}
/** Chainage from free text: road notation "12+400" (km+m) always wins; otherwise the
* first number, interpreted in `unit`. Returns meters, or null when nothing parses. */
function parseChainageText(text: string, unit: "km" | "m"): number | null {
const plus = text.match(/(\d+)\s*\+\s*(\d+(?:\.\d+)?)/);
if (plus) return parseInt(plus[1], 10) * 1000 + parseFloat(plus[2]);
const num = text.match(/-?\d+(?:[.,]\d+)?/);
if (!num) return null;
const v = parseFloat(num[0].replace(",", "."));
if (isNaN(v)) return null;
return unit === "km" ? v * 1000 : v;
}
/** Extract Point placemarks (name + lat/lon) from a KML document. LineStrings and
* multi-coordinate placemarks are skipped (counted) — only survey points import. */
function parseKmlPoints(text: string): { rows: { name: string; lat: number; lon: number }[]; skipped: number } {
const doc = new DOMParser().parseFromString(text, "application/xml");
if (doc.getElementsByTagName("parsererror").length > 0) {
throw new Error("not a valid KML/XML file (KMZ must be unzipped first)");
}
const rows: { name: string; lat: number; lon: number }[] = [];
let skipped = 0;
for (const pm of Array.from(doc.getElementsByTagNameNS("*", "Placemark"))) {
const coords = pm.getElementsByTagNameNS("*", "coordinates")[0]?.textContent?.trim() ?? "";
const tuples = coords.split(/\s+/).filter(Boolean);
if (tuples.length !== 1) {
skipped += 1; // a LineString/Polygon (or empty) — not a survey point
continue;
}
const [lon, lat] = tuples[0].split(",").map(parseFloat);
if (isNaN(lat) || isNaN(lon)) {
skipped += 1;
continue;
}
const name = pm.getElementsByTagNameNS("*", "name")[0]?.textContent?.trim()
|| pm.getElementsByTagNameNS("*", "description")[0]?.textContent?.trim()
|| "";
rows.push({ name, lat, lon });
}
return { rows, skipped };
}
/**
* Admin chainage panel: manage road-type labels, enter the client's calibration
* points (lat/lon ↔ chainage), run the two-point quick assign ("first video starts
* at X, last ends at Y") or a full recompute over the current group, and read the
* resulting per-span scales + per-video chainage. `sr` (GPS-measured) chainage is
* shown but never editable — only the client-calibrated values come from points.
*/
export function ChainagePanel({
projectId,
videos,
roadTypes,
onChanged,
}: {
projectId: number;
videos: VideoRow[];
roadTypes: RoadType[];
onChanged: () => void;
}) {
const [open, setOpen] = useState(false);
const [group, setGroup] = useState(""); // road_type calibration group ('' = all videos)
const [points, setPoints] = useState<ChainagePoint[]>([]);
const [calibs, setCalibs] = useState<CalibrationRow[]>([]);
const [calibsErr, setCalibsErr] = useState<string | null>(null);
const [summary, setSummary] = useState<CalibrationSummary | null>(null);
// true = loaded from the server (someone's earlier compute); false = just run here.
const [summaryIsSaved, setSummaryIsSaved] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// add-point form
const [latlon, setLatlon] = useState("");
const [pointKm, setPointKm] = useState("");
const [pointNote, setPointNote] = useState("");
// inline note edit on an existing point
const [noteEdit, setNoteEdit] = useState<{ id: number; text: string } | null>(null);
// quick two-point form
const [startKm, setStartKm] = useState("0");
const [endKm, setEndKm] = useState("");
// road-type editor
const [newType, setNewType] = useState("");
// KML import preview
const [kmlRows, setKmlRows] = useState<KmlRow[] | null>(null);
const [kmlUnit, setKmlUnit] = useState<"km" | "m">("km");
const [kmlSkipped, setKmlSkipped] = useState(0);
const kmlFileRef = useRef<HTMLInputElement>(null);
const loadPoints = useCallback(async () => {
try {
setPoints(await api.chainagePoints(projectId));
} catch (e) {
setError(String(e));
}
// Calibration list failing must not hide the points editor — but it must not
// fail silently either (a broken endpoint/auth issue should be visible).
try {
setCalibs(await api.chainageCalibrations(projectId));
setCalibsErr(null);
} catch (e) {
setCalibs([]);
setCalibsErr(String(e));
}
}, [projectId]);
useEffect(() => {
if (open) void loadPoints();
}, [open, loadPoints]);
// On open / group change, show the SAVED result of the last recompute (persisted
// server-side) so a freshly signed-in admin sees the existing compute for LHS/RHS
// instead of an empty panel asking to recompute.
useEffect(() => {
setSummary(null);
setSummaryIsSaved(false);
if (!open) return;
let alive = true;
api.chainageSummary(projectId, group)
.then((s) => {
if (alive && s && typeof s === "object") {
setSummary(s);
setSummaryIsSaved(true);
}
})
.catch(() => {}); // calibrations block surfaces endpoint failures already
return () => {
alive = false;
};
}, [projectId, group, open]);
/** The route's videos = current group, ignored ones excluded. */
const groupVideos = useMemo(
() => videos.filter((v) => !v.ignored && (group === "" || v.road_type === group)),
[videos, group],
);
const groupPoints = useMemo(
() => points.filter((p) => p.road_type === group || p.road_type === ""),
[points, group],
);
// Suggest quick-assign values from the group's own videos: an existing
// calibration's range when one exists, else 0 → the summed GPS length rounded to
// the nearest whole km. Re-suggested when the group changes (still editable).
useEffect(() => {
if (!open) return;
const ch = groupVideos
.flatMap((v) => [v.chainage_start_m, v.chainage_end_m])
.filter((x): x is number => x != null);
if (ch.length > 0) {
setStartKm((Math.min(...ch) / 1000).toFixed(4));
setEndKm((Math.max(...ch) / 1000).toFixed(4));
} else {
const len = groupVideos.reduce((s, v) => s + (v.gps_len_m ?? 0), 0);
setStartKm("0");
setEndKm(len > 0 ? String(Math.max(1, Math.round(len / 1000))) : "");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, group]);
const run = async (fn: () => Promise<CalibrationSummary>) => {
setBusy(true);
setError(null);
try {
setSummary(await fn());
setSummaryIsSaved(false);
await loadPoints();
onChanged();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const addPoint = async () => {
const m = latlon.trim().match(/^\s*(-?\d+(?:\.\d+)?)\s*[,;\s]\s*(-?\d+(?:\.\d+)?)\s*$/);
const km = parseFloat(pointKm);
if (!m || isNaN(km)) {
setError('Point needs "lat, lon" and a chainage in km.');
return;
}
setBusy(true);
setError(null);
try {
await api.addChainagePoint(projectId, {
lat: parseFloat(m[1]),
lon: parseFloat(m[2]),
chainage_m: km * 1000,
note: pointNote.trim(),
road_type: group,
});
setLatlon("");
setPointKm("");
setPointNote("");
await loadPoints();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const deletePoint = async (id: number) => {
setBusy(true);
try {
await api.deleteChainagePoint(id);
await loadPoints();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
/** Persist an inline note edit (Enter or blur). */
const saveNote = async () => {
if (!noteEdit) return;
const { id, text } = noteEdit;
setNoteEdit(null);
try {
await api.updateChainagePointNote(id, text);
await loadPoints();
} catch (e) {
setError(String(e));
}
};
const quick = () => {
const s = parseFloat(startKm);
const e = parseFloat(endKm);
if (isNaN(s) || isNaN(e)) {
setError("Quick assign needs start and end chainage in km.");
return;
}
void run(() =>
api.chainageQuick(projectId, {
video_ids: groupVideos.map((v) => v.id),
start_m: s * 1000,
end_m: e * 1000,
road_type: group,
}),
);
};
const recompute = () =>
run(() => api.chainageRecompute(projectId, groupVideos.map((v) => v.id), group));
const addRoadType = async () => {
const name = newType.trim();
if (!name) return;
setBusy(true);
setError(null);
try {
await api.setRoadTypes(projectId, [...roadTypes.map((r) => r.name), name]);
setNewType("");
onChanged();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const onKmlFile = async (file: File) => {
setError(null);
try {
let text: string;
if (/\.kmz$/i.test(file.name)) {
// KMZ = zipped KML; take the first .kml entry (usually doc.kml).
const entries = unzipSync(new Uint8Array(await file.arrayBuffer()));
const key = Object.keys(entries).find((k) => k.toLowerCase() === "doc.kml")
?? Object.keys(entries).find((k) => /\.kml$/i.test(k));
if (!key) {
setError(`${file.name} contains no .kml document.`);
return;
}
text = new TextDecoder().decode(entries[key]);
} else {
text = await file.text();
}
const { rows, skipped } = parseKmlPoints(text);
if (rows.length === 0) {
setError(`No point placemarks found in ${file.name}${skipped ? ` (${skipped} non-point entries skipped)` : ""}.`);
return;
}
setKmlSkipped(skipped);
setKmlRows(rows.map((r) => {
const m = parseChainageText(r.name, kmlUnit);
return { ...r, km: m != null ? (m / 1000).toFixed(4) : "", include: m != null };
}));
} catch (e) {
setError(String(e));
} finally {
if (kmlFileRef.current) kmlFileRef.current.value = ""; // allow re-picking the same file
}
};
/** Re-read every row's chainage from its placemark name under the new unit. */
const setKmlUnitAndReparse = (u: "km" | "m") => {
setKmlUnit(u);
setKmlRows((rows) => rows?.map((r) => {
const m = parseChainageText(r.name, u);
return { ...r, km: m != null ? (m / 1000).toFixed(4) : "", include: m != null };
}) ?? null);
};
const importKml = async () => {
if (!kmlRows) return;
const points = kmlRows
.filter((r) => r.include && r.km.trim() !== "" && !isNaN(parseFloat(r.km)))
.map((r) => ({
lat: r.lat,
lon: r.lon,
chainage_m: parseFloat(r.km) * 1000,
note: r.name,
road_type: group,
}));
if (points.length === 0) {
setError("No rows selected with a valid chainage.");
return;
}
setBusy(true);
setError(null);
try {
await api.addChainagePointsBulk(projectId, points);
setKmlRows(null);
await loadPoints();
onChanged();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const removeRoadType = async (name: string) => {
setBusy(true);
setError(null);
try {
await api.setRoadTypes(projectId, roadTypes.map((r) => r.name).filter((n) => n !== name));
if (group === name) setGroup("");
onChanged();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
if (!open) {
return (
<section className="panel">
<div className="panel-head">
<h3>Chainage &amp; road types</h3>
<button className="btn" onClick={() => setOpen(true)}>Open </button>
</div>
</section>
);
}
return (
<section className="panel">
<div className="panel-head">
<h3>Chainage &amp; road types</h3>
<button className="btn" onClick={() => setOpen(false)}>Close </button>
</div>
{error && <div className="error"> {error}</div>}
{/* Road-type labels (project-wide) */}
<div className="chainage-block">
<div className="dim block-label">Road types (assign them to selected videos in the table below)</div>
<div className="member-chips">
{roadTypes.map((r) => (
<span key={r.name} className="member-chip">
{r.name} <span className="dim">({r.video_count})</span>
<button className="chip-x" disabled={busy} onClick={() => removeRoadType(r.name)} title="Remove label">×</button>
</span>
))}
<input
className="chip-input"
value={newType}
onChange={(e) => setNewType(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void addRoadType(); }}
placeholder="Add: LHS / RHS / MCW…"
/>
<button className="btn" disabled={busy || !newType.trim()} onClick={addRoadType}>Add</button>
</div>
</div>
{/* Calibration group + the videos it covers */}
<div className="chainage-block form-row">
<label className="dim">Calibration group</label>
<select value={group} onChange={(e) => setGroup(e.target.value)}>
<option value="">(all videos)</option>
{roadTypes.map((r) => (
<option key={r.name} value={r.name}>{r.name}</option>
))}
</select>
<span className="dim">
{groupVideos.length} video(s) in the route · {groupPoints.length} calibration point(s)
</span>
</div>
{/* Saved calibrations — persisted server-side, so every user (dashboard AND
desktop exports) sees the same computed chainage without re-running anything. */}
{(calibs.length > 0 || calibsErr) && (
<div className="chainage-block">
<div className="dim block-label">Saved calibrations (shared all users see these chainages)</div>
{calibsErr && <div className="err-tag" style={{ fontSize: 12 }}> calibrations unavailable: {calibsErr}</div>}
{calibs.map((c) => (
<div key={c.road_type || "(any)"} className="dim" style={{ fontSize: 12 }}>
<b>{c.road_type || "(ungrouped)"}</b> · route {fmtChainage(c.route_len_m)} km
· {c.anchor_count} anchor(s) · computed by {c.computed_by} · {fmtWhen(c.computed_at)}
{!c.has_summary && (
<span className="err-tag" title="This calibration predates saved results — select its group and hit 'Recompute from points' once (same points ⇒ same values); after that every admin sees the full result here.">
{" "}· result not saved yet recompute once
</span>
)}
</div>
))}
</div>
)}
<div className="grid2">
{/* Control points */}
<div className="chainage-block">
<div className="dim block-label">Client calibration points (lat/lon chainage)</div>
<table>
<thead>
<tr><th>Chainage</th><th>Lat, Lon</th><th>Group</th><th>Note</th><th></th></tr>
</thead>
<tbody>
{groupPoints.map((p) => (
<tr key={p.id}>
<td>
{fmtChainage(p.chainage_m)}
{p.origin === "quick" && (
<span className="dim" title="Generated by the two-point quick assign — replaced automatically on the next quick run"> · auto</span>
)}
</td>
<td className="mono">{p.lat.toFixed(6)}, {p.lon.toFixed(6)}</td>
<td className="dim">{p.road_type || "any"}</td>
<td
className="dim"
style={{ cursor: "pointer" }}
title="Click to edit — e.g. 'start point', 'toll plaza', a survey remark"
onClick={() => { if (noteEdit?.id !== p.id) setNoteEdit({ id: p.id, text: p.note }); }}
>
{noteEdit?.id === p.id ? (
<input
autoFocus
value={noteEdit.text}
style={{ width: 140 }}
onChange={(e) => setNoteEdit({ id: p.id, text: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") void saveNote();
if (e.key === "Escape") setNoteEdit(null);
}}
onBlur={() => void saveNote()}
/>
) : (
p.note || "— (click to add)"
)}
</td>
<td><button className="chip-x" disabled={busy} onClick={() => deletePoint(p.id)} title="Delete point">×</button></td>
</tr>
))}
{groupPoints.length === 0 && (
<tr><td colSpan={5} className="dim">no points yet add them, or use the quick assign</td></tr>
)}
</tbody>
</table>
<div className="form-row">
<input value={latlon} onChange={(e) => setLatlon(e.target.value)} placeholder="lat, lon" style={{ width: 190 }} />
<input value={pointKm} onChange={(e) => setPointKm(e.target.value)} placeholder="chainage (km)" style={{ width: 110 }} />
<input value={pointNote} onChange={(e) => setPointNote(e.target.value)} placeholder="note (optional)" style={{ width: 140 }} />
<button className="btn" disabled={busy} onClick={addPoint}>Add point</button>
<input
ref={kmlFileRef}
type="file"
accept=".kml,.kmz,.xml"
style={{ display: "none" }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) void onKmlFile(f); }}
/>
<button className="btn" disabled={busy} onClick={() => kmlFileRef.current?.click()}
title="Upload a KML/KMZ of survey points — chainage read from each placemark's name (12+400 or a number)">
Import KML/KMZ
</button>
<button className="btn" onClick={downloadSampleKml}
title="Download the template your client's points file should follow">
Sample KML
</button>
</div>
{kmlRows && (
<div className="kml-preview">
<div className="form-row">
<b>{kmlRows.filter((r) => r.include).length}</b>
<span className="dim">of {kmlRows.length} points will import into group "{group || "any"}"
{kmlSkipped > 0 && ` · ${kmlSkipped} non-point placemark(s) skipped`}</span>
<span className="dim">· values in names are</span>
<select value={kmlUnit} onChange={(e) => setKmlUnitAndReparse(e.target.value as "km" | "m")}>
<option value="km">km</option>
<option value="m">meters</option>
</select>
<span className="dim">("12+400" always = 12 km + 400 m)</span>
</div>
<div className="kml-preview-list">
<table>
<thead>
<tr><th></th><th>Placemark</th><th>Lat, Lon</th><th>Chainage (km)</th></tr>
</thead>
<tbody>
{kmlRows.map((r, i) => (
<tr key={i} className={r.include ? undefined : "row-ignored"}>
<td><input type="checkbox" checked={r.include}
onChange={() => setKmlRows((rows) => rows!.map((x, k) => k === i ? { ...x, include: !x.include } : x))} /></td>
<td className="mono">{r.name || <span className="dim">(unnamed)</span>}</td>
<td className="mono dim">{r.lat.toFixed(6)}, {r.lon.toFixed(6)}</td>
<td><input value={r.km} style={{ width: 90 }} placeholder="?"
onChange={(e) => setKmlRows((rows) => rows!.map((x, k) => k === i ? { ...x, km: e.target.value, include: true } : x))} /></td>
</tr>
))}
</tbody>
</table>
</div>
<div className="form-row">
<button className="btn primary" disabled={busy} onClick={importKml}>
Import {kmlRows.filter((r) => r.include).length} point(s)
</button>
<button className="btn" disabled={busy} onClick={() => setKmlRows(null)}>Cancel</button>
</div>
</div>
)}
</div>
{/* Quick assign + recompute */}
<div className="chainage-block">
<div className="dim block-label">
Two-point quick assign client gives just the chainage at the route's two ends; the
error is distributed over the whole route. Values are pre-filled from the {group ? `"${group}"` : "selected"} videos.
</div>
<div className="form-row">
<label className="dim">One end</label>
<input value={startKm} onChange={(e) => setStartKm(e.target.value)} style={{ width: 90 }} /> km
<label className="dim">other end</label>
<input value={endKm} onChange={(e) => setEndKm(e.target.value)} style={{ width: 90 }} /> km
<button className="btn" disabled={busy}
title="The 0 landed on the wrong physical end? Swap the two values and re-compute."
onClick={() => { const s = startKm; setStartKm(endKm); setEndKm(s); }}>
⇄ swap
</button>
<button className="btn primary" disabled={busy || groupVideos.length === 0} onClick={quick}>
Assign &amp; compute
</button>
</div>
<div className="dim" style={{ marginTop: 6, fontSize: 12 }}>
The two auto points ("quick: chain start/end") are saved into group <b>{group || "any"}</b>.
After computing, check the km stones on the map — if 0 sits at the wrong end of the road,
hit ⇄ swap and compute again. Footage direction doesn't matter (handled automatically).
</div>
<div className="form-row" style={{ marginTop: 10 }}>
<button className="btn" disabled={busy || groupVideos.length === 0} onClick={recompute}
title="Re-run the calibration with the points above">
Recompute from points
</button>
<span className="dim">orders the {groupVideos.length} video(s) into a route, snaps the points, distributes the error</span>
</div>
</div>
</div>
{/* Result summary — freshly run, or the saved result of the last recompute */}
{summary && (
<div className="chainage-block">
{summaryIsSaved && (() => {
const c = calibs.find((x) => x.road_type === group);
return (
<div className="dim" style={{ fontSize: 12, marginBottom: 4 }}>
Saved result of the last compute
{c ? <> by <b>{c.computed_by}</b> · {fmtWhen(c.computed_at)}</> : null}
{" "}(recompute only if points or videos changed)
</div>
);
})()}
<div className="block-label">
Route <b>{(summary.route_len_m / 1000).toFixed(3)} km</b> (GPS-measured)
· {summary.points_used} point(s) used
{summary.points_excluded > 0 && <span className="err-tag"> · {summary.points_excluded} excluded</span>}
{summary.reversed && <span className="dim"> · direction auto-reversed</span>}
{summary.majority_flipped && (
<span className="dim" title="Most videos here were filmed driving toward decreasing chainage — that's this route's normal pattern, so only videos running AGAINST it are flagged below.">
{" "}· footage mostly runs high low chainage
</span>
)}
{summary.videos.length > 1 && (() => {
const ag = summary.videos.filter((v) => v.against_flow).length;
return (
<span className={ag > 0 ? "err-tag" : "dim"}
title={ag > 0 ? "Videos running against the route's dominant direction — on a divided road this often means the video belongs to the other carriageway (wrong group)." : "Every video runs in the route's dominant direction."}>
{" "}· {summary.videos.length - ag}/{summary.videos.length} follow the dominant direction{ag > 0 ? `, ${ag} against` : ""}
</span>
);
})()}
</div>
{summary.warnings.length > 0 && (
<ul className="chainage-warnings">
{summary.warnings.map((w, i) => <li key={i}> {w}</li>)}
</ul>
)}
{summary.spans.length > 0 && (
<table>
<thead>
<tr><th>Span (client)</th><th>GPS length</th><th>Client length</th><th>Scale</th></tr>
</thead>
<tbody>
{summary.spans.map((sp, i) => (
<tr key={i} className={Math.abs(sp.scale - 1) > 0.05 ? "span-warn" : undefined}>
<td>{fmtChainage(sp.from_chainage_m)} {fmtChainage(sp.to_chainage_m)}</td>
<td>{(sp.gps_len_m / 1000).toFixed(3)} km</td>
<td>{(sp.client_len_m / 1000).toFixed(3)} km</td>
<td>×{sp.scale.toFixed(4)}</td>
</tr>
))}
</tbody>
</table>
)}
<table style={{ marginTop: 8 }}>
<thead>
<tr><th>#</th><th>Video</th><th>Chainage (client)</th><th>SR chainage (GPS)</th><th></th></tr>
</thead>
<tbody>
{summary.videos.map((v) => (
<tr key={v.id}>
<td className="dim">{v.seq + 1}</td>
<td className="mono">{v.file_name}</td>
<td>{fmtChainageRange(v.chainage_start_m, v.chainage_end_m)}</td>
<td className="dim">{fmtChainageRange(v.sr_start_m, v.sr_end_m)}</td>
<td className="dim">{v.against_flow && (
<span className="err-tag" title="This video's travel direction is OPPOSITE to most of the route. Chainage is still computed correctly — but if it's unexpected, the video may belong to the other carriageway (wrong group).">
against flow
</span>
)}</td>
</tr>
))}
</tbody>
</table>
{summary.skipped.length > 0 && (
<div className="dim" style={{ marginTop: 6 }}>
Skipped (no GPS): {summary.skipped.join(", ")}
</div>
)}
</div>
)}
</section>
);
}

1133
dashboard/src/MapView.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,14 @@ export interface Remark {
created_at: string;
}
/** One verification pass (a push). `count` is the annotation total after that pass;
* null for legacy pushes logged before we recorded it. */
export interface Pass {
username: string;
at: string;
count: number | null;
}
export interface VideoRow {
id: number;
file_name: string;
@@ -75,6 +83,135 @@ export interface VideoRow {
ignored: boolean;
ignored_by: string | null;
remarks: Remark[];
passes: Pass[]; // per-pass annotation-count history (push order)
road_type: string; // '' = unassigned
gps_status: string; // pending | ok | none | error
gps_len_m: number | null;
gps_error: string | null;
gps_sample_count: number | null; // raw fixes found (≈1/s expected)
gps_max_gap_s: number | null; // biggest dropout between fixes
duration_s: number | null; // real container duration from the scan
chainage_start_m: number | null; // client-calibrated
chainage_end_m: number | null;
sr_chainage_start_m: number | null; // GPS-measured (immutable)
sr_chainage_end_m: number | null;
route_seq: number | null;
}
/** One video as the map sees it. `track` = [[lat, lon, t_s], …] (corrected when a
* correction was kept, else raw); null until the GPS sweep has scanned it. */
export interface MapVideo {
id: number;
file_name: string;
rel_path: string;
status: string;
workflow_status: string;
ignored: boolean;
hand_raised: boolean;
assigned_to: string | null;
claimed_by: string | null;
annotation_count: number;
road_type: string;
gps_status: string;
gps_len_m: number | null;
gps_error: string | null;
chainage_start_m: number | null;
chainage_end_m: number | null;
sr_chainage_start_m: number | null;
sr_chainage_end_m: number | null;
route_seq: number | null;
remark_count: number;
track: [number, number, number][] | null;
corrected: boolean;
gps_corrected_by: string | null; // 'auto' or a username
gps_corrected_note: string | null;
gps_sample_count: number | null; // raw fixes found by the scan
gps_max_gap_s: number | null; // biggest dropout between fixes
duration_s: number | null;
gps_start_epoch: number | null; // recording window (UTC epoch of first/last fix)
gps_end_epoch: number | null;
speed_avg_mps: number | null; // over the full-resolution track
speed_max_mps: number | null;
}
export interface RoadType {
name: string;
video_count: number;
}
export interface ChainagePoint {
id: number;
lat: number;
lon: number;
chainage_m: number;
note: string;
road_type: string;
/** '' = manual; 'quick' = generated by quick assign (replaced on the next run). */
origin: string;
created_by: string;
created_at: string;
}
export interface SpanRow {
from_chainage_m: number;
to_chainage_m: number;
gps_len_m: number;
client_len_m: number;
scale: number;
}
export interface VideoChainageRow {
id: number;
file_name: string;
seq: number;
flipped: boolean;
/** Runs OPPOSITE to the majority of the route — the only direction flag shown. */
against_flow: boolean;
sr_start_m: number;
sr_end_m: number;
chainage_start_m: number;
chainage_end_m: number;
}
export interface CalibrationSummary {
route_len_m: number;
reversed: boolean;
/** Most footage runs toward decreasing chainage — the route's normal pattern. */
majority_flipped: boolean;
points_used: number;
points_excluded: number;
spans: SpanRow[];
warnings: string[];
videos: VideoChainageRow[];
skipped: string[];
}
/** A saved calibration (persisted per road-type group by every recompute) — any
* member who opens the project sees what's already computed, by whom and when. */
export interface CalibrationRow {
road_type: string;
route_len_m: number;
anchor_count: number;
computed_by: string;
computed_at: string;
/** False only for pre-summary calibrations — one recompute seeds the saved result. */
has_summary: boolean;
}
/** Result of the GPS correction preview/keep — original + corrected tracks + stats.
* The pipeline = spike removal + (when a temporal predecessor exists) a
* missing-start stitch that re-bases the clock to the true video start. */
export interface CorrectResult {
action: string;
points: number;
replaced: number;
stitched_from: string | null; // predecessor file the start was borrowed from
shift_s: number; // seconds of GPS missing at the start (clock re-based by this)
note: string;
len_before_m: number;
len_after_m: number;
original: [number, number, number][];
corrected: [number, number, number][];
}
export interface LeaderRow {
@@ -91,7 +228,8 @@ export interface VerifierRow {
total_time_ms: number;
avg_time_ms: number;
annotations: number; // total annotations this user authored
rank: number; // 1 = fastest; 0 = no completions yet
annotations_per_min: number; // throughput: annotations added per minute (ranking metric)
rank: number; // 1 = highest throughput; 0 = not yet ranked
}
export interface ProjectTime {
@@ -176,6 +314,9 @@ export const exportVideoUrl = (id: number) =>
`${BASE}/videos/${id}/export?token=${encodeURIComponent(authToken)}`;
export const exportProjectUrl = (id: number) =>
`${BASE}/projects/${id}/export?token=${encodeURIComponent(authToken)}`;
/** All video tracks (colored by status) + calibration points as a Google-Earth KML. */
export const tracksKmlUrl = (id: number) =>
`${BASE}/projects/${id}/tracks.kml?token=${encodeURIComponent(authToken)}`;
function authHeaders(token?: string): Record<string, string> {
const t = token ?? authToken;
@@ -278,8 +419,96 @@ export const api = {
folders: (projectId: number) => get<Folder[]>(`/projects/${projectId}/folders`),
setFolders: (projectId: number, folders: string[], token?: string) =>
post<{ folders: number }>(`/projects/${projectId}/folders`, { folders }, token),
// ---- Map view + GPS pipeline + road_type + chainage ----
map: (projectId: number) => get<MapVideo[]>(`/projects/${projectId}/map`),
remarks: (videoId: number) => get<Remark[]>(`/videos/${videoId}/remarks`),
// Queue a fresh exiftool scan for a video / for every scanned video (admin).
gpsRescan: (videoId: number) => post<{ queued: boolean }>(`/videos/${videoId}/gps/rescan`),
gpsRescanAll: (projectId: number) => post<{ queued: number }>(`/projects/${projectId}/gps/rescan_all`),
// "Viterbi" correction: preview (nothing saved) / keep (persist) / discard (revert).
gpsCorrect: (videoId: number, action: "preview" | "keep" | "discard") =>
post<CorrectResult>(`/videos/${videoId}/gps/correct`, { action }),
// Road types: admin-defined labels + bulk assignment to selected videos.
roadTypes: (projectId: number) => get<RoadType[]>(`/projects/${projectId}/road_types`),
setRoadTypes: (projectId: number, names: string[]) =>
post<{ road_types: number }>(`/projects/${projectId}/road_types`, { names }),
assignRoadType: (projectId: number, video_ids: number[], road_type: string) =>
post<{ assigned: number }>(`/projects/${projectId}/road_type_assign`, { video_ids, road_type }),
// Chainage: client calibration points + the recompute/quick-assign actions.
chainagePoints: (projectId: number) => get<ChainagePoint[]>(`/projects/${projectId}/chainage/points`),
addChainagePoint: (
projectId: number,
p: { lat: number; lon: number; chainage_m: number; note?: string; road_type?: string },
) => post<ChainagePoint>(`/projects/${projectId}/chainage/points`, p),
// Bulk insert (KML import): one transaction, all-or-nothing.
addChainagePointsBulk: (
projectId: number,
points: { lat: number; lon: number; chainage_m: number; note?: string; road_type?: string }[],
) => post<{ added: number }>(`/projects/${projectId}/chainage/points/bulk`, { points }),
deleteChainagePoint: (pointId: number) => del<{ deleted: boolean }>(`/chainage/points/${pointId}`),
// Edit a point's note in place (admin); position/chainage stay immutable.
updateChainagePointNote: (pointId: number, note: string) =>
post<{ updated: boolean }>(`/chainage/points/${pointId}`, { note }),
// Saved calibrations per group (member or admin) — proof the chainage is computed.
chainageCalibrations: (projectId: number) =>
get<CalibrationRow[]>(`/projects/${projectId}/chainage/calibrations`),
// The saved result of the last recompute for a group (null if never computed) —
// any admin who signs in later sees the existing compute without re-running it.
chainageSummary: (projectId: number, road_type: string) =>
get<CalibrationSummary | null>(
`/projects/${projectId}/chainage/summary?road_type=${encodeURIComponent(road_type)}`,
),
chainageRecompute: (projectId: number, video_ids: number[], road_type: string) =>
post<CalibrationSummary>(`/projects/${projectId}/chainage/recompute`, { video_ids, road_type }),
chainageQuick: (
projectId: number,
body: { video_ids: number[]; start_m: number; end_m: number; road_type: string },
) => post<CalibrationSummary>(`/projects/${projectId}/chainage/quick`, body),
};
/** Display label for a workflow/status value. `annotated` really means "has
* annotations, nobody verified them yet"; `pending` means the video has no
* annotations at all — admins need to see that plainly. Display-only: the DB/API
* keep the raw values. */
export function statusLabel(s: string): string {
if (s === "annotated") return "awaiting verification";
if (s === "pending") return "no annotations";
return s;
}
/** Chainage convention (user-fixed): km with FOUR decimals — "22.4456".
* Input is meters (internal unit); em-dash when not computed yet. */
export function fmtChainage(m: number | null | undefined): string {
if (m == null || isNaN(m)) return "—";
return (m / 1000).toFixed(4);
}
/** "12.4000 → 13.7500" for a start/end pair (direction preserved — a flipped video
* legitimately runs high→low); em-dash when not computed. */
export function fmtChainageRange(a: number | null | undefined, b: number | null | undefined): string {
if (a == null || b == null) return "—";
return `${(a / 1000).toFixed(4)}${(b / 1000).toFixed(4)}`;
}
/** Per-pass annotation deltas from the imported baseline: verify +Δ, re-verify +Δ, …
* Skips legacy passes that have no recorded count. The first pass is "verify",
* subsequent ones "re-verify". `total` is the running annotation count after the pass. */
export function passDeltas(
importedCount: number,
passes: Pass[],
): { label: string; delta: number; username: string; total: number }[] {
const out: { label: string; delta: number; username: string; total: number }[] = [];
let prev = importedCount;
let n = 0;
for (const p of passes ?? []) {
if (p.count == null) continue;
n += 1;
out.push({ label: n === 1 ? "verify" : "re-verify", delta: p.count - prev, username: p.username, total: p.count });
prev = p.count;
}
return out;
}
/** Human-readable byte size. */
export function fmtBytes(n: number): string {
if (!n || n <= 0) return "0 B";
@@ -288,12 +517,20 @@ export function fmtBytes(n: number): string {
return `${(n / 1024 ** i).toFixed(i ? 1 : 0)} ${u[i]}`;
}
/** Readable local date+time for a server ISO timestamp; "" when missing/invalid. */
/** Readable date+time for a server ISO timestamp, shown in IST (Asia/Kolkata)
* regardless of the viewer's/VM's timezone; "" when missing/invalid. Server
* timestamps are UTC (TIMESTAMPTZ), so the instant is converted, not relabelled. */
export function fmtWhen(iso: string | null): string {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
return d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
return d.toLocaleString("en-IN", {
timeZone: "Asia/Kolkata",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}) + " IST";
}
export function fmtDuration(ms: number): string {

View File

@@ -98,10 +98,14 @@ tbody tr:hover { background: rgba(255,255,255,0.02); }
padding: 6px 8px; font-family: ui-monospace, Menlo, monospace; font-size: 12px;
word-break: break-all; flex: 1; }
/* Remark thread cell (video table) */
.remark-col { max-width: 360px; }
/* Remark thread cell (video table) — needs a guaranteed floor so the crowded table
can't crush it; the table itself scrolls horizontally instead (.table-scroll). */
.remark-col { max-width: 360px; min-width: 150px; }
.remark-collapsed { background: none; border: none; color: var(--fg, #dce3f5); cursor: pointer;
text-align: left; padding: 2px 0; font-size: 12px; display: inline-flex; gap: 6px; align-items: center; max-width: 340px; }
text-align: left; padding: 2px 0; font-size: 12px; display: inline-flex; gap: 6px; align-items: center;
max-width: 340px; white-space: nowrap; }
/* Wide tables scroll inside their panel instead of squeezing columns off the edge. */
.table-scroll { overflow-x: auto; }
.remark-collapsed:hover { filter: brightness(1.2); }
.remark-badge { background: var(--bg0); border: 1px solid var(--border); border-radius: 10px;
padding: 1px 7px; font-size: 11px; white-space: nowrap; }
@@ -247,6 +251,105 @@ tr.rank-top { background: rgba(245,200,56,0.08); }
line-height: 1; padding: 0 2px; }
.chip-x:hover { filter: brightness(1.2); }
/* ---- Map view ---- */
.videos-toggle { margin-left: 12px; vertical-align: middle; }
.mapview { display: flex; flex-direction: column; gap: 10px; }
.map-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.map-search { position: relative; }
.map-search input { background: var(--bg2); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 7px 10px; font-size: 13px; width: 260px; }
.map-search input:focus { outline: none; border-color: var(--info); }
.map-search-results { position: absolute; top: calc(100% + 4px); left: 0; z-index: 1000; width: 340px;
background: var(--bg1); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 10px 30px rgba(0,0,0,0.45); overflow: hidden; }
.map-search-results button { display: block; width: 100%; text-align: left; background: none; border: none;
color: var(--text); padding: 7px 10px; cursor: pointer; font-size: 12px; }
.map-search-results button:hover { background: var(--bg2); }
.map-colorby { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
.map-colorby button { background: var(--bg1); color: var(--dim); border: none; padding: 6px 12px;
cursor: pointer; font-size: 12px; }
.map-colorby button.active { background: var(--bg2); color: var(--text); font-weight: 600; }
.map-chips { display: flex; gap: 6px; flex-wrap: wrap; }
.map-chip { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
padding: 3px 10px; font-size: 11px; font-weight: 600; cursor: pointer; color: var(--text); }
.map-chip.off { opacity: 0.35; text-decoration: line-through; }
.map-body { display: grid; grid-template-columns: 1fr 320px; gap: 12px; }
@media (max-width: 900px) { .map-body { grid-template-columns: 1fr; } }
.map-canvas { height: 560px; border: 1px solid var(--border); border-radius: 10px;
background: var(--bg2); z-index: 0; }
.map-side { display: flex; flex-direction: column; gap: 10px; max-height: 560px; overflow: auto; }
.map-hint { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
padding: 12px; font-size: 12px; }
.map-details { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
.map-details-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.map-details-head b { word-break: break-all; font-size: 12px; }
.map-kv { display: grid; grid-template-columns: 92px 1fr; gap: 3px 8px; font-size: 12px; }
.map-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.map-correction { background: rgba(92,207,117,0.08); border: 1px solid rgba(92,207,117,0.4);
border-radius: 8px; padding: 8px 10px; font-size: 12px; display: flex; flex-direction: column; gap: 6px; }
.map-remarks { display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
.map-nometa { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
.map-nometa-head { font-size: 12px; margin-bottom: 6px; }
.map-nometa-head b { color: var(--warn); }
.map-nometa-list { display: flex; flex-direction: column; gap: 3px; max-height: 200px; overflow: auto; }
.map-nometa-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.map-nometa-name { background: none; border: none; color: var(--text); cursor: pointer; padding: 0;
font-size: 11px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.map-nometa-name:hover { color: var(--info); }
.btn.mini { padding: 1px 7px; font-size: 11px; }
/* Travel-direction arrows along every track (bigger on the selected one) */
.dir-arrow-wrap { background: none; border: none; }
.dir-arrow { color: #ffffff; font-size: 13px; line-height: 16px; text-align: center;
text-shadow: 0 0 3px #000, 0 0 5px #000; pointer-events: none; }
.dir-arrow.sm { font-size: 10px; line-height: 12px; opacity: 0.85; }
/* Whole-km chainage milestones pinned on calibrated tracks */
.km-mark-wrap { background: none; border: none; }
.km-mark { min-width: 22px; padding: 0 4px; text-align: center; font-size: 10px; font-weight: 700;
color: #0a1020; background: #f5c838; border: 1px solid #0a1020; border-radius: 8px;
line-height: 13px; box-shadow: 0 0 3px rgba(0,0,0,.6); }
/* Idle / dropout tags in the data-quality panel */
.idle-tag { color: var(--warn); font-size: 11px; white-space: nowrap; }
/* Per-video GPS coverage bar (details card): green real fixes, amber interpolated
gaps, red missing start/end, blue tick = stitched start */
.gps-bar { position: relative; height: 8px; min-width: 120px; background: #2a3350;
border-radius: 4px; overflow: hidden; margin-top: 4px; }
.gps-bar-seg { position: absolute; top: 0; bottom: 0; }
.gps-bar-seg.ok { background: #5ccf75; }
.gps-bar-seg.gap { background: #f5a623; }
.gps-bar-seg.miss { background: #ff5252; }
.gps-bar-stitch { position: absolute; top: 0; bottom: 0; width: 3px; background: #6ea8ff; }
/* Leaflet dark-theme nudges */
.leaflet-container { font: inherit; }
.leaflet-container a.leaflet-attribution-flag { display: none !important; }
/* ---- Road type + chainage ---- */
.badge.road-tag { background: rgba(245,200,56,0.16); color: var(--accent); }
.chainage-block { margin-top: 12px; }
.block-label { font-size: 12px; margin-bottom: 6px; }
.chainage-block input, .chainage-block select { background: var(--bg2); color: var(--text);
border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; font-size: 12px; }
.chainage-block input:focus, .chainage-block select:focus { outline: none; border-color: var(--info); }
.chip-input { width: 150px; }
.chainage-warnings { margin: 6px 0; padding-left: 18px; color: #f3d3a0; font-size: 12px; }
tr.span-warn { background: rgba(245,166,35,0.10); }
.kml-preview { margin-top: 10px; border: 1px solid var(--border); border-radius: 8px;
padding: 10px 12px; background: var(--bg2); display: flex; flex-direction: column; gap: 8px; }
.kml-preview-list { max-height: 240px; overflow: auto; }
/* GPS fix-count line + the tap-to-explain data-quality warning (video table) */
.gps-pts { position: relative; }
.gps-warn { background: rgba(245,166,35,0.18); border: 1px solid var(--warn); color: var(--warn);
border-radius: 8px; cursor: pointer; font-size: 11px; line-height: 1; padding: 1px 5px; margin-left: 5px; }
.gps-warn:hover { filter: brightness(1.2); }
.gps-explain { position: absolute; z-index: 40; margin-top: 4px; width: 340px; white-space: normal;
background: var(--bg1); border: 1px solid var(--warn); border-radius: 8px; padding: 9px 11px;
font-size: 12px; line-height: 1.45; box-shadow: 0 10px 30px rgba(0,0,0,0.45); cursor: pointer; }
.gps-explain b { color: var(--warn); }
/* Map details: correction attribution line */
.corrected-note { font-size: 11px; margin-top: 2px; white-space: normal; }
/* Storage panel */
.storage-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
.storage-grid > div { display: flex; flex-direction: column; gap: 2px; background: var(--bg2);

View File

@@ -1 +1 @@
{"root":["./src/AdminPanel.tsx","./src/App.tsx","./src/FoldersPicker.tsx","./src/MembersPicker.tsx","./src/RemarkCell.tsx","./src/SignIn.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"}
{"root":["./src/AdminPanel.tsx","./src/App.tsx","./src/ChainagePanel.tsx","./src/FoldersPicker.tsx","./src/MapView.tsx","./src/MembersPicker.tsx","./src/RemarkCell.tsx","./src/SignIn.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"}

View File

@@ -222,3 +222,97 @@ CREATE TABLE IF NOT EXISTS project_folders (
folder TEXT NOT NULL, -- directory part of rel_path ('' = project root)
PRIMARY KEY (project_id, folder)
);
-- ---------------------------------------------------------------------------
-- GPS pipeline (map view + chainage). A background sweep extracts each video's
-- GPS track with exiftool (dashcam Doc{N} samples), one video at a time:
-- gps_status: 'pending' → 'ok' (track stored) | 'none' (no GPS metadata)
-- | 'error' (exiftool/mount failure, see gps_error)
-- The RAW track is immutable once scanned; a kept "Viterbi" correction goes to
-- gps_track_corrected (map/chainage prefer it, raw is never modified).
-- Track format: [[lat, lon, t_s], ...] (t_s = seconds from first sample).
-- ---------------------------------------------------------------------------
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_track JSONB;
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_len_m DOUBLE PRECISION;
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_error TEXT;
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_scanned_at TIMESTAMPTZ;
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_track_corrected JSONB;
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_corrected_by TEXT;
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_corrected_at TIMESTAMPTZ;
-- Human-readable summary of what the correction did ("2 spikes · start stitched …").
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_corrected_note TEXT;
-- Data-quality + continuity metadata from the raw scan: absolute epoch of the
-- first/last GPS fix (lets consecutive files stitch across a missing start),
-- raw sample count and the largest inter-fix gap (dropout visibility).
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_start_epoch DOUBLE PRECISION;
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_end_epoch DOUBLE PRECISION;
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_sample_count INTEGER;
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_max_gap_s DOUBLE PRECISION;
-- Real media duration read from the container by the exiftool scan. Load-bearing for
-- the stitch guard: an inter-file gap is only "missing start" when the track covers
-- LESS than the file's duration — otherwise the camera simply wasn't recording.
ALTER TABLE videos ADD COLUMN IF NOT EXISTS duration_s DOUBLE PRECISION;
CREATE INDEX IF NOT EXISTS idx_videos_gps ON videos(gps_status) WHERE gps_status = 'pending';
-- ---------------------------------------------------------------------------
-- Road types: admin-defined per-project labels (LHS/RHS/MCW/Servicelane, …)
-- assigned to videos. Injected into the export/claim JSON as `road_type` and
-- re-stamped on push (server-authoritative).
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS project_road_types (
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
PRIMARY KEY (project_id, name)
);
ALTER TABLE videos ADD COLUMN IF NOT EXISTS road_type TEXT NOT NULL DEFAULT '';
-- ---------------------------------------------------------------------------
-- Chainage (linear referencing with piecewise-linear calibration).
-- chainage_points = admin-entered client calibration pairs (lat/lon ↔ chainage).
-- Optional road_type tag scopes a point to one calibration group (LHS vs RHS are
-- separate routes); '' = usable by any group.
-- Per video:
-- sr_chainage_* = ACTUAL measured position along the GPS route (immutable,
-- regenerated only from GPS — never admin-editable)
-- chainage_* = client-calibrated values (error distributed between
-- consecutive control points)
-- route_seq = position of the video along the calibrated route
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS chainage_points (
id SERIAL PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
lat DOUBLE PRECISION NOT NULL,
lon DOUBLE PRECISION NOT NULL,
chainage_m DOUBLE PRECISION NOT NULL,
note TEXT NOT NULL DEFAULT '',
road_type TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_chainage_points_project ON chainage_points(project_id);
-- origin marks generated points ('quick' = the two-point quick assign) so they can
-- be replaced on the next quick run regardless of the (freely editable) note text.
ALTER TABLE chainage_points ADD COLUMN IF NOT EXISTS origin TEXT NOT NULL DEFAULT '';
UPDATE chainage_points SET origin='quick' WHERE origin='' AND note LIKE 'quick:%';
-- The full result of the last recompute (route/spans/per-video/warnings JSON) so
-- any admin who signs in later sees the existing computed data, not a recompute ask.
ALTER TABLE chainage_calibrations ADD COLUMN IF NOT EXISTS summary JSONB;
ALTER TABLE videos ADD COLUMN IF NOT EXISTS chainage_start_m DOUBLE PRECISION;
ALTER TABLE videos ADD COLUMN IF NOT EXISTS chainage_end_m DOUBLE PRECISION;
ALTER TABLE videos ADD COLUMN IF NOT EXISTS sr_chainage_start_m DOUBLE PRECISION;
ALTER TABLE videos ADD COLUMN IF NOT EXISTS sr_chainage_end_m DOUBLE PRECISION;
ALTER TABLE videos ADD COLUMN IF NOT EXISTS route_seq INTEGER;
-- The piecewise-linear calibration itself (per project + group), persisted at
-- recompute time as sorted [s_m, chainage_m] anchors. Needed to evaluate chainage at
-- ANY route position later — e.g. frame-exact per-annotation chainage in exports.
CREATE TABLE IF NOT EXISTS chainage_calibrations (
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
road_type TEXT NOT NULL DEFAULT '',
anchors JSONB NOT NULL,
route_len_m DOUBLE PRECISION NOT NULL DEFAULT 0,
computed_by TEXT NOT NULL DEFAULT '',
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (project_id, road_type)
);

View File

@@ -1,5 +1,6 @@
services:
db:
restart: unless-stopped
image: postgres:16-alpine
environment:
POSTGRES_USER: central
@@ -17,6 +18,11 @@ services:
retries: 10
server:
restart: unless-stopped
# The server spawns exiftool + mount.nfs children; without an init as PID 1
# their orphans become unreapable zombies and docker can't stop the container
# ("PID … is zombie and can not be killed"). tini forwards signals + reaps.
init: true
build:
context: .
dockerfile: server/Dockerfile
@@ -48,9 +54,10 @@ services:
db:
condition: service_healthy
ports:
- "8080:8080"
- "8091:8080"
dashboard:
restart: unless-stopped
build: ./dashboard
environment:
# The dashboard calls the API through nginx's /api proxy, so no host needed.
@@ -58,7 +65,7 @@ services:
depends_on:
- server
ports:
- "8081:80"
- "8090:80"
# Nightly pg_dump → NAS, with automatic daily/weekly/monthly pruning.
# Writes gzipped SQL dumps to /backups (an NFS mount of the NAS folder below).

View File

@@ -10,7 +10,9 @@ RUN cargo build --release
FROM debian:bookworm-slim
# nfs-common provides mount.nfs for `nfs`-kind projects (mounted on demand at sync/download time).
# libimage-exiftool-perl provides exiftool for the GPS-track extraction sweep (map/chainage).
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates nfs-common \
libimage-exiftool-perl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /build/server/target/release/central-server /usr/local/bin/central-server
ENV BIND_ADDR=0.0.0.0:8080

1259
server/src/chainage.rs Normal file

File diff suppressed because it is too large Load Diff

999
server/src/gps.rs Normal file
View File

@@ -0,0 +1,999 @@
//! GPS pipeline: exiftool track extraction (background sweep), shared track math,
//! and the Stage-1 "Viterbi" smoother with its preview/keep/discard endpoint.
//!
//! Track representation everywhere: `[[lat, lon, t_s], …]` (t_s = seconds since the
//! first GPS sample). The RAW extracted track (`videos.gps_track`) is immutable once
//! scanned; a kept correction lives in `gps_track_corrected` and is preferred by the
//! map + chainage. Raw is never modified — discarding a correction restores raw.
//!
//! Lifecycle (`videos.gps_status`): 'pending' → 'ok' | 'none' (no GPS metadata) |
//! 'error' (exiftool/mount failure, message in `gps_error`). The sweep processes ONE
//! video at a time (exiftool -ee reads the whole file over NFS — never hammer the NAS).
use crate::auth::AuthUser;
use crate::{err, ApiResult, AppState};
use axum::{
extract::{Path as AxPath, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::time::Duration;
/// One GPS sample: [lat, lon, t_s].
pub type Pt = [f64; 3];
/// Max samples stored per track (1 Hz ⇒ ~83 min of video at full fidelity).
const MAX_TRACK_PTS: usize = 5000;
/// Hard deadline for one exiftool scan (-ee reads the whole file over NFS).
const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(600);
/// Sweep pacing: idle poll when nothing is pending / pause between videos / back-off
/// after a failure (so an unreachable NAS isn't probed in a hot loop).
const SWEEP_IDLE: Duration = Duration::from_secs(60);
const SWEEP_BETWEEN: Duration = Duration::from_millis(500);
const SWEEP_AFTER_ERROR: Duration = Duration::from_secs(5);
// ---- track math (shared with chainage) ----
/// WGS84 ellipsoid: semi-major axis + first eccentricity squared.
const WGS84_A: f64 = 6_378_137.0;
const WGS84_E2: f64 = 0.006_694_379_990_14;
/// Meters per degree of latitude / longitude at a given latitude, from the WGS84
/// meridian (M) and prime-vertical (N) radii. Distance and snapping both use this,
/// so every meter in the system is measured the same way.
pub fn wgs84_m_per_deg(lat_deg: f64) -> (f64, f64) {
let phi = lat_deg.to_radians();
let w = (1.0 - WGS84_E2 * phi.sin().powi(2)).sqrt();
let m = WGS84_A * (1.0 - WGS84_E2) / (w * w * w);
let n = WGS84_A / w;
let rad = std::f64::consts::PI / 180.0;
(m * rad, n * phi.cos() * rad)
}
/// WGS84 local-ellipsoid distance between two nearby points. Essentially exact at
/// GPS sample spacing (meterstens of meters) and <2e-5 relative for spans up to
/// ~100 km — unlike spherical haversine, whose 0.10.3% systematic bias would land
/// directly in sr_chainage. (Not valid across the ±180° meridian — irrelevant here.)
pub fn dist_m(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
let (mlat, mlon) = wgs84_m_per_deg((lat1 + lat2) * 0.5);
let dy = (lat2 - lat1) * mlat;
let dx = (lon2 - lon1) * mlon;
(dx * dx + dy * dy).sqrt()
}
pub fn polyline_len_m(track: &[Pt]) -> f64 {
track
.windows(2)
.map(|w| dist_m(w[0][0], w[0][1], w[1][0], w[1][1]))
.sum()
}
/// Parse a stored JSONB track (`[[lat,lon,t_s],…]`) back into points. Tolerant:
/// skips malformed entries.
pub fn parse_track(v: &Value) -> Vec<Pt> {
v.as_array()
.map(|a| {
a.iter()
.filter_map(|p| {
let p = p.as_array()?;
Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2).and_then(|t| t.as_f64()).unwrap_or(0.0)])
})
.collect()
})
.unwrap_or_default()
}
/// Serialize a track for storage/transport, rounding to keep the JSON small
/// (1e-6 deg ≈ 0.1 m; 0.1 s time resolution).
pub fn track_value(track: &[Pt]) -> Value {
Value::Array(
track
.iter()
.map(|p| {
json!([
(p[0] * 1e6).round() / 1e6,
(p[1] * 1e6).round() / 1e6,
(p[2] * 10.0).round() / 10.0
])
})
.collect(),
)
}
/// 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 {
return track;
}
let stride = track.len().div_ceil(max);
let last = *track.last().unwrap();
let mut out: Vec<Pt> = track.into_iter().step_by(stride).collect();
if out.last() != Some(&last) {
out.push(last);
}
out
}
// ---- exiftool extraction ----
/// What one exiftool scan yields: the GPS samples (t_s relative to the first fix),
/// the first fix's absolute epoch (None when the camera wrote no GPSDateTime), the
/// container's media duration in seconds, and the video frame rate. Empty samples =
/// no GPS metadata. Duration + fps let us do frame→position math on raw NAS videos
/// that were never annotated (no sibling JSON ⇒ no fps in our DB otherwise).
struct ScanResult {
samples: Vec<Pt>,
t0_abs: Option<f64>,
duration_s: Option<f64>,
fps: Option<f64>,
}
/// Run exiftool over a video and collect its embedded GPS time-series (Doc{N}
/// groups, the format dashcams/DJI write at ~1 Hz). Same flags as the proven
/// desktop extractor, plus container Duration + VideoFrameRate.
async fn run_exiftool(path: &std::path::Path) -> Result<ScanResult, String> {
let child = tokio::process::Command::new("exiftool")
.args([
"-ee", "-api", "LargeFileSupport=1", "-G3", "-json", "-n",
"-GPSLatitude", "-GPSLongitude", "-GPSDateTime", "-Duration", "-VideoFrameRate",
])
.arg(path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| format!("failed to run exiftool (is it installed?): {e}"))?;
let output = match tokio::time::timeout(EXIFTOOL_TIMEOUT, child.wait_with_output()).await {
Ok(r) => r.map_err(|e| format!("exiftool failed: {e}"))?,
// kill_on_drop terminates the scan when the timeout drops the child.
Err(_) => return Err("exiftool timed out (file too slow to read?)".into()),
};
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: Value = serde_json::from_str(stdout.trim()).map_err(|_| {
let se = String::from_utf8_lossy(&output.stderr);
let msg: String = se.trim().chars().take(300).collect();
if msg.is_empty() {
"exiftool produced no output".to_string()
} else {
format!("exiftool: {msg}")
}
})?;
let obj = parsed
.as_array()
.and_then(|a| a.first())
.and_then(|v| v.as_object())
.ok_or_else(|| "exiftool output was not a non-empty array".to_string())?;
// exiftool reports per-file problems inline (e.g. "Error: File is empty").
if let Some((_, e)) = obj.iter().find(|(k, _)| *k == "Error" || k.ends_with(":Error")) {
if let Some(msg) = e.as_str() {
return Err(format!("exiftool: {msg}"));
}
}
// Numeric field across any -G3 group (container + per-track may each report one);
// `reduce` picks the max, which is the sensible choice for both Duration and fps.
let numeric = |name: &str| -> Option<f64> {
obj.iter()
.filter(|(k, _)| k.as_str() == name || k.ends_with(&format!(":{name}")))
.filter_map(|(_, v)| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
.reduce(f64::max)
};
let duration = numeric("Duration");
let fps = numeric("VideoFrameRate").filter(|f| *f > 0.0);
let (samples, t0_abs) = collect_samples(obj);
Ok(ScanResult { samples, t0_abs, duration_s: duration, fps })
}
/// Group `Doc{N}:GPSLatitude/GPSLongitude/GPSDateTime` keys into ordered samples
/// with relative timestamps (falls back to 1 Hz when GPSDateTime is missing).
/// Also returns the first fix's ABSOLUTE epoch — GPS time is UTC on every camera,
/// so consecutive files can be matched by timestamp (start-of-video stitching).
fn collect_samples(obj: &serde_json::Map<String, Value>) -> (Vec<Pt>, Option<f64>) {
#[derive(Default)]
struct Partial {
lat: Option<f64>,
lon: Option<f64>,
t: Option<f64>,
}
let mut by_doc: BTreeMap<u32, Partial> = BTreeMap::new();
for (k, v) in obj {
let Some((doc_idx, field)) = split_doc_key(k) else { continue };
let entry = by_doc.entry(doc_idx).or_default();
match field {
"GPSLatitude" => entry.lat = v.as_f64(),
"GPSLongitude" => entry.lon = v.as_f64(),
// Plausibility gate: cameras write garbage dates (1970/zero) on some
// fixes — treat anything outside [2000, ~2096] as unstamped.
"GPSDateTime" => {
entry.t = v
.as_str()
.and_then(parse_gps_datetime)
.filter(|&t| (946_684_800.0..4_100_000_000.0).contains(&t))
}
_ => {}
}
}
let raw: Vec<(u32, f64, f64, Option<f64>)> = by_doc
.into_iter()
.filter_map(|(idx, p)| Some((idx, p.lat?, p.lon?, p.t)))
.collect();
let t0_abs = raw.first().and_then(|r| r.3); // real epoch only when stamped
// ONE timebase for the whole track. Mixing them (epoch for stamped samples,
// doc index for unstamped ones) once produced billion-second "dropouts" when a
// camera skipped GPSDateTime on some fixes — so unless EVERY sample is stamped,
// fall back to index-based 1 Hz for all of them.
let index_based = |raw: &[(u32, f64, f64, Option<f64>)]| -> Vec<Pt> {
let idx0 = raw.first().map(|r| r.0).unwrap_or(0);
raw.iter().map(|&(idx, lat, lon, _)| [lat, lon, (idx - idx0) as f64]).collect()
};
let all_stamped = !raw.is_empty() && raw.iter().all(|r| r.3.is_some());
let mut samples: Vec<Pt> = if all_stamped {
let t0 = raw[0].3.unwrap();
raw.iter().map(|&(_, lat, lon, t)| [lat, lon, t.unwrap() - t0]).collect()
} else {
index_based(&raw)
};
// Clock-glitch guard: a negative or >1 h step between consecutive fixes means
// the stamps themselves are unusable (camera clock jumped) — rebuild the whole
// track on the index timebase rather than trust any of them.
if samples
.windows(2)
.any(|w| !(0.0..=3600.0).contains(&(w[1][2] - w[0][2])))
{
samples = index_based(&raw);
}
(samples, t0_abs)
}
fn split_doc_key(k: &str) -> Option<(u32, &str)> {
let (group, field) = k.split_once(':')?;
let idx: u32 = group.strip_prefix("Doc")?.parse().ok()?;
Some((idx, field))
}
/// "2025:08:02 09:33:31Z" → seconds since epoch (approx; only relative deltas are
/// used). Civil-to-days via Howard Hinnant's algorithm — no chrono parse needed for
/// exiftool's colon-separated dates.
fn parse_gps_datetime(s: &str) -> Option<f64> {
let s = s.trim_end_matches('Z').trim();
let (date, time) = s.split_once(' ')?;
let mut dp = date.split(':');
let y: i64 = dp.next()?.parse().ok()?;
let mo: i64 = dp.next()?.parse().ok()?;
let d: i64 = dp.next()?.parse().ok()?;
let mut tp = time.split(':');
let h: i64 = tp.next()?.parse().ok()?;
let mi: i64 = tp.next()?.parse().ok()?;
let sec: f64 = tp.next()?.parse().ok()?;
let y_adj = if mo <= 2 { y - 1 } else { y };
let era = y_adj.div_euclid(400);
let yoe = y_adj - era * 400;
let m = if mo > 2 { mo - 3 } else { mo + 9 };
let doy = (153 * m + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146097 + doe - 719468;
Some(days as f64 * 86400.0 + (h * 3600 + mi * 60) as f64 + sec)
}
// ---- background sweep ----
/// Forever-loop: pick ONE pending video, scan it, store the result, repeat.
/// Serial by design (requirement: throttle — never scan the NAS in parallel).
pub async fn sweep(state: AppState) {
loop {
let next: Option<(i32, String, String, String)> = sqlx::query_as(
r#"SELECT v.id, v.rel_path, p.source_path, p.source_kind
FROM videos v JOIN projects p ON v.project_id = p.id
WHERE v.gps_status = 'pending'
ORDER BY v.id LIMIT 1"#,
)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
let Some((id, rel_path, source_path, source_kind)) = next else {
tokio::time::sleep(SWEEP_IDLE).await;
continue;
};
let ok = scan_one(&state, id, &rel_path, &source_path, &source_kind).await;
tokio::time::sleep(if ok { SWEEP_BETWEEN } else { SWEEP_AFTER_ERROR }).await;
}
}
/// Scan a single video and persist the outcome. Returns false on error (the video
/// is marked 'error' either way, so the sweep never re-picks it until a re-scan).
async fn scan_one(state: &AppState, id: i32, rel_path: &str, source_path: &str, source_kind: &str) -> bool {
let (sp, kind, host, opts) =
(source_path.to_string(), source_kind.to_string(), state.nfs_host.clone(), state.nfs_opts.clone());
let dir = match tokio::task::spawn_blocking(move || crate::nfs::resolve_dir(&sp, &kind, &host, &opts)).await {
Ok(Ok(d)) => d,
Ok(Err(e)) => return mark_error(&state.db, id, &e).await,
Err(e) => return mark_error(&state.db, id, &format!("mount task failed: {e}")).await,
};
match run_exiftool(&dir.join(rel_path)).await {
Ok(ScanResult { samples, t0_abs, duration_s, fps }) if samples.len() >= 2 => {
// Data-quality stats are measured BEFORE the storage cap: the raw fix
// count and the largest inter-fix gap (dropouts) must reflect reality.
let sample_count = samples.len() as i32;
let max_gap = samples.windows(2).map(|w| w[1][2] - w[0][2]).fold(0.0_f64, f64::max);
let end_epoch = t0_abs.map(|t0| t0 + samples.last().unwrap()[2]);
let samples = downsample(samples, MAX_TRACK_PTS);
let len = polyline_len_m(&samples);
let res = sqlx::query(
r#"UPDATE videos SET gps_status='ok', gps_track=$2, gps_len_m=$3, gps_error=NULL,
gps_scanned_at=now(),
gps_start_epoch=$4, gps_end_epoch=$5, gps_sample_count=$6, gps_max_gap_s=$7,
duration_s=$8,
-- fill fps for raw NAS videos (no sibling JSON) so frame→position
-- math works; never clobber a JSON/desktop-derived fps.
fps=COALESCE(fps, $9),
-- a fresh raw track invalidates any correction derived from the old one
gps_track_corrected=NULL, gps_corrected_by=NULL, gps_corrected_at=NULL,
gps_corrected_note=NULL
WHERE id=$1"#,
)
.bind(id)
.bind(track_value(&samples))
.bind(len)
.bind(t0_abs)
.bind(end_epoch)
.bind(sample_count)
.bind(max_gap)
.bind(duration_s)
.bind(fps)
.execute(&state.db)
.await;
match res {
Ok(_) => {
tracing::info!("gps: video {id} ok — {sample_count} samples, {len:.0} m, max gap {max_gap:.0}s");
// Auto-correct this video (spikes + start stitch), then its
// temporal successor — its start stitch may only now be possible.
auto_correct(&state.db, id).await;
if let Some(end) = end_epoch {
let succ: Option<i32> = sqlx::query_scalar(
r#"SELECT v.id FROM videos v, videos me
WHERE me.id=$1 AND v.id<>$1 AND v.project_id=me.project_id
AND v.gps_status='ok' AND v.gps_start_epoch IS NOT NULL
AND v.gps_start_epoch > $2 AND v.gps_start_epoch - $2 <= $3
AND (CASE WHEN v.rel_path LIKE '%/%' THEN regexp_replace(v.rel_path,'/[^/]*$','') ELSE '' END)
= (CASE WHEN me.rel_path LIKE '%/%' THEN regexp_replace(me.rel_path,'/[^/]*$','') ELSE '' END)
ORDER BY v.gps_start_epoch LIMIT 1"#,
)
.bind(id)
.bind(end)
.bind(STITCH_MAX_GAP_S)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
if let Some(succ) = succ {
auto_correct(&state.db, succ).await;
}
}
true
}
Err(e) => mark_error(&state.db, id, &format!("db write failed: {e}")).await,
}
}
Ok(_) => {
let _ = sqlx::query(
"UPDATE videos SET gps_status='none', gps_error=NULL, gps_scanned_at=now() WHERE id=$1",
)
.bind(id)
.execute(&state.db)
.await;
tracing::info!("gps: video {id} has no GPS metadata");
true
}
Err(e) => mark_error(&state.db, id, &e).await,
}
}
async fn mark_error(db: &sqlx::PgPool, id: i32, msg: &str) -> bool {
tracing::warn!("gps: video {id} scan failed: {msg}");
let _ = sqlx::query(
"UPDATE videos SET gps_status='error', gps_error=$2, gps_scanned_at=now() WHERE id=$1",
)
.bind(id)
.bind(msg)
.execute(db)
.await;
false
}
// ---- Stage-1 "Viterbi" smoother ----
/// Speed gate: hops implying more than this are implausible (≈160 km/h).
const MAX_SPEED_MPS: f64 = 45.0;
/// Cost of dropping (re-interpolating) one sample. An outlier is dropped when
/// keeping it costs more than this.
const DROP_PENALTY: f64 = 30.0;
/// Max consecutive dropped samples + 1 (DP transition window).
const VITERBI_WINDOW: usize = 12;
/// Min-cost keep/drop path over the samples (Viterbi over a keep-lattice):
/// transition cost between consecutive KEPT samples grows quadratically with the
/// implied speed above `MAX_SPEED_MPS`; every dropped sample costs `DROP_PENALTY`.
/// Dropped samples are re-interpolated (by time) between their kept neighbors —
/// principled outlier rejection with no road network. Returns the corrected track
/// (same length + timestamps as the input) and how many samples were replaced.
pub fn viterbi_smooth(track: &[Pt]) -> (Vec<Pt>, usize) {
let n = track.len();
if n < 3 {
return (track.to_vec(), 0);
}
let trans_cost = |a: &Pt, b: &Pt| -> f64 {
let dt = (b[2] - a[2]).max(0.5);
let v = dist_m(a[0], a[1], b[0], b[1]) / dt;
let excess = (v - MAX_SPEED_MPS).max(0.0);
(excess / 5.0).powi(2)
};
// cost[i] = min cost of a path whose last kept sample is i.
let mut cost = vec![f64::INFINITY; n];
let mut prev = vec![usize::MAX; n];
for i in 0..n {
if i <= VITERBI_WINDOW {
// Start the path at i, paying for the dropped leading samples.
cost[i] = DROP_PENALTY * i as f64;
}
for j in i.saturating_sub(VITERBI_WINDOW)..i {
if cost[j].is_finite() {
let c = cost[j] + DROP_PENALTY * (i - j - 1) as f64 + trans_cost(&track[j], &track[i]);
if c < cost[i] {
cost[i] = c;
prev[i] = j;
}
}
}
}
// End the path at the sample minimizing total cost incl. dropped trailing ones.
let mut end = n - 1;
let mut best = f64::INFINITY;
for i in n.saturating_sub(VITERBI_WINDOW + 1)..n {
let c = cost[i] + DROP_PENALTY * (n - 1 - i) as f64;
if c < best {
best = c;
end = i;
}
}
let mut kept = vec![false; n];
let mut i = end;
loop {
kept[i] = true;
if prev[i] == usize::MAX {
break;
}
i = prev[i];
}
// Rebuild: kept samples stay; dropped ones are re-interpolated between the
// nearest kept neighbors (clamped to the first/last kept at the edges).
let kept_idx: Vec<usize> = (0..n).filter(|&k| kept[k]).collect();
let mut out = track.to_vec();
let mut replaced = 0usize;
for k in 0..n {
if kept[k] {
continue;
}
let after = kept_idx.partition_point(|&ki| ki < k);
let (lo, hi) = match (after.checked_sub(1).map(|a| kept_idx[a]), kept_idx.get(after)) {
(Some(lo), Some(&hi)) => (lo, hi),
(None, Some(&hi)) => (hi, hi),
(Some(lo), None) => (lo, lo),
(None, None) => continue, // unreachable: at least one sample is kept
};
let (a, b) = (&track[lo], &track[hi]);
let f = if b[2] > a[2] { ((track[k][2] - a[2]) / (b[2] - a[2])).clamp(0.0, 1.0) } else { 0.0 };
out[k] = [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, track[k][2]];
replaced += 1;
}
(out, replaced)
}
// ---- auto-correction: spikes + start-of-video stitching ----
/// Two files are one continuous recording only when the GPS gap between them is at
/// most this many seconds.
const STITCH_MAX_GAP_S: f64 = 180.0;
/// Gaps at or below this are the normal ~1 Hz cadence across a file boundary —
/// nothing is missing.
const STITCH_MIN_GAP_S: f64 = 2.0;
/// Decide the time-shift for a missing-start stitch. `gap_s` = seconds between the
/// predecessor's last fix and this video's first fix; `span_s` = this track's own
/// first→last duration; `duration_s` = the video's real length when known
/// (frame_count / fps); `jump_m` = distance from the predecessor's last fix to this
/// video's first fix. Returns the seconds this track starts late (its samples are
/// shifted by this), or None when nothing should be stitched.
fn plan_stitch(gap_s: f64, span_s: f64, duration_s: Option<f64>, jump_m: f64) -> Option<f64> {
if gap_s <= STITCH_MIN_GAP_S || gap_s > STITCH_MAX_GAP_S {
return None; // healthy rollover, or not a continuous recording
}
if jump_m / gap_s > MAX_SPEED_MPS {
return None; // the vehicle can't have covered the jump — different session
}
let mut shift = gap_s - 1.0; // one normal sample period isn't missing data
if let Some(dur) = duration_s {
// The track already covers the whole file ⇒ the gap was the PREDECESSOR's
// problem (missing end), not ours — shifting would misalign every frame.
let missing = dur - span_s;
if missing < STITCH_MIN_GAP_S {
return None;
}
shift = shift.min(missing);
}
(shift > 0.5).then_some(shift)
}
/// The unified correction result: Viterbi spike removal + (when a temporal
/// predecessor exists in the same folder) a missing-start stitch that re-bases the
/// clock so t = 0 is the actual video start. Raw is never modified.
pub struct CorrectionOutcome {
pub raw: Vec<Pt>,
pub corrected: Vec<Pt>,
pub replaced: usize,
pub stitched_from: Option<String>,
pub shift_s: f64,
pub note: String,
}
impl CorrectionOutcome {
pub fn changed(&self) -> bool {
self.replaced > 0 || self.stitched_from.is_some()
}
}
/// Compute (but do not store) the correction for a video. Errors when the video has
/// no usable raw track.
pub async fn compute_correction(db: &sqlx::PgPool, video_id: i32) -> Result<CorrectionOutcome, String> {
#[allow(clippy::type_complexity)]
let row: Option<(Option<Value>, Option<f64>, Option<f64>, Option<i32>, Option<f64>, i32, String)> = sqlx::query_as(
r#"SELECT gps_track, gps_start_epoch, fps, frame_count, duration_s, project_id, rel_path
FROM videos WHERE id=$1 AND gps_status='ok'"#,
)
.bind(video_id)
.fetch_optional(db)
.await
.map_err(|e| e.to_string())?;
let Some((track, start_epoch, fps, frame_count, duration_s, project_id, rel_path)) = row else {
return Err("video has no GPS track".into());
};
let raw = track.as_ref().map(parse_track).unwrap_or_default();
if raw.len() < 2 {
return Err("video has no GPS track".into());
}
let (mut corrected, replaced) = viterbi_smooth(&raw);
let mut notes: Vec<String> = Vec::new();
if replaced > 0 {
notes.push(format!("{replaced} spike(s) re-interpolated"));
}
// Missing-start stitch: the nearest earlier-ending video in the same folder,
// matched by absolute GPS time (UTC on every camera — no timezone games).
let mut stitched_from = None;
let mut shift_s = 0.0;
if let Some(start_epoch) = start_epoch {
let folder = match rel_path.rfind('/') {
Some(i) => rel_path[..i].to_string(),
None => String::new(),
};
let prev: Option<(String, f64, Value)> = sqlx::query_as(
r#"SELECT file_name, gps_end_epoch, gps_track FROM videos
WHERE project_id=$1 AND id<>$2 AND gps_track IS NOT NULL AND gps_end_epoch IS NOT NULL
AND gps_end_epoch < $3 AND $3 - gps_end_epoch <= $4
AND (CASE WHEN rel_path LIKE '%/%' THEN regexp_replace(rel_path,'/[^/]*$','') ELSE '' END) = $5
ORDER BY gps_end_epoch DESC LIMIT 1"#,
)
.bind(project_id)
.bind(video_id)
.bind(start_epoch)
.bind(STITCH_MAX_GAP_S)
.bind(&folder)
.fetch_optional(db)
.await
.map_err(|e| e.to_string())?;
if let Some((prev_name, prev_end, prev_track)) = prev {
// Borrow from the predecessor's RAW track (clean provenance — its own
// correction may already contain stitched points).
let prev_pts = parse_track(&prev_track);
if let Some(anchor) = prev_pts.last() {
let gap = start_epoch - prev_end;
let span = corrected.last().unwrap()[2] - corrected[0][2];
// Container duration (from the scan) first; the annotation-doc
// fps/frame_count only as a fallback for pre-upgrade rows.
let duration = duration_s.or(match (fps, frame_count) {
(Some(f), Some(n)) if f > 0.0 => Some(n as f64 / f),
_ => None,
});
let jump = dist_m(anchor[0], anchor[1], corrected[0][0], corrected[0][1]);
if let Some(shift) = plan_stitch(gap, span, duration, jump) {
for p in corrected.iter_mut() {
p[2] += shift;
}
corrected.insert(0, [anchor[0], anchor[1], 0.0]);
shift_s = shift;
notes.push(format!(
"start stitched from {prev_name} (first {shift:.1}s of GPS were missing — clock re-based)"
));
stitched_from = Some(prev_name);
}
}
}
}
Ok(CorrectionOutcome {
raw,
corrected,
replaced,
stitched_from,
shift_s,
note: notes.join(" · "),
})
}
/// Compute + store the correction as 'auto' — but never over a human decision:
/// only when the video is untouched or its current correction is itself 'auto'.
/// A no-op correction clears a stale auto one. Best-effort (sweep path).
pub async fn auto_correct(db: &sqlx::PgPool, video_id: i32) {
let eligible: Option<bool> = sqlx::query_scalar(
"SELECT (gps_corrected_by IS NULL OR gps_corrected_by='auto') FROM videos WHERE id=$1 AND gps_status='ok'",
)
.bind(video_id)
.fetch_optional(db)
.await
.ok()
.flatten();
if eligible != Some(true) {
return;
}
match compute_correction(db, video_id).await {
Ok(o) if o.changed() => {
let len = polyline_len_m(&o.corrected);
let res = sqlx::query(
r#"UPDATE videos SET gps_track_corrected=$2, gps_corrected_by='auto',
gps_corrected_at=now(), gps_corrected_note=$3, gps_len_m=$4 WHERE id=$1"#,
)
.bind(video_id)
.bind(track_value(&o.corrected))
.bind(&o.note)
.bind(len)
.execute(db)
.await;
match res {
Ok(_) => tracing::info!("gps: video {video_id} auto-corrected — {}", o.note),
Err(e) => tracing::warn!("gps: video {video_id} auto-correct store failed: {e}"),
}
}
Ok(o) => {
// Nothing to fix (anymore) — drop a stale auto correction if present.
let _ = sqlx::query(
r#"UPDATE videos SET gps_track_corrected=NULL, gps_corrected_by=NULL,
gps_corrected_at=NULL, gps_corrected_note=NULL, gps_len_m=$2
WHERE id=$1 AND gps_corrected_by='auto'"#,
)
.bind(video_id)
.bind(polyline_len_m(&o.raw))
.execute(db)
.await;
}
Err(e) => tracing::debug!("gps: video {video_id} auto-correct skipped: {e}"),
}
}
// ---- endpoints ----
/// `POST /api/videos/:id/gps/rescan` — queue a video for a fresh metadata scan
/// (admin). The sweep picks it up; any previous error is cleared.
pub async fn rescan(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Value> {
user.require_admin()?;
let updated: Option<i32> =
sqlx::query_scalar("UPDATE videos SET gps_status='pending', gps_error=NULL WHERE id=$1 RETURNING id")
.bind(id)
.fetch_optional(&s.db)
.await
.map_err(err)?;
if updated.is_none() {
return Err((StatusCode::NOT_FOUND, "video not found".into()));
}
Ok(Json(json!({ "queued": true })))
}
#[derive(Deserialize)]
pub struct CorrectRequest {
/// 'preview' (compute, don't save) | 'keep' (save corrected) | 'discard' (revert to raw).
pub action: String,
}
/// `POST /api/videos/:id/gps/correct {action}` — the keep-or-discard correction flow
/// (admin), running the SAME pipeline the scan sweep auto-applies (spike removal +
/// missing-start stitch). Preview returns original + corrected tracks so the map can
/// overlay them; keep persists (attributed to the admin); discard reverts to raw AND
/// tombstones the video so the sweep won't re-auto-correct it (a re-scan resets that).
pub async fn correct(
State(s): State<AppState>,
user: AuthUser,
AxPath(id): AxPath<i32>,
Json(req): Json<CorrectRequest>,
) -> ApiResult<Value> {
user.require_admin()?;
let exists: Option<String> = sqlx::query_scalar("SELECT gps_status FROM videos WHERE id=$1")
.bind(id)
.fetch_optional(&s.db)
.await
.map_err(err)?;
let Some(gps_status) = exists else {
return Err((StatusCode::NOT_FOUND, "video not found".into()));
};
if gps_status != "ok" {
return Err((StatusCode::BAD_REQUEST, "video has no GPS track".into()));
}
match req.action.as_str() {
"discard" => {
let raw_track: Option<Value> = sqlx::query_scalar("SELECT gps_track FROM videos WHERE id=$1")
.bind(id)
.fetch_one(&s.db)
.await
.map_err(err)?;
let len = raw_track.as_ref().map(|t| polyline_len_m(&parse_track(t))).unwrap_or_default();
// Tombstone: gps_corrected_by keeps the admin's name with a NULL track,
// so auto_correct (which only touches NULL/'auto') leaves this video raw.
sqlx::query(
r#"UPDATE videos SET gps_track_corrected=NULL,
gps_corrected_by=$2, gps_corrected_at=now(),
gps_corrected_note='reverted to raw — auto-correction off (re-scan to reset)',
gps_len_m=$3
WHERE id=$1"#,
)
.bind(id)
.bind(&user.username)
.bind(len)
.execute(&s.db)
.await
.map_err(err)?;
Ok(Json(json!({ "action": "discard", "len_m": len })))
}
"preview" | "keep" => {
let o = compute_correction(&s.db, id).await.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let len_before = polyline_len_m(&o.raw);
let len_after = polyline_len_m(&o.corrected);
if req.action == "keep" {
sqlx::query(
r#"UPDATE videos SET gps_track_corrected=$2, gps_corrected_by=$3,
gps_corrected_at=now(), gps_corrected_note=$4, gps_len_m=$5 WHERE id=$1"#,
)
.bind(id)
.bind(track_value(&o.corrected))
.bind(&user.username)
.bind(&o.note)
.bind(len_after)
.execute(&s.db)
.await
.map_err(err)?;
crate::worker::log_event(&s.db, id, &user.username, "gps_corrected",
json!({ "replaced": o.replaced, "stitched": o.stitched_from.is_some() })).await;
}
Ok(Json(json!({
"action": req.action,
"points": o.raw.len(),
"replaced": o.replaced,
"stitched_from": o.stitched_from,
"shift_s": o.shift_s,
"note": o.note,
"len_before_m": len_before,
"len_after_m": len_after,
"original": track_value(&o.raw),
"corrected": track_value(&o.corrected),
})))
}
_ => Err((StatusCode::BAD_REQUEST, "action must be preview, keep or discard".into())),
}
}
/// `POST /api/projects/:id/gps/rescan_all` — queue EVERY already-scanned video in the
/// project for a fresh scan (admin). Needed once after upgrading: pre-upgrade scans
/// have no absolute timestamps, so stitching/data-quality stats only exist after a
/// re-scan. The serial sweep works through them in the background.
pub async fn rescan_all(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Value> {
user.require_admin()?;
let n = sqlx::query(
"UPDATE videos SET gps_status='pending', gps_error=NULL WHERE project_id=$1 AND gps_status <> 'pending'",
)
.bind(id)
.execute(&s.db)
.await
.map_err(err)?
.rows_affected();
crate::admin::audit(&s.db, &user.username, "gps_rescan_all", json!({ "project_id": id, "queued": n })).await;
Ok(Json(json!({ "queued": n })))
}
#[cfg(test)]
mod tests {
use super::*;
/// A straight east-west track at the equator: 1 pt/s, ~11.1 m apart (40 km/h).
fn straight(n: usize) -> Vec<Pt> {
(0..n).map(|i| [0.0, i as f64 * 0.0001, i as f64]).collect()
}
#[test]
fn smoother_removes_a_jump() {
let mut t = straight(60);
// one wild point 0.01° (~1.1 km) off the line
t[30][0] = 0.01;
let (fixed, replaced) = viterbi_smooth(&t);
assert_eq!(replaced, 1);
assert!(fixed[30][0].abs() < 1e-6, "outlier not re-interpolated: {}", fixed[30][0]);
// everything else untouched
assert_eq!(fixed[29], t[29]);
assert_eq!(fixed[31], t[31]);
}
#[test]
fn smoother_keeps_a_clean_track() {
let t = straight(60);
let (fixed, replaced) = viterbi_smooth(&t);
assert_eq!(replaced, 0);
assert_eq!(fixed, t);
}
#[test]
fn mixed_gps_datetime_never_mixes_timebases() {
// Doc1 has NO GPSDateTime, Doc2/Doc3 do (real epoch ~1.69e9). The old code
// seeded t0 from the index and then subtracted it from the epoch — a
// 1,693,958,401-second "dropout". Every sample must share one timebase.
let mut obj = serde_json::Map::new();
let ins = |o: &mut serde_json::Map<String, Value>, d: u32, lat: f64, t: Option<&str>| {
o.insert(format!("Doc{d}:GPSLatitude"), serde_json::json!(lat));
o.insert(format!("Doc{d}:GPSLongitude"), serde_json::json!(78.0));
if let Some(t) = t {
o.insert(format!("Doc{d}:GPSDateTime"), serde_json::json!(t));
}
};
ins(&mut obj, 1, 21.0, None);
ins(&mut obj, 2, 21.0001, Some("2023:09:06 00:00:01Z"));
ins(&mut obj, 3, 21.0002, Some("2023:09:06 00:00:02Z"));
let (samples, t0_abs) = collect_samples(&obj);
assert_eq!(samples.len(), 3);
let max_gap = samples.windows(2).map(|w| w[1][2] - w[0][2]).fold(0.0_f64, f64::max);
assert!(max_gap <= 1.0 + 1e-9, "timebases mixed: max gap {max_gap}");
assert_eq!(samples[0][2], 0.0);
assert!(t0_abs.is_none(), "first fix is unstamped — no reliable epoch");
// Fully-stamped tracks still use real GPS time (t0_abs = first fix's epoch).
let mut obj2 = serde_json::Map::new();
ins(&mut obj2, 1, 21.0, Some("2023:09:06 00:00:00Z"));
ins(&mut obj2, 2, 21.0001, Some("2023:09:06 00:00:03Z"));
let (s2, t0b) = collect_samples(&obj2);
assert_eq!(s2[1][2], 3.0);
assert!(t0b.is_some());
// The live-data case: a GARBAGE first stamp (1970) followed by real ones —
// "all stamped", but the epoch jump would be the whole epoch. The
// plausibility gate (>2000) turns the 1970 stamp into "unstamped" → index
// timebase for the whole track, and no epoch is trusted for stitching.
let mut obj3 = serde_json::Map::new();
ins(&mut obj3, 1, 21.0, Some("1970:01:01 00:00:00Z"));
ins(&mut obj3, 2, 21.0001, Some("2023:09:06 00:00:01Z"));
ins(&mut obj3, 3, 21.0002, Some("2023:09:06 00:00:02Z"));
let (s3, t0c) = collect_samples(&obj3);
let g3 = s3.windows(2).map(|w| w[1][2] - w[0][2]).fold(0.0_f64, f64::max);
assert!(g3 <= 1.0 + 1e-9, "garbage stamp leaked: max gap {g3}");
assert!(t0c.is_none());
// Clock glitch WITHIN plausible dates (2023 → 2026 jump): the >1 h step
// guard rebuilds on the index timebase too.
let mut obj4 = serde_json::Map::new();
ins(&mut obj4, 1, 21.0, Some("2023:09:06 00:00:00Z"));
ins(&mut obj4, 2, 21.0001, Some("2026:05:28 12:00:00Z"));
ins(&mut obj4, 3, 21.0002, Some("2026:05:28 12:00:01Z"));
let (s4, _) = collect_samples(&obj4);
let g4 = s4.windows(2).map(|w| w[1][2] - w[0][2]).fold(0.0_f64, f64::max);
assert!(g4 <= 1.0 + 1e-9, "clock glitch leaked: max gap {g4}");
}
#[test]
fn smoother_removes_a_burst() {
let mut t = straight(80);
for k in 40..44 {
t[k][0] = 0.02; // 2.2 km off for 4 consecutive samples
}
let (fixed, replaced) = viterbi_smooth(&t);
assert_eq!(replaced, 4);
for k in 40..44 {
assert!(fixed[k][0].abs() < 1e-6);
}
}
#[test]
fn downsample_caps_and_keeps_last() {
let t = straight(12000);
let d = downsample(t.clone(), 5000);
assert!(d.len() <= 5001);
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.
assert_eq!(plan_stitch(1.0, 299.0, Some(300.0), 20.0), None);
// 9 s gap, duration unknown → shift 8 s.
assert_eq!(plan_stitch(9.0, 291.0, None, 100.0), Some(8.0));
// 9 s gap but the track already covers the whole file → the PREDECESSOR was
// missing its end; shifting us would misalign every frame.
assert_eq!(plan_stitch(9.0, 299.0, Some(300.0), 100.0), None);
// Duration caps the shift: 20 s gap but only 5 s missing from this file.
assert_eq!(plan_stitch(20.0, 295.0, Some(300.0), 100.0), Some(5.0));
// Teleport guard: 5 km jump in 9 s can't be the same vehicle.
assert_eq!(plan_stitch(9.0, 291.0, None, 5000.0), None);
// Different session: 10 minutes apart.
assert_eq!(plan_stitch(600.0, 291.0, None, 100.0), None);
}
#[test]
fn wgs84_distance_matches_reference_arcs() {
// Textbook WGS84 arc lengths: 1° latitude ≈ 110.574 km (equator) /
// 111.132 km (45°); 1° longitude at the equator ≈ 111.320 km.
assert!((dist_m(0.0, 0.0, 1.0, 0.0) - 110_574.0).abs() < 200.0);
assert!((dist_m(0.0, 0.0, 0.0, 1.0) - 111_320.0).abs() < 30.0);
assert!((dist_m(44.5, 0.0, 45.5, 0.0) - 111_132.0).abs() < 200.0);
}
#[test]
fn track_roundtrip() {
let t = straight(10);
let v = track_value(&t);
let back = parse_track(&v);
assert_eq!(back.len(), 10);
assert!((back[9][1] - t[9][1]).abs() < 1e-5);
}
}

View File

@@ -72,15 +72,27 @@ pub async fn ingest_project(db: &PgPool, project_id: i32, dir: &Path) -> anyhow:
// Never clobber a verified push (Phase 3): a worker's pushed annotations +
// completer/verify-time are authoritative, so skip re-ingesting this video.
let existing_status: Option<String> =
sqlx::query_scalar("SELECT status FROM videos WHERE project_id=$1 AND rel_path=$2")
.bind(project_id)
.bind(&rel_path)
.fetch_optional(db)
.await?;
if existing_status.as_deref() == Some("verified") {
tracing::debug!("skip re-ingest of verified video: {rel_path}");
continue;
// Also never clobber a video that is *actively claimed* (a worker is mid-edit)
// — a stale NAS JSON must not overwrite in-progress work. Both checks read the
// current row's status + claim lease.
let existing: Option<(String, Option<String>, Option<DateTime<Utc>>)> = sqlx::query_as(
"SELECT status, claimed_by, lease_expires_at FROM videos WHERE project_id=$1 AND rel_path=$2",
)
.bind(project_id)
.bind(&rel_path)
.fetch_optional(db)
.await?;
if let Some((status, claimed_by, lease)) = existing.as_ref() {
if status == "verified" {
tracing::debug!("skip re-ingest of verified video: {rel_path}");
continue;
}
let active_claim = claimed_by.is_some()
&& lease.map(|l| l > Utc::now()).unwrap_or(false);
if active_claim {
tracing::debug!("skip re-ingest of actively-claimed video: {rel_path}");
continue;
}
}
let json_path = sibling_json(path);
@@ -138,6 +150,10 @@ pub async fn ingest_project(db: &PgPool, project_id: i32, dir: &Path) -> anyhow:
std::fs::read_to_string(jp).ok().and_then(|t| serde_json::from_str(&t).ok())
});
// Upsert the video + replace its annotation rows atomically: either the new
// metadata AND the new annotation set both land, or neither does. Prevents a
// failed insert mid-loop from leaving the video with partial/zero annotations.
let mut tx = db.begin().await?;
// Upsert the video. Keep 'verified' status sticky (Phase 3 pushes).
let video_id: i32 = sqlx::query_scalar(
r#"
@@ -179,18 +195,19 @@ pub async fn ingest_project(db: &PgPool, project_id: i32, dir: &Path) -> anyhow:
.bind(latest)
.bind(raw_json)
.bind(ann_count)
.fetch_one(db)
.fetch_one(&mut *tx)
.await?;
// Replace detailed annotation rows for this video.
// Replace detailed annotation rows for this video (same transaction).
if let Some(d) = doc.as_ref() {
replace_annotations(db, video_id, d).await?;
replace_annotations(&mut tx, video_id, d).await?;
} else {
sqlx::query("DELETE FROM annotations WHERE video_id=$1")
.bind(video_id)
.execute(db)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
count += 1;
}
Ok(count)
@@ -199,27 +216,33 @@ pub async fn ingest_project(db: &PgPool, project_id: i32, dir: &Path) -> anyhow:
/// Replace all annotation rows for a video with the contents of an export doc.
/// Shared by ingest (NAS JSON) and the Phase 3 worker push. Returns the row count
/// written (each range annotation contributes 12 rows: start, and end if present).
///
/// Runs on a caller-supplied transaction connection so the delete + all inserts are
/// **atomic** — either every annotation row lands or none do. Callers (`ingest`, worker
/// `push`) own the transaction so the surrounding metadata update commits together with
/// the annotation set. A failed insert mid-loop rolls back the delete, so annotations
/// can never be partially lost.
pub async fn replace_annotations(
db: &PgPool,
conn: &mut sqlx::PgConnection,
video_id: i32,
d: &ExportDoc,
) -> anyhow::Result<i32> {
sqlx::query("DELETE FROM annotations WHERE video_id=$1")
.bind(video_id)
.execute(db)
.execute(&mut *conn)
.await?;
let mut written = 0i32;
for a in &d.fixed_annotations {
insert_ann(db, video_id, a.frame_number, &a.label, &a.side, &a.shape_type,
insert_ann(&mut *conn, video_id, a.frame_number, &a.label, &a.side, &a.shape_type,
&a.vertices, &a.annotated_by, &a.review_status, &a.remark, &a.subclass, &a.created_at).await?;
written += 1;
}
for a in &d.range_annotations {
insert_ann(db, video_id, a.start_frame, &a.label, &a.side, &a.shape_type,
insert_ann(&mut *conn, video_id, a.start_frame, &a.label, &a.side, &a.shape_type,
&a.start_vertices, &a.annotated_by, &a.review_status, &a.remark, &a.subclass, &a.created_at).await?;
written += 1;
if let (Some(ef), Some(ev)) = (a.end_frame, a.end_vertices.as_ref()) {
insert_ann(db, video_id, ef, &a.label, &a.side, &a.shape_type,
insert_ann(&mut *conn, video_id, ef, &a.label, &a.side, &a.shape_type,
ev, &a.annotated_by, &a.review_status, &a.remark, &a.subclass, &a.created_at).await?;
written += 1;
}
@@ -229,7 +252,7 @@ pub async fn replace_annotations(
#[allow(clippy::too_many_arguments)]
async fn insert_ann(
db: &PgPool, video_id: i32, frame: i64, label: &str, side: &str, shape: &str,
conn: &mut sqlx::PgConnection, video_id: i32, frame: i64, label: &str, side: &str, shape: &str,
vertices: &serde_json::Value, by: &str, review: &str, remark: &str, subclass: &str, created_at: &str,
) -> anyhow::Result<()> {
sqlx::query(
@@ -248,7 +271,7 @@ async fn insert_ann(
.bind(remark)
.bind(subclass)
.bind(created_at)
.execute(db)
.execute(&mut *conn)
.await?;
Ok(())
}

View File

@@ -1,5 +1,7 @@
mod admin;
mod auth;
mod chainage;
mod gps;
mod ingest;
mod models;
mod nfs;
@@ -41,6 +43,17 @@ const FOLDER_FILTER: &str = " AND (NOT EXISTS (SELECT 1 FROM project_folders pf
OR (CASE WHEN rel_path LIKE '%/%' THEN regexp_replace(rel_path, '/[^/]*$', '') ELSE '' END) \
IN (SELECT folder FROM project_folders WHERE project_id = $1)) ";
/// SQL fragment: the derived lifecycle label for the UI badge. References the
/// unaliased `videos` table; shared by the video list and the map endpoint.
const WORKFLOW_STATUS_SQL: &str = "CASE
WHEN videos.claimed_by IS NOT NULL AND videos.lease_expires_at > now() THEN 'in-progress'
WHEN videos.status = 'verified' THEN
CASE WHEN (SELECT count(*) FROM video_events e WHERE e.video_id = videos.id AND e.event='push') >= 2
THEN 're-verified' ELSE 'verified' END
WHEN videos.assigned_to IS NOT NULL AND videos.assigned_to <> '' THEN 'assigned'
ELSE videos.status
END";
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
@@ -117,6 +130,10 @@ async fn main() -> anyhow::Result<()> {
let state = AppState { db, ingest_dir, admin_token, lease_secs, nfs_host, nfs_opts };
// GPS pipeline: serial background sweep extracting each pending video's track
// with exiftool (one at a time — never hammers the NAS).
tokio::spawn(gps::sweep(state.clone()));
// Phase 3: background task that auto-releases expired claims back to the pool.
{
let db = state.db.clone();
@@ -180,6 +197,21 @@ async fn main() -> anyhow::Result<()> {
.route("/api/projects/:id/members/:username", delete(admin::remove_member))
.route("/api/projects/:id/timer", post(project_timer))
.route("/api/projects/:id/folders", get(list_folders).post(set_folders))
// Map view + GPS pipeline + road_type + chainage.
.route("/api/projects/:id/map", get(map_data))
.route("/api/projects/:id/tracks.kml", get(export_tracks_kml))
.route("/api/projects/:id/road_types", get(list_road_types).post(set_road_types))
.route("/api/projects/:id/road_type_assign", post(assign_road_type))
.route("/api/videos/:id/gps/rescan", post(gps::rescan))
.route("/api/projects/:id/gps/rescan_all", post(gps::rescan_all))
.route("/api/videos/:id/gps/correct", post(gps::correct))
.route("/api/projects/:id/chainage/points", get(chainage::list_points).post(chainage::add_point))
.route("/api/projects/:id/chainage/points/bulk", post(chainage::add_points_bulk))
.route("/api/chainage/points/:id", delete(chainage::delete_point).post(chainage::update_point_note))
.route("/api/projects/:id/chainage/recompute", post(chainage::recompute))
.route("/api/projects/:id/chainage/quick", post(chainage::quick_assign))
.route("/api/projects/:id/chainage/calibrations", get(chainage::list_calibrations))
.route("/api/projects/:id/chainage/summary", get(chainage::saved_summary))
// Phase 3 admin (token-gated).
.route("/api/admin/storage", get(storage_info))
.route("/api/users", get(admin::list_users).post(admin::create_user))
@@ -387,6 +419,263 @@ async fn set_folders(
Ok(Json(json!({ "folders": req.folders.len() })))
}
/// Max polyline points per video in the map payload. Full-resolution tracks stay in
/// the DB for chainage math; the map only needs the shape.
const MAP_MAX_PTS: usize = 400;
/// `GET /api/projects/:id/map` — everything the map needs per video: GPS track
/// (corrected preferred), status/workflow labels, road_type + chainage. Videos with
/// `gps_status` none/error feed the "videos without metadata" panel. Folder-aware.
async fn map_data(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Vec<MapVideo>> {
require_project_access(&s, &user, id).await?;
let q = format!(
r#"SELECT id, file_name, rel_path, status, {WORKFLOW_STATUS_SQL} AS workflow_status,
ignored, hand_raised, assigned_to, claimed_by, annotation_count,
road_type, gps_status, gps_len_m, gps_error,
chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m, route_seq,
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,
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)
.bind(id)
.fetch_all(&s.db)
.await
.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)));
}
}
Ok(Json(rows))
}
/// `GET /api/projects/:id/road_types` — the project's road-type labels + how many
/// videos carry each (admin or member).
async fn list_road_types(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Vec<RoadTypeRow>> {
require_project_access(&s, &user, id).await?;
let rows = sqlx::query_as::<_, RoadTypeRow>(
r#"SELECT rt.name, COALESCE(c.cnt, 0) AS video_count
FROM project_road_types rt
LEFT JOIN (SELECT road_type, count(*)::bigint AS cnt
FROM videos WHERE project_id=$1 GROUP BY road_type) c
ON c.road_type = rt.name
WHERE rt.project_id=$1 ORDER BY rt.name"#,
)
.bind(id)
.fetch_all(&s.db)
.await
.map_err(err)?;
Ok(Json(rows))
}
/// `POST /api/projects/:id/road_types {names}` — replace the label set (admin).
/// Videos keep whatever label they already carry; removing a name never rewrites videos.
async fn set_road_types(
State(s): State<AppState>,
user: AuthUser,
AxPath(id): AxPath<i32>,
Json(req): Json<RoadTypesRequest>,
) -> ApiResult<serde_json::Value> {
user.require_admin()?;
let names: Vec<String> = req
.names
.iter()
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.collect();
let mut tx = s.db.begin().await.map_err(err)?;
sqlx::query("DELETE FROM project_road_types WHERE project_id=$1")
.bind(id)
.execute(&mut *tx)
.await
.map_err(err)?;
for n in &names {
sqlx::query("INSERT INTO project_road_types (project_id, name) VALUES ($1,$2) ON CONFLICT DO NOTHING")
.bind(id)
.bind(n)
.execute(&mut *tx)
.await
.map_err(err)?;
}
tx.commit().await.map_err(err)?;
admin::audit(&s.db, &user.username, "road_types_updated", json!({ "project_id": id, "count": names.len() })).await;
Ok(Json(json!({ "road_types": names.len() })))
}
/// `POST /api/projects/:id/road_type_assign {video_ids, road_type}` — bulk-assign a
/// road type to the selected videos (admin; empty road_type clears it).
async fn assign_road_type(
State(s): State<AppState>,
user: AuthUser,
AxPath(id): AxPath<i32>,
Json(req): Json<RoadTypeAssignRequest>,
) -> ApiResult<serde_json::Value> {
user.require_admin()?;
if req.video_ids.is_empty() {
return Err((StatusCode::BAD_REQUEST, "no videos selected".into()));
}
let rt = req.road_type.trim().to_string();
if !rt.is_empty() {
let known: Option<i32> =
sqlx::query_scalar("SELECT 1 FROM project_road_types WHERE project_id=$1 AND name=$2")
.bind(id)
.bind(&rt)
.fetch_optional(&s.db)
.await
.map_err(err)?;
if known.is_none() {
return Err((StatusCode::BAD_REQUEST, format!("'{rt}' is not a road type of this project")));
}
}
let n = sqlx::query("UPDATE videos SET road_type=$3 WHERE project_id=$1 AND id = ANY($2)")
.bind(id)
.bind(&req.video_ids)
.bind(&rt)
.execute(&s.db)
.await
.map_err(err)?
.rows_affected();
admin::audit(&s.db, &user.username, "road_type_assigned",
json!({ "project_id": id, "road_type": rt, "count": n })).await;
Ok(Json(json!({ "assigned": n, "road_type": rt })))
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
/// KML line color (aabbggrr) per workflow status — same buckets as the map view.
fn kml_status_color(status: &str) -> &'static str {
match status {
"pending" => "ff5252ff",
"annotated" => "ff23a6f5",
"assigned" => "ffff8cb4",
"in-progress" => "ffffa86e",
"verified" => "ff75cf5c",
"re-verified" => "ffa8bf2b",
_ => "ffae958f", // ignored / unknown → gray
}
}
const KML_STATUSES: &[&str] =
&["pending", "annotated", "assigned", "in-progress", "verified", "re-verified", "ignored"];
/// `GET /api/projects/:id/tracks.kml?token=` — every scanned video's track as a KML
/// LineString colored by status (+ the calibration points), for Google Earth /
/// client hand-off. Auth via `?token=` (file download).
async fn export_tracks_kml(
State(s): State<AppState>,
Query(q): Query<TokenQuery>,
AxPath(id): AxPath<i32>,
) -> Result<Response, (StatusCode, String)> {
if auth::validate_token(&s, q.token.as_deref().unwrap_or("")).await.is_none() {
return Err((StatusCode::UNAUTHORIZED, "missing or invalid token".into()));
}
let vq = format!(
r#"SELECT file_name, {WORKFLOW_STATUS_SQL} AS workflow_status, ignored, road_type,
chainage_start_m, chainage_end_m, COALESCE(gps_track_corrected, gps_track) AS track
FROM videos WHERE project_id=$1 AND COALESCE(gps_track_corrected, gps_track) IS NOT NULL
{FOLDER_FILTER} ORDER BY file_name"#
);
#[allow(clippy::type_complexity)]
let rows: Vec<(String, String, bool, String, Option<f64>, Option<f64>, serde_json::Value)> =
sqlx::query_as(&vq).bind(id).fetch_all(&s.db).await.map_err(err)?;
let points: Vec<(f64, f64, f64, String, String)> = sqlx::query_as(
"SELECT lat, lon, chainage_m, note, road_type FROM chainage_points WHERE project_id=$1 ORDER BY chainage_m",
)
.bind(id)
.fetch_all(&s.db)
.await
.map_err(err)?;
let mut kml = String::with_capacity(1 << 20);
kml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<kml xmlns=\"http://www.opengis.net/kml/2.2\"><Document>\n");
kml.push_str(&format!("<name>project {id} — video tracks</name>\n"));
for st in KML_STATUSES {
kml.push_str(&format!(
"<Style id=\"st-{st}\"><LineStyle><color>{}</color><width>4</width></LineStyle></Style>\n",
kml_status_color(st)
));
}
kml.push_str("<Folder><name>Videos</name>\n");
for (file_name, workflow_status, ignored, road_type, ch_s, ch_e, track) in rows {
let status = if ignored { "ignored".to_string() } else { workflow_status };
let pts = gps::downsample(gps::parse_track(&track), 1000);
if pts.len() < 2 {
continue;
}
let mut desc = format!("status: {status}");
if !road_type.is_empty() {
desc.push_str(&format!(" · road: {road_type}"));
}
if let (Some(a), Some(b)) = (ch_s, ch_e) {
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>",
xml_escape(&file_name), xml_escape(&desc), status
));
for p in &pts {
kml.push_str(&format!("{},{},0 ", p[1], p[0]));
}
kml.push_str("</coordinates></LineString></Placemark>\n");
}
kml.push_str("</Folder>\n<Folder><name>Calibration points</name>\n");
for (lat, lon, chainage_m, note, road_type) in points {
let name = format!("{:.3} km{}", chainage_m / 1000.0, if road_type.is_empty() { String::new() } else { format!(" ({road_type})") });
kml.push_str(&format!(
"<Placemark><name>{}</name><description>{}</description><Point><coordinates>{lon},{lat},0</coordinates></Point></Placemark>\n",
xml_escape(&name), xml_escape(&note)
));
}
kml.push_str("</Folder>\n</Document></kml>\n");
Response::builder()
.header(header::CONTENT_TYPE, "application/vnd.google-earth.kml+xml")
.header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"project_{id}_tracks.kml\""))
.body(Body::from(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,
chainage: (Option<f64>, Option<f64>, Option<f64>, Option<f64>),
) {
if let Some(obj) = doc.as_object_mut() {
obj.insert("road_type".into(), json!(road_type));
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);
}
}
}
async fn connect_with_retry(url: &str, attempts: u32) -> anyhow::Result<PgPool> {
let mut last = None;
for i in 1..=attempts {
@@ -538,18 +827,20 @@ async fn list_videos(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPa
WHERE video_id = videos.id AND event='push' GROUP BY username) s), '') AS verifiers,
assigned_to,
-- derived lifecycle label for the UI badge
CASE
WHEN videos.claimed_by IS NOT NULL AND videos.lease_expires_at > now() THEN 'in-progress'
WHEN videos.status = 'verified' THEN
CASE WHEN (SELECT count(*) FROM video_events e WHERE e.video_id = videos.id AND e.event='push') >= 2
THEN 're-verified' ELSE 'verified' END
WHEN videos.assigned_to IS NOT NULL AND videos.assigned_to <> '' THEN 'assigned'
ELSE videos.status
END AS workflow_status,
{WORKFLOW_STATUS_SQL} AS workflow_status,
road_type, gps_status, gps_len_m, gps_error, gps_sample_count, gps_max_gap_s, duration_s,
chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m, route_seq,
hand_raised, hand_raised_by, hand_raised_at, ignored, ignored_by,
COALESCE((SELECT json_agg(json_build_object('username', r.username, 'body', r.body, 'created_at', r.created_at)
ORDER BY r.created_at)
FROM video_remarks r WHERE r.video_id = videos.id), '[]'::json) AS remarks
FROM video_remarks r WHERE r.video_id = videos.id), '[]'::json) AS remarks,
-- per-pass annotation-count history (each push, in order): lets the UI
-- show imported baseline → verify Δ → re-verify Δ. count is null for
-- legacy pushes logged before we recorded it.
COALESCE((SELECT json_agg(json_build_object(
'username', e.username, 'at', e.at,
'count', (e.meta->>'annotation_count')::int) ORDER BY e.at)
FROM video_events e WHERE e.video_id = videos.id AND e.event='push'), '[]'::json) AS passes
FROM videos WHERE project_id=$1 {FOLDER_FILTER} ORDER BY file_name"#);
let rows = sqlx::query_as::<_, VideoRow>(&q)
.bind(id)
@@ -559,6 +850,31 @@ async fn list_videos(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPa
Ok(Json(rows))
}
/// Ensure an annotations export doc carries a `video` identity block. Some ingested /
/// pushed docs omit it, which makes a bulk export impossible to tie back to a file.
/// Injects `{ file_name, rel_path }` when absent; leaves an existing block untouched.
fn ensure_video_block(mut doc: serde_json::Value, file_name: &str, rel_path: &str) -> serde_json::Value {
if let Some(obj) = doc.as_object_mut() {
let has_video = obj.get("video").map(|v| !v.is_null()).unwrap_or(false);
if !has_video {
obj.insert("video".into(), json!({ "file_name": file_name, "rel_path": rel_path }));
}
}
doc
}
/// One entry in the bulk project export. Serialized as a struct (NOT the `json!` macro)
/// so the video identity fields come FIRST — serde_json orders `json!` object keys
/// alphabetically, which otherwise buries `file_name` far below the huge `annotations`
/// array and makes it look missing.
#[derive(serde::Serialize)]
struct ExportedVideo {
file_name: String,
rel_path: String,
video_id: i32,
annotations: serde_json::Value,
}
/// `GET /api/videos/:id/export` — download one video's annotations as the canonical
/// export JSON (`videos.raw_json`, the doc pushed by the worker / ingested from NAS).
/// Auth required (via `?token=`); served as a file attachment.
@@ -566,14 +882,29 @@ async fn export_video(State(s): State<AppState>, Query(q): Query<TokenQuery>, Ax
if auth::validate_token(&s, q.token.as_deref().unwrap_or("")).await.is_none() {
return Err((StatusCode::UNAUTHORIZED, "missing or invalid token".into()));
}
let row: Option<(String, Option<serde_json::Value>)> =
sqlx::query_as("SELECT file_name, raw_json FROM videos WHERE id=$1")
#[allow(clippy::type_complexity)]
let row: Option<(String, String, Option<serde_json::Value>, String, Option<f64>, Option<f64>, Option<f64>, Option<f64>)> =
sqlx::query_as(
r#"SELECT file_name, rel_path, raw_json, road_type,
chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m
FROM videos WHERE id=$1"#,
)
.bind(id)
.fetch_optional(&s.db)
.await
.map_err(err)?;
let (file_name, raw_json) = row.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?;
let doc = raw_json.unwrap_or_else(|| json!({ "fixed_annotations": [], "range_annotations": [] }));
let (file_name, rel_path, raw_json, road_type, ch_s, ch_e, sr_s, sr_e) =
row.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?;
// Embed the video identity inside the doc so the file's name/path travel with it.
let mut doc = ensure_video_block(
raw_json.unwrap_or_else(|| json!({ "fixed_annotations": [], "range_annotations": [] })),
&file_name,
&rel_path,
);
stamp_road_meta(&mut doc, &road_type, (ch_s, ch_e, sr_s, sr_e));
if let Some(geo) = chainage::video_geo(&s.db, id).await {
chainage::stamp_annotation_geo(&mut doc, &geo);
}
let body = serde_json::to_vec_pretty(&doc).map_err(err)?;
let stem = file_name.rsplit_once('.').map(|(a, _)| a).unwrap_or(&file_name);
let fname = format!("{stem}_annotations.json");
@@ -590,17 +921,31 @@ async fn export_project(State(s): State<AppState>, Query(q): Query<TokenQuery>,
if auth::validate_token(&s, q.token.as_deref().unwrap_or("")).await.is_none() {
return Err((StatusCode::UNAUTHORIZED, "missing or invalid token".into()));
}
let rows: Vec<(i32, String, String, Option<serde_json::Value>)> = sqlx::query_as(
"SELECT id, file_name, rel_path, raw_json FROM videos WHERE project_id=$1 AND raw_json IS NOT NULL ORDER BY rel_path",
#[allow(clippy::type_complexity)]
let rows: Vec<(i32, String, String, Option<serde_json::Value>, String, Option<f64>, Option<f64>, Option<f64>, Option<f64>)> = sqlx::query_as(
r#"SELECT id, file_name, rel_path, raw_json, road_type,
chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m
FROM videos WHERE project_id=$1 AND raw_json IS NOT NULL ORDER BY rel_path"#,
)
.bind(id)
.fetch_all(&s.db)
.await
.map_err(err)?;
let videos: Vec<serde_json::Value> = rows
.into_iter()
.map(|(vid, fname, rel, raw)| json!({ "video_id": vid, "file_name": fname, "rel_path": rel, "annotations": raw }))
.collect();
let mut videos: Vec<ExportedVideo> = Vec::with_capacity(rows.len());
for (vid, fname, rel, raw, road_type, ch_s, ch_e, sr_s, sr_e) in rows {
// file_name first (struct order) AND embedded in the annotations doc, so the
// name is both immediately visible per entry and self-contained with the data.
let mut annotations = ensure_video_block(
raw.unwrap_or_else(|| json!({ "fixed_annotations": [], "range_annotations": [] })),
&fname,
&rel,
);
stamp_road_meta(&mut annotations, &road_type, (ch_s, ch_e, sr_s, sr_e));
if let Some(geo) = chainage::video_geo(&s.db, vid).await {
chainage::stamp_annotation_geo(&mut annotations, &geo);
}
videos.push(ExportedVideo { file_name: fname, rel_path: rel, video_id: vid, annotations });
}
let doc = json!({ "project_id": id, "video_count": videos.len(), "videos": videos });
let body = serde_json::to_vec_pretty(&doc).map_err(err)?;
Response::builder()
@@ -790,7 +1135,7 @@ async fn stats(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPath<i32
COALESCE((SELECT SUM(v2.annotation_time_ms) FROM videos v2
WHERE v2.project_id=$1 AND v2.primary_annotator = a.annotated_by), 0)::bigint
FROM annotations a JOIN videos v ON a.video_id=v.id
WHERE v.project_id=$1 AND a.annotated_by <> ''
WHERE v.project_id=$1 AND a.annotated_by <> '' AND lower(a.annotated_by) <> 'auto'
GROUP BY a.annotated_by"#,
)
.bind(id)
@@ -812,14 +1157,17 @@ async fn stats(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPath<i32
let ann_by_user: std::collections::HashMap<String, i64> =
leaderboard.iter().map(|r| (r.user.clone(), r.annotations)).collect();
// Verification leaderboard: ALL assigned users (members) ranked by completion
// SPEED (avg verify time per video, fastest first). Anyone who has completed
// videos but isn't a member is included too; members with no completions sort
// last (unranked). Refreshed every poll → "live" ranking.
// Verification leaderboard: ALL assigned users (members) ranked by ANNOTATION
// THROUGHPUT — annotations added per minute of work (most annotations in the least
// time first), not by video count. Anyone who has completed videos but isn't a
// member is included too; users with no annotations/time sort last (unranked).
// The synthetic 'auto' author (auto-segmented/imported) is excluded. Refreshed
// every poll → "live" ranking.
let ver_rows: Vec<(String, i64, i64)> = sqlx::query_as(
r#"SELECT completed_by, COUNT(*), COALESCE(SUM(verify_time_ms),0)::bigint
FROM videos
WHERE project_id=$1 AND status='verified' AND completed_by IS NOT NULL AND completed_by <> ''
WHERE project_id=$1 AND status='verified' AND completed_by IS NOT NULL
AND completed_by <> '' AND lower(completed_by) <> 'auto'
GROUP BY completed_by"#,
)
.bind(id)
@@ -834,29 +1182,48 @@ async fn stats(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPath<i32
let mut stats_by_user: std::collections::HashMap<String, (i64, i64)> =
ver_rows.into_iter().map(|(u, v, t)| (u, (v, t))).collect();
for m in &members {
if m.eq_ignore_ascii_case("auto") {
continue;
}
stats_by_user.entry(m.clone()).or_insert((0, 0));
}
let mut verifiers: Vec<VerifierRow> = stats_by_user
.into_iter()
.map(|(user, (videos, total_time_ms))| VerifierRow {
avg_time_ms: if videos > 0 { total_time_ms / videos } else { 0 },
videos,
total_time_ms,
annotations: ann_by_user.get(&user).copied().unwrap_or(0),
rank: 0,
user,
.map(|(user, (videos, total_time_ms))| {
let annotations = ann_by_user.get(&user).copied().unwrap_or(0);
// annotations added per minute of work; 0 when no time recorded yet.
let annotations_per_min = if total_time_ms > 0 {
annotations as f64 / (total_time_ms as f64 / 60_000.0)
} else {
0.0
};
VerifierRow {
avg_time_ms: if videos > 0 { total_time_ms / videos } else { 0 },
videos,
total_time_ms,
annotations,
annotations_per_min,
rank: 0,
user,
}
})
.collect();
// Fastest first: completers by avg ascending; non-completers last by name.
verifiers.sort_by(|a, b| match (a.videos > 0, b.videos > 0) {
// Highest throughput first (most annotations per minute). A user is rankable only
// once they have both annotations AND recorded time; everyone else sorts last by name.
let rankable = |v: &VerifierRow| v.annotations > 0 && v.annotations_per_min > 0.0;
verifiers.sort_by(|a, b| match (rankable(a), rankable(b)) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
(true, true) => a.avg_time_ms.cmp(&b.avg_time_ms).then_with(|| a.user.cmp(&b.user)),
(true, true) => b
.annotations_per_min
.partial_cmp(&a.annotations_per_min)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.user.cmp(&b.user)),
(false, false) => a.user.cmp(&b.user),
});
let mut rank = 0i64;
for v in verifiers.iter_mut() {
if v.videos > 0 {
if rankable(v) {
rank += 1;
v.rank = rank;
}

View File

@@ -148,6 +148,24 @@ pub struct VideoRow {
// pending | annotated | assigned | in-progress | verified | re-verified.
pub assigned_to: Option<String>,
pub workflow_status: String,
// Road type label ('' = unassigned) — injected into the export/claim JSON.
pub road_type: String,
// GPS pipeline: pending | ok | none | error (+ measured track length / last error).
pub gps_status: String,
pub gps_len_m: Option<f64>,
pub gps_error: Option<String>,
// Data quality: raw fix count + biggest dropout — the table compares the count
// against the video duration (≈1 fix/s) and flags mismatches.
pub gps_sample_count: Option<i32>,
pub gps_max_gap_s: Option<f64>,
// Real container duration from the scan (raw NAS videos have no fps/frame_count).
pub duration_s: Option<f64>,
// Client-calibrated chainage + the immutable GPS-measured (sr) chainage, meters.
pub chainage_start_m: Option<f64>,
pub chainage_end_m: Option<f64>,
pub sr_chainage_start_m: Option<f64>,
pub sr_chainage_end_m: Option<f64>,
pub route_seq: Option<i32>,
// "Raise hand" signal — anyone may raise (to start a conversation) or lower it.
pub hand_raised: bool,
pub hand_raised_by: Option<String>,
@@ -157,6 +175,74 @@ pub struct VideoRow {
pub ignored_by: Option<String>,
// Remark thread (json array of {username, body, created_at}); '[]' when none.
pub remarks: serde_json::Value,
// Per-pass annotation-count history (json array of {username, at, count}, one per
// push in order); '[]' when never pushed. Drives the imported→verify→re-verify
// count breakdown in the UI.
pub passes: serde_json::Value,
}
/// One row of `GET /api/projects/:id/map` — a video as the map sees it. `track` is
/// the (transport-downsampled) effective polyline: corrected when kept, else raw;
/// None until the GPS sweep has scanned the video (or when it has no metadata).
#[derive(Serialize, sqlx::FromRow)]
pub struct MapVideo {
pub id: i32,
pub file_name: String,
pub rel_path: String,
pub status: String,
pub workflow_status: String,
pub ignored: bool,
pub hand_raised: bool,
pub assigned_to: Option<String>,
pub claimed_by: Option<String>,
pub annotation_count: i32,
pub road_type: String,
pub gps_status: String,
pub gps_len_m: Option<f64>,
pub gps_error: Option<String>,
pub chainage_start_m: Option<f64>,
pub chainage_end_m: Option<f64>,
pub sr_chainage_start_m: Option<f64>,
pub sr_chainage_end_m: Option<f64>,
pub route_seq: Option<i32>,
pub remark_count: i64,
pub track: Option<Value>,
pub corrected: bool,
// Who/what produced the correction ('auto' or a username) + its summary note.
pub gps_corrected_by: Option<String>,
pub gps_corrected_note: Option<String>,
// 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.
#[derive(Serialize, sqlx::FromRow)]
pub struct RoadTypeRow {
pub name: String,
pub video_count: i64,
}
/// `POST /api/projects/:id/road_types {names}` — replace the label set (admin).
#[derive(Deserialize)]
pub struct RoadTypesRequest {
pub names: Vec<String>,
}
/// `POST /api/projects/:id/road_type_assign {video_ids, road_type}` — bulk-assign.
#[derive(Deserialize)]
pub struct RoadTypeAssignRequest {
pub video_ids: Vec<i32>,
pub road_type: String, // '' = clear
}
/// `POST /api/videos/:id/hand {raised}` — raise/lower the hand on a video.
@@ -286,7 +372,11 @@ pub struct VerifierRow {
pub avg_time_ms: i64,
/// Total annotations this user authored (annotated_by) in the project.
pub annotations: i64,
/// 1-based rank by completion speed (fastest avg first); 0 = no completions yet.
/// Annotations added per minute of work (annotations ÷ total_time). This is the
/// ranking metric: "most annotations in the least time". 0 when no time/annotations.
pub annotations_per_min: f64,
/// 1-based rank by annotations-per-minute (highest first); 0 = not yet ranked
/// (no annotations or no recorded time).
pub rank: i64,
}

View File

@@ -52,14 +52,16 @@ pub async fn claim(
user: AuthUser,
AxPath(id): AxPath<i32>,
) -> ApiResult<ClaimResponse> {
let row: Option<(i32, String, String, Option<i32>, Option<i32>, Option<f64>, Option<i32>, chrono::DateTime<chrono::Utc>, Option<Value>)> =
#[allow(clippy::type_complexity)]
let row: Option<(i32, String, String, Option<i32>, Option<i32>, Option<f64>, Option<i32>, chrono::DateTime<chrono::Utc>, Option<Value>, String, Option<f64>, Option<f64>, Option<f64>, Option<f64>)> =
sqlx::query_as(
r#"
UPDATE videos
SET claimed_by=$2, claimed_at=now(), lease_expires_at=now() + ($3::int * interval '1 second')
WHERE id=$1
AND (claimed_by IS NULL OR claimed_by=$2 OR lease_expires_at < now())
RETURNING id, file_name, rel_path, width, height, fps, frame_count, lease_expires_at, raw_json
RETURNING id, file_name, rel_path, width, height, fps, frame_count, lease_expires_at, raw_json,
road_type, chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m
"#,
)
.bind(id)
@@ -69,7 +71,8 @@ pub async fn claim(
.await
.map_err(err)?;
let Some((video_id, file_name, rel_path, width, height, fps, frame_count, lease_expires_at, raw_json)) = row
let Some((video_id, file_name, rel_path, width, height, fps, frame_count, lease_expires_at, raw_json,
road_type, ch_s, ch_e, sr_s, sr_e)) = row
else {
// Distinguish "doesn't exist" from "not claimable".
let exists: Option<i32> = sqlx::query_scalar("SELECT id FROM videos WHERE id=$1")
@@ -88,6 +91,21 @@ pub async fn claim(
log_event(&s.db, video_id, &user.username, "claim", json!({})).await;
// Inject the server-authoritative road_type + chainage into the doc the worker
// pulls (only when a doc exists — a pending video legitimately has none; push
// re-stamps regardless, so the metadata always persists). Each annotation also
// gets its frame-exact lat/lon + chainage.
let annotations = match raw_json {
Some(mut doc) => {
crate::stamp_road_meta(&mut doc, &road_type, (ch_s, ch_e, sr_s, sr_e));
if let Some(geo) = crate::chainage::video_geo(&s.db, video_id).await {
crate::chainage::stamp_annotation_geo(&mut doc, &geo);
}
doc
}
None => Value::Null,
};
Ok(Json(ClaimResponse {
video_id,
file_name,
@@ -98,7 +116,7 @@ pub async fn claim(
frame_count,
lease_expires_at,
download_url: format!("/api/videos/{video_id}/download"),
annotations: raw_json.unwrap_or(Value::Null),
annotations,
}))
}
@@ -218,20 +236,32 @@ pub async fn push(
AxPath(id): AxPath<i32>,
Json(body): Json<Value>,
) -> ApiResult<Value> {
// The current holder (or an admin) may push. A null holder (auto-released, not
// re-claimed) is tolerated; a different holder is rejected.
let claimed_by: Option<Option<String>> =
sqlx::query_scalar("SELECT claimed_by FROM videos WHERE id=$1")
// A non-admin pusher must currently hold an ACTIVE claim on this video. This blocks
// a stale/expired claim from overwriting newer authoritative server state: if the
// lease lapsed (auto-released, or re-claimed by someone else) the worker must
// re-claim before pushing — their local work is never lost, it just can't clobber a
// claim they no longer hold. Admins may always push.
#[allow(clippy::type_complexity)]
let row: Option<(Option<String>, Option<chrono::DateTime<chrono::Utc>>, String, Option<f64>, Option<f64>, Option<f64>, Option<f64>)> =
sqlx::query_as(
r#"SELECT claimed_by, lease_expires_at, road_type,
chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m
FROM videos WHERE id=$1"#,
)
.bind(id)
.fetch_optional(&s.db)
.await
.map_err(err)?;
let claimed_by = claimed_by.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?;
let (claimed_by, lease_expires_at, road_type, ch_s, ch_e, sr_s, sr_e) =
row.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?;
if !user.is_admin() {
if let Some(holder) = claimed_by.as_deref() {
if holder != user.username {
return Err((StatusCode::CONFLICT, "claim is held by another worker".into()));
}
let holds_active_claim = claimed_by.as_deref() == Some(user.username.as_str())
&& lease_expires_at.map(|l| l > chrono::Utc::now()).unwrap_or(false);
if !holds_active_claim {
return Err((
StatusCode::CONFLICT,
"your claim has expired or is held by someone else — re-claim the video before pushing".into(),
));
}
}
@@ -243,9 +273,34 @@ pub async fn push(
let doc: ExportDoc = serde_json::from_value(body.clone())
.map_err(|e| (StatusCode::BAD_REQUEST, format!("invalid export doc: {e}")))?;
crate::ingest::replace_annotations(&s.db, id, &doc)
.await
.map_err(err)?;
// Adopt the doc's fps/frame_count/resolution BEFORE stamping. A raw (from-scratch)
// NAS video has no fps in our DB — but the desktop decoded the real file, so its
// fps is authoritative. Without this, per-annotation lat/lon/chainage can't be
// computed (frame→time needs fps) and the geo silently wouldn't be written.
// COALESCE(doc, existing) so a doc that omits a field never wipes it.
if let Some(v) = doc.video.as_ref() {
let _ = sqlx::query(
r#"UPDATE videos SET fps=COALESCE($2, fps), frame_count=COALESCE($3, frame_count),
width=COALESCE($4, width), height=COALESCE($5, height) WHERE id=$1"#,
)
.bind(id)
.bind(v.fps)
.bind(v.frame_count.map(|x| x as i32))
.bind(v.width.map(|x| x as i32))
.bind(v.height.map(|x| x as i32))
.execute(&s.db)
.await;
}
// Re-stamp the server-authoritative road_type + chainage onto the stored doc —
// whatever the worker's client sent (or dropped), the server's values win. Every
// annotation gets its frame-exact lat/lon + chainage too (from the fps just set).
let mut body = body;
crate::stamp_road_meta(&mut body, &road_type, (ch_s, ch_e, sr_s, sr_e));
if let Some(geo) = crate::chainage::video_geo(&s.db, id).await {
crate::chainage::stamp_annotation_geo(&mut body, &geo);
}
let ann_count = (doc.fixed_annotations.len() + doc.range_annotations.len()) as i32;
// Recompute the video-level annotator fields from the pushed doc (same idea as
@@ -261,6 +316,15 @@ pub async fn push(
let primary_annotator = freq.into_iter().max_by_key(|(_, c)| *c).map(|(u, _)| u).unwrap_or_default();
let ann_time_ms = doc.video.as_ref().map(|v| v.annotation_time_ms).unwrap_or(0);
// Replace the annotation set AND flip the video to verified in ONE transaction:
// either the pushed annotations + the verified status both commit, or neither does.
// This makes a push all-or-nothing — a verifier can never end up "verified" with a
// half-written annotation set, and a failed push leaves the prior pass intact.
let mut tx = s.db.begin().await.map_err(err)?;
crate::ingest::replace_annotations(&mut tx, id, &doc)
.await
.map_err(err)?;
// Accumulate verify_time_ms across passes (re-verification adds up). The list
// of verifiers + the pass count are derived from video_events (each push logs one).
sqlx::query(
@@ -269,7 +333,11 @@ pub async fn push(
verify_time_ms = COALESCE(videos.verify_time_ms, 0) + $3,
annotation_count=$4, raw_json=$5,
primary_annotator=$6, annotation_time_ms=$7, annotated_at=now(),
imported_count = COALESCE(NULLIF($8, 0), videos.imported_count),
-- imported_count is the ORIGINAL auto-imported baseline (set at ingest).
-- Keep it fixed once set: verification/re-verification must NOT fold added
-- annotations into it (otherwise "imported 200" would drift to 210). Only
-- establish it from the claim-time count if it was never set.
imported_count = COALESCE(NULLIF(videos.imported_count, 0), $8),
claimed_by=NULL, claimed_at=NULL, lease_expires_at=NULL
WHERE id=$1"#,
)
@@ -281,11 +349,21 @@ pub async fn push(
.bind(&primary_annotator)
.bind(ann_time_ms)
.bind(imported_count)
.execute(&s.db)
.execute(&mut *tx)
.await
.map_err(err)?;
tx.commit().await.map_err(err)?;
log_event(&s.db, id, &user.username, "push", json!({ "verify_time_ms": verify_time_ms })).await;
// Record this pass's resulting annotation count so the dashboard can show a
// per-pass breakdown (imported baseline → verify Δ → re-verify Δ …), not just a net.
log_event(
&s.db,
id,
&user.username,
"push",
json!({ "verify_time_ms": verify_time_ms, "annotation_count": ann_count }),
)
.await;
// Mark project activity + auto-resume its timer (a verify/re-verify restarts it).
let _ = sqlx::query(