● Zion Boggan
repos/Sightline/backend/app/api/routes/alpr.py
zionboggan.com ↗
209 lines · python
History for this file →
1
"""ALPR recognition API: turn images (uploads or public snapshot URLs) into plate reads.
2
 
3
  POST /alpr/recognize       multipart image  -> detected plates (no DB write; demo/test)
4
  POST /alpr/recognize-url   {url}            -> fetch public image, detect plates
5
  POST /alpr/ingest          image + camera   -> detect plates AND create real reads via the pipeline
6
 
7
Recognized plates from real images are real reads and flow through normalize/dedup/watchlist
8
exactly like a file import or live stream."""
9
from __future__ import annotations
10
 
11
from datetime import datetime, timezone
12
 
13
import httpx
14
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
15
from pydantic import BaseModel
16
from sqlalchemy import select
17
from sqlalchemy.orm import Session
18
 
19
from app.api.deps import Perm, get_db, require
20
from app.core.config import settings
21
from app.models import AuditLog, Camera, Location, User
22
from app.services.ingest import ingest_live_reads
23
 
24
router = APIRouter(prefix="/alpr", tags=["alpr"])
25
 
26
 
27
def _to_dicts(dets):
28
    return [{"plate": d.plate, "plate_norm": d.plate_norm,
29
             "detection_confidence": round(d.detection_confidence, 3),
30
             "ocr_confidence": round(d.ocr_confidence, 3), "bbox": d.bbox} for d in dets]
31
 
32
 
33
@router.post("/recognize")
34
async def recognize(file: UploadFile = File(...), min_conf: float = Form(default=0.4),
35
                    user: User = Depends(require(Perm.READ))):
36
    from app.services.alpr import recognize_bytes
37
    data = await file.read()
38
    if not data:
39
        raise HTTPException(400, "empty image")
40
    try:
41
        dets = recognize_bytes(data, min_conf=min_conf)
42
    except Exception as e:
43
        raise HTTPException(422, f"recognition failed: {e}")
44
    return {"plates": _to_dicts(dets), "count": len(dets)}
45
 
46
 
47
class UrlReq(BaseModel):
48
    url: str
49
    min_conf: float = 0.4
50
 
51
 
52
@router.post("/recognize-url")
53
def recognize_url(req: UrlReq, user: User = Depends(require(Perm.READ))):
54
    from app.services.alpr import recognize_bytes
55
    try:
56
        r = httpx.get(req.url, timeout=30, follow_redirects=True,
57
                      headers={"User-Agent": "Sightline-ALPR/1.0"})
58
        r.raise_for_status()
59
    except Exception as e:
60
        raise HTTPException(502, f"could not fetch image: {e}")
61
    try:
62
        dets = recognize_bytes(r.content, min_conf=req.min_conf)
63
    except Exception as e:
64
        raise HTTPException(422, f"recognition failed: {e}")
65
    return {"url": req.url, "bytes": len(r.content), "plates": _to_dicts(dets), "count": len(dets)}
66
 
67
 
68
@router.post("/ingest")
69
async def ingest_image(
70
    file: UploadFile = File(...),
71
    camera_id: str | None = Form(default=None),
72
    camera_external_id: str | None = Form(default=None),
73
    state: str | None = Form(default=None),
74
    min_conf: float = Form(default=0.4),
75
    db: Session = Depends(get_db),
76
    user: User = Depends(require(Perm.INGEST)),
77
):
78
    """Recognize plate(s) in an authorized image and create real reads attached to a camera."""
79
    from app.services.alpr import recognize_bytes
80
    data = await file.read()
81
    if not data:
82
        raise HTTPException(400, "empty image")
83
 
84
    cam = None
85
    if camera_id:
86
        cam = db.get(Camera, camera_id)
87
    elif camera_external_id:
88
        cam = db.scalar(select(Camera).where(Camera.external_camera_id == camera_external_id))
89
    if (camera_id or camera_external_id) and not cam:
90
        raise HTTPException(404, "camera not found")
91
 
92
    try:
93
        dets = recognize_bytes(data, min_conf=min_conf)
94
    except Exception as e:
95
        raise HTTPException(422, f"recognition failed: {e}")
96
    if not dets:
97
        return {"recognized": 0, "inserted": 0, "plates": []}
98
 
99
    loc = db.get(Location, cam.location_id) if cam and cam.location_id else None
100
    now = datetime.now(timezone.utc)
101
    reads = [{"plate": d.plate_norm, "state": (state or (cam.state if cam else None)),
102
              "timestamp": now.isoformat(),
103
              "camera": cam.external_camera_id if cam else "alpr-upload",
104
              "latitude": loc.lat if loc else None, "longitude": loc.lon if loc else None,
105
              "confidence": d.ocr_confidence} for d in dets]
106
    summary = ingest_live_reads(db, cam.source_id if cam else None, reads,
107
                                label="alpr-recognition", created_by=user.id)
