diff --git a/Dockerfile b/Dockerfile index 2e2e39d..2d42c1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app/database.py b/app/database.py index b3390eb..92872f0 100644 --- a/app/database.py +++ b/app/database.py @@ -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() diff --git a/app/lpr.py b/app/lpr.py index 53ce385..1f006e9 100644 --- a/app/lpr.py +++ b/app/lpr.py @@ -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).""" + 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)) diff --git a/app/main.py b/app/main.py index 0e2db1c..dd7c130 100644 --- a/app/main.py +++ b/app/main.py @@ -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"): diff --git a/app/requirements.txt b/app/requirements.txt index 4884a42..35e5873 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -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 diff --git a/app/templates/event_detail.html b/app/templates/event_detail.html index a70d82a..4ecaa3b 100644 --- a/app/templates/event_detail.html +++ b/app/templates/event_detail.html @@ -45,13 +45,26 @@
-
+
{% if ev.plate %} {{ ev.plate }} {% else %} - Non lue + Non lue automatiquement {% endif %}
+ {% if plate_crop %} +
+ + +
+ {% endif %} +
+ + +
diff --git a/app/watcher.py b/app/watcher.py index 742ab77..aafee1a 100644 --- a/app/watcher.py +++ b/app/watcher.py @@ -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)