● Zion Boggan
repos/Sightline/frontend/src/app/recognize/page.tsx
zionboggan.com ↗
184 lines · tsx
History for this file →
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 &lt;url&gt; &lt;camera&gt;</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
}