Add LPR test button in annotation UI

New POST /event/{id}/test-lpr endpoint runs PlateRecognizer on the full
frame and local OCR on the drawn bbox region (with perspective correction
when possible). Annotation UI shows results inline with a one-click
"Utiliser" button to copy the best plate into the input field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-03 15:20:14 +02:00
co-authored by Claude Sonnet 4.6
parent ea4118ffcd
commit c43652d187
2 changed files with 192 additions and 0 deletions
+81
View File
@@ -316,6 +316,87 @@ async def whitelist_remove(plate: str, back: str = Form("")):
return RedirectResponse(back or "/whitelist", status_code=303) return RedirectResponse(back or "/whitelist", status_code=303)
@app.post("/event/{event_id}/test-lpr")
async def test_lpr(event_id: str, frame_path: str = Form(""), bbox: str = Form("")):
import json, base64, cv2, numpy as np
frame_abs = os.path.join("/data", frame_path) if frame_path else None
if not frame_abs or not os.path.exists(frame_abs):
raise HTTPException(404, "Frame introuvable")
def _run():
frame = cv2.imread(frame_abs)
if frame is None:
return {"error": "Impossible de lire l'image"}
result: dict = {"has_pr": bool(PLATERECOGNIZER_KEY)}
if PLATERECOGNIZER_KEY:
from lpr import call_platerecognizer
from watcher import _normalize_plate
p, c = call_platerecognizer(frame, PLATERECOGNIZER_KEY)
if p:
result["platerecognizer"] = {
"plate": _normalize_plate(p) or p,
"raw": p,
"conf": round(c, 3),
}
analyzer = watcher._analyzer
if bbox and analyzer:
try:
box = json.loads(bbox)
h, w = frame.shape[:2]
cx2, cy2 = box["cx"] * w, box["cy"] * h
bw2, bh2 = box["w"] * w, box["h"] * h
x1 = max(0, int(cx2 - bw2 / 2))
y1 = max(0, int(cy2 - bh2 / 2))
x2 = min(w, int(cx2 + bw2 / 2))
y2 = min(h, int(cy2 + bh2 / 2))
source = "raw_crop"
ocr_img = None
quad = analyzer._find_plate_quad(frame, x1, y1, x2, y2)
if quad is not None:
corrected = analyzer._perspective_correct(frame, quad)
if corrected is not None and corrected.size > 0:
ocr_img = analyzer._enhance_crop(corrected)
source = "perspective_corrected"
if ocr_img is None:
crop = frame[y1:y2, x1:x2]
ocr_img = analyzer._enhance_crop(crop) if crop.size > 0 else None
if ocr_img is not None:
text, conf = analyzer._ocr_paddle(ocr_img)
if not text:
text, conf = analyzer._ocr_tesseract(ocr_img)
oh, ow = ocr_img.shape[:2]
if ow > 0 and ow < 300:
scale = 300 / ow
ocr_display = cv2.resize(ocr_img, (300, int(oh * scale)), interpolation=cv2.INTER_CUBIC)
else:
ocr_display = ocr_img
_, buf = cv2.imencode(".jpg", ocr_display, [cv2.IMWRITE_JPEG_QUALITY, 90])
from watcher import _normalize_plate
result["local"] = {
"plate": _normalize_plate(text) or text,
"raw": text,
"conf": round(conf, 3),
"img_b64": base64.b64encode(buf).decode(),
"source": source,
}
except Exception as e:
result["local_error"] = str(e)
return result
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _run)
@app.get("/health") @app.get("/health")
async def health(): async def health():
return {"status": "ok"} return {"status": "ok"}
+111
View File
@@ -102,12 +102,20 @@
style="background:#166534;color:#4ade80;border:1px solid #16a34a;"> style="background:#166534;color:#4ade80;border:1px solid #16a34a;">
✓ Sauvegarder l'annotation ✓ Sauvegarder l'annotation
</button> </button>
<button type="button" id="btn-test-lpr" onclick="testLPR()"
class="px-4 py-2 rounded font-medium"
style="background:#1e293b;color:#60a5fa;border:1px solid #2563eb;">
🔍 Tester LPR
</button>
<span id="save-warning" class="text-yellow-400 text-sm hidden">⚠ Dessine d'abord le rectangle autour de la plaque</span> <span id="save-warning" class="text-yellow-400 text-sm hidden">⚠ Dessine d'abord le rectangle autour de la plaque</span>
<span id="save-ok" class="text-green-400 text-sm hidden">✓ Rectangle défini — prêt à sauvegarder</span> <span id="save-ok" class="text-green-400 text-sm hidden">✓ Rectangle défini — prêt à sauvegarder</span>
</div> </div>
</div> </div>
</form> </form>
<!-- LPR test results -->
<div id="lpr-result" class="card p-4 hidden"></div>
</div> </div>
</div> </div>
@@ -442,6 +450,109 @@
redraw(); redraw();
}; };
// ── LPR test ───────────────────────────────────────────────────────────
async function testLPR() {
const framePathEl = document.getElementById('input-frame');
const bboxEl = document.getElementById('input-bbox');
const resultEl = document.getElementById('lpr-result');
const btn = document.getElementById('btn-test-lpr');
if (!framePathEl.value) {
resultEl.innerHTML = '<p class="text-yellow-400 text-sm">Aucune frame sélectionnée.</p>';
resultEl.classList.remove('hidden');
return;
}
btn.disabled = true;
btn.textContent = '⏳ En cours…';
resultEl.innerHTML = '<p class="text-slate-500 text-sm">Requête en cours…</p>';
resultEl.classList.remove('hidden');
const fd = new FormData();
fd.append('frame_path', framePathEl.value);
fd.append('bbox', bboxEl.value);
try {
const resp = await fetch('/event/{{ ev.id }}/test-lpr', { method: 'POST', body: fd });
const data = await resp.json();
renderLPRResult(data);
} catch(e) {
resultEl.innerHTML = `<p class="text-red-400 text-sm">Erreur: ${e.message}</p>`;
} finally {
btn.disabled = false;
btn.textContent = '🔍 Tester LPR';
}
}
function usePlate(plate) {
document.getElementById('input-plate').value = plate;
}
function renderLPRResult(data) {
const resultEl = document.getElementById('lpr-result');
if (data.error) {
resultEl.innerHTML = `<p class="text-red-400 text-sm">${data.error}</p>`;
return;
}
let html = '<div class="space-y-4">';
html += '<label class="block">Résultats LPR</label>';
// PlateRecognizer
if (data.platerecognizer) {
const pr = data.platerecognizer;
const pct = Math.round(pr.conf * 100);
const col = pr.conf >= 0.7 ? '#4ade80' : pr.conf >= 0.4 ? '#fbbf24' : '#f87171';
html += `<div>
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide font-semibold">PlateRecognizer API</div>
<div class="flex items-center gap-3 flex-wrap">
<span class="font-mono text-lg font-bold px-4 py-1 rounded" style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;">${pr.plate || '—'}</span>
<span class="text-sm font-mono" style="color:${col};">${pct}%</span>
${pr.plate ? `<button type="button" onclick="usePlate('${pr.plate}')" class="btn-ghost text-xs py-1 px-2 text-blue-400">↑ Utiliser</button>` : ''}
</div>
${pr.raw && pr.raw.replace(/-/g,'') !== pr.plate.replace(/-/g,'') ? `<p class="text-slate-500 text-xs mt-1">Brut API: ${pr.raw}</p>` : ''}
</div>`;
} else if (data.has_pr) {
html += `<div>
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide font-semibold">PlateRecognizer API</div>
<p class="text-slate-500 text-sm">Aucune plaque détectée</p>
</div>`;
}
// Local OCR
if (data.local) {
const loc = data.local;
const pct = Math.round(loc.conf * 100);
const col = loc.conf >= 0.7 ? '#4ade80' : loc.conf >= 0.4 ? '#fbbf24' : '#f87171';
const src = loc.source === 'perspective_corrected' ? 'correction perspective ✓' : 'recadrage direct';
html += `<div>
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide font-semibold">OCR local — ${src}</div>
<div class="flex items-center gap-3 flex-wrap mb-2">
<span class="font-mono text-lg font-bold px-4 py-1 rounded" style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;">${loc.plate || '—'}</span>
<span class="text-sm font-mono" style="color:${col};">${pct}%</span>
${loc.plate ? `<button type="button" onclick="usePlate('${loc.plate}')" class="btn-ghost text-xs py-1 px-2 text-blue-400">↑ Utiliser</button>` : ''}
</div>
${loc.raw && loc.raw !== loc.plate ? `<p class="text-slate-500 text-xs mb-2">Brut OCR: "${loc.raw}"</p>` : ''}
${loc.img_b64 ? `<img src="data:image/jpeg;base64,${loc.img_b64}" class="rounded border border-slate-600" style="image-rendering:pixelated;max-height:56px;" title="Image envoyée à l'OCR">` : ''}
</div>`;
} else if (data.local_error) {
html += `<div>
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide font-semibold">OCR local</div>
<p class="text-red-400 text-sm">${data.local_error}</p>
</div>`;
} else if (document.getElementById('input-bbox').value) {
html += `<div>
<div class="text-xs text-slate-500 mb-1 uppercase tracking-wide font-semibold">OCR local</div>
<p class="text-slate-500 text-sm">Aucun résultat</p>
</div>`;
} else {
html += `<p class="text-slate-500 text-sm italic">Dessine un rectangle autour de la plaque pour tester l'OCR local.</p>`;
}
html += '</div>';
resultEl.innerHTML = html;
}
// ── Form validation ──────────────────────────────────────────────────── // ── Form validation ────────────────────────────────────────────────────
document.getElementById('ann-form').addEventListener('submit', function(e) { document.getElementById('ann-form').addEventListener('submit', function(e) {
if (!document.getElementById('input-bbox').value) { if (!document.getElementById('input-bbox').value) {