Deeraj commited on
Commit
f17ffc5
·
1 Parent(s): d5beafd

Feat: add fleet mode and inference telemetry worker

Browse files
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.onnx filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -35,7 +35,8 @@ that auto-filters spam.
35
  - **Frontend:** React + Vite + TypeScript, Tailwind CSS, react-leaflet (OpenStreetMap), Recharts,
36
  lucide-react.
37
 
38
- ---
 
39
 
40
  ## Setup
41
 
 
35
  - **Frontend:** React + Vite + TypeScript, Tailwind CSS, react-leaflet (OpenStreetMap), Recharts,
36
  lucide-react.
37
 
38
+ ---Read test_dashboard.py
39
+
40
 
41
  ## Setup
42
 
backend/app/api/fleet.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fleet Mode telemetry ingestion.
2
+
3
+ Mounted phones run inference locally and send only compact hazard telemetry plus an optional
4
+ privacy-processed thumbnail. This endpoint intentionally does no model inference and no DBSCAN in
5
+ the request path.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import io
11
+ import uuid
12
+ from datetime import datetime, timedelta, timezone
13
+
14
+ from fastapi import APIRouter, Depends, HTTPException
15
+ from PIL import Image, UnidentifiedImageError
16
+ from sqlmodel import Session
17
+
18
+ from app.config import settings
19
+ from app.db.repository import Repository
20
+ from app.db.session import get_session
21
+ from app.models.schemas import (
22
+ FleetTelemetryIn,
23
+ FleetTelemetryResponse,
24
+ GeoSource,
25
+ HazardType,
26
+ Severity,
27
+ Source,
28
+ )
29
+ from app.models.tables import HazardReport
30
+
31
+ router = APIRouter(prefix="/fleet", tags=["fleet"])
32
+
33
+
34
+ def _utc(dt: datetime) -> datetime:
35
+ if dt.tzinfo is None:
36
+ return dt.replace(tzinfo=timezone.utc)
37
+ return dt.astimezone(timezone.utc)
38
+
39
+
40
+ def _severity_from_area(area_frac: float | None) -> Severity:
41
+ if area_frac is None:
42
+ return Severity.low
43
+ if area_frac >= settings.SEV_HIGH:
44
+ return Severity.high
45
+ if area_frac >= settings.SEV_MED:
46
+ return Severity.medium
47
+ return Severity.low
48
+
49
+
50
+ def _validate_timestamp(ts: datetime) -> datetime:
51
+ observed_at = _utc(ts)
52
+ now = datetime.now(timezone.utc)
53
+ if observed_at < now - timedelta(hours=settings.FLEET_MAX_EVENT_AGE_HOURS):
54
+ raise HTTPException(status_code=422, detail="Telemetry timestamp is too old.")
55
+ if observed_at > now + timedelta(minutes=settings.FLEET_MAX_CLOCK_SKEW_MINUTES):
56
+ raise HTTPException(status_code=422, detail="Telemetry timestamp is in the future.")
57
+ return observed_at
58
+
59
+
60
+ def _store_thumbnail(thumbnail_b64: str | None) -> str | None:
61
+ if not thumbnail_b64:
62
+ return None
63
+
64
+ raw_b64 = thumbnail_b64.split(",", 1)[1] if "," in thumbnail_b64 else thumbnail_b64
65
+ if len(raw_b64) > settings.FLEET_MAX_THUMBNAIL_BYTES * 2:
66
+ raise HTTPException(status_code=413, detail="Thumbnail payload is too large.")
67
+
68
+ try:
69
+ data = base64.b64decode(raw_b64, validate=True)
70
+ except ValueError as exc:
71
+ raise HTTPException(status_code=422, detail="Thumbnail is not valid Base64.") from exc
72
+
73
+ if len(data) > settings.FLEET_MAX_THUMBNAIL_BYTES:
74
+ raise HTTPException(status_code=413, detail="Thumbnail payload is too large.")
75
+
76
+ try:
77
+ img = Image.open(io.BytesIO(data)).convert("RGB")
78
+ except (UnidentifiedImageError, OSError) as exc:
79
+ raise HTTPException(status_code=422, detail="Thumbnail is not a valid image.") from exc
80
+
81
+ img.thumbnail((320, 320))
82
+ out = io.BytesIO()
83
+ img.save(out, format="JPEG", quality=62, optimize=True)
84
+
85
+ fleet_dir = settings.MEDIA_DIR / "fleet"
86
+ fleet_dir.mkdir(parents=True, exist_ok=True)
87
+ filename = f"{uuid.uuid4().hex}.jpg"
88
+ path = fleet_dir / filename
89
+ path.write_bytes(out.getvalue())
90
+ return f"media/fleet/{filename}"
91
+
92
+
93
+ @router.post("/telemetry", response_model=FleetTelemetryResponse)
94
+ async def ingest_telemetry(
95
+ payload: FleetTelemetryIn,
96
+ session: Session = Depends(get_session),
97
+ ) -> FleetTelemetryResponse:
98
+ if payload.hazard_type == HazardType.other:
99
+ raise HTTPException(status_code=422, detail="Fleet telemetry requires a concrete hazard type.")
100
+ if payload.confidence < settings.FLEET_MIN_CONFIDENCE:
101
+ raise HTTPException(status_code=422, detail="Detection confidence is below the fleet threshold.")
102
+ if payload.bbox is not None and len(payload.bbox) != 4:
103
+ raise HTTPException(status_code=422, detail="bbox must contain [x1, y1, x2, y2].")
104
+
105
+ observed_at = _validate_timestamp(payload.timestamp)
106
+ repo = Repository(session)
107
+ if payload.client_event_id:
108
+ duplicate = repo.get_report_by_client_event(payload.client_event_id)
109
+ if duplicate is not None:
110
+ return FleetTelemetryResponse(
111
+ status="accepted",
112
+ report_id=duplicate.id,
113
+ queued_for_cluster=False,
114
+ )
115
+
116
+ thumbnail_path = _store_thumbnail(payload.thumbnail_b64)
117
+ report = HazardReport(
118
+ observed_at=observed_at,
119
+ source=Source.fleet_stream,
120
+ hazard_type=payload.hazard_type,
121
+ severity=_severity_from_area(payload.bbox_area_frac),
122
+ confidence=round(payload.confidence, 4),
123
+ lat=payload.lat,
124
+ lon=payload.lon,
125
+ geo_source=GeoSource.device,
126
+ media_path=thumbnail_path or "",
127
+ thumbnail_path=thumbnail_path,
128
+ bbox=payload.bbox or [],
129
+ bbox_area_frac=payload.bbox_area_frac,
130
+ device_speed=payload.speed,
131
+ client_event_id=payload.client_event_id,
132
+ auto_validated=True,
133
+ )
134
+ report = repo.add_report(report)
135
+ return FleetTelemetryResponse(status="accepted", report_id=report.id, queued_for_cluster=True)
backend/app/config.py CHANGED
@@ -41,6 +41,13 @@ class Settings(BaseSettings):
41
  DBSCAN_EPS_M: float = 20.0 # cluster radius in metres
42
  DBSCAN_MIN_SAMPLES: int = 1 # single reports still form a (low-confidence) cluster
43
  RECLUSTER_RADIUS_M: float = 200.0 # bounding box around a new report to re-cluster
 
 
 
 
 
 
 
44
 
45
  # --- Anonymization ---
46
  ANON_BLUR_KERNEL: int = 31 # Gaussian blur kernel (odd); scaled by region size at runtime
 
41
  DBSCAN_EPS_M: float = 20.0 # cluster radius in metres
42
  DBSCAN_MIN_SAMPLES: int = 1 # single reports still form a (low-confidence) cluster
43
  RECLUSTER_RADIUS_M: float = 200.0 # bounding box around a new report to re-cluster
44
+ FLEET_CLUSTER_EPS_M: float = 25.0
45
+ FLEET_CLUSTER_INTERVAL_SECONDS: int = 300
46
+ FLEET_CLUSTER_LOOKBACK_HOURS: int = 24 * 14
47
+ FLEET_MIN_CONFIDENCE: float = 0.35
48
+ FLEET_MAX_THUMBNAIL_BYTES: int = 160_000
49
+ FLEET_MAX_EVENT_AGE_HOURS: int = 24
50
+ FLEET_MAX_CLOCK_SKEW_MINUTES: int = 5
51
 
52
  # --- Anonymization ---
53
  ANON_BLUR_KERNEL: int = 31 # Gaussian blur kernel (odd); scaled by region size at runtime
backend/app/db/repository.py CHANGED
@@ -5,7 +5,7 @@ This is the single swap boundary: moving SQLite -> Postgres/PostGIS should only
5
  """
6
  from __future__ import annotations
7
 
8
- from datetime import datetime, timezone
9
 
10
  from sqlalchemy import case, func
11
  from sqlmodel import Session, select
@@ -28,6 +28,10 @@ class Repository:
28
  def get_report(self, report_id: str) -> HazardReport | None:
29
  return self.session.get(HazardReport, report_id)
30
 
 
 
 
 
31
  def reports_near(self, lat: float, lon: float, radius_m: float) -> list[HazardReport]:
32
  """Reports within a lat/lon bounding box around a point (cheap pre-filter for dedup).
33
 
@@ -73,6 +77,16 @@ class Repository:
73
  )
74
  return list(self.session.exec(stmt).all())
75
 
 
 
 
 
 
 
 
 
 
 
76
  def assign_cluster(self, report_ids: list[str], cluster_id: str) -> None:
77
  for rid in report_ids:
78
  r = self.session.get(HazardReport, rid)
@@ -81,6 +95,14 @@ class Repository:
81
  self.session.add(r)
82
  self.session.commit()
83
 
 
 
 
 
 
 
 
 
84
  # --- Clusters --------------------------------------------------------------
85
  def add_cluster(self, cluster: HazardCluster) -> HazardCluster:
86
  self.session.add(cluster)
@@ -202,6 +224,9 @@ class Repository:
202
  )
203
  return list(self.session.exec(stmt).all())
204
 
 
 
 
205
  def reports_over_time(self) -> list[tuple[str, int]]:
206
  """(date, report-count) grouped by calendar day, ascending."""
207
  day = func.date(HazardReport.created_at)
 
5
  """
6
  from __future__ import annotations
7
 
8
+ from datetime import datetime, timedelta, timezone
9
 
10
  from sqlalchemy import case, func
11
  from sqlmodel import Session, select
 
28
  def get_report(self, report_id: str) -> HazardReport | None:
29
  return self.session.get(HazardReport, report_id)
30
 
31
+ def get_report_by_client_event(self, client_event_id: str) -> HazardReport | None:
32
+ stmt = select(HazardReport).where(HazardReport.client_event_id == client_event_id)
33
+ return self.session.exec(stmt).first()
34
+
35
  def reports_near(self, lat: float, lon: float, radius_m: float) -> list[HazardReport]:
36
  """Reports within a lat/lon bounding box around a point (cheap pre-filter for dedup).
37
 
 
77
  )
78
  return list(self.session.exec(stmt).all())
79
 
80
+ def reports_for_batch_clustering(self, lookback_hours: int) -> list[HazardReport]:
81
+ """Active reports considered by the periodic Fleet Mode clustering pass."""
82
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=lookback_hours)
83
+ stmt = (
84
+ select(HazardReport)
85
+ .where(HazardReport.status != Status.fixed)
86
+ .where(HazardReport.created_at >= cutoff)
87
+ )
88
+ return list(self.session.exec(stmt).all())
89
+
90
  def assign_cluster(self, report_ids: list[str], cluster_id: str) -> None:
91
  for rid in report_ids:
92
  r = self.session.get(HazardReport, rid)
 
95
  self.session.add(r)
96
  self.session.commit()
97
 
98
+ def clear_cluster_assignments(self, report_ids: list[str]) -> None:
99
+ for rid in report_ids:
100
+ r = self.session.get(HazardReport, rid)
101
+ if r is not None:
102
+ r.cluster_id = None
103
+ self.session.add(r)
104
+ self.session.commit()
105
+
106
  # --- Clusters --------------------------------------------------------------
107
  def add_cluster(self, cluster: HazardCluster) -> HazardCluster:
108
  self.session.add(cluster)
 
224
  )
225
  return list(self.session.exec(stmt).all())
226
 
227
+ def count_reports_by_source(self) -> dict[str, int]:
228
+ return self._counts(HazardReport.source)
229
+
230
  def reports_over_time(self) -> list[tuple[str, int]]:
231
  """(date, report-count) grouped by calendar day, ascending."""
232
  day = func.date(HazardReport.created_at)
backend/app/db/session.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
 
4
  from collections.abc import Iterator
5
 
 
6
  from sqlmodel import Session, SQLModel, create_engine
7
 
8
  from app.config import settings
@@ -20,6 +21,63 @@ def init_db() -> None:
20
  from app.models import tables # noqa: F401
21
 
22
  SQLModel.metadata.create_all(engine)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
 
25
  def get_session() -> Iterator[Session]:
 
3
 
4
  from collections.abc import Iterator
5
 
6
+ from sqlalchemy import inspect, text
7
  from sqlmodel import Session, SQLModel, create_engine
8
 
9
  from app.config import settings
 
21
  from app.models import tables # noqa: F401
22
 
23
  SQLModel.metadata.create_all(engine)
