| 1 | "use client"; |
| 2 | import { useRef, useState } from "react"; |
| 3 | import Link from "next/link"; |
| 4 | import { api, PlateDetection, VideoPlate, VideoResult } from "@/lib/api"; |
| 5 | |
| 6 | type Mode = "image" | "video" | "rtsp"; |
| 7 | |
| 8 | export default function RecognizePage() { |
| 9 | const [mode, setMode] = useState<Mode>("image"); |
| 10 | const [busy, setBusy] = useState(false); |
| 11 | const [err, setErr] = useState<string | null>(null); |
| 12 | const [ingest, setIngest] = useState(false); |
| 13 | |
| 14 | const [preview, setPreview] = useState<string | null>(null); |
| 15 | const [dims, setDims] = useState({ w: 0, h: 0 }); |
| 16 | const [plates, setPlates] = useState<PlateDetection[]>([]); |
| 17 | const imgRef = useRef<HTMLInputElement>(null); |
| 18 | |
| 19 | const [vres, setVres] = useState<VideoResult | null>(null); |
| 20 | const vidRef = useRef<HTMLInputElement>(null); |
| 21 | const [rtspUrl, setRtspUrl] = useState(""); |
| 22 | const [probe, setProbe] = useState<string | null>(null); |
| 23 | |
| 24 | async function runImage(file: File) { |
| 25 | setErr(null); setPlates([]); setBusy(true); |
| 26 | const url = URL.createObjectURL(file); setPreview(url); |
| 27 | const im = new Image(); im.onload = () => setDims({ w: im.naturalWidth, h: im.naturalHeight }); im.src = url; |
| 28 | try { |
| 29 | const r = ingest ? await api.alprIngest(file, { min_conf: 0.35 }) : await api.alprRecognize(file, 0.35); |
| 30 | setPlates(r.plates); |
| 31 | } catch (e: any) { setErr(msg(e)); } finally { setBusy(false); } |
| 32 | } |
| 33 | async function runVideo(file: File) { |
| 34 | setErr(null); setVres(null); setBusy(true); |
| 35 | try { setVres(await api.alprVideo(file, { sample_fps: 3, min_conf: 0.35, ingest })); } |
| 36 | catch (e: any) { setErr(msg(e)); } finally { setBusy(false); } |
| 37 | } |
| 38 | async function doProbe() { |
| 39 | setProbe(null); setErr(null); |
| 40 | try { const p = await api.rtspProbe(rtspUrl); setProbe(p.decoded ? `reachable · ${p.width}×${p.height}` : "reachable but no frame decoded"); } |
| 41 | catch { setProbe("unreachable"); } |
| 42 | } |
| 43 | async function runRtsp() { |
| 44 | setErr(null); setVres(null); setBusy(true); |
| 45 | try { setVres(await api.rtspCapture({ url: rtspUrl, seconds: 15, sample_fps: 2, min_conf: 0.4, ingest })); } |
| 46 | catch (e: any) { setErr(msg(e)); } finally { setBusy(false); } |
| 47 | } |
| 48 | function msg(e: any) { return e.status === 401 ? "Sign in." : e.status === 403 ? "Ingest needs analyst role." : e.status === 502 ? "Stream/fetch failed." : "Recognition failed."; } |
| 49 | |
| 50 | return ( |
| 51 | <div> |
| 52 | <h1>ALPR Recognition Engine</h1> |
| 53 | <p className="sub">Plate detection (YOLO/ONNX) + OCR over images, video clips, and RTSP/HTTP streams. |
| 54 | Recognized plates flow through the same pipeline as everything else.</p> |
| 55 | |
| 56 | <div className="row" style={{ margin: "12px 0" }}> |
| 57 | {(["image", "video", "rtsp"] as Mode[]).map((m) => ( |
| 58 | <span key={m} className={"chip " + (mode === m ? "on" : "")} onClick={() => { setMode(m); setVres(null); setPlates([]); }}> |
| 59 | {m === "image" ? "Image" : m === "video" ? "Video clip" : "RTSP / stream"} |
| 60 | </span> |
| 61 | ))} |
| 62 | <div style={{ flex: 1 }} /> |
| 63 | <label className="row" style={{ gap: 6, fontSize: 12 }}> |
| 64 | <input type="checkbox" checked={ingest} onChange={(e) => setIngest(e.target.checked)} style={{ width: 14 }} /> |
| 65 | create real reads |
| 66 | </label> |
| 67 | </div> |
| 68 | {err && <div className="badge danger" style={{ marginBottom: 10 }}>{err}</div>} |
| 69 | |
| 70 | {mode === "image" && ( |
| 71 | <> |
| 72 | <div className="card" onClick={() => imgRef.current?.click()} onDragOver={(e) => e.preventDefault()} |
| 73 | onDrop={(e) => { e.preventDefault(); if (e.dataTransfer.files[0]) runImage(e.dataTransfer.files[0]); }} |
| 74 | style={{ textAlign: "center", padding: 28, borderStyle: "dashed", cursor: "pointer" }}> |
| 75 | <input ref={imgRef} type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && runImage(e.target.files[0])} /> |
| 76 | {busy ? "Recognizing…" : "Drop a vehicle/plate image, or click to browse"} |
| 77 | </div> |
| 78 | {preview && ( |
| 79 | <div className="card" style={{ marginTop: 14, display: "inline-block" }}> |
| 80 | <div style={{ position: "relative", display: "inline-block", maxWidth: "100%" }}> |
| 81 | <img src={preview} alt="" style={{ maxWidth: "100%", borderRadius: 8, display: "block" }} /> |
| 82 | <svg viewBox={`0 0 ${dims.w} ${dims.h}`} preserveAspectRatio="none" style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }}> |
| 83 | {plates.map((p, i) => p.bbox.length === 4 && ( |
| 84 | <g key={i}> |
| 85 | <rect x={p.bbox[0]} y={p.bbox[1]} width={p.bbox[2] - p.bbox[0]} height={p.bbox[3] - p.bbox[1]} fill="none" stroke="#2dd4bf" strokeWidth={Math.max(2, dims.w / 300)} /> |
| 86 | <text x={p.bbox[0]} y={p.bbox[1] - 6} fill="#2dd4bf" fontSize={Math.max(14, dims.w / 28)} fontWeight="700" fontFamily="monospace">{p.plate_norm}</text> |
| 87 | </g> |
| 88 | ))} |
| 89 | </svg> |
| 90 | </div> |
| 91 | </div> |
| 92 | )} |
| 93 | <PlateTable rows={plates.map((p) => ({ plate_norm: p.plate_norm, ocr: p.ocr_confidence, extra: `${(p.detection_confidence * 100).toFixed(0)}% det` }))} ingest={ingest} /> |
| 94 | </> |
| 95 | )} |
| 96 | |
| 97 | {mode === "video" && ( |
| 98 | <> |
| 99 | <div className="card" onClick={() => vidRef.current?.click()} onDragOver={(e) => e.preventDefault()} |
| 100 | onDrop={(e) => { e.preventDefault(); if (e.dataTransfer.files[0]) runVideo(e.dataTransfer.files[0]); }} |
| 101 | style={{ textAlign: "center", padding: 28, borderStyle: "dashed", cursor: "pointer" }}> |
| 102 | <input ref={vidRef} type="file" accept="video/*" hidden onChange={(e) => e.target.files?.[0] && runVideo(e.target.files[0])} /> |
| 103 | {busy ? "Processing frames…" : "Drop an .mp4 clip, or click to browse (samples ~3 frames/sec)"} |
| 104 | </div> |
| 105 | <VideoOut res={vres} ingest={ingest} /> |
| 106 | </> |
| 107 | )} |
| 108 | |
| 109 | {mode === "rtsp" && ( |
| 110 | <> |
| 111 | <div className="card"> |
| 112 | <div className="row"> |
| 113 | <input style={{ flex: 1, minWidth: 280 }} placeholder="rtsp://user:pass@host:554/stream (or http(s) MJPEG/HLS)" |
| 114 | value={rtspUrl} onChange={(e) => setRtspUrl(e.target.value)} /> |
| 115 | <button onClick={doProbe} disabled={!rtspUrl}>Probe</button> |
| 116 | <button className="primary" onClick={runRtsp} disabled={!rtspUrl || busy}>{busy ? "Capturing…" : "Capture 15s"}</button> |
| 117 | </div> |
| 118 | {probe && <div className="badge" style={{ marginTop: 10 }}>{probe}</div>} |
| 119 | <p className="muted" style={{ fontSize: 12, marginTop: 10, marginBottom: 0 }}> |
| 120 | Captures a bounded 15s window. For always-on ingest, run <code>rtsp_worker.py <url> <camera></code> on the server. |
| 121 | </p> |
| 122 | </div> |
| 123 | <VideoOut res={vres} ingest={ingest} /> |
| 124 | </> |
| 125 | )} |
| 126 | |
| 127 | <p className="muted" style={{ fontSize: 12, marginTop: 14 }}> |
| 128 | Best on plate-quality footage. Each distinct plate aggregates to one read (best OCR across |
| 129 | the frames it appears in), not one per frame. |
| 130 | </p> |
| 131 | </div> |
| 132 | ); |
| 133 | } |
| 134 | |
| 135 | function PlateTable({ rows, ingest }: { rows: { plate_norm: string; ocr: number; extra: string }[]; ingest: boolean }) { |
| 136 | return ( |
| 137 | <div style={{ marginTop: 14 }}> |
| 138 | <h2>Detected plates</h2> |
| 139 | <div className="card"> |
| 140 | <table> |
| 141 | <thead><tr><th>Plate</th><th>OCR</th><th>Detail</th></tr></thead> |
| 142 | <tbody> |
| 143 | {rows.map((r, i) => ( |
| 144 | <tr key={i}><td><Link href={`/vehicles/${r.plate_norm}`} className="mono">{r.plate_norm}</Link></td> |
| 145 | <td><span className="badge accent">{(r.ocr * 100).toFixed(0)}%</span></td><td className="muted">{r.extra}</td></tr> |
| 146 | ))} |
| 147 | {!rows.length && <tr><td colSpan={3} className="muted">No plates.</td></tr>} |
| 148 | </tbody> |
| 149 | </table> |
| 150 | {ingest && rows.length > 0 && <div className="badge ok" style={{ marginTop: 10 }}>Reads created in pipeline.</div>} |
| 151 | </div> |
| 152 | </div> |
| 153 | ); |
| 154 | } |
| 155 | |
| 156 | function VideoOut({ res, ingest }: { res: VideoResult | null; ingest: boolean }) { |
| 157 | if (!res) return null; |
| 158 | return ( |
| 159 | <div style={{ marginTop: 14 }}> |
| 160 | <div className="row" style={{ marginBottom: 8 }}> |
| 161 | <span className="badge">{res.frames_sampled} frames sampled</span> |
| 162 | <span className="badge accent">{res.frames_with_detections} with plates</span> |
| 163 | <span className="badge">{res.plates.length} distinct plates</span> |
| 164 | {res.ingest && <span className="badge ok">{res.ingest.inserted} reads created</span>} |
| 165 | </div> |
| 166 | <div className="card"> |
| 167 | <table> |
| 168 | <thead><tr><th>Plate</th><th>OCR</th><th>Frames</th><th>Window</th></tr></thead> |
| 169 | <tbody> |
| 170 | {res.plates.map((p: VideoPlate, i) => ( |
| 171 | <tr key={i}> |
| 172 | <td><Link href={`/vehicles/${p.plate_norm}`} className="mono">{p.plate_norm}</Link></td> |
| 173 | <td><span className="badge accent">{(p.ocr_confidence * 100).toFixed(0)}%</span></td> |
| 174 | <td className="mono">{p.frames_seen}</td> |
| 175 | <td className="muted">{p.first_ts.toFixed(1)}s → {p.last_ts.toFixed(1)}s</td> |
| 176 | </tr> |
| 177 | ))} |
| 178 | {!res.plates.length && <tr><td colSpan={4} className="muted">No plates detected in sampled frames.</td></tr>} |
| 179 | </tbody> |
| 180 | </table> |
| 181 | </div> |
| 182 | </div> |
| 183 | ); |
| 184 | } |