● Zion Boggan
repos/Sightline/backend/app/services/live_alpr_scan.py
zionboggan.com ↗
91 lines · python
History for this file →
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