24
+ _migrate_existing_sqlite()
25
+
26
+
27
+ def _migrate_existing_sqlite() -> None:
28
+ """Small additive migrations for the hackathon SQLite prototype.
29
+
30
+ SQLModel's `create_all` creates fresh tables but does not alter existing demo DBs. Fleet Mode
31
+ adds only nullable/defaulted columns, so simple `ALTER TABLE ADD COLUMN` statements are enough.
32
+ """
33
+ if not settings.DB_URL.startswith("sqlite"):
34
+ return
35
+
36
+ inspector = inspect(engine)
37
+ table_names = set(inspector.get_table_names())
38
+ if "hazardreport" not in table_names or "hazardcluster" not in table_names:
39
+ return
40
+
41
+ report_cols = {c["name"] for c in inspector.get_columns("hazardreport")}
42
+ cluster_cols = {c["name"] for c in inspector.get_columns("hazardcluster")}
43
+ report_additions = {
44
+ "observed_at": "DATETIME",
45
+ "device_speed": "FLOAT",
46
+ "thumbnail_path": "VARCHAR",
47
+ "bbox_area_frac": "FLOAT",
48
+ "client_event_id": "VARCHAR",
49
+ }
50
+ cluster_additions = {
51
+ "fleet_report_count": "INTEGER DEFAULT 0",
52
+ "citizen_report_count": "INTEGER DEFAULT 0",
53
+ "avg_device_speed": "FLOAT",
54
+ }
55
+
56
+ with engine.begin() as conn:
57
+ for col, ddl in report_additions.items():
58
+ if col not in report_cols:
59
+ conn.execute(text(f"ALTER TABLE hazardreport ADD COLUMN {col} {ddl}"))
60
+ for col, ddl in cluster_additions.items():
61
+ if col not in cluster_cols:
62
+ conn.execute(text(f"ALTER TABLE hazardcluster ADD COLUMN {col} {ddl}"))
63
+
64
+ # Normalize pre-Fleet rows to the new public enums.
65
+ conn.execute(text("UPDATE hazardreport SET source = 'fleet_stream' WHERE source = 'dashcam'"))
66
+ conn.execute(
67
+ text(
68
+ "UPDATE hazardreport SET hazard_type = 'crack' "
69
+ "WHERE hazard_type IN ('longitudinal_crack', 'transverse_crack', 'alligator_crack')"
70
+ )
71
+ )
72
+ conn.execute(text("UPDATE hazardreport SET hazard_type = 'construction_zone' WHERE hazard_type = 'debris'"))
73
+ conn.execute(
74
+ text(
75
+ "UPDATE hazardcluster SET hazard_type = 'crack' "
76
+ "WHERE hazard_type IN ('longitudinal_crack', 'transverse_crack', 'alligator_crack')"
77
+ )
78
+ )
79
+ conn.execute(text("UPDATE hazardcluster SET hazard_type = 'construction_zone' WHERE hazard_type = 'debris'"))
80
+ conn.execute(text("UPDATE hazardreport SET observed_at = created_at WHERE observed_at IS NULL"))
81
 
82
 
83
  def get_session() -> Iterator[Session]:
backend/app/main.py CHANGED
@@ -5,9 +5,12 @@ dedup, and dashboard endpoints fill in over later milestones.
5
  """
6
  from __future__ import annotations
7
 
8
- from contextlib import asynccontextmanager
 
 
9
 
10
  from fastapi import FastAPI
 
11
  from fastapi.middleware.cors import CORSMiddleware
12
  from fastapi.responses import FileResponse
13
  from fastapi.staticfiles import StaticFiles
@@ -16,11 +19,38 @@ from app.config import settings
16
  from app.config import BACKEND_DIR
17
  from app.db.session import init_db
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  @asynccontextmanager
21
  async def lifespan(app: FastAPI):
22
  init_db()
23
- yield
 
 
 
 
 
 
24
 
25
 
26
  app = FastAPI(title="RoadRecon API", version="0.1.0", lifespan=lifespan)
@@ -54,6 +84,13 @@ try:
54
  except Exception: # pragma: no cover - routes land in M2/M5
55
  pass
56
 
 
 
 
 
 
 
 
57
  try:
58
  from app.api import dashboard as dashboard_api
59
 
 
5
  """
6
  from __future__ import annotations
7
 
8
+ import asyncio
9
+ import logging
10
+ from contextlib import asynccontextmanager, suppress
11
 
12
  from fastapi import FastAPI
13
+ from fastapi.concurrency import run_in_threadpool
14
  from fastapi.middleware.cors import CORSMiddleware
15
  from fastapi.responses import FileResponse
16
  from fastapi.staticfiles import StaticFiles
 
19
  from app.config import BACKEND_DIR
20
  from app.db.session import init_db
21
 
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _run_fleet_batch_cluster() -> None:
26
+ from sqlmodel import Session
27
+
28
+ from app.db.session import engine
29
+ from app.pipeline.dedup import batch_recluster_fleet
30
+
31
+ with Session(engine) as session:
32
+ batch_recluster_fleet(session)
33
+
34
+
35
+ async def _fleet_cluster_loop() -> None:
36
+ while True:
37
+ await asyncio.sleep(settings.FLEET_CLUSTER_INTERVAL_SECONDS)
38
+ try:
39
+ await run_in_threadpool(_run_fleet_batch_cluster)
40
+ except Exception:
41
+ logger.exception("Fleet clustering batch failed")
42
+
43
 
44
  @asynccontextmanager
45
  async def lifespan(app: FastAPI):
46
  init_db()
47
+ fleet_task = asyncio.create_task(_fleet_cluster_loop())
48
+ try:
49
+ yield
50
+ finally:
51
+ fleet_task.cancel()
52
+ with suppress(asyncio.CancelledError):
53
+ await fleet_task
54
 
55
 
56
  app = FastAPI(title="RoadRecon API", version="0.1.0", lifespan=lifespan)
 
84
  except Exception: # pragma: no cover - routes land in M2/M5
85
  pass
86
 
87
+ try:
88
+ from app.api import fleet as fleet_api
89
+
90
+ app.include_router(fleet_api.router)
91
+ except Exception: # pragma: no cover - routes land in Fleet Mode
92
+ pass
93
+
94
  try:
95
  from app.api import dashboard as dashboard_api
96
 
backend/app/models/schemas.py CHANGED
@@ -4,15 +4,15 @@ from __future__ import annotations
4
  from datetime import datetime
5
  from enum import Enum
6
 
7
- from pydantic import BaseModel
8
 
9
 
10
  class HazardType(str, Enum):
11
  pothole = "pothole"
12
- longitudinal_crack = "longitudinal_crack"
13
- transverse_crack = "transverse_crack"
14
- alligator_crack = "alligator_crack"
15
- debris = "debris"
16
  other = "other"
17
 
18
 
@@ -24,7 +24,7 @@ class Severity(str, Enum):
24
 
25
  class Source(str, Enum):
26
  citizen = "citizen"
27
- dashcam = "dashcam"
28
 
29
 
30
  class GeoSource(str, Enum):
@@ -80,6 +80,9 @@ class ClusterOut(BaseModel):
80
  last_seen: datetime
81
  status: Status
82
  resolved_at: datetime | None = None
 
 
 
83
 
84
 
85
  class ReportOut(BaseModel):
@@ -87,6 +90,7 @@ class ReportOut(BaseModel):
87
 
88
  id: str
89
  created_at: datetime
 
90
  source: Source
91
  hazard_type: HazardType
92
  severity: Severity
@@ -95,7 +99,11 @@ class ReportOut(BaseModel):
95
  lon: float
96
  geo_source: GeoSource
97
  media_path: str
 
98
  bbox: list[float]
 
 
 
99
  status: Status
100
  auto_validated: bool
101
  cluster_id: str | None
@@ -132,3 +140,24 @@ class DashboardSummary(BaseModel):
132
  by_status: dict[str, int]
133
  reports_over_time: list[TimePoint]
134
  top_priority: list[ClusterOut]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  from datetime import datetime
5
  from enum import Enum
6
 
7
+ from pydantic import BaseModel, Field
8
 
9
 
10
  class HazardType(str, Enum):
11
  pothole = "pothole"
12
+ crack = "crack"
13
+ water_logging = "water_logging"
14
+ construction_zone = "construction_zone"
15
+ traffic_block = "traffic_block"
16
  other = "other"
17
 
18
 
 
24
 
25
  class Source(str, Enum):
26
  citizen = "citizen"
27
+ fleet_stream = "fleet_stream"
28
 
29
 
30
  class GeoSource(str, Enum):
 
80
  last_seen: datetime
81
  status: Status
82
  resolved_at: datetime | None = None
83
+ fleet_report_count: int = 0
84
+ citizen_report_count: int = 0
85
+ avg_device_speed: float | None = None
86
 
87
 
88
  class ReportOut(BaseModel):
 
90
 
91
  id: str
92
  created_at: datetime
93
+ observed_at: datetime | None = None
94
  source: Source
95
  hazard_type: HazardType
96
  severity: Severity
 
99
  lon: float
100
  geo_source: GeoSource
101
  media_path: str
102
+ thumbnail_path: str | None = None
103
  bbox: list[float]
104
+ bbox_area_frac: float | None = None
105
+ device_speed: float | None = None
106
+ client_event_id: str | None = None
107
  status: Status
108
  auto_validated: bool
109
  cluster_id: str | None
 
140
  by_status: dict[str, int]
141
  reports_over_time: list[TimePoint]
142
  top_priority: list[ClusterOut]
143
+
144
+
145
+ class FleetTelemetryIn(BaseModel):
146
+ """Telemetry-only payload emitted by an on-device Fleet Mode edge node."""
147
+
148
+ lat: float = Field(ge=-90, le=90)
149
+ lon: float = Field(ge=-180, le=180)
150
+ hazard_type: HazardType
151
+ confidence: float = Field(ge=0, le=1)
152
+ timestamp: datetime
153
+ speed: float | None = Field(default=None, ge=0)
154
+ bbox: list[float] | None = None
155
+ bbox_area_frac: float | None = Field(default=None, ge=0, le=1)
156
+ thumbnail_b64: str | None = None
157
+ client_event_id: str | None = Field(default=None, min_length=8, max_length=128)
158
+
159
+
160
+ class FleetTelemetryResponse(BaseModel):
161
+ status: str
162
+ report_id: str
163
+ queued_for_cluster: bool
backend/app/models/tables.py CHANGED
@@ -24,6 +24,7 @@ class HazardReport(SQLModel, table=True):
24
 
25
  id: str = Field(default_factory=_uuid, primary_key=True)
26
  created_at: datetime = Field(default_factory=_now, index=True)
 
27
  source: Source = Field(index=True)
28
  hazard_type: HazardType = Field(index=True)
29
  severity: Severity = Field(index=True)
@@ -31,8 +32,12 @@ class HazardReport(SQLModel, table=True):
31
  lat: float = Field(index=True)
32
  lon: float = Field(index=True)
33
  geo_source: GeoSource
34
- media_path: str # path to the ANONYMIZED image (never the raw one)
 
35
  bbox: list[float] = Field(sa_column=Column(JSON)) # [x1, y1, x2, y2]
 
 
 
36
  status: Status = Field(default=Status.reported, index=True)
37
  # True when CV detected the hazard; False for citizen "report anyway" (awaits officer review).
38
  auto_validated: bool = Field(default=True, index=True)
@@ -52,6 +57,9 @@ class HazardCluster(SQLModel, table=True):
52
  last_seen: datetime = Field(default_factory=_now)
53
  status: Status = Field(default=Status.reported, index=True) # rolled up from members
54
  resolved_at: datetime | None = Field(default=None) # set when status -> fixed (time-to-repair)
 
 
 
55
 
56
 
57
  class StatusEvent(SQLModel, table=True):
 
24
 
25
  id: str = Field(default_factory=_uuid, primary_key=True)
26
  created_at: datetime = Field(default_factory=_now, index=True)
27
+ observed_at: datetime | None = Field(default_factory=_now, index=True)
28
  source: Source = Field(index=True)
29
  hazard_type: HazardType = Field(index=True)
30
  severity: Severity = Field(index=True)
 
32
  lat: float = Field(index=True)
33
  lon: float = Field(index=True)
34
  geo_source: GeoSource
35
+ media_path: str = "" # path to anonymized citizen media or a privacy-safe fleet thumbnail
36
+ thumbnail_path: str | None = None
37
  bbox: list[float] = Field(sa_column=Column(JSON)) # [x1, y1, x2, y2]
38
+ bbox_area_frac: float | None = None
39
+ device_speed: float | None = Field(default=None, index=True)
40
+ client_event_id: str | None = Field(default=None, index=True)
41
  status: Status = Field(default=Status.reported, index=True)
42
  # True when CV detected the hazard; False for citizen "report anyway" (awaits officer review).
43
  auto_validated: bool = Field(default=True, index=True)
 
57
  last_seen: datetime = Field(default_factory=_now)
58
  status: Status = Field(default=Status.reported, index=True) # rolled up from members
59
  resolved_at: datetime | None = Field(default=None) # set when status -> fixed (time-to-repair)
60
+ fleet_report_count: int = Field(default=0, index=True)
61
+ citizen_report_count: int = Field(default=0, index=True)
62
+ avg_device_speed: float | None = None
63
 
64
 
65
  class StatusEvent(SQLModel, table=True):
backend/app/pipeline/dedup.py CHANGED
@@ -9,8 +9,7 @@ bounding box around that point, then upsert the affected clusters.
9
  """
10
  from __future__ import annotations
11
 
12
- import math
13
- from collections import Counter
14
 
15
  import numpy as np
16
  from sklearn.cluster import DBSCAN
@@ -18,7 +17,7 @@ from sqlmodel import Session
18
 
19
  from app.config import settings
20
  from app.db.repository import Repository
21
- from app.models.schemas import HazardType, Severity, Status
22
  from app.models.tables import HazardCluster, HazardReport
23
 
24
  _EARTH_RADIUS_M = 6_371_000.0
@@ -45,6 +44,114 @@ def _rollup_status(reports: list[HazardReport]) -> Status:
45
  return max(statuses, key=lambda s: _STATUS_RANK[s])
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  def recluster_near(lat: float, lon: float, session: Session, radius_m: float | None = None) -> list[HazardCluster]:
49
  """Re-cluster all reports near (lat, lon) and upsert their HazardCluster rows.
50
 
