| 1 | """Live-camera ALPR sweep: run the recognition engine over live DOT snapshot feeds. |
| 2 | |
| 3 | Selects cameras with has_live_feed=True (optionally filtered by id/state), fetches each |
| 4 | camera's current snapshot, runs recognize_bytes, and pushes every recognized plate through |
| 5 | ingest_live_reads as a real read tied to that camera. |
| 6 | |
| 7 | HONEST LIMITATION: public wide-area traffic cams (NYC DOT, Caltrans) are low-resolution |
| 8 | overview shots - plates are rarely legible, so most scans find 0 plates. That is expected; |
| 9 | this job exists for the cameras/angles where it *does* work and as the mechanism for any |
| 10 | future higher-resolution feed.""" |
| 11 | from __future__ import annotations |
| 12 | |
| 13 | from datetime import datetime, timezone |
| 14 | from urllib.parse import urlparse |
| 15 | |
| 16 | import httpx |
| 17 | from sqlalchemy import select |
| 18 | from sqlalchemy.orm import Session |
| 19 | |
| 20 | from app.models import Camera, Location |
| 21 | |
| 22 | FETCH_TIMEOUT = 10.0 |
| 23 | USER_AGENT = "Mozilla/5.0 Sightline/1.0" |
| 24 | |
| 25 | |
| 26 | def _fetch_snapshot(client: httpx.Client, image_url: str) -> bytes: |
| 27 | """Fetch a camera snapshot the same way the snapshot proxy does (Referer = feed origin).""" |
| 28 | p = urlparse(image_url) |
| 29 | referer = f"{p.scheme}://{p.netloc}/" |
| 30 | r = client.get(image_url, timeout=FETCH_TIMEOUT, follow_redirects=True, |
| 31 | headers={"User-Agent": USER_AGENT, "Referer": referer}) |
| 32 | r.raise_for_status() |
| 33 | return r.content |
| 34 | |
| 35 | |
| 36 | def scan_live_cameras(db: Session, camera_ids: list[str] | None = None, state: str | None = None, |
| 37 | limit: int = 20, min_conf: float = 0.5, |
| 38 | created_by: str | None = None) -> dict: |
| 39 | """Scan a batch of live cameras for plates and ingest anything recognized. |
| 40 | |
| 41 | Resilient per camera: fetch errors, undecodable images, and zero-plate frames are all |
| 42 | skipped, never fatal. Returns a summary; reads (if any) go through the normal |
| 43 | normalize/dedup/watchlist pipeline via a single ingest_live_reads call.""" |
| 44 | from app.services.alpr import recognize_bytes |
| 45 | from app.services.ingest import ingest_live_reads |
| 46 | |
| 47 | q = select(Camera).where(Camera.has_live_feed.is_(True), Camera.image_url.isnot(None)) |
| 48 | if camera_ids: |
| 49 | q = q.where(Camera.id.in_(camera_ids)) |
| 50 | if state: |
| 51 | q = q.where(Camera.state == state.upper()) |
| 52 | cams = db.scalars(q.order_by(Camera.name).limit(limit)).all() |
| 53 | |
| 54 | scanned = 0 |
| 55 | reads: list[dict] = [] |
| 56 | results: list[dict] = [] |
| 57 | source_id: str | None = None |
| 58 | now = datetime.now(timezone.utc) |
| 59 | |
| 60 | with httpx.Client() as client: |
| 61 | for cam in cams: |
| 62 | try: |
| 63 | data = _fetch_snapshot(client, cam.image_url) |
| 64 | dets = recognize_bytes(data, min_conf=min_conf) |
| 65 | except Exception: |
| 66 | continue |
| 67 | scanned += 1 |
| 68 | if not dets: |
| 69 | continue |
| 70 | loc = db.get(Location, cam.location_id) if cam.location_id else None |
| 71 | for d in dets: |
| 72 | reads.append({"plate": d.plate_norm, "state": cam.state, |
| 73 | "timestamp": now.isoformat(), |
| 74 | "camera": cam.external_camera_id, |
| 75 | "latitude": loc.lat if loc else None, |
| 76 | "longitude": loc.lon if loc else None, |
| 77 | "confidence": d.ocr_confidence}) |
| 78 | if source_id is None: |
| 79 | source_id = cam.source_id |
| 80 | results.append({"camera": cam.external_camera_id, |
| 81 | "plates": [{"plate": d.plate_norm, |
| 82 | "ocr_confidence": round(d.ocr_confidence, 3), |
| 83 | "detection_confidence": round(d.detection_confidence, 3), |
| 84 | "bbox": d.bbox} for d in dets]}) |
| 85 | |
| 86 | summary = {"cameras_scanned": scanned, "cameras_with_plates": len(results), |
| 87 | "plates_found": len(reads), "results": results} |
| 88 | if reads: |
| 89 | summary["ingest"] = ingest_live_reads(db, source_id, reads, label="live-alpr-scan", |
| 90 | created_by=created_by) |
| 91 | return summary |