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:
co-authored by
Claude Sonnet 4.6
parent
ea4118ffcd
commit
c43652d187
+81
@@ -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"}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user