@@ -56,61 +163,26 @@ def recluster_near(lat: float, lon: float, session: Session, radius_m: float | N
56
  if not reports:
57
  return []
58
 
59
- coords = np.radians([[r.lat, r.lon] for r in reports])
60
- eps_rad = settings.DBSCAN_EPS_M / _EARTH_RADIUS_M
61
- labels = DBSCAN(
62
- eps=eps_rad,
63
- min_samples=settings.DBSCAN_MIN_SAMPLES,
64
- metric="haversine",
65
- ).fit_predict(coords)
66
 
67
- # Clear stale cluster rows for any cluster these reports previously belonged to, then rebuild.
68
- old_cluster_ids = {r.cluster_id for r in reports if r.cluster_id}
69
 
70
- clusters: list[HazardCluster] = []
71
- groups: dict[int, list[HazardReport]] = {}
72
- for label, report in zip(labels, reports):
73
- groups.setdefault(int(label), []).append(report)
74
-
75
- for members in groups.values():
76
- lats = [m.lat for m in members]
77
- lons = [m.lon for m in members]
78
- # Reuse an existing cluster id among the members if present (stable ids across re-clusters).
79
- existing_id = next((m.cluster_id for m in members if m.cluster_id), None)
80
- first_seen = min(m.created_at for m in members)
81
- last_seen = max(m.created_at for m in members)
82
- cluster = repo.get_cluster(existing_id) if existing_id else None
83
- if cluster is None:
84
- cluster = HazardCluster(
85
- centroid_lat=float(np.mean(lats)),
86
- centroid_lon=float(np.mean(lons)),
87
- hazard_type=_majority_type(members),
88
- severity=_max_severity(members),
89
- report_count=len(members),
90
- first_seen=first_seen,
91
- last_seen=last_seen,
92
- status=_rollup_status(members),
93
- )
94
- if existing_id:
95
- cluster.id = existing_id
96
- cluster = repo.add_cluster(cluster)
97
- else:
98
- cluster.centroid_lat = float(np.mean(lats))
99
- cluster.centroid_lon = float(np.mean(lons))
100
- cluster.hazard_type = _majority_type(members)
101
- cluster.severity = _max_severity(members)
102
- cluster.report_count = len(members)
103
- cluster.first_seen = first_seen
104
- cluster.last_seen = last_seen
105
- cluster.status = _rollup_status(members)
106
- cluster = repo.save_cluster(cluster)
107
- repo.assign_cluster([m.id for m in members], cluster.id)
108
- clusters.append(cluster)
109
-
110
- # Drop any old cluster rows that no longer have members (e.g. merged away).
111
- live_ids = {c.id for c in clusters}
112
- for cid in old_cluster_ids - live_ids:
113
- if not repo.reports_in_cluster(cid):
114
- repo.delete_cluster(cid)
115
 
 
 
 
 
 
 
 
 
 
 
 
116
  return clusters
 
9
  """
10
  from __future__ import annotations
11
 
12
+ from collections import Counter, defaultdict
 
13
 
14
  import numpy as np
15
  from sklearn.cluster import DBSCAN
 
17
 
18
  from app.config import settings
19
  from app.db.repository import Repository
20
+ from app.models.schemas import HazardType, Severity, Source, Status
21
  from app.models.tables import HazardCluster, HazardReport
22
 
23
  _EARTH_RADIUS_M = 6_371_000.0
 
44
  return max(statuses, key=lambda s: _STATUS_RANK[s])
45
 
46
 
47
+ def _source_value(source: Source | str) -> str:
48
+ return source.value if hasattr(source, "value") else str(source)
49
+
50
+
51
+ def _source_counts(reports: list[HazardReport]) -> tuple[int, int]:
52
+ fleet = sum(1 for r in reports if _source_value(r.source) == Source.fleet_stream.value)
53
+ citizen = sum(1 for r in reports if _source_value(r.source) == Source.citizen.value)
54
+ return fleet, citizen
55
+
56
+
57
+ def _avg_device_speed(reports: list[HazardReport]) -> float | None:
58
+ speeds = [r.device_speed for r in reports if r.device_speed is not None]
59
+ return round(float(np.mean(speeds)), 2) if speeds else None
60
+
61
+
62
+ def _robust_centroid(reports: list[HazardReport]) -> tuple[float, float]:
63
+ """Median centroid resists GPS wobble from moving bikes better than a raw mean."""
64
+ return float(np.median([r.lat for r in reports])), float(np.median([r.lon for r in reports]))
65
+
66
+
67
+ def _upsert_cluster(repo: Repository, members: list[HazardReport]) -> HazardCluster:
68
+ lat, lon = _robust_centroid(members)
69
+ fleet_count, citizen_count = _source_counts(members)
70
+ existing_id = next((m.cluster_id for m in members if m.cluster_id), None)
71
+ first_seen = min((m.observed_at or m.created_at) for m in members)
72
+ last_seen = max((m.observed_at or m.created_at) for m in members)
73
+ cluster = repo.get_cluster(existing_id) if existing_id else None
74
+
75
+ if cluster is None:
76
+ cluster = HazardCluster(
77
+ centroid_lat=lat,
78
+ centroid_lon=lon,
79
+ hazard_type=_majority_type(members),
80
+ severity=_max_severity(members),
81
+ report_count=len(members),
82
+ first_seen=first_seen,
83
+ last_seen=last_seen,
84
+ status=_rollup_status(members),
85
+ fleet_report_count=fleet_count,
86
+ citizen_report_count=citizen_count,
87
+ avg_device_speed=_avg_device_speed(members),
88
+ )
89
+ if existing_id:
90
+ cluster.id = existing_id
91
+ return repo.add_cluster(cluster)
92
+
93
+ cluster.centroid_lat = lat
94
+ cluster.centroid_lon = lon
95
+ cluster.hazard_type = _majority_type(members)
96
+ cluster.severity = _max_severity(members)
97
+ cluster.report_count = len(members)
98
+ cluster.first_seen = first_seen
99
+ cluster.last_seen = last_seen
100
+ cluster.status = _rollup_status(members)
101
+ cluster.fleet_report_count = fleet_count
102
+ cluster.citizen_report_count = citizen_count
103
+ cluster.avg_device_speed = _avg_device_speed(members)
104
+ return repo.save_cluster(cluster)
105
+
106
+
107
+ def _cluster_reports_by_type(
108
+ reports: list[HazardReport],
109
+ session: Session,
110
+ eps_m: float,
111
+ fleet_min_samples: int,
112
+ ) -> list[HazardCluster]:
113
+ """Cluster reports per hazard class and keep fleet-only singletons unclustered."""
114
+ if not reports:
115
+ return []
116
+
117
+ repo = Repository(session)
118
+ old_cluster_ids = {r.cluster_id for r in reports if r.cluster_id}
119
+ live_report_ids: set[str] = set()
120
+ clusters: list[HazardCluster] = []
121
+
122
+ reports_by_type: dict[HazardType, list[HazardReport]] = defaultdict(list)
123
+ for report in reports:
124
+ reports_by_type[report.hazard_type].append(report)
125
+
126
+ eps_rad = eps_m / _EARTH_RADIUS_M
127
+ for type_reports in reports_by_type.values():
128
+ coords = np.radians([[r.lat, r.lon] for r in type_reports])
129
+ labels = DBSCAN(eps=eps_rad, min_samples=1, metric="haversine").fit_predict(coords)
130
+ groups: dict[int, list[HazardReport]] = {}
131
+ for label, report in zip(labels, type_reports):
132
+ groups.setdefault(int(label), []).append(report)
133
+
134
+ for members in groups.values():
135
+ fleet_count, citizen_count = _source_counts(members)
136
+ if citizen_count == 0 and fleet_count < fleet_min_samples:
137
+ continue
138
+ cluster = _upsert_cluster(repo, members)
139
+ repo.assign_cluster([m.id for m in members], cluster.id)
140
+ live_report_ids.update(m.id for m in members)
141
+ clusters.append(cluster)
142
+
143
+ skipped_report_ids = [r.id for r in reports if r.id not in live_report_ids and r.cluster_id]
144
+ if skipped_report_ids:
145
+ repo.clear_cluster_assignments(skipped_report_ids)
146
+
147
+ live_ids = {c.id for c in clusters}
148
+ for cid in old_cluster_ids - live_ids:
149
+ if not repo.reports_in_cluster(cid):
150
+ repo.delete_cluster(cid)
151
+
152
+ return clusters
153
+
154
+
155
  def recluster_near(lat: float, lon: float, session: Session, radius_m: float | None = None) -> list[HazardCluster]:
156
  """Re-cluster all reports near (lat, lon) and upsert their HazardCluster rows.
157
 
 
163
  if not reports:
164
  return []
165
 
166
+ return _cluster_reports_by_type(
167
+ reports,
168
+ session,
169
+ eps_m=settings.DBSCAN_EPS_M,
170
+ fleet_min_samples=1,
171
+ )
 
172
 
 
 
173
 
174
+ def batch_recluster_fleet(session: Session) -> list[HazardCluster]:
175
+ """Periodic clustering pass for high-frequency Fleet Mode telemetry.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
+ Fleet-only observations need at least two nearby passes before they become authority-facing
178
+ clusters. Citizen reports still form single-report clusters so the manual demo remains instant.
179
+ """
180
+ repo = Repository(session)
181
+ reports = repo.reports_for_batch_clustering(settings.FLEET_CLUSTER_LOOKBACK_HOURS)
182
+ clusters = _cluster_reports_by_type(
183
+ reports,
184
+ session,
185
+ eps_m=settings.FLEET_CLUSTER_EPS_M,
186
+ fleet_min_samples=max(2, settings.DBSCAN_MIN_SAMPLES),
187
+ )
188
  return clusters
backend/app/pipeline/detect.py CHANGED
@@ -20,14 +20,22 @@ logger = logging.getLogger(__name__)
20
  _CLASS_TO_HAZARD = {
21
  "pothole": HazardType.pothole,
22
  "potholes": HazardType.pothole,
23
- "d00": HazardType.longitudinal_crack, # RDD2022 codes (Option B forward-compat)
24
- "d10": HazardType.transverse_crack,
25
- "d20": HazardType.alligator_crack,
26
  "d40": HazardType.pothole,
27
- "longitudinal_crack": HazardType.longitudinal_crack,
28
- "transverse_crack": HazardType.transverse_crack,
29
- "alligator_crack": HazardType.alligator_crack,
30
- "debris": HazardType.debris,
 
 
 
 
 
 
 
 
31
  }
32
 
33
 
 
20
  _CLASS_TO_HAZARD = {
21
  "pothole": HazardType.pothole,
22
  "potholes": HazardType.pothole,
23
+ "d00": HazardType.crack, # RDD2022 codes (Option B forward-compat)
24
+ "d10": HazardType.crack,
25
+ "d20": HazardType.crack,
26
  "d40": HazardType.pothole,
27
+ "crack": HazardType.crack,
28
+ "longitudinal_crack": HazardType.crack,
29
+ "transverse_crack": HazardType.crack,
30
+ "alligator_crack": HazardType.crack,
31
+ "water_logging": HazardType.water_logging,
32
+ "waterlogging": HazardType.water_logging,
33
+ "flooded_road": HazardType.water_logging,
34
+ "construction_zone": HazardType.construction_zone,
35
+ "construction": HazardType.construction_zone,
36
+ "roadwork": HazardType.construction_zone,
37
+ "traffic_block": HazardType.traffic_block,
38
+ "traffic_jam": HazardType.traffic_block,
39
  }
40
 
41
 
backend/seed.py CHANGED
@@ -37,8 +37,8 @@ PUNE_SPOTS = [
37
 
38
  TYPES = [
39
  HazardType.pothole, HazardType.pothole, HazardType.pothole, # weight potholes higher
40
- HazardType.alligator_crack, HazardType.longitudinal_crack,
41
- HazardType.transverse_crack, HazardType.debris,
42
  ]
43
  SEVERITIES = [Severity.low, Severity.medium, Severity.high]
44
 
@@ -88,7 +88,7 @@ def seed(reset: bool = True, seed_value: int = 42) -> None:
88
  created = now - first_offset + timedelta(hours=rng.uniform(0, 24) * k / max(1, n_passes))
89
  session.add(
90
  HazardReport(
91
- source=rng.choice([Source.citizen, Source.dashcam]),
92
  hazard_type=HazardType.other if review_item else htype,
93
  severity=Severity.medium if review_item else sev,
94
  confidence=0.0 if review_item else round(rng.uniform(0.4, 0.95), 2),
@@ -100,6 +100,8 @@ def seed(reset: bool = True, seed_value: int = 42) -> None:
100
  status=Status.pending_review if review_item else Status.reported,
101
  auto_validated=not review_item,
102
  created_at=created,
 
 
103
  )
104
  )
105
  total_reports += 1
 
37
 
38
  TYPES = [
39
  HazardType.pothole, HazardType.pothole, HazardType.pothole, # weight potholes higher
40
+ HazardType.crack, HazardType.crack,
41
+ HazardType.water_logging, HazardType.construction_zone, HazardType.traffic_block,
42
  ]
43
  SEVERITIES = [Severity.low, Severity.medium, Severity.high]
44
 
 
88
  created = now - first_offset + timedelta(hours=rng.uniform(0, 24) * k / max(1, n_passes))
89
  session.add(
90
  HazardReport(
91
+ source=rng.choice([Source.citizen, Source.fleet_stream]),
92
  hazard_type=HazardType.other if review_item else htype,
93
  severity=Severity.medium if review_item else sev,
94
  confidence=0.0 if review_item else round(rng.uniform(0.4, 0.95), 2),
 
100
  status=Status.pending_review if review_item else Status.reported,
101
  auto_validated=not review_item,
102
  created_at=created,
103
+ observed_at=created,
104
+ device_speed=round(rng.uniform(0.0, 11.5), 1) if htype == HazardType.traffic_block else None,
105
  )
106
  )
107
  total_reports += 1
backend/tests/test_dashboard.py CHANGED
@@ -18,7 +18,7 @@ def _seed_two_passes(c):
18
  raw = f.read()
19
  for la, lo in [(18.5204, 73.8567), (18.52047, 73.85676)]:
20
  c.post("/report", files={"file": ("p.jpg", raw, "image/jpeg")},
21
- data={"source": "dashcam", "device_lat": str(la), "device_lon": str(lo), "annotate": "false"})
22
 
23
 
24
  @needs_model
 
18
  raw = f.read()
19
  for la, lo in [(18.5204, 73.8567), (18.52047, 73.85676)]:
20
  c.post("/report", files={"file": ("p.jpg", raw, "image/jpeg")},
21
+ data={"source": "fleet_stream", "device_lat": str(la), "device_lon": str(lo), "annotate": "false"})
22
 
23
 
24
  @needs_model
backend/tests/test_dedup.py CHANGED
@@ -51,19 +51,19 @@ def test_far_reports_stay_separate(tmp_path):
51
  assert all(c.report_count == 1 for c in clusters)
52
 
53
 
54
- def test_cluster_severity_is_max_and_type_majority(tmp_path):
55
  eng = _engine(tmp_path)
56
  with Session(eng) as s:
57
  s.add(_report(*PT, sev=Severity.low, htype=HazardType.pothole))
58
  s.add(_report(*PT, sev=Severity.high, htype=HazardType.pothole))
59
- s.add(_report(*NEAR, sev=Severity.medium, htype=HazardType.debris))
60
  s.commit()
61
  clusters = recluster_near(*PT, s)
62
- assert len(clusters) == 1
63
- c = clusters[0]
64
  assert c.severity == Severity.high # max severity
65
- assert c.hazard_type == HazardType.pothole # majority vote (2 pothole vs 1 debris)
66
- assert c.report_count == 3
67
 
68
 
69
  def test_status_rolls_up_to_active(tmp_path):
 
51
  assert all(c.report_count == 1 for c in clusters)
52
 
53
 
54
+ def test_cluster_severity_is_max_and_types_do_not_merge(tmp_path):
55
  eng = _engine(tmp_path)
56
  with Session(eng) as s:
57
  s.add(_report(*PT, sev=Severity.low, htype=HazardType.pothole))
58
  s.add(_report(*PT, sev=Severity.high, htype=HazardType.pothole))
59
+ s.add(_report(*NEAR, sev=Severity.medium, htype=HazardType.crack))
60
  s.commit()
61
  clusters = recluster_near(*PT, s)
62
+ assert len(clusters) == 2
63
+ c = next(c for c in clusters if c.hazard_type == HazardType.pothole)
64
  assert c.severity == Severity.high # max severity
65
+ assert c.report_count == 2
66
+ assert any(c.hazard_type == HazardType.crack and c.report_count == 1 for c in clusters)
67
 
68
 
69
  def test_status_rolls_up_to_active(tmp_path):
backend/tests/test_fleet.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fleet Mode telemetry ingestion and batch clustering tests."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import asyncio
6
+ from datetime import datetime, timedelta, timezone
7
+
8
+ import httpx
9
+ from PIL import Image
10
+ from sqlmodel import Session, SQLModel, create_engine, select
11
+
12
+ from app.db.session import get_session
13
+ from app.main import app
14
+ from app.models.schemas import GeoSource, HazardType, Source
15
+ from app.models.tables import HazardReport
16
+ from app.pipeline.dedup import batch_recluster_fleet
17
+
18
+
19
+ def _payload(**overrides):
20
+ data = {
21
+ "lat": 18.5204,
22
+ "lon": 73.8567,
23
+ "hazard_type": "pothole",
24
+ "confidence": 0.82,
25
+ "timestamp": datetime.now(timezone.utc).isoformat(),
26
+ "speed": 4.2,
27
+ "bbox": [10, 20, 120, 150],
28
+ "bbox_area_frac": 0.12,
29
+ "client_event_id": "evt-00000001",
30
+ }
31
+ data.update(overrides)
32
+ return data
33
+
34
+
35
+ def _tiny_thumbnail_b64() -> str:
36
+ import io
37
+
38
+ img = Image.new("RGB", (24, 24), (80, 120, 160))
39
+ out = io.BytesIO()
40
+ img.save(out, format="PNG")
41
+ return base64.b64encode(out.getvalue()).decode("ascii")
42
+
43
+
44
+ def _engine(tmp_path):
45
+ eng = create_engine(f"sqlite:///{tmp_path/'fleet.db'}", connect_args={"check_same_thread": False})
46
+ SQLModel.metadata.create_all(eng)
47
+ return eng
48
+
49
+
50
+ async def _post(engine, payload):
51
+ async def _override():
52
+ with Session(engine) as s:
53
+ yield s
54
+
55
+ app.dependency_overrides[get_session] = _override
56
+ try:
57
+ transport = httpx.ASGITransport(app=app)
58
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
59
+ return await c.post("/fleet/telemetry", json=payload)
60
+ finally:
61
+ app.dependency_overrides.clear()
62
+
63
+
64
+ def post(engine, payload):
65
+ return asyncio.run(_post(engine, payload))
66
+
67
+
68
+ def test_fleet_telemetry_accepts_minimal_payload(tmp_path):
69
+ engine = _engine(tmp_path)
70
+ r = post(engine, _payload())
71
+ assert r.status_code == 200, r.text
72
+ j = r.json()
73
+ assert j["status"] == "accepted"
74
+ assert j["report_id"]
75
+ assert j["queued_for_cluster"] is True
76
+
77
+ with Session(engine) as s:
78
+ report = s.get(HazardReport, j["report_id"])
79
+ assert report is not None
80
+ assert report.source == Source.fleet_stream
81
+ assert report.geo_source == GeoSource.device
82
+ assert report.device_speed == 4.2
83
+ assert report.hazard_type == HazardType.pothole
84
+
85
+
86
+ def test_fleet_telemetry_dedupes_client_event_id(tmp_path):
87
+ engine = _engine(tmp_path)
88
+ payload = _payload(client_event_id="evt-same-0001")
89
+ first = post(engine, payload).json()
90
+ second = post(engine, payload).json()
91
+ assert first["report_id"] == second["report_id"]
92
+ assert second["queued_for_cluster"] is False
93
+
94
+
95
+ def test_fleet_telemetry_stores_sanitized_thumbnail(tmp_path):
96
+ engine = _engine(tmp_path)
97
+ r = post(engine, _payload(thumbnail_b64=_tiny_thumbnail_b64()))
98
+ assert r.status_code == 200, r.text
99
+ with Session(engine) as s:
100
+ report = s.get(HazardReport, r.json()["report_id"])
101
+ assert report.thumbnail_path
102
+ assert report.media_path == report.thumbnail_path
103
+ assert report.thumbnail_path.startswith("media/fleet/")
104
+
105
+
106
+ def test_fleet_telemetry_rejects_invalid_payloads(tmp_path):
107
+ engine = _engine(tmp_path)
108
+ assert post(engine, _payload(lat=100)).status_code == 422
109
+ assert post(engine, _payload(confidence=0.1)).status_code == 422
110
+ stale = datetime.now(timezone.utc) - timedelta(days=3)
111
+ assert post(engine, _payload(timestamp=stale.isoformat())).status_code == 422
112
+ assert post(engine, _payload(hazard_type="other")).status_code == 422
113
+ assert post(engine, _payload(thumbnail_b64="not-base64")).status_code == 422
114
+
115
+
116
+ def test_batch_clustering_collapses_noisy_fleet_reports_by_type(tmp_path):
117
+ engine = _engine(tmp_path)
118
+ post(engine, _payload(client_event_id="evt-pothole-1"))
119
+ post(engine, _payload(lat=18.52048, lon=73.85675, client_event_id="evt-pothole-2"))
120
+ post(engine, _payload(hazard_type="crack", client_event_id="evt-crack-1"))
121
+ post(engine, _payload(hazard_type="crack", lat=18.52047, lon=73.85676, client_event_id="evt-crack-2"))
122
+
123
+ with Session(engine) as s:
124
+ clusters = batch_recluster_fleet(s)
125
+ reports = list(s.exec(select(HazardReport)).all())
126
+
127
+ assert len(clusters) == 2
128
+ assert sorted(c.hazard_type for c in clusters) == [HazardType.crack, HazardType.pothole]
129
+ assert all(c.report_count == 2 for c in clusters)
130
+ assert all(c.fleet_report_count == 2 for c in clusters)
131
+ assert all(r.cluster_id for r in reports)
backend/tests/test_privacy_gate.py CHANGED
@@ -81,7 +81,7 @@ def test_persisted_media_is_anonymized(client, tmp_path):
81
  raw_bytes = enc.tobytes()
82
 
83
  r = c.post("/report", files={"file": ("c.jpg", raw_bytes, "image/jpeg")},
84
- data={"source": "dashcam", "device_lat": "18.5", "device_lon": "73.8"})
85
  j = r.json()
86
  assert j["status"] == "ok", j
87
 
 
81
  raw_bytes = enc.tobytes()
82
 
83
  r = c.post("/report", files={"file": ("c.jpg", raw_bytes, "image/jpeg")},
84
+ data={"source": "fleet_stream", "device_lat": "18.5", "device_lon": "73.8"})
85
  j = r.json()
86
  assert j["status"] == "ok", j
87
 
backend/tests/test_review_history.py CHANGED
@@ -89,7 +89,7 @@ def test_resolution_history_and_time_to_repair(client):
89
  with open(POTHOLE, "rb") as f:
90
  raw = f.read()
91
  c.post("/report", files={"file": ("p.jpg", raw, "image/jpeg")},
92
- data={"source": "dashcam", "device_lat": "18.5", "device_lon": "73.8", "annotate": "false"})
93
  cid = c.get("/hazards", params={"bbox": "18,73,19,74.5"}).json()[0]["id"]
94
 
95
  c.patch(f"/hazards/{cid}/status", json={"status": "acknowledged"})
 
89
  with open(POTHOLE, "rb") as f:
90
  raw = f.read()
91
  c.post("/report", files={"file": ("p.jpg", raw, "image/jpeg")},
92
+ data={"source": "fleet_stream", "device_lat": "18.5", "device_lon": "73.8", "annotate": "false"})
93
  cid = c.get("/hazards", params={"bbox": "18,73,19,74.5"}).json()[0]["id"]
94
 
95
  c.patch(f"/hazards/{cid}/status", json={"status": "acknowledged"})
backend/tests/test_video.py CHANGED
@@ -61,7 +61,7 @@ def test_video_pipeline_samples_and_persists(tmp_path):
61
  eng = create_engine(f"sqlite:///{tmp_path/'v.db'}", connect_args={"check_same_thread": False})
62
  SQLModel.metadata.create_all(eng)
63
  with Session(eng) as s:
64
- res = process_upload(raw, Source.dashcam, s, device_lat=18.52, device_lon=73.85,
65
  content_type="video/mp4", filename="clip.mp4")
66
  assert res.status == "ok"
67
  assert len(res.reports) >= 1
 
61
  eng = create_engine(f"sqlite:///{tmp_path/'v.db'}", connect_args={"check_same_thread": False})
62
  SQLModel.metadata.create_all(eng)
63
  with Session(eng) as s:
64
+ res = process_upload(raw, Source.fleet_stream, s, device_lat=18.52, device_lon=73.85,
65
  content_type="video/mp4", filename="clip.mp4")
66
  assert res.status == "ok"
67
  assert len(res.reports) >= 1
frontend/package-lock.json CHANGED
@@ -10,6 +10,7 @@
10
  "dependencies": {
11
  "leaflet": "^1.9.4",
12
  "lucide-react": "^1.18.0",
 
13
  "react": "^19.2.6",
14
  "react-dom": "^19.2.6",
15
  "react-leaflet": "^5.0.0",
@@ -80,6 +81,7 @@
80
  "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
81
  "dev": true,
82
  "license": "MIT",
 
83
  "dependencies": {
84
  "@babel/code-frame": "^7.29.7",
85
  "@babel/generator": "^7.29.7",
@@ -289,29 +291,6 @@
289
  "node": ">=6.9.0"
290
  }
291
  },
292
- "node_modules/@emnapi/core": {
293
- "version": "1.10.0",
294
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
295
- "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
296
- "dev": true,
297
- "license": "MIT",
298
- "optional": true,
299
- "dependencies": {
300
- "@emnapi/wasi-threads": "1.2.1",
301
- "tslib": "^2.4.0"
302
- }
303
- },
304
- "node_modules/@emnapi/runtime": {
305
- "version": "1.10.0",
306
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
307
- "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
308
- "dev": true,
309
- "license": "MIT",
310
- "optional": true,
311
- "dependencies": {
312
- "tslib": "^2.4.0"
313
- }
314
- },
315
  "node_modules/@emnapi/wasi-threads": {
316
  "version": "1.2.1",
317
  "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -634,6 +613,63 @@
634
  "url": "https://github.com/sponsors/Boshen"
635
  }
636
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
637
  "node_modules/@react-leaflet/core": {
638
  "version": "3.0.0",
639
  "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
@@ -1073,7 +1109,6 @@
1073
  "version": "24.13.2",
1074
  "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
1075
  "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
1076
- "dev": true,
1077
  "license": "MIT",
1078
  "dependencies": {
1079
  "undici-types": "~7.18.0"
@@ -1085,6 +1120,7 @@
1085
  "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
1086
  "devOptional": true,
1087
  "license": "MIT",
 
1088
  "dependencies": {
1089
  "csstype": "^3.2.2"
1090
  }
@@ -1150,6 +1186,7 @@
1150
  "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
1151
  "dev": true,
1152
  "license": "MIT",
 
1153
  "dependencies": {
1154
  "@typescript-eslint/scope-manager": "8.61.1",
1155
  "@typescript-eslint/types": "8.61.1",
@@ -1380,6 +1417,7 @@
1380
  "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
1381
  "dev": true,
1382
  "license": "MIT",
 
1383
  "bin": {
1384
  "acorn": "bin/acorn"
1385
  },
@@ -1574,6 +1612,7 @@
1574
  }
1575
  ],
1576
  "license": "MIT",
 
1577
  "dependencies": {
1578
  "baseline-browser-mapping": "^2.10.12",
1579
  "caniuse-lite": "^1.0.30001782",
@@ -1963,6 +2002,7 @@
1963
  "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==",
1964
  "dev": true,
1965
  "license": "MIT",
 
1966
  "workspaces": [
1967
  "packages/*"
1968
  ],
@@ -2284,6 +2324,12 @@
2284
  "node": ">=16"
2285
  }
2286
  },
 
 
 
 
 
 
2287
  "node_modules/flatted": {
2288
  "version": "3.4.2",
2289
  "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
@@ -2366,6 +2412,12 @@
2366
  "url": "https://github.com/sponsors/sindresorhus"
2367
  }
2368
  },
 
 
 
 
 
 
2369
  "node_modules/hasown": {
2370
  "version": "2.0.4",
2371
  "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -2510,6 +2562,7 @@
2510
  "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
2511
  "dev": true,
2512
  "license": "MIT",
 
2513
  "bin": {
2514
  "jiti": "bin/jiti.js"
2515
  }
@@ -2582,7 +2635,8 @@
2582
  "version": "1.9.4",
2583
  "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
2584
  "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
2585
- "license": "BSD-2-Clause"
 
2586
  },
2587
  "node_modules/levn": {
2588
  "version": "0.4.1",
@@ -2895,6 +2949,12 @@
2895
  "url": "https://github.com/sponsors/sindresorhus"
2896
  }
2897
  },
 
 
 
 
 
 
