// HK Plate Scanner v3 — HK-only, PP-OCR v6, YOLOv11 small, multi-frame voting, debug mode const { useState, useEffect, useRef, useCallback } = React; function beep(kind) { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const o = ctx.createOscillator(); const g = ctx.createGain(); o.connect(g); g.connect(ctx.destination); if (kind === 'new') { o.frequency.setValueAtTime(880, ctx.currentTime); o.frequency.setValueAtTime(1320, ctx.currentTime + 0.06); } else if (kind === 'partial') { o.frequency.setValueAtTime(440, ctx.currentTime); } else { o.frequency.setValueAtTime(660, ctx.currentTime); } g.gain.setValueAtTime(0.001, ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.12, ctx.currentTime + 0.02); g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.18); o.start(); o.stop(ctx.currentTime + 0.22); setTimeout(() => ctx.close(), 400); } catch (e) {} } function App() { const [modelReady, setModelReady] = useState(false); const [loading, setLoading] = useState(true); const [loadMsg, setLoadMsg] = useState('初始化…'); const [loadProg, setLoadProg] = useState(0); const [loadErr, setLoadErr] = useState(null); const [records, setRecords] = useState([]); const [search, setSearch] = useState(''); const [detail, setDetail] = useState(null); const [scanning, setScanning] = useState(false); const [torch, setTorch] = useState(false); const [cameraStatus, setCameraStatus] = useState('init'); const [liveChips, setLiveChips] = useState([]); const [exportMenu, setExportMenu] = useState(false); const [toast, setToast] = useState(null); const [soundOn, setSoundOn] = useState(true); const [debugMode, setDebugMode] = useState(false); const [debugData, setDebugData] = useState(null); // last frame's debug info const [clearDialog, setClearDialog] = useState(null); // null | { step, unexported } const videoRef = useRef(null); const overlayRef = useRef(null); const streamRef = useRef(null); const rafRef = useRef(null); const busyRef = useRef(false); const trackerRef = useRef(null); const lastConfirmRef = useRef(new Map()); // plate -> ts (throttle re-adds) // Load models useEffect(() => { (async () => { try { setLoadMsg('載入引擎…'); setLoadProg(0.02); await window.__loadHeavy(); await window.PlateDetectorV3.loadModels(({ progress, msg }) => { if (progress != null) setLoadProg(progress); if (msg) setLoadMsg(msg); }); setModelReady(true); setLoading(false); trackerRef.current = new window.PlateDetectorV3.VotingTracker({ windowMs: 3500, minVotes: 2 }); } catch (e) { console.error(e); setLoadErr(String(e.message || e)); setLoading(false); } })(); }, []); // Records + camera useEffect(() => { (async () => { // Optional demo seed via ?demo=1 if (new URLSearchParams(location.search).get('demo') === '1') { const c = await window.PlateStorageV3.count(); if (c === 0) { const now = Date.now(); const seeds = [ { plate: 'AB1234', display: 'AB 1234', lastSeen: now, confidence: 0.92 }, { plate: 'AB1234', display: 'AB 1234', lastSeen: now, confidence: 0.92 }, { plate: 'LM88', display: 'LM 88', lastSeen: now - 1200000, confidence: 0.95 }, { plate: 'PV6543', display: 'PV 6543', lastSeen: now - 7200000, confidence: 0.87 }, { plate: 'HK1', display: 'HK 1', lastSeen: now - 86400000, confidence: 0.90 }, ]; for (const s of seeds) await window.PlateStorageV3.addOrUpdate(s); } } refresh(); })(); startCamera(); return () => stopCamera(); }, []); useEffect(() => { refresh(); }, [search]); async function refresh() { if (!window.PlateStorageV3) return; const items = await window.PlateStorageV3.getAll({ search }); setRecords(items); } async function startCamera() { try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 }, }, audio: false, }); streamRef.current = stream; if (videoRef.current) { videoRef.current.srcObject = stream; await videoRef.current.play(); } setCameraStatus('ok'); } catch (e) { console.warn('Camera err', e); setCameraStatus('err'); } } function stopCamera() { if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null; } if (rafRef.current) cancelAnimationFrame(rafRef.current); } async function toggleTorch() { if (!streamRef.current) return; const track = streamRef.current.getVideoTracks()[0]; const caps = track.getCapabilities?.(); if (!caps || !caps.torch) { showToast('本裝置不支援手電筒'); return; } try { await track.applyConstraints({ advanced: [{ torch: !torch }] }); setTorch(!torch); } catch (e) { console.warn(e); } } function showToast(msg, dur = 1800) { setToast(msg); setTimeout(() => setToast(null), dur); } // === Live detection loop === const runFrame = useCallback(async () => { if (!scanning || busyRef.current) { if (scanning) rafRef.current = requestAnimationFrame(runFrame); return; } const v = videoRef.current; if (!v || v.readyState < 2) { rafRef.current = requestAnimationFrame(runFrame); return; } busyRef.current = true; try { const w = v.videoWidth, h = v.videoHeight; const results = await window.PlateDetectorV3.processFrame(v, w, h, { debug: debugMode }); renderOverlay(results, w, h); if (debugMode) setDebugData({ ts: Date.now(), results }); await handleResults(results, v, w, h); } catch (e) { console.warn(e); } busyRef.current = false; if (scanning) rafRef.current = requestAnimationFrame(runFrame); }, [scanning, debugMode]); useEffect(() => { if (scanning) rafRef.current = requestAnimationFrame(runFrame); else if (rafRef.current) cancelAnimationFrame(rafRef.current); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; }, [scanning, runFrame]); function renderOverlay(results, srcW, srcH) { const svg = overlayRef.current; if (!svg) return; svg.setAttribute('viewBox', `0 0 ${srcW} ${srcH}`); svg.setAttribute('preserveAspectRatio', 'xMidYMid slice'); svg.innerHTML = ''; for (const r of results) { const b = r.box; const color = r.valid ? '#34c759' : (r.raw ? '#ff9500' : '#8e8e93'); const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', b.x1); rect.setAttribute('y', b.y1); rect.setAttribute('width', b.x2 - b.x1); rect.setAttribute('height', b.y2 - b.y1); rect.setAttribute('fill', 'none'); rect.setAttribute('stroke', color); rect.setAttribute('stroke-width', '3'); rect.setAttribute('rx', '4'); svg.appendChild(rect); // Label: show plate if valid, else raw text (in debug) if (r.plate || (debugMode && r.raw)) { const label = r.display || r.raw || ''; const textElem = document.createElementNS('http://www.w3.org/2000/svg', 'text'); textElem.setAttribute('x', b.x1); textElem.setAttribute('y', Math.max(20, b.y1 - 6)); textElem.setAttribute('fill', color); textElem.setAttribute('font-family', 'SF Mono, monospace'); textElem.setAttribute('font-size', Math.max(16, Math.round((b.x2 - b.x1) * 0.08))); textElem.setAttribute('font-weight', '700'); textElem.setAttribute('stroke', 'rgba(0,0,0,0.7)'); textElem.setAttribute('stroke-width', '3'); textElem.setAttribute('paint-order', 'stroke'); textElem.textContent = label; svg.appendChild(textElem); } } } async function handleResults(results, imageSource, srcW, srcH) { for (const r of results) { // Only valid HK plates count if (!r.plate) { trackerRef.current?.vote(null, 0); continue; } const vote = trackerRef.current?.vote(r.plate, r.rawConfidence || 0.5) || { confirmed: true, votes: 1 }; if (!vote.confirmed) { // Just show as partial candidate pushLiveChip({ display: r.display, plate: r.plate, votes: vote.votes, pending: true }); if (soundOn) beep('partial'); continue; } // Throttle: don't re-add if we just added this plate in last 5s const lastTs = lastConfirmRef.current.get(r.plate); if (lastTs && Date.now() - lastTs < 5000) continue; lastConfirmRef.current.set(r.plate, Date.now()); const thumb = await makeThumbnail(imageSource, r.box, srcW, srcH); const { record: saved, isNew } = await window.PlateStorageV3.addOrUpdate({ plate: r.plate, display: r.display, lastSeen: Date.now(), confidence: r.rawConfidence, thumbnail: thumb, }); if (soundOn) beep(isNew ? 'new' : 'ok'); pushLiveChip({ display: r.display, plate: r.plate, isNew }); refresh(); } } function pushLiveChip(chip) { const c = { ...chip, id: Date.now() + Math.random() }; setLiveChips(prev => [c, ...prev].slice(0, 2)); setTimeout(() => setLiveChips(p => p.filter(x => x.id !== c.id)), 2400); } async function makeThumbnail(src, box, srcW, srcH) { const pad = 12; const bx1 = Math.max(0, box.x1 - pad); const by1 = Math.max(0, box.y1 - pad); const bx2 = Math.min(srcW, box.x2 + pad); const by2 = Math.min(srcH, box.y2 + pad); const w = bx2 - bx1, h = by2 - by1; const targetW = 260; const targetH = Math.round(h * (targetW / w)); const canvas = document.createElement('canvas'); canvas.width = targetW; canvas.height = targetH; canvas.getContext('2d').drawImage(src, bx1, by1, w, h, 0, 0, targetW, targetH); return await new Promise(res => canvas.toBlob(res, 'image/jpeg', 0.8)); } // Photo / video upload async function handlePhotoUpload(e) { const file = e.target.files?.[0]; if (!file) return; e.target.value = ''; const url = URL.createObjectURL(file); const img = new Image(); img.onload = async () => { try { showToast('分析中…'); const results = await window.PlateDetectorV3.processFrame(img, img.naturalWidth, img.naturalHeight, { detectConf: 0.1, maxBoxes: 6, debug: debugMode, }); // For static image, always accept (no voting) const validResults = results.filter(r => r.plate); for (const r of validResults) { const thumb = await makeThumbnail(img, r.box, img.naturalWidth, img.naturalHeight); await window.PlateStorageV3.addOrUpdate({ plate: r.plate, display: r.display, lastSeen: Date.now(), confidence: r.rawConfidence, thumbnail: thumb, }); pushLiveChip({ display: r.display, isNew: true }); if (soundOn) beep('new'); } if (debugMode) setDebugData({ ts: Date.now(), results }); refresh(); if (validResults.length === 0 && results.length > 0 && debugMode) { showToast(`偵測到 ${results.length} 個 box 但無有效車牌`); } else { showToast(`識別到 ${validResults.length} 個香港車牌`); } } catch (err) { console.error(err); showToast('識別失敗'); } URL.revokeObjectURL(url); }; img.src = url; } async function handleVideoUpload(e) { const file = e.target.files?.[0]; if (!file) return; e.target.value = ''; const url = URL.createObjectURL(file); const video = document.createElement('video'); video.src = url; video.muted = true; video.playsInline = true; await new Promise(r => { video.onloadedmetadata = r; }); await video.play(); showToast('逐幀掃描影片…', 3000); let processed = 0, found = 0; const localTracker = new window.PlateDetectorV3.VotingTracker({ windowMs: 5000, minVotes: 2 }); const scan = async () => { if (video.ended) { showToast(`影片掃描完成 · ${processed} 幀 · 找到 ${found} 個車牌`); URL.revokeObjectURL(url); return; } try { const results = await window.PlateDetectorV3.processFrame(video, video.videoWidth, video.videoHeight, { maxBoxes: 3 }); for (const r of results) { if (!r.plate) continue; const v = localTracker.vote(r.plate, r.rawConfidence); if (!v.confirmed) continue; const lastTs = lastConfirmRef.current.get(r.plate); if (lastTs && Date.now() - lastTs < 3000) continue; lastConfirmRef.current.set(r.plate, Date.now()); const thumb = await makeThumbnail(video, r.box, video.videoWidth, video.videoHeight); await window.PlateStorageV3.addOrUpdate({ plate: r.plate, display: r.display, lastSeen: Date.now(), confidence: r.rawConfidence, thumbnail: thumb, }); found++; pushLiveChip({ display: r.display, isNew: true }); refresh(); } processed++; } catch (err) {} setTimeout(scan, 400); }; scan(); } async function handleExport(kind) { setExportMenu(false); if (!records.length) { showToast('沒有記錄可匯出'); return; } try { if (kind === 'csv') { await window.PlateStorageV3.downloadCSV(); showToast('CSV 已匯出'); } else if (kind === 'pdf') { showToast('生成 PDF 中…'); await window.PlateStorageV3.downloadPDF(); showToast('PDF 已匯出'); } } catch (e) { showToast('匯出失敗: ' + e.message); } } // ==== Clear flow ==== async function openClearDialog() { console.log('[clear] button pressed, records=', records.length); if (!records.length) { showToast('沒有記錄'); return; } try { if (!window.PlateStorageV3?.hasUnexportedRecords) { // Fallback: skip the "unexported" check if storage was not upgraded console.warn('[clear] hasUnexportedRecords not available, using default'); setClearDialog({ step: 'confirm', unexported: false, count: records.length }); return; } const unexported = await window.PlateStorageV3.hasUnexportedRecords(); console.log('[clear] unexported =', unexported); setClearDialog({ step: unexported ? 'warn' : 'confirm', unexported, count: records.length }); } catch (e) { console.error('[clear] error', e); showToast('無法開啟清除對話框: ' + e.message); // Still show a basic confirm even on error setClearDialog({ step: 'confirm', unexported: false, count: records.length }); } } async function handleClearAction(action) { if (action === 'cancel') { setClearDialog(null); return; } if (action === 'export-csv') { try { await window.PlateStorageV3.downloadCSV(); setClearDialog({ ...clearDialog, step: 'exported', exportKind: 'CSV' }); } catch (e) { showToast('匯出失敗'); setClearDialog(null); } return; } if (action === 'export-pdf') { try { setClearDialog({ ...clearDialog, step: 'exporting', exportKind: 'PDF' }); await window.PlateStorageV3.downloadPDF(); setClearDialog({ ...clearDialog, step: 'exported', exportKind: 'PDF' }); } catch (e) { showToast('匯出失敗'); setClearDialog(null); } return; } if (action === 'confirm-delete') { try { const n = records.length; await window.PlateStorageV3.clear(); setClearDialog(null); refresh(); showToast(`已清除 ${n} 條記錄`); } catch (e) { showToast('清除失敗'); setClearDialog(null); } return; } } async function handleDelete(plate) { if (!confirm(`刪除 ${plate}?`)) return; await window.PlateStorageV3.remove(plate); setDetail(null); refresh(); showToast('已刪除'); } // Called by DetailModal when user saves a plate edit. // Returns { ok, error, merged } — component uses this to decide next step. // Wrapped in try/catch so we ALWAYS resolve and never leave DetailModal stuck. async function handleRename(oldPlate, newPlate, newDisplay) { let result; try { result = await window.PlateStorageV3.rename(oldPlate, newPlate, newDisplay); } catch (e) { console.error('[rename] storage error', e); showToast('儲存失敗: ' + (e.message || e)); return { ok: false, error: String(e) }; } if (result.ok) { try { await refresh(); if (result.merged) { showToast(`已合併到 ${newPlate}`); setDetail(null); } else { showToast('已更新'); const items = await window.PlateStorageV3.getAll(); const updated = items.find(r => r.plate === newPlate); if (updated) setDetail(updated); } } catch (e) { console.error('[rename] post-save error', e); // Save actually succeeded — don't fail the operation showToast('已更新(頁面刷新失敗)'); } } else { showToast(result.error || '更新失敗'); } return result; } return ( <> {/* ===== Camera half ===== */}