108
    db.add(AuditLog(actor_user_id=user.id, action="alpr.ingest",
109
                    entity_type="import", entity_id=summary.get("import_id"),
110
                    meta={"recognized": len(dets), "camera": camera_external_id or camera_id}))
111
    db.commit()
112
    return {"recognized": len(dets), **summary, "plates": _to_dicts(dets)}
113
 
114
 
115
def _resolve_cam(db, camera_id, camera_external_id):
116
    cam = None
117
    if camera_id:
118
        cam = db.get(Camera, camera_id)
119
    elif camera_external_id:
120
        cam = db.scalar(select(Camera).where(Camera.external_camera_id == camera_external_id))
121
    if (camera_id or camera_external_id) and not cam:
122
        raise HTTPException(404, "camera not found")
123
    return cam
124
 
125
 
126
def _ingest_tracks(db, user, cam, plates, state, label):
127
    """Turn aggregated video/stream plate tracks into reads through the pipeline."""
128
    loc = db.get(Location, cam.location_id) if cam and cam.location_id else None
129
    now = datetime.now(timezone.utc)
130
    reads = [{"plate": p["plate_norm"], "state": state or (cam.state if cam else None),
131
              "timestamp": now.isoformat(), "camera": cam.external_camera_id if cam else "alpr-video",
132
              "latitude": loc.lat if loc else None, "longitude": loc.lon if loc else None,
133
              "confidence": p["ocr_confidence"]} for p in plates]
134
    summary = ingest_live_reads(db, cam.source_id if cam else None, reads, label=label, created_by=user.id)
135
    db.add(AuditLog(actor_user_id=user.id, action="alpr.video_ingest",
136
                    entity_type="import", entity_id=summary.get("import_id"),
137
                    meta={"plates": len(plates), "label": label}))
138
    db.commit()
139
    return summary
140
 
141
 
142
@router.post("/video")
143
async def recognize_video(
144
    file: UploadFile = File(...),
145
    sample_fps: float = Form(default=2.0),
146
    max_frames: int = Form(default=200),
147
    min_conf: float = Form(default=0.4),
148
    ingest: bool = Form(default=False),
149
    camera_external_id: str | None = Form(default=None),
150
    state: str | None = Form(default=None),
151
    db: Session = Depends(get_db),
152
    user: User = Depends(require(Perm.READ)),
153
):
154
    """Process an uploaded video clip: sample frames, recognize plates, aggregate to one entry per
155
    plate. Set ingest=true (analyst role) to create reads."""
156
    from app.services.video_alpr import process_video_bytes
157
    data = await file.read()
158
    if not data:
159
        raise HTTPException(400, "empty video")
160
    try:
161
        result = process_video_bytes(data, sample_fps=sample_fps, max_frames=max_frames, min_conf=min_conf)
162
    except Exception as e:
163
        raise HTTPException(422, f"video processing failed: {e}")
164
    if ingest and result["plates"]:
165
        from app.core.permissions import Perm as _P, role_has
166
        if not role_has(user.role, _P.INGEST):
167
            raise HTTPException(403, "ingest requires analyst role")
168
        cam = _resolve_cam(db, None, camera_external_id)
169
        result["ingest"] = _ingest_tracks(db, user, cam, result["plates"], state, "alpr-video")
170
    return result
171
 
172
 
173
class RtspReq(BaseModel):
174
    url: str
175
    seconds: float = 15.0
176
    sample_fps: float = 2.0
177
    min_conf: float = 0.4
178
    ingest: bool = False
179
    camera_external_id: str | None = None
180
    state: str | None = None
181
 
182
 
183
@router.post("/rtsp/probe")
184
def rtsp_probe(req: RtspReq, user: User = Depends(require(Perm.READ))):
185
    """Verify an RTSP/HTTP stream is reachable and decodable (grabs one frame)."""
186
    from app.services.video_alpr import probe_stream
187
    try:
188
        return probe_stream(req.url)
189
    except Exception as e:
190
        raise HTTPException(502, f"stream probe failed: {e}")
191
 
192
 
193
@router.post("/rtsp/capture")
194
def rtsp_capture(req: RtspReq, db: Session = Depends(get_db), user: User = Depends(require(Perm.READ))):
195
    """Capture a bounded window from an RTSP/HTTP stream and recognize plates. Optionally ingest."""
196
    from app.services.video_alpr import process_stream
197
    if req.seconds > 120:
198
        raise HTTPException(400, "capture window capped at 120s")
199
    try:
200
        result = process_stream(req.url, seconds=req.seconds, sample_fps=req.sample_fps, min_conf=req.min_conf)
201
    except Exception as e:
202
        raise HTTPException(502, f"stream capture failed: {e}")
203
    if req.ingest and result["plates"]:
204
        from app.core.permissions import Perm as _P, role_has
205
        if not role_has(user.role, _P.INGEST):
206
            raise HTTPException(403, "ingest requires analyst role")
207
        cam = _resolve_cam(db, None, req.camera_external_id)
208
        result["ingest"] = _ingest_tracks(db, user, cam, result["plates"], req.state, "alpr-rtsp")
209
    return result