2898
  "node_modules/lru-cache": {
2899
  "version": "5.1.1",
2900
  "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3052,6 +3112,26 @@
3052
  "node": ">= 6"
3053
  }
3054
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3055
  "node_modules/optionator": {
3056
  "version": "0.9.4",
3057
  "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3142,6 +3222,7 @@
3142
  "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
3143
  "dev": true,
3144
  "license": "MIT",
 
3145
  "engines": {
3146
  "node": ">=12"
3147
  },
@@ -3169,6 +3250,12 @@
3169
  "node": ">= 6"
3170
  }
3171
  },
 
 
 
 
 
 
3172
  "node_modules/playwright": {
3173
  "version": "1.61.0",
3174
  "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
@@ -3236,6 +3323,7 @@
3236
  }
3237
  ],
3238
  "license": "MIT",
 
3239
  "dependencies": {
3240
  "nanoid": "^3.3.12",
3241
  "picocolors": "^1.1.1",
@@ -3389,6 +3477,29 @@
3389
  "node": ">= 0.8.0"
3390
  }
3391
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3392
  "node_modules/punycode": {
3393
  "version": "2.3.1",
3394
  "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3425,6 +3536,7 @@
3425
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
3426
  "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
3427
  "license": "MIT",
 
3428
  "engines": {
3429
  "node": ">=0.10.0"
3430
  }
@@ -3434,6 +3546,7 @@
3434
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
3435
  "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
3436
  "license": "MIT",
 
3437
  "dependencies": {
3438
  "scheduler": "^0.27.0"
3439
  },
@@ -3467,6 +3580,7 @@
3467
  "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
3468
  "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
3469
  "license": "MIT",
 
3470
  "dependencies": {
3471
  "@types/use-sync-external-store": "^0.0.6",
3472
  "use-sync-external-store": "^1.4.0"
@@ -3593,7 +3707,8 @@
3593
  "version": "5.0.1",
3594
  "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
3595
  "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
3596
- "license": "MIT"
 
3597
  },
3598
  "node_modules/redux-thunk": {
3599
  "version": "3.1.0",
@@ -3936,6 +4051,7 @@
3936
  "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
3937
  "dev": true,
3938
  "license": "Apache-2.0",
 
3939
  "bin": {
3940
  "tsc": "bin/tsc",
3941
  "tsserver": "bin/tsserver"
@@ -3972,7 +4088,6 @@
3972
  "version": "7.18.2",
3973
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
3974
  "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
3975
- "dev": true,
3976
  "license": "MIT"
3977
  },
3978
  "node_modules/update-browserslist-db": {
@@ -4060,6 +4175,7 @@
4060
  "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
4061
  "dev": true,
4062
  "license": "MIT",
 
4063
  "dependencies": {
4064
  "lightningcss": "^1.32.0",
4065
  "picomatch": "^4.0.4",
@@ -4184,6 +4300,7 @@
4184
  "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
4185
  "dev": true,
4186
  "license": "MIT",
 
4187
  "funding": {
4188
  "url": "https://github.com/sponsors/colinhacks"
4189
  }
 
10
  "dependencies": {
11
  "leaflet": "^1.9.4",
12
  "lucide-react": "^1.18.0",
13
+ "onnxruntime-web": "^1.26.0",
14
  "react": "^19.2.6",
15
  "react-dom": "^19.2.6",
16
  "react-leaflet": "^5.0.0",
 
81
  "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
82
  "dev": true,
83
  "license": "MIT",
84
+ "peer": true,
85
  "dependencies": {
86
  "@babel/code-frame": "^7.29.7",
87
  "@babel/generator": "^7.29.7",
 
291
  "node": ">=6.9.0"
292
  }
293
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  "node_modules/@emnapi/wasi-threads": {
295
  "version": "1.2.1",
296
  "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
 
613
  "url": "https://github.com/sponsors/Boshen"
614
  }
615
  },
616
+ "node_modules/@protobufjs/aspromise": {
617
+ "version": "1.1.2",
618
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
619
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
620
+ "license": "BSD-3-Clause"
621
+ },
622
+ "node_modules/@protobufjs/base64": {
623
+ "version": "1.1.2",
624
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
625
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
626
+ "license": "BSD-3-Clause"
627
+ },
628
+ "node_modules/@protobufjs/codegen": {
629
+ "version": "2.0.5",
630
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
631
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
632
+ "license": "BSD-3-Clause"
633
+ },
634
+ "node_modules/@protobufjs/eventemitter": {
635
+ "version": "1.1.1",
636
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
637
+ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
638
+ "license": "BSD-3-Clause"
639
+ },
640
+ "node_modules/@protobufjs/fetch": {
641
+ "version": "1.1.1",
642
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
643
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
644
+ "license": "BSD-3-Clause",
645
+ "dependencies": {
646
+ "@protobufjs/aspromise": "^1.1.1"
647
+ }
648
+ },
649
+ "node_modules/@protobufjs/float": {
650
+ "version": "1.0.2",
651
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
652
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
653
+ "license": "BSD-3-Clause"
654
+ },
655
+ "node_modules/@protobufjs/path": {
656
+ "version": "1.1.2",
657
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
658
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
659
+ "license": "BSD-3-Clause"
660
+ },
661
+ "node_modules/@protobufjs/pool": {
662
+ "version": "1.1.0",
663
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
664
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
665
+ "license": "BSD-3-Clause"
666
+ },
667
+ "node_modules/@protobufjs/utf8": {
668
+ "version": "1.1.1",
669
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
670
+ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
671
+ "license": "BSD-3-Clause"
672
+ },
673
  "node_modules/@react-leaflet/core": {
674
  "version": "3.0.0",
675
  "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
 
1109
  "version": "24.13.2",
1110
  "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
1111
  "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
 
1112
  "license": "MIT",
1113
  "dependencies": {
1114
  "undici-types": "~7.18.0"
 
1120
  "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
1121
  "devOptional": true,
1122
  "license": "MIT",
1123
+ "peer": true,
1124
  "dependencies": {
1125
  "csstype": "^3.2.2"
1126
  }
 
1186
  "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
1187
  "dev": true,
1188
  "license": "MIT",
1189
+ "peer": true,
1190
  "dependencies": {
1191
  "@typescript-eslint/scope-manager": "8.61.1",
1192
  "@typescript-eslint/types": "8.61.1",
 
1417
  "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
1418
  "dev": true,
1419
  "license": "MIT",
1420
+ "peer": true,
1421
  "bin": {
1422
  "acorn": "bin/acorn"
1423
  },
 
1612
  }
1613
  ],
1614
  "license": "MIT",
1615
+ "peer": true,
1616
  "dependencies": {
1617
  "baseline-browser-mapping": "^2.10.12",
1618
  "caniuse-lite": "^1.0.30001782",
 
2002
  "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==",
2003
  "dev": true,
2004
  "license": "MIT",
2005
+ "peer": true,
2006
  "workspaces": [
2007
  "packages/*"
2008
  ],
 
2324
  "node": ">=16"
2325
  }
2326
  },
2327
+ "node_modules/flatbuffers": {
2328
+ "version": "25.9.23",
2329
+ "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
2330
+ "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
2331
+ "license": "Apache-2.0"
2332
+ },
2333
  "node_modules/flatted": {
2334
  "version": "3.4.2",
2335
  "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
 
2412
  "url": "https://github.com/sponsors/sindresorhus"
2413
  }
2414
  },
2415
+ "node_modules/guid-typescript": {
2416
+ "version": "1.0.9",
2417
+ "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
2418
+ "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
2419
+ "license": "ISC"
2420
+ },
2421
  "node_modules/hasown": {
2422
  "version": "2.0.4",
2423
  "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
 
2562
  "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
2563
  "dev": true,
2564
  "license": "MIT",
2565
+ "peer": true,
2566
  "bin": {
2567
  "jiti": "bin/jiti.js"
2568
  }
 
2635
  "version": "1.9.4",
2636
  "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
2637
  "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
2638
+ "license": "BSD-2-Clause",
2639
+ "peer": true
2640
  },
2641
  "node_modules/levn": {
2642
  "version": "0.4.1",
 
2949
  "url": "https://github.com/sponsors/sindresorhus"
2950
  }
2951
  },
2952
+ "node_modules/long": {
2953
+ "version": "5.3.2",
2954
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
2955
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
2956
+ "license": "Apache-2.0"
2957
+ },
2958
  "node_modules/lru-cache": {
2959
  "version": "5.1.1",
2960
  "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
 
3112
  "node": ">= 6"
3113
  }
3114
  },
3115
+ "node_modules/onnxruntime-common": {
3116
+ "version": "1.26.0",
3117
+ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.26.0.tgz",
3118
+ "integrity": "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==",
3119
+ "license": "MIT"
3120
+ },
3121
+ "node_modules/onnxruntime-web": {
3122
+ "version": "1.26.0",
3123
+ "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0.tgz",
3124
+ "integrity": "sha512-LbRr/8zZt2xilI2smrVQGGKINo0U46i8qJp+UXyMBGfqN7KjnH1BiwCwLwyNIVV4i9CKFv7Sf4PwLKWnT8/bEA==",
3125
+ "license": "MIT",
3126
+ "dependencies": {
3127
+ "flatbuffers": "^25.1.24",
3128
+ "guid-typescript": "^1.0.9",
3129
+ "long": "^5.2.3",
3130
+ "onnxruntime-common": "1.26.0",
3131
+ "platform": "^1.3.6",
3132
+ "protobufjs": "^7.2.4"
3133
+ }
3134
+ },
3135
  "node_modules/optionator": {
3136
  "version": "0.9.4",
3137
  "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
 
3222
  "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
3223
  "dev": true,
3224
  "license": "MIT",
3225
+ "peer": true,
3226
  "engines": {
3227
  "node": ">=12"
3228
  },
 
3250
  "node": ">= 6"
3251
  }
3252
  },
