// 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 ===== */}
{/* ===== Bottom sheet ===== */}

記錄

{records.length} 條 · 香港
{exportMenu && (
)}
setSearch(e.target.value.toUpperCase())} />
{debugMode && debugData && (
DEBUG · {new Date(debugData.ts).toLocaleTimeString()} · {debugData.results.length} boxes
{debugData.results.map((r, i) => { const color = r.valid ? '#34c759' : (r.raw ? '#ff9500' : '#8e8e93'); return (
● {Math.round((r.box.x2-r.box.x1))}px {(r.box.conf * 100).toFixed(0)}% {r.bg || '?'} {r.twoLine && 2L} {r.raw || '—'} → {r.plate || 'x'}
{(r.rejectReason || r.twoLineDebug) && (
{r.rejectReason && reject: {r.rejectReason} } {r.twoLineDebug && 2L: [{r.twoLineDebug.top}]+[{r.twoLineDebug.bot}]}
)}
); })}
)}
{records.length === 0 ? (
未有記錄
開始掃描以記錄香港車牌
需要連續 2 次辨識到才會確認
) : ( records.map(r => setDetail(r)} />) )}
{detail && ( setDetail(null)} onDelete={handleDelete} onRename={handleRename} showToast={showToast} /> )} {clearDialog && } {toast &&
{toast}
} ); } // Smart clear-confirmation dialog with 3 possible states: // warn — records have unexported changes; offer export first // confirm — already exported (or user chose to skip); final confirmation // exporting — PDF being generated // exported — export just finished; asking whether to now delete function ClearConfirm({ state, onAction }) { const { step, count, exportKind } = state; if (step === 'exporting') { return (
正在生成 {exportKind}…
請稍候
); } if (step === 'warn') { return (
{ if (e.target === e.currentTarget) onAction('cancel'); }}>
你未匯出過記錄
有 {count} 條記錄未匯出。
清除之後無法復原,建議先匯出。
); } if (step === 'exported') { return (
{ if (e.target === e.currentTarget) onAction('cancel'); }}>
清除全部 {count} 條記錄?
✓ {exportKind} 已匯出
此操作無法復原
); } // step === 'confirm' (already exported previously) return (
{ if (e.target === e.currentTarget) onAction('cancel'); }}>
清除全部 {count} 條記錄?
此操作無法復原
); } function RecordCard({ rec, onOpen }) { return (
{rec.thumbnail ? :
無圖
}
{rec.display} HK
{timeAgo(rec.lastSeen)} {rec.count > 1 && <>·×{rec.count}}
); } function DetailModal({ rec, onClose, onDelete, onRename, showToast }) { const [editing, setEditing] = useState(false); const [inputVal, setInputVal] = useState(rec.plate); const [saving, setSaving] = useState(false); const inputRef = useRef(null); // Reset local state whenever rec changes useEffect(() => { setInputVal(rec.plate); setEditing(false); }, [rec.plate]); useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } }, [editing]); const cleanedVal = (inputVal || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); const isValidHK = /^[A-Z0-9]{1,8}$/.test(cleanedVal); // 1-8 alnum accepted const isStrictHK = /^[A-Z]{1,3}\d{1,4}$/.test(cleanedVal); // classic layout const hasChange = cleanedVal !== rec.plate; function formatDisplay(plate) { // Insert space between letters and digits ONLY for classic HK format. // Personalised plates (like all-letter "GENIUS") stay as-is. if (!/^[A-Z]{1,3}\d{1,4}$/.test(plate)) return plate; const m = plate.match(/^([A-Z]+)(\d+)$/); return m ? `${m[1]} ${m[2]}` : plate; } async function handleSave() { if (!cleanedVal) { showToast?.('車牌不能為空'); return; } if (!hasChange) { setEditing(false); return; } setSaving(true); const display = isValidHK ? formatDisplay(cleanedVal) : cleanedVal; let result; try { result = await onRename(rec.plate, cleanedVal, display); } catch (e) { console.error('[edit] save failed', e); showToast?.('儲存失敗: ' + (e.message || e)); } finally { // Always release the "saving" lock so the modal never gets stuck setSaving(false); } if (result?.ok) setEditing(false); } function handleCancel() { setInputVal(rec.plate); setEditing(false); } return (
{ if (e.target === e.currentTarget && !editing) onClose(); }}>
{editing ? '編輯車牌' : '車牌詳情'}
{rec.thumbnail ? :
未有截圖
}
{editing ? (
setInputVal(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') handleCancel(); }} placeholder="例如 AB1234" maxLength={10} autoCapitalize="characters" autoCorrect="off" spellCheck={false} />
{cleanedVal ? ( isStrictHK ? ( ✓ 標準香港車牌 · 顯示為「{formatDisplay(cleanedVal)}」 ) : isValidHK ? ( ✓ 自定義車牌 (1-8 位英文/數字) ) : ( ⚠ 超過 8 位或包含特殊字符,仍可儲存 ) ) : ( 1-8 個英文字母或數字,例如 AB1234 / GENIUS / 12345 )}
) : ( <>
{rec.display} {rec.edited && ( 已編輯 )}
香港 Hong Kong
首次識別 {window.PlateStorageV3.fmtDate(rec.firstSeen)}
最後識別 {window.PlateStorageV3.fmtDate(rec.lastSeen)}
出現次數 {rec.count} 次
信心度 {(rec.confidence * 100).toFixed(0)}%
)}
{editing ? ( <> ) : ( <> )}
); } function ThumbImg({ blob }) { const [url, setUrl] = useState(null); useEffect(() => { if (!blob) return; const u = URL.createObjectURL(blob); setUrl(u); return () => URL.revokeObjectURL(u); }, [blob]); return url ? : null; } function timeAgo(ts) { const diff = (Date.now() - ts) / 1000; if (diff < 10) return '剛剛'; if (diff < 60) return `${Math.floor(diff)}秒前`; if (diff < 3600) return `${Math.floor(diff / 60)}分鐘前`; if (diff < 86400) return `${Math.floor(diff / 3600)}小時前`; const d = new Date(ts); return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; } ReactDOM.createRoot(document.getElementById('app')).render();