Improve LPR accuracy and add manual plate edit

- Tiled YOLO (3x2, 20% overlap): plate confidence 0.307 -> 0.838 on test frame
- Aspect ratio filter (2.0-6.5:1): rejects non-plate shapes
- Confidence threshold 0.03 -> 0.04: removes fence/noise false positives
- Exclude top 15% (sky) and bottom 8% (timestamp overlay) from detection zone
- Add Tesseract as primary OCR (better for Latin plates), PaddleOCR as fallback
- Enhance plate crop before OCR: CLAHE + sharpening + min 80px upscale
- Save plate_crop.jpg (4x upscaled) to event dir for manual review
- Show plate crop in event detail page
- Add manual plate edit form in event detail (POST /event/{id}/plate)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-02 17:45:50 +02:00
co-authored by Claude Sonnet 4.6
parent 92f435935e
commit 13e6038069
7 changed files with 188 additions and 38 deletions
+1
View File
@@ -2,6 +2,7 @@ FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 libglib2.0-0 libgomp1 ffmpeg \
tesseract-ocr tesseract-ocr-fra tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
+7
View File
@@ -107,6 +107,13 @@ def get_event(event_id: str) -> dict | None:
return dict(row) if row else None
def update_plate(event_id: str, plate: str):
conn = get_db()
conn.execute("UPDATE events SET plate = ? WHERE id = ?", (plate.strip().upper() or None, event_id))
conn.commit()
conn.close()
def get_cameras():
conn = get_db()
rows = conn.execute("SELECT DISTINCT camera FROM events ORDER BY camera").fetchall()
+136 -35
View File
@@ -7,8 +7,35 @@ log = logging.getLogger("lpr")
MODEL_CACHE = os.environ.get("MODEL_CACHE", "/models")
# Output format: [N, 7] where each row = [batch_idx, x1, y1, x2, y2, class_id, confidence]
_YOLO_CONF_THRESHOLD = 0.03
_YOLO_CONF_THRESHOLD = 0.04
def _iou(a: tuple, b: tuple) -> float:
ax1, ay1, ax2, ay2 = a[:4]
bx1, by1, bx2, by2 = b[:4]
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
if inter == 0:
return 0.0
union = (ax2 - ax1) * (ay2 - ay1) + (bx2 - bx1) * (by2 - by1) - inter
return inter / union if union > 0 else 0.0
def _nms(boxes: list, iou_threshold: float = 0.3) -> list:
if not boxes:
return []
boxes = sorted(boxes, key=lambda b: -b[4])
suppressed = [False] * len(boxes)
result = []
for i, b1 in enumerate(boxes):
if suppressed[i]:
continue
result.append(b1)
for j in range(i + 1, len(boxes)):
if not suppressed[j] and _iou(b1, boxes[j]) > iou_threshold:
suppressed[j] = True
return result
class PlateAnalyzer:
@@ -47,58 +74,123 @@ class PlateAnalyzer:
except Exception as e:
log.error(f"LPR model load error: {e}")
def _yolo_on_tile(self, tile: np.ndarray, offset_x: int, offset_y: int) -> list[tuple]:
"""Run YOLO on a single tile, return boxes in original frame coordinates."""
th, tw = tile.shape[:2]
inp = cv2.resize(tile, (256, 256))
inp = cv2.cvtColor(inp, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
inp = inp.transpose(2, 0, 1)[np.newaxis]
out = self._yolo.run(None, {"images": inp})[0]
boxes = []
for row in out:
if len(row) < 7:
continue
_, x1, y1, x2, y2, _, conf = row
if conf < _YOLO_CONF_THRESHOLD:
continue
x1 = offset_x + max(0.0, float(x1) / 256.0 * tw)
y1 = offset_y + max(0.0, float(y1) / 256.0 * th)
x2 = offset_x + min(float(tw), float(x2) / 256.0 * tw)
y2 = offset_y + min(float(th), float(y2) / 256.0 * th)
bw, bh = x2 - x1, y2 - y1
if bw < 8 or bh < 4:
continue
# Plates are always wider than tall — French plates ~4.7:1
ratio = bw / bh
if ratio < 2.0 or ratio > 6.5:
continue
boxes.append((x1, y1, x2, y2, float(conf)))
return boxes
def detect_plates(self, frame: np.ndarray) -> list[tuple]:
"""Returns [(x1, y1, x2, y2, conf), ...] in original frame coordinates."""
"""Tiled YOLO detection — returns [(x1,y1,x2,y2,conf),...] in original coords."""
if self._yolo is None:
return []
h, w = frame.shape[:2]
inp = cv2.resize(frame, (256, 256))
inp = cv2.cvtColor(inp, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
inp = inp.transpose(2, 0, 1)[np.newaxis]
all_boxes = []
out = self._yolo.run(None, {"images": inp})[0] # [N, 7]
# Ignore top 15% (sky/trees) and bottom 8% (timestamp overlay)
y_start = int(h * 0.15)
y_end = int(h * 0.92)
work = frame[y_start:y_end, :]
wh = y_end - y_start
boxes = []
for row in out:
# row: [batch_idx, x1, y1, x2, y2, class_id, confidence]
if len(row) >= 7:
_, x1, y1, x2, y2, _, conf = row
if conf < _YOLO_CONF_THRESHOLD:
continue
# Scale from 256x256 to original
x1 = max(0.0, float(x1) / 256.0 * w)
y1 = max(0.0, float(y1) / 256.0 * h)
x2 = min(float(w), float(x2) / 256.0 * w)
y2 = min(float(h), float(y2) / 256.0 * h)
if x2 > x1 + 4 and y2 > y1 + 4:
boxes.append((x1, y1, x2, y2, float(conf)))
# 3×2 tiles with 20% overlap so plates near tile edges are caught
cols, rows = 3, 2
overlap = 0.2
tw = int(w / (cols - overlap * (cols - 1)))
th = int(wh / (rows - overlap * (rows - 1)))
step_x = int(tw * (1 - overlap))
step_y = int(th * (1 - overlap))
return sorted(boxes, key=lambda b: -b[4])
for row in range(rows):
for col in range(cols):
tx1 = col * step_x
ty1 = row * step_y
tx2 = min(w, tx1 + tw)
ty2 = min(wh, ty1 + th)
tile = work[ty1:ty2, tx1:tx2]
# offset_y accounts for the cropped top strip
all_boxes.extend(self._yolo_on_tile(tile, tx1, ty1 + y_start))
def _ocr_crop(self, crop: np.ndarray) -> tuple[str, float]:
"""Run PaddleOCR recognition on a crop. Returns (text, confidence)."""
if self._rec is None or not self._keys or crop.size == 0:
return _nms(all_boxes, iou_threshold=0.3)
def _enhance_crop(self, crop: np.ndarray) -> np.ndarray:
"""Upscale + CLAHE + sharpen a plate crop for better OCR."""
h, w = crop.shape[:2]
# Upscale so the plate is at least 80px tall
target_h = 80
if h < target_h:
scale = target_h / h
crop = cv2.resize(crop, (max(10, int(w * scale)), target_h), interpolation=cv2.INTER_CUBIC)
# CLAHE contrast enhancement
lab = cv2.cvtColor(crop, cv2.COLOR_BGR2LAB)
l, a, b = cv2.split(lab)
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(4, 4))
l = clahe.apply(l)
crop = cv2.cvtColor(cv2.merge([l, a, b]), cv2.COLOR_LAB2BGR)
# Mild sharpening
kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], dtype=np.float32)
return cv2.filter2D(crop, -1, kernel)
def _ocr_tesseract(self, crop: np.ndarray) -> tuple[str, float]:
"""Tesseract OCR tuned for license plates."""
try:
import pytesseract
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
# Try both single-word and single-line modes, take the longer result
cfg = "--psm 8 --oem 3 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"
text8 = pytesseract.image_to_string(gray, config=cfg).strip().replace(" ", "").upper()
cfg7 = "--psm 7 --oem 3 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"
text7 = pytesseract.image_to_string(gray, config=cfg7).strip().replace(" ", "").upper()
text = text8 if len(text8) >= len(text7) else text7
alnum = "".join(c for c in text if c.isalnum())
if len(alnum) >= 4:
return text, 0.6 # Tesseract doesn't give per-char conf easily; use fixed score
except Exception as e:
log.debug(f"Tesseract error: {e}")
return "", 0.0
def _ocr_paddle(self, crop: np.ndarray) -> tuple[str, float]:
"""PaddleOCR recognition fallback."""
if self._rec is None or not self._keys or crop.size == 0:
return "", 0.0
h, w = crop.shape[:2]
if h == 0 or w == 0:
return "", 0.0
inp_h = 48
inp_w = max(10, int(inp_h * w / h))
resized = cv2.resize(crop, (inp_w, inp_h))
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB).astype(np.float32)
normalized = (rgb / 127.5) - 1.0
inp = normalized.transpose(2, 0, 1)[np.newaxis]
out = self._rec.run(None, {"x": inp})[0][0] # [seq, 6625]
out = self._rec.run(None, {"x": inp})[0][0]
chars: list[str] = []
confs: list[float] = []
prev_idx = 0
for step in out:
idx = int(np.argmax(step))
conf = float(step[idx])
@@ -106,14 +198,24 @@ class PlateAnalyzer:
chars.append(self._keys[idx - 1])
confs.append(conf)
prev_idx = idx
text = "".join(chars)
avg_conf = float(np.mean(confs)) if confs else 0.0
return text, avg_conf
def _ocr_crop(self, crop: np.ndarray) -> tuple[str, float]:
"""Run OCR on a plate crop. Tesseract first, PaddleOCR as fallback."""
if crop.size == 0:
return "", 0.0
enhanced = self._enhance_crop(crop)
# Tesseract is better for Latin/French plates
text, conf = self._ocr_tesseract(enhanced)
if text:
return text, conf
# Fallback to PaddleOCR
return self._ocr_paddle(enhanced)
def read_plate(self, frame: np.ndarray) -> tuple[str, float, tuple | None]:
"""Detect plate, read text. Returns (plate_text, confidence, bbox_or_None).
bbox = (x1, y1, x2, y2) in pixel coords."""
"""Detect plate, read text. Returns (plate_text, confidence, bbox_or_None)."""
plate_boxes = self.detect_plates(frame)
if not plate_boxes:
return "", 0.0, None
@@ -128,7 +230,6 @@ class PlateAnalyzer:
crop = frame[cy1:cy2, cx1:cx2]
text, ocr_conf = self._ocr_crop(crop)
# Filter noise: plate must have at least 4 alphanumeric chars
alnum = "".join(c for c in text if c.isalnum())
if len(alnum) < 4:
return "", 0.0, (int(x1), int(y1), int(x2), int(y2))
+14 -1
View File
@@ -5,7 +5,7 @@ import threading
import tempfile
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Query, HTTPException, UploadFile, File
from fastapi import FastAPI, Request, Query, HTTPException, UploadFile, File, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -103,12 +103,16 @@ async def event_detail(request: Request, event_id: str):
size = os.path.getsize(clip_abs)
clip_size = f"{size // 1024 // 1024}MB" if size > 1024 * 1024 else f"{size // 1024}KB"
plate_crop_abs = os.path.join(event_dir, "plate_crop.jpg")
plate_crop = f"events/{event_id}/plate_crop.jpg" if os.path.exists(plate_crop_abs) else None
return templates.TemplateResponse("event_detail.html", {
"request": request,
"ev": ev,
"frames": frames,
"has_clip": has_clip,
"clip_size": clip_size,
"plate_crop": plate_crop,
"capture_duration": CAPTURE_DURATION,
"capture_fps": CAPTURE_FPS,
"capture_total": CAPTURE_DURATION * CAPTURE_FPS,
@@ -136,6 +140,15 @@ async def api_events(
return {"events": events, "total": total}
@app.post("/event/{event_id}/plate")
async def update_plate(event_id: str, plate: str = Form("")):
ev = database.get_event(event_id)
if not ev:
raise HTTPException(status_code=404, detail="Événement introuvable")
database.update_plate(event_id, plate)
return RedirectResponse(f"/event/{event_id}", status_code=303)
@app.post("/upload")
async def upload_clip(file: UploadFile = File(...)):
if not file.filename.lower().endswith(".mp4"):
+1
View File
@@ -6,3 +6,4 @@ requests==2.32.3
opencv-python-headless==4.10.0.84
numpy==1.26.4
onnxruntime==1.20.0
pytesseract==0.3.13
+15 -2
View File
@@ -45,13 +45,26 @@
<div class="card p-4 space-y-4">
<div>
<label>Plaque</label>
<div class="mt-1">
<div class="mt-1 mb-2">
{% if ev.plate %}
<span class="plate text-xl font-bold px-4 py-2 rounded" style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;">{{ ev.plate }}</span>
{% else %}
<span class="text-slate-500 italic">Non lue</span>
<span class="text-slate-500 italic text-sm">Non lue automatiquement</span>
{% endif %}
</div>
{% if plate_crop %}
<div class="mb-2">
<label>Crop détecté</label>
<img src="/{{ plate_crop }}" class="mt-1 rounded border border-slate-600 w-full" style="image-rendering:pixelated;" title="Zoom plate crop">
</div>
{% endif %}
<form action="/event/{{ ev.id }}/plate" method="post" class="flex gap-2 mt-1">
<input type="text" name="plate" value="{{ ev.plate or '' }}"
placeholder="Saisir plaque…"
class="flex-1 text-sm font-mono uppercase"
style="background:#0f172a;border:1px solid #475569;color:#e2e8f0;border-radius:6px;padding:5px 8px;">
<button type="submit" class="text-xs px-3 py-1 rounded" style="background:#1e3a5f;color:#60a5fa;border:1px solid #2563eb;"></button>
</form>
</div>
<div>
+14
View File
@@ -172,6 +172,20 @@ def _process_clip(event_id: str, event_dir: str, clip_path: str, camera_name: st
plate, conf, plate_bbox = _analyzer.read_plate(best_frame)
log.info(f"LPR: plate={plate!r} conf={conf:.2f} bbox={plate_bbox}")
# Save plate crop for manual review (even when OCR fails)
if plate_bbox:
px1, py1, px2, py2 = plate_bbox
fh, fw = best_frame.shape[:2]
pad = max(8, int((py2 - py1) * 0.5))
cx1, cy1 = max(0, px1 - pad), max(0, py1 - pad)
cx2, cy2 = min(fw, px2 + pad), min(fh, py2 + pad)
plate_crop = best_frame[cy1:cy2, cx1:cx2]
if plate_crop.size > 0:
# Save at 4× upscale for readability
ph, pw = plate_crop.shape[:2]
big = cv2.resize(plate_crop, (pw * 4, ph * 4), interpolation=cv2.INTER_CUBIC)
cv2.imwrite(os.path.join(event_dir, "plate_crop.jpg"), big, [cv2.IMWRITE_JPEG_QUALITY, 95])
# Save thumbnail
snapshot_file = f"{event_id}.jpg"
snapshot_path = os.path.join(SNAPSHOTS_DIR, snapshot_file)