3253
+ "node_modules/platform": {
3254
+ "version": "1.3.6",
3255
+ "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
3256
+ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
3257
+ "license": "MIT"
3258
+ },
3259
  "node_modules/playwright": {
3260
  "version": "1.61.0",
3261
  "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
 
3323
  }
3324
  ],
3325
  "license": "MIT",
3326
+ "peer": true,
3327
  "dependencies": {
3328
  "nanoid": "^3.3.12",
3329
  "picocolors": "^1.1.1",
 
3477
  "node": ">= 0.8.0"
3478
  }
3479
  },
3480
+ "node_modules/protobufjs": {
3481
+ "version": "7.6.4",
3482
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
3483
+ "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
3484
+ "hasInstallScript": true,
3485
+ "license": "BSD-3-Clause",
3486
+ "dependencies": {
3487
+ "@protobufjs/aspromise": "^1.1.2",
3488
+ "@protobufjs/base64": "^1.1.2",
3489
+ "@protobufjs/codegen": "^2.0.5",
3490
+ "@protobufjs/eventemitter": "^1.1.1",
3491
+ "@protobufjs/fetch": "^1.1.1",
3492
+ "@protobufjs/float": "^1.0.2",
3493
+ "@protobufjs/path": "^1.1.2",
3494
+ "@protobufjs/pool": "^1.1.0",
3495
+ "@protobufjs/utf8": "^1.1.1",
3496
+ "@types/node": ">=13.7.0",
3497
+ "long": "^5.3.2"
3498
+ },
3499
+ "engines": {
3500
+ "node": ">=12.0.0"
3501
+ }
3502
+ },
3503
  "node_modules/punycode": {
3504
  "version": "2.3.1",
3505
  "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
 
3536
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
3537
  "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
3538
  "license": "MIT",
3539
+ "peer": true,
3540
  "engines": {
3541
  "node": ">=0.10.0"
3542
  }
 
3546
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
3547
  "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
3548
  "license": "MIT",
3549
+ "peer": true,
3550
  "dependencies": {
3551
  "scheduler": "^0.27.0"
3552
  },
 
3580
  "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
3581
  "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
3582
  "license": "MIT",
3583
+ "peer": true,
3584
  "dependencies": {
3585
  "@types/use-sync-external-store": "^0.0.6",
3586
  "use-sync-external-store": "^1.4.0"
 
3707
  "version": "5.0.1",
3708
  "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
3709
  "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
3710
+ "license": "MIT",
3711
+ "peer": true
3712
  },
3713
  "node_modules/redux-thunk": {
3714
  "version": "3.1.0",
 
4051
  "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
4052
  "dev": true,
4053
  "license": "Apache-2.0",
4054
+ "peer": true,
4055
  "bin": {
4056
  "tsc": "bin/tsc",
4057
  "tsserver": "bin/tsserver"
 
4088
  "version": "7.18.2",
4089
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
4090
  "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
 
4091
  "license": "MIT"
4092
  },
4093
  "node_modules/update-browserslist-db": {
 
4175
  "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
4176
  "dev": true,
4177
  "license": "MIT",
4178
+ "peer": true,
4179
  "dependencies": {
4180
  "lightningcss": "^1.32.0",
4181
  "picomatch": "^4.0.4",
 
4300
  "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
4301
  "dev": true,
4302
  "license": "MIT",
4303
+ "peer": true,
4304
  "funding": {
4305
  "url": "https://github.com/sponsors/colinhacks"
4306
  }
frontend/package.json CHANGED
@@ -12,6 +12,7 @@
12
  "dependencies": {
13
  "leaflet": "^1.9.4",
14
  "lucide-react": "^1.18.0",
 
15
  "react": "^19.2.6",
16
  "react-dom": "^19.2.6",
17
  "react-leaflet": "^5.0.0",
 
12
  "dependencies": {
13
  "leaflet": "^1.9.4",
14
  "lucide-react": "^1.18.0",
15
+ "onnxruntime-web": "^1.26.0",
16
  "react": "^19.2.6",
17
  "react-dom": "^19.2.6",
18
  "react-leaflet": "^5.0.0",
frontend/public/models/README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Place the browser Fleet Mode model here:
2
+
3
+ ```text
4
+ roadrecon-yolov8n-320-int8.onnx
5
+ ```
6
+
7
+ The worker expects a YOLOv8 detector exported at 320x320 with the class order:
8
+
9
+ ```text
10
+ pothole, crack, water_logging, construction_zone, traffic_block
11
+ ```
frontend/public/models/roadrecon-yolov8n-320-int8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:215ea71e03d0b3f004fc99c67e5eea8adb0cc2a4d171f15fa662d6afd0fdd77d
3
+ size 12753328
frontend/src/App.tsx CHANGED
@@ -2,6 +2,7 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
2
  import { I18nProvider } from "./lib/i18n";
3
  import Report from "./pages/Report";
4
  import Dashboard from "./pages/Dashboard";
 
5
 
6
  export default function App() {
7
  return (
@@ -10,6 +11,7 @@ export default function App() {
10
  <Routes>
11
  <Route path="/" element={<Navigate to="/report" replace />} />
12
  <Route path="/report" element={<Report />} />
 
13
  <Route path="/dashboard" element={<Dashboard />} />
14
  </Routes>
15
  </BrowserRouter>
 
2
  import { I18nProvider } from "./lib/i18n";
3
  import Report from "./pages/Report";
4
  import Dashboard from "./pages/Dashboard";
5
+ import FleetMode from "./pages/FleetMode";
6
 
7
  export default function App() {
8
  return (
 
11
  <Routes>
12
  <Route path="/" element={<Navigate to="/report" replace />} />
13
  <Route path="/report" element={<Report />} />
14
+ <Route path="/fleet" element={<FleetMode />} />
15
  <Route path="/dashboard" element={<Dashboard />} />
16
  </Routes>
17
  </BrowserRouter>
frontend/src/components/ClusterDrawer.tsx CHANGED
@@ -42,7 +42,15 @@ export default function ClusterDrawer({ cluster, onClose, onStatusChange }: Prop
42
  }
43
  }
44
 
45
- const images = detail ? [...new Set(detail.reports.map((r) => r.media_path))].slice(0, 6) : [];
 
 
 
 
 
 
 
 
46
 
47
  return (
48
  <div className="absolute inset-y-0 right-0 z-[1000] flex w-96 max-w-full flex-col border-l border-line bg-white shadow-xl">
 
42
  }
43
  }
44
 
45
+ const images = detail
46
+ ? [
47
+ ...new Set(
48
+ detail.reports
49
+ .map((r) => r.thumbnail_path || r.media_path)
50
+ .filter((path): path is string => Boolean(path)),
51
+ ),
52
+ ].slice(0, 6)
53
+ : [];
54
 
55
  return (
56
  <div className="absolute inset-y-0 right-0 z-[1000] flex w-96 max-w-full flex-col border-l border-line bg-white shadow-xl">
frontend/src/lib/api.ts CHANGED
@@ -6,10 +6,10 @@ export const API_BASE =
6
  export type Severity = "low" | "medium" | "high";
7
  export type HazardType =
8
  | "pothole"
9
- | "longitudinal_crack"
10
- | "transverse_crack"
11
- | "alligator_crack"
12
- | "debris"
13
  | "other";
14
  export type Status = "pending_review" | "reported" | "acknowledged" | "fixed";
15
  export type GeoSource = "exif" | "device" | "manual";
@@ -48,6 +48,9 @@ export interface Cluster {
48
  last_seen: string;
49
  status: Status;
50
  resolved_at: string | null;
 
 
 
51
  }
52
 
53
  export interface StatusEvent {
@@ -59,6 +62,7 @@ export interface StatusEvent {
59
  export interface ReportOut {
60
  id: string;
61
  created_at: string;
 
62
  source: string;
63
  hazard_type: HazardType;
64
  severity: Severity;
@@ -67,7 +71,11 @@ export interface ReportOut {
67
  lon: number;
68
  geo_source: GeoSource;
69
  media_path: string;
 
70
  bbox: number[];
 
 
 
71
  status: Status;
72
  cluster_id: string | null;
73
  }
@@ -113,7 +121,7 @@ export async function health(): Promise<{ ok: boolean; service: string }> {
113
 
114
  export async function submitReport(opts: {
115
  file: File;
116
- source?: "citizen" | "dashcam";
117
  deviceLat?: number;
118
  deviceLon?: number;
119
  force?: boolean;
@@ -169,6 +177,37 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
169
  return r.json();
170
  }
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  export const SEVERITY_COLOR: Record<Severity, string> = {
173
  low: "#16a34a",
174
  medium: "#f59e0b",
@@ -177,9 +216,9 @@ export const SEVERITY_COLOR: Record<Severity, string> = {
177
 
178
  export const HAZARD_LABEL: Record<HazardType, string> = {
179
  pothole: "Pothole",
180
- longitudinal_crack: "Longitudinal crack",
181
- transverse_crack: "Transverse crack",
182
- alligator_crack: "Alligator crack",
183
- debris: "Debris",
184
  other: "Other",
185
  };
 
6
  export type Severity = "low" | "medium" | "high";
7
  export type HazardType =
8
  | "pothole"
9
+ | "crack"
10
+ | "water_logging"
11
+ | "construction_zone"
12
+ | "traffic_block"
13
  | "other";
14
  export type Status = "pending_review" | "reported" | "acknowledged" | "fixed";
15
  export type GeoSource = "exif" | "device" | "manual";
 
48
  last_seen: string;
49
  status: Status;
50
  resolved_at: string | null;
51
+ fleet_report_count: number;
52
+ citizen_report_count: number;
53
+ avg_device_speed: number | null;
54
  }
55
 
56
  export interface StatusEvent {
 
62
  export interface ReportOut {
63
  id: string;
64
  created_at: string;
65
+ observed_at: string | null;
66
  source: string;
67
  hazard_type: HazardType;
68
  severity: Severity;
 
71
  lon: number;
72
  geo_source: GeoSource;
73
  media_path: string;
74
+ thumbnail_path: string | null;
75
  bbox: number[];
76
+ bbox_area_frac: number | null;
77
+ device_speed: number | null;
78
+ client_event_id: string | null;
79
  status: Status;
80
  cluster_id: string | null;
81
  }
 
121
 
122
  export async function submitReport(opts: {
123
  file: File;
124
+ source?: "citizen" | "fleet_stream";
125
  deviceLat?: number;
126
  deviceLon?: number;
127
  force?: boolean;
 
177
  return r.json();
178
  }
179
 
180
+ export interface FleetTelemetryPayload {
181
+ lat: number;
182
+ lon: number;
183
+ hazard_type: Exclude<HazardType, "other">;
184
+ confidence: number;
185
+ timestamp: string;
186
+ speed?: number;
187
+ bbox?: [number, number, number, number];
188
+ bbox_area_frac?: number;
189
+ thumbnail_b64?: string;
190
+ client_event_id?: string;
191
+ }
192
+
193
+ export interface FleetTelemetryResponse {
194
+ status: "accepted";
195
+ report_id: string;
196
+ queued_for_cluster: boolean;
197
+ }
198
+
199
+ export async function submitFleetTelemetry(
200
+ payload: FleetTelemetryPayload,
201
+ ): Promise<FleetTelemetryResponse> {
202
+ const r = await fetch(`${API_BASE}/fleet/telemetry`, {
203
+ method: "POST",
204
+ headers: { "Content-Type": "application/json" },
205
+ body: JSON.stringify(payload),
206
+ });
207
+ if (!r.ok) throw new Error(`fleet telemetry ${r.status}`);
208
+ return r.json();
209
+ }
210
+
211
  export const SEVERITY_COLOR: Record<Severity, string> = {
212
  low: "#16a34a",
213
  medium: "#f59e0b",
 
216
 
217
  export const HAZARD_LABEL: Record<HazardType, string> = {
218
  pothole: "Pothole",
219
+ crack: "Crack",
220
+ water_logging: "Water logging",
221
+ construction_zone: "Construction zone",
222
+ traffic_block: "Traffic block",
223
  other: "Other",
224
  };
frontend/src/lib/fleetTelemetry.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { FleetTelemetryPayload, HazardType } from "./api";
2
+
3
+ export type FleetHazardType = Exclude<HazardType, "other">;
4
+
5
+ export interface FleetDetection {
6
+ hazard_type: FleetHazardType;
7
+ confidence: number;
8
+ bbox: [number, number, number, number];
9
+ }
10
+
11
+ export interface FleetLocation {
12
+ lat: number;
13
+ lon: number;
14
+ speed: number | null;
15
+ at: number;
16
+ }
17
+
18
+ export interface LastFleetReport {
19
+ lat: number;
20
+ lon: number;
21
+ at: number;
22
+ }
23
+
24
+ export const FLEET_CONFIDENCE = 0.55;
25
+ export const TRAFFIC_BLOCK_CONFIDENCE = 0.45;
26
+ export const TRAFFIC_BLOCK_SPEED_MPS = 2;
27
+ export const TRAFFIC_BLOCK_DWELL_MS = 10_000;
28
+ export const FLEET_DEBOUNCE_METERS = 10;
29
+
30
+ const EARTH_RADIUS_M = 6_371_000;
31
+
32
+ export function haversineMeters(a: Pick<FleetLocation, "lat" | "lon">, b: Pick<FleetLocation, "lat" | "lon">): number {
33
+ const dLat = ((b.lat - a.lat) * Math.PI) / 180;
34
+ const dLon = ((b.lon - a.lon) * Math.PI) / 180;
35
+ const lat1 = (a.lat * Math.PI) / 180;
36
+ const lat2 = (b.lat * Math.PI) / 180;
37
+ const h =
38
+ Math.sin(dLat / 2) ** 2 +
39
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
40
+ return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(h));
41
+ }
42
+
43
+ export function createClientEventId(): string {
44
+ if ("randomUUID" in crypto) return crypto.randomUUID();
45
+ return `fleet-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
46
+ }
47
+
48
+ export function shouldSendFleetReport(
49
+ detection: FleetDetection,
50
+ location: FleetLocation,
51
+ lastReports: Map<FleetHazardType, LastFleetReport>,
52
+ ): boolean {
53
+ const previous = lastReports.get(detection.hazard_type);
54
+ if (!previous) return true;
55
+ return haversineMeters(previous, location) > FLEET_DEBOUNCE_METERS;
56
+ }
57
+
58
+ export function bboxAreaFraction(bbox: [number, number, number, number], frameSize: number): number {
59
+ const [x1, y1, x2, y2] = bbox;
60
+ const w = Math.max(0, x2 - x1);
61
+ const h = Math.max(0, y2 - y1);
62
+ return Math.min(1, (w * h) / (frameSize * frameSize));
63
+ }
64
+
65
+ export function toFleetPayload(opts: {
66
+ detection: FleetDetection;
67
+ location: FleetLocation;
68
+ thumbnailB64?: string;
69
+ frameSize: number;
70
+ }): FleetTelemetryPayload {
71
+ return {
72
+ lat: opts.location.lat,
73
+ lon: opts.location.lon,
74
+ hazard_type: opts.detection.hazard_type,
75
+ confidence: opts.detection.confidence,
76
+ timestamp: new Date().toISOString(),
77
+ speed: opts.location.speed ?? undefined,
78
+ bbox: opts.detection.bbox,
79
+ bbox_area_frac: bboxAreaFraction(opts.detection.bbox, opts.frameSize),
80
+ thumbnail_b64: opts.thumbnailB64,
81
+ client_event_id: createClientEventId(),
82
+ };
83
+ }
frontend/src/lib/i18n.tsx CHANGED
@@ -68,11 +68,22 @@ const en: Dict = {
68
  st_acknowledged: "Acknowledged",
69
  st_fixed: "Resolved",
70
  hz_pothole: "Pothole",
71
- hz_longitudinal_crack: "Longitudinal crack",
72
- hz_transverse_crack: "Transverse crack",
73
- hz_alligator_crack: "Alligator crack",
74
- hz_debris: "Debris",
75
  hz_other: "Other hazard",
 
 
 
 
 
 
 
 
 
 
 
76
  };
77
 
78
  const hi: Dict = {
@@ -132,11 +143,22 @@ const hi: Dict = {
132
  st_acknowledged: "स्वीकृत",
133
  st_fixed: "ठीक किया",
134
  hz_pothole: "गड्ढा",
135
- hz_longitudinal_crack: "अनु्ध्य दरार",
136
- hz_transverse_crack: "अनुप्स्थ दर",
137
- hz_alligator_crack: "मगमच ार",
138
- hz_debris: "मलबा",
139
  hz_other: "अन्य ख़तरा",
 
 
 
 
 
 
 
 
 
 
 
140
  };
141
 
142
  const mr: Dict = {
@@ -196,11 +218,22 @@ const mr: Dict = {
196
  st_acknowledged: "स्वीकारले",
197
  st_fixed: "दुरुस्त",
198
  hz_pothole: "खड्डा",
199
- hz_longitudinal_crack: "रेखांशीय भेग",
200
- hz_transverse_crack: "आडव",
201
- hz_alligator_crack: "मगर ",
202
- hz_debris: "कचरा",
203
  hz_other: "इतर धोका",
 
 
 
 
 
 
 
 
 
 
 
204
  };
205
 
206
  const DICTS: Record<Lang, Dict> = { en, hi, mr };
 
68
  st_acknowledged: "Acknowledged",
69
  st_fixed: "Resolved",
70
  hz_pothole: "Pothole",
71
+ hz_crack: "Crack",
72
+ hz_water_logging: "Water logging",
73
+ hz_construction_zone: "Construction zone",
74
+ hz_traffic_block: "Traffic block",
75
  hz_other: "Other hazard",
76
+ fleet_mode: "Fleet Mode",
77
+ fleet_tagline: "Edge road sensing",
78
+ fleet_start: "Start",
79
+ fleet_stop: "Stop",
80
+ fleet_ready: "Ready",
81
+ fleet_live: "Live",
82
+ fleet_sent: "Sent",
83
+ fleet_infer: "Inference",
84
+ fleet_frames: "Frames",
85
+ fleet_speed: "Speed",
86
+ fleet_last: "Last event",
87
  };
88
 
89
  const hi: Dict = {
 
143
  st_acknowledged: "स्वीकृत",
144
  st_fixed: "ठीक किया",
145
  hz_pothole: "गड्ढा",
146
+ hz_crack: "दरार",
147
+ hz_water_logging: "जलभरा",
148
+ hz_construction_zone: "निर्माण क्षेत्र",
149
+ hz_traffic_block: "ट्रैफिक अवरोध",
150
  hz_other: "अन्य ख़तरा",
151
+ fleet_mode: "फ्लीट मोड",
152
+ fleet_tagline: "एज सड़क सेंसिंग",
153
+ fleet_start: "शुरू करें",
154
+ fleet_stop: "रोकें",
155
+ fleet_ready: "तैयार",
156
+ fleet_live: "लाइव",
157
+ fleet_sent: "भेजे गए",
158
+ fleet_infer: "इन्फरेंस",
159
+ fleet_frames: "फ्रेम",
160
+ fleet_speed: "गति",
161
+ fleet_last: "अंतिम इवेंट",
162
  };
163
 
164
  const mr: Dict = {
 
218
  st_acknowledged: "स्वीकारले",
219
  st_fixed: "दुरुस्त",
220
  hz_pothole: "खड्डा",
221
+ hz_crack: "भेग",
222
+ hz_water_logging: "पाणसाचणे",
223
+ hz_construction_zone: "बांधकाक्षत्र",
224
+ hz_traffic_block: "वाहतू अडथळा",
225
  hz_other: "इतर धोका",
226
+ fleet_mode: "फ्लीट मोड",
227
+ fleet_tagline: "एज रस्ता सेन्सिंग",
228
+ fleet_start: "सुरू करा",
229
+ fleet_stop: "थांबवा",
230
+ fleet_ready: "तयार",
231
+ fleet_live: "लाइव्ह",
232
+ fleet_sent: "पाठवले",
233
+ fleet_infer: "इन्फरन्स",
234
+ fleet_frames: "फ्रेम",
235
+ fleet_speed: "वेग",
236
+ fleet_last: "शेवटचा इव्हेंट",
237
  };
238
 
239
  const DICTS: Record<Lang, Dict> = { en, hi, mr };
frontend/src/pages/FleetMode.tsx ADDED
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { Camera, Loader2, RadioTower, ShieldCheck, Square, Zap } from "lucide-react";
4
+ import Masthead from "../components/Masthead";
5
+ import { submitFleetTelemetry, type FleetTelemetryPayload } from "../lib/api";
6
+ import {
7
+ FLEET_CONFIDENCE,
8
+ TRAFFIC_BLOCK_CONFIDENCE,
9
+ TRAFFIC_BLOCK_DWELL_MS,
10
+ TRAFFIC_BLOCK_SPEED_MPS,
11
+ haversineMeters,
12
+ shouldSendFleetReport,
13
+ toFleetPayload,
14
+ type FleetDetection,
15
+ type FleetHazardType,
16
+ type FleetLocation,
17
+ type LastFleetReport,
18
+ } from "../lib/fleetTelemetry";
19
+ import { useT } from "../lib/i18n";
20
+
21
+ const FRAME_SIZE = 320;
22
+ const MODEL_URL = "/models/roadrecon-yolov8n-320-int8.onnx";
23
+
24
+ type FleetStatus = "idle" | "starting" | "active" | "error";
25
+ type WorkerMessage =
26
+ | { type: "ready" }
27
+ | { type: "result"; detections: FleetDetection[]; inferMs: number }
28
+ | { type: "error"; message: string };
29
+
30
+ interface Stats {
31
+ frames: number;
32
+ detections: number;
33
+ sent: number;
34
+ inferMs: number | null;
35
+ lastHazard: FleetHazardType | null;
36
+ lastError: string | null;
37
+ }
38
+
39
+ function formatSpeed(speed: number | null): string {
40
+ if (speed == null || !Number.isFinite(speed)) return "--";
41
+ return `${Math.round(speed * 3.6)} km/h`;
42
+ }
43
+
44
+ export default function FleetMode() {
45
+ const { t } = useT();
46
+ const videoRef = useRef<HTMLVideoElement>(null);
47
+ const canvasRef = useRef<HTMLCanvasElement>(null);
48
+ const workerRef = useRef<Worker | null>(null);
49
+ const streamRef = useRef<MediaStream | null>(null);
50
+ const watchIdRef = useRef<number | null>(null);
51
+ const inFlightRef = useRef(false);
52
+ const processDetectionsRef = useRef<(detections: FleetDetection[]) => void>(() => {});
53
+ const lastReportsRef = useRef(new Map<FleetHazardType, LastFleetReport>());
54
+ const pendingClassesRef = useRef(new Set<FleetHazardType>());
55
+ const locationRef = useRef<FleetLocation | null>(null);
56
+ const previousLocationRef = useRef<FleetLocation | null>(null);
57
+ const smoothedSpeedRef = useRef<number | null>(null);
58
+ const lowSpeedSinceRef = useRef<number | null>(null);
59
+
60
+ const [status, setStatus] = useState<FleetStatus>("idle");
61
+ const [workerReady, setWorkerReady] = useState(false);
62
+ const [location, setLocation] = useState<FleetLocation | null>(null);
63
+ const [stats, setStats] = useState<Stats>({
64
+ frames: 0,
65
+ detections: 0,
66
+ sent: 0,
67
+ inferMs: null,
68
+ lastHazard: null,
69
+ lastError: null,
70
+ });
71
+
72
+ const makeThumbnail = useCallback((bbox: [number, number, number, number]): string | undefined => {
73
+ const src = canvasRef.current;
74
+ if (!src) return undefined;
75
+
76
+ const [x1, y1, x2, y2] = bbox;
77
+ const pad = Math.max(8, Math.max(x2 - x1, y2 - y1) * 0.2);
78
+ const sx = Math.max(0, Math.floor(x1 - pad));
79
+ const sy = Math.max(0, Math.floor(y1 - pad));
80
+ const ex = Math.min(FRAME_SIZE, Math.ceil(x2 + pad));
81
+ const ey = Math.min(FRAME_SIZE, Math.ceil(y2 + pad));
82
+ const sw = Math.max(1, ex - sx);
83
+ const sh = Math.max(1, ey - sy);
84
+ const scale = Math.min(160 / sw, 160 / sh, 1);
85
+ const dw = Math.max(1, Math.round(sw * scale));
86
+ const dh = Math.max(1, Math.round(sh * scale));
87
+
88
+ const out = document.createElement("canvas");
89
+ out.width = dw;
90
+ out.height = dh;
91
+ const ctx = out.getContext("2d");
92
+ if (!ctx) return undefined;
93
+
94
+ ctx.filter = "blur(8px)";
95
+ ctx.drawImage(src, sx, sy, sw, sh, 0, 0, dw, dh);
96
+ ctx.filter = "none";
97
+ ctx.drawImage(
98
+ src,
99
+ x1,
100
+ y1,
101
+ Math.max(1, x2 - x1),
102
+ Math.max(1, y2 - y1),
103
+ (x1 - sx) * scale,
104
+ (y1 - sy) * scale,
105
+ Math.max(1, (x2 - x1) * scale),
106
+ Math.max(1, (y2 - y1) * scale),
107
+ );
108
+ return out.toDataURL("image/jpeg", 0.45);
109
+ }, []);
110
+
111
+ const sendDetection = useCallback(
112
+ async (detection: FleetDetection, loc: FleetLocation) => {
113
+ if (pendingClassesRef.current.has(detection.hazard_type)) return;
114
+ if (!shouldSendFleetReport(detection, loc, lastReportsRef.current)) return;
115
+
116
+ pendingClassesRef.current.add(detection.hazard_type);
117
+ const thumbnail = makeThumbnail(detection.bbox);
118
+ const payload: FleetTelemetryPayload = toFleetPayload({
119
+ detection,
120
+ location: loc,
121
+ thumbnailB64: thumbnail,
122
+ frameSize: FRAME_SIZE,
123
+ });
124
+
125
+ try {
126
+ await submitFleetTelemetry(payload);
127
+ lastReportsRef.current.set(detection.hazard_type, {
128
+ lat: loc.lat,
129
+ lon: loc.lon,
130
+ at: Date.now(),
131
+ });
132
+ setStats((s) => ({
133
+ ...s,
134
+ sent: s.sent + 1,
135
+ lastHazard: detection.hazard_type,
136
+ lastError: null,
137
+ }));
138
+ } catch (err) {
139
+ setStats((s) => ({
140
+ ...s,
141
+ lastError: err instanceof Error ? err.message : "Telemetry failed",
142
+ }));
143
+ } finally {
144
+ pendingClassesRef.current.delete(detection.hazard_type);
145
+ }
146
+ },
147
+ [makeThumbnail],
148
+ );
149
+
150
+ const processDetections = useCallback(
151
+ (detections: FleetDetection[]) => {
152
+ const loc = locationRef.current;
153
+ setStats((s) => ({ ...s, detections: s.detections + detections.length }));
154
+ if (!loc) return;
155
+
156
+ const lowSpeedReady =
157
+ lowSpeedSinceRef.current != null &&
158
+ Date.now() - lowSpeedSinceRef.current >= TRAFFIC_BLOCK_DWELL_MS;
159
+ const accepted = detections
160
+ .filter((d) => {
161
+ if (d.hazard_type === "traffic_block") {
162
+ return d.confidence >= TRAFFIC_BLOCK_CONFIDENCE && lowSpeedReady;
163
+ }
164
+ return d.confidence >= FLEET_CONFIDENCE;
165
+ })
166
+ .sort((a, b) => b.confidence - a.confidence)
167
+ .slice(0, 2);
168
+
169
+ for (const detection of accepted) {
170
+ void sendDetection(detection, loc);
171
+ }
172
+ },
173
+ [sendDetection],
174
+ );
175
+
176
+ useEffect(() => {
177
+ processDetectionsRef.current = processDetections;
178
+ }, [processDetections]);
179
+
180
+ useEffect(() => {
181
+ const worker = new Worker(new URL("../workers/fleetInference.worker.ts", import.meta.url), {
182
+ type: "module",
183
+ });
184
+ workerRef.current = worker;
185
+ worker.onmessage = (event: MessageEvent<WorkerMessage>) => {
186
+ const msg = event.data;
187
+ if (msg.type === "ready") {
188
+ setWorkerReady(true);
189
+ return;
190
+ }
191
+ if (msg.type === "error") {
192
+ inFlightRef.current = false;
193
+ setStatus("error");
194
+ setStats((s) => ({ ...s, lastError: msg.message }));
195
+ return;
196
+ }
197
+ inFlightRef.current = false;
198
+ setStats((s) => ({
199
+ ...s,
200
+ frames: s.frames + 1,
201
+ inferMs: msg.inferMs,
202
+ }));
203
+ processDetectionsRef.current(msg.detections);
204
+ };
205
+ worker.postMessage({ type: "init", modelUrl: MODEL_URL });
206
+ return () => {
207
+ worker.terminate();
208
+ workerRef.current = null;
209
+ };
210
+ }, []);
211
+
212
+ const onPosition = useCallback((pos: GeolocationPosition) => {
213
+ const now = Date.now();
214
+ const previous = previousLocationRef.current;
215
+ let speed = pos.coords.speed;
216
+ if ((speed == null || !Number.isFinite(speed)) && previous) {
217
+ const dt = Math.max(0.1, (now - previous.at) / 1000);
218
+ speed = haversineMeters(previous, {
219
+ lat: pos.coords.latitude,
220
+ lon: pos.coords.longitude,
221
+ }) / dt;
222
+ }
223
+ if (speed != null && Number.isFinite(speed)) {
224
+ smoothedSpeedRef.current =
225
+ smoothedSpeedRef.current == null ? speed : smoothedSpeedRef.current * 0.7 + speed * 0.3;
226
+ }
227
+ const loc: FleetLocation = {
228
+ lat: pos.coords.latitude,
229
+ lon: pos.coords.longitude,
230
+ speed: smoothedSpeedRef.current,
231
+ at: now,
232
+ };
233
+ if ((loc.speed ?? Number.POSITIVE_INFINITY) < TRAFFIC_BLOCK_SPEED_MPS) {
234
+ lowSpeedSinceRef.current ??= now;
235
+ } else {
236
+ lowSpeedSinceRef.current = null;
237
+ }
238
+ previousLocationRef.current = loc;
239
+ locationRef.current = loc;
240
+ setLocation(loc);
241
+ }, []);
242
+
243
+ const stop = useCallback(() => {
244
+ if (watchIdRef.current != null) {
245
+ navigator.geolocation.clearWatch(watchIdRef.current);
246
+ watchIdRef.current = null;
247
+ }
248
+ streamRef.current?.getTracks().forEach((track) => track.stop());
249
+ streamRef.current = null;
250
+ if (videoRef.current) videoRef.current.srcObject = null;
251
+ inFlightRef.current = false;
252
+ setStatus("idle");
253
+ }, []);
254
+
255
+ const start = useCallback(async () => {
256
+ setStatus("starting");
257
+ setStats((s) => ({ ...s, lastError: null }));
258
+ try {
259
+ const stream = await navigator.mediaDevices.getUserMedia({
260
+ video: {
261
+ facingMode: { ideal: "environment" },
262
+ width: { ideal: 1280 },
263
+ height: { ideal: 720 },
264
+ },
265
+ audio: false,
266
+ });
267
+ streamRef.current = stream;
268
+ if (videoRef.current) {
269
+ videoRef.current.srcObject = stream;
270
+ await videoRef.current.play();
271
+ }
272
+ watchIdRef.current = navigator.geolocation.watchPosition(
273
+ onPosition,
274
+ (err) => setStats((s) => ({ ...s, lastError: err.message })),
275
+ { enableHighAccuracy: true, maximumAge: 1000, timeout: 10_000 },
276
+ );
277
+ setStatus("active");
278
+ } catch (err) {
279
+ setStatus("error");
280
+ setStats((s) => ({
281
+ ...s,
282
+ lastError: err instanceof Error ? err.message : "Could not start Fleet Mode",
283
+ }));
284
+ }
285
+ }, [onPosition]);
286
+
287
+ useEffect(() => {
288
+ if (status !== "active" || !workerReady) return undefined;
289
+ const timer = window.setInterval(() => {
290
+ const video = videoRef.current;
291
+ const canvas = canvasRef.current;
292
+ const worker = workerRef.current;
293
+ if (!video || !canvas || !worker || document.hidden || inFlightRef.current) return;
294
+ if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
295
+
296
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
297
+ if (!ctx) return;
298
+ ctx.drawImage(video, 0, 0, FRAME_SIZE, FRAME_SIZE);
299
+ const imageData = ctx.getImageData(0, 0, FRAME_SIZE, FRAME_SIZE);
300
+ inFlightRef.current = true;
301
+ worker.postMessage(
302
+ { type: "infer", width: FRAME_SIZE, height: FRAME_SIZE, buffer: imageData.data.buffer },
303
+ [imageData.data.buffer],
304
+ );
305
+ }, 1000);
306
+ return () => window.clearInterval(timer);
307
+ }, [status, workerReady]);
308
+
309
+ useEffect(() => stop, [stop]);
310
+
311
+ const active = status === "active";
312
+ const busy = status === "starting";
313
+
314
+ return (
315
+ <div className="flex min-h-full flex-col bg-paper">
316
+ <Masthead compact />
317
+ <main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-4 p-4">
318
+ <div className="flex flex-wrap items-center gap-3 border-b border-line pb-3">
319
+ <div>
320
+ <h1 className="text-xl font-bold text-ink">{t("fleet_mode")}</h1>
321
+ <p className="text-sm text-ink-soft">{t("fleet_tagline")}</p>
322
+ </div>
323
+ <button
324
+ onClick={active || busy ? stop : start}
325
+ disabled={!workerReady && !active}
326
+ className={`ml-auto inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-semibold text-white ${
327
+ active ? "bg-sev-high hover:bg-red-700" : "bg-gov-700 hover:bg-gov-900"
328
+ } disabled:cursor-not-allowed disabled:opacity-50`}
329
+ >
330
+ {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : active ? <Square className="h-4 w-4" /> : <Camera className="h-4 w-4" />}
331
+ {active || busy ? t("fleet_stop") : t("fleet_start")}
332
+ </button>
333
+ </div>
334
+
335
+ <div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
336
+ <section className="relative min-h-[420px] overflow-hidden rounded-md border border-line bg-black">
337
+ <video ref={videoRef} muted playsInline className="h-full min-h-[420px] w-full object-cover" />
338
+ <canvas ref={canvasRef} width={FRAME_SIZE} height={FRAME_SIZE} className="hidden" />
339
+ <div className="absolute left-3 top-3 flex items-center gap-2 rounded-sm bg-black/65 px-2 py-1 text-xs font-medium text-white">
340
+ <span className={`h-2 w-2 rounded-full ${active ? "bg-emerald-400" : "bg-slate-400"}`} />
341
+ {active ? t("fleet_live") : workerReady ? t("fleet_ready") : t("loading")}
342
+ </div>
343
+ {stats.lastError && (
344
+ <div className="absolute inset-x-3 bottom-3 rounded-md bg-red-600/90 px-3 py-2 text-sm font-medium text-white">
345
+ {stats.lastError}
346
+ </div>
347
+ )}
348
+ </section>
349
+
350
+ <aside className="grid content-start gap-3">
351
+ <Metric icon={<RadioTower className="h-4 w-4" />} label={t("fleet_sent")} value={stats.sent} />
352
+ <Metric icon={<Zap className="h-4 w-4" />} label={t("fleet_infer")} value={stats.inferMs == null ? "--" : `${stats.inferMs} ms`} />
353
+ <Metric icon={<Camera className="h-4 w-4" />} label={t("fleet_frames")} value={stats.frames} />
354
+ <Metric icon={<ShieldCheck className="h-4 w-4" />} label={t("fleet_speed")} value={formatSpeed(location?.speed ?? null)} />
355
+ <div className="rounded-md border border-line bg-white p-3">
356
+ <div className="text-xs font-semibold uppercase tracking-wide text-ink-soft">{t("fleet_last")}</div>
357
+ <div className="mt-1 text-base font-semibold text-ink">
358
+ {stats.lastHazard ? t(`hz_${stats.lastHazard}`) : "--"}
359
+ </div>
360
+ <div className="mt-1 text-xs text-ink-soft">
361
+ {location ? `${location.lat.toFixed(5)}, ${location.lon.toFixed(5)}` : "--"}
362
+ </div>
363
+ </div>
364
+ </aside>
365
+ </div>
366
+ </main>
367
+ </div>
368
+ );
369
+ }
370
+
371
+ function Metric({
372
+ icon,
373
+ label,
374
+ value,
375
+ }: {
376
+ icon: ReactNode;
377
+ label: string;
378
+ value: string | number;
379
+ }) {
380
+ return (
381
+ <div className="rounded-md border border-line bg-white p-3">
382
+ <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-ink-soft">
383
+ {icon}
384
+ {label}
385
+ </div>
386
+ <div className="mt-1 text-2xl font-bold text-ink">{value}</div>
387
+ </div>
388
+ );
389
+ }
frontend/src/workers/fleetInference.worker.ts ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as ort from "onnxruntime-web";
2
+ import type { FleetDetection, FleetHazardType } from "../lib/fleetTelemetry";
3
+
4
+ const HAZARD_CLASSES: FleetHazardType[] = [
5
+ "pothole",
6
+ "crack",
7
+ "water_logging",
8
+ "construction_zone",
9
+ "traffic_block",
10
+ ];
11
+
12
+ type WorkerRequest =
13
+ | { type: "init"; modelUrl: string }
14
+ | { type: "infer"; width: number; height: number; buffer: ArrayBuffer };
15
+
16
+ type WorkerResponse =
17
+ | { type: "ready" }
18
+ | { type: "result"; detections: FleetDetection[]; inferMs: number }
19
+ | { type: "error"; message: string };
20
+
21
+ let session: ort.InferenceSession | null = null;
22
+
23
+ ort.env.wasm.numThreads = 1;
24
+
25
+ function post(message: WorkerResponse): void {
26
+ self.postMessage(message);
27
+ }
28
+
29
+ async function init(modelUrl: string): Promise<void> {
30
+ if (session) {
31
+ post({ type: "ready" });
32
+ return;
33
+ }
34
+ session = await ort.InferenceSession.create(modelUrl, {
35
+ executionProviders: ["wasm"],
36
+ graphOptimizationLevel: "all",
37
+ });
38
+ post({ type: "ready" });
39
+ }
40
+
41
+ function preprocess(buffer: ArrayBuffer, width: number, height: number): ort.Tensor {
42
+ const pixels = new Uint8ClampedArray(buffer);
43
+ const tensor = new Float32Array(3 * width * height);
44
+ const plane = width * height;
45
+
46
+ for (let i = 0; i < plane; i += 1) {
47
+ const src = i * 4;
48
+ tensor[i] = pixels[src] / 255;
49
+ tensor[plane + i] = pixels[src + 1] / 255;
50
+ tensor[plane * 2 + i] = pixels[src + 2] / 255;
51
+ }
52
+ return new ort.Tensor("float32", tensor, [1, 3, height, width]);
53
+ }
54
+
55
+ function clipBox(bbox: [number, number, number, number], size: number): [number, number, number, number] {
56
+ const [x1, y1, x2, y2] = bbox;
57
+ return [
58
+ Math.max(0, Math.min(size, x1)),
59
+ Math.max(0, Math.min(size, y1)),
60
+ Math.max(0, Math.min(size, x2)),
61
+ Math.max(0, Math.min(size, y2)),
62
+ ];
63
+ }
64
+
65
+ function detectionFromRow(row: ArrayLike<number>, offset: number, stride: number, size: number): FleetDetection | null {
66
+ const confidence = Number(row[offset + 4]);
67
+ const classId = Math.round(Number(row[offset + 5]));
68
+ const hazardType = HAZARD_CLASSES[classId];
69
+ if (!hazardType || !Number.isFinite(confidence)) return null;
70
+
71
+ const bbox: [number, number, number, number] = clipBox(
72
+ [
73
+ Number(row[offset]),
74
+ Number(row[offset + 1]),
75
+ Number(row[offset + 2]),
76
+ Number(row[offset + 3]),
77
+ ],
78
+ size,
79
+ );
80
+ if (bbox[2] <= bbox[0] || bbox[3] <= bbox[1] || stride < 6) return null;
81
+ return { hazard_type: hazardType, confidence, bbox };
82
+ }
83
+
84
+ function parseNmsOutput(output: ort.Tensor, size: number): FleetDetection[] | null {
85
+ const data = output.data as Float32Array;
86
+ const dims = output.dims;
87
+ let rows = 0;
88
+ let stride = 0;
89
+
90
+ if (dims.length === 2 && dims[1] >= 6) {
91
+ rows = dims[0];
92
+ stride = dims[1];
93
+ } else if (dims.length === 3 && dims[2] >= 6) {
94
+ rows = dims[1];
95
+ stride = dims[2];
96
+ }
97
+ if (!rows || !stride) return null;
98
+
99
+ const detections: FleetDetection[] = [];
100
+ for (let r = 0; r < rows; r += 1) {
101
+ const det = detectionFromRow(data, r * stride, stride, size);
102
+ if (det && det.confidence > 0.25) detections.push(det);
103
+ }
104
+ return detections;
105
+ }
106
+
107
+ function parseRawYoloOutput(output: ort.Tensor, size: number): FleetDetection[] {
108
+ const data = output.data as Float32Array;
109
+ const dims = output.dims;
110
+ if (dims.length !== 3 || dims[1] < 9) return [];
111
+
112
+ const featureCount = dims[1];
113
+ const boxCount = dims[2];
114
+ const detections: FleetDetection[] = [];
115
+ for (let i = 0; i < boxCount; i += 1) {
116
+ let bestClass = -1;
117
+ let bestScore = 0;
118
+ for (let c = 0; c < HAZARD_CLASSES.length; c += 1) {
119
+ const score = data[(4 + c) * boxCount + i];
120
+ if (score > bestScore) {
121
+ bestScore = score;
122
+ bestClass = c;
123
+ }
124
+ }
125
+ const hazardType = HAZARD_CLASSES[bestClass];
126
+ if (!hazardType || bestScore <= 0.25) continue;
127
+
128
+ const cx = data[i];
129
+ const cy = data[boxCount + i];
130
+ const w = data[boxCount * 2 + i];
131
+ const h = data[boxCount * 3 + i];
132
+ if (!Number.isFinite(cx + cy + w + h) || featureCount < 9) continue;
133
+ const bbox = clipBox([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], size);
134
+ detections.push({ hazard_type: hazardType, confidence: bestScore, bbox });
135
+ }
136
+ return detections.sort((a, b) => b.confidence - a.confidence).slice(0, 12);
137
+ }
138
+
139
+ function parseDetections(results: Record<string, ort.Tensor>, size: number): FleetDetection[] {
140
+ const output = Object.values(results)[0];
141
+ if (!output) return [];
142
+ return parseNmsOutput(output, size) ?? parseRawYoloOutput(output, size);
143
+ }
144
+
145
+ async function infer(width: number, height: number, buffer: ArrayBuffer): Promise<void> {
146
+ if (!session) throw new Error("Inference worker is not initialized.");
147
+ const started = performance.now();
148
+ const tensor = preprocess(buffer, width, height);
149
+ const inputName = session.inputNames[0];
150
+ const results = (await session.run({ [inputName]: tensor })) as Record<string, ort.Tensor>;
151
+ post({
152
+ type: "result",
153
+ detections: parseDetections(results, width),
154
+ inferMs: Math.round(performance.now() - started),
155
+ });
156
+ }
157
+
158
+ self.addEventListener("message", (event: MessageEvent<WorkerRequest>) => {
159
+ const msg = event.data;
160
+ const task = msg.type === "init" ? init(msg.modelUrl) : infer(msg.width, msg.height, msg.buffer);
161
+ task.catch((err: unknown) => {
162
+ post({ type: "error", message: err instanceof Error ? err.message : "Fleet worker failed." });
163
+ });
164
+ });