Deeraj commited on
Commit ·
f17ffc5
1
Parent(s): d5beafd
Feat: add fleet mode and inference telemetry worker
Browse files- .gitattributes +1 -0
- README.md +2 -1
- backend/app/api/fleet.py +135 -0
- backend/app/config.py +7 -0
- backend/app/db/repository.py +26 -1
- backend/app/db/session.py +58 -0
- backend/app/main.py +39 -2
- backend/app/models/schemas.py +35 -6
- backend/app/models/tables.py +9 -1
- backend/app/pipeline/dedup.py +129 -57
- backend/app/pipeline/detect.py +15 -7
- backend/seed.py +5 -3
- backend/tests/test_dashboard.py +1 -1
- backend/tests/test_dedup.py +6 -6
- backend/tests/test_fleet.py +131 -0
- backend/tests/test_privacy_gate.py +1 -1
- backend/tests/test_review_history.py +1 -1
- backend/tests/test_video.py +1 -1
- frontend/package-lock.json +144 -27
- frontend/package.json +1 -0
- frontend/public/models/README.md +11 -0
- frontend/public/models/roadrecon-yolov8n-320-int8.onnx +3 -0
- frontend/src/App.tsx +2 -0
- frontend/src/components/ClusterDrawer.tsx +9 -1
- frontend/src/lib/api.ts +48 -9
- frontend/src/lib/fleetTelemetry.ts +83 -0
- frontend/src/lib/i18n.tsx +45 -12
- frontend/src/pages/FleetMode.tsx +389 -0
- frontend/src/workers/fleetInference.worker.ts +164 -0
.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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
other = "other"
|
| 17 |
|
| 18 |
|
|
@@ -24,7 +24,7 @@ class Severity(str, Enum):
|
|
| 24 |
|
| 25 |
class Source(str, Enum):
|
| 26 |
citizen = "citizen"
|
| 27 |
-
|
| 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
|
|
|
|
| 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
|
| 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 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 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 |
-
|
| 71 |
-
|
| 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.
|
| 24 |
-
"d10": HazardType.
|
| 25 |
-
"d20": HazardType.
|
| 26 |
"d40": HazardType.pothole,
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
"
|
| 30 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 41 |
-
HazardType.
|
| 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.
|
| 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": "
|
| 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
|
| 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.
|
| 60 |
s.commit()
|
| 61 |
clusters = recluster_near(*PT, s)
|
| 62 |
-
assert len(clusters) ==
|
| 63 |
-
c = clusters
|
| 64 |
assert c.severity == Severity.high # max severity
|
| 65 |
-
assert c.
|
| 66 |
-
assert c.report_count ==
|
| 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": "
|
| 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": "
|
| 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.
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
| "
|
| 10 |
-
| "
|
| 11 |
-
| "
|
| 12 |
-
| "
|
| 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" | "
|
| 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 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 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 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 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 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 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 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 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 |
+
});
|