- Couleur : crop autour de la plaque (zone carrosserie au-dessus) au lieu du frame entier ; fallback center 50% si pas de plaque - lpr.read_plate() retourne maintenant (text, conf, bbox) pour exposer la position de la plaque - Index : supprime filtre caméra et badge caméra (une seule caméra) - Carte index : badge ▶ clip si clip disponible - Page détail : section debug (nb frames, taille clip, id), gestion propre des anciens événements sans clip/frames Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
137 lines
4.9 KiB
Python
137 lines
4.9 KiB
Python
import os
|
|
import cv2
|
|
import numpy as np
|
|
import logging
|
|
|
|
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
|
|
|
|
|
|
class PlateAnalyzer:
|
|
def __init__(self):
|
|
self._yolo = None
|
|
self._rec = None
|
|
self._keys: list[str] = []
|
|
self._load()
|
|
|
|
def _load(self):
|
|
try:
|
|
import onnxruntime as ort
|
|
providers = ["CPUExecutionProvider"]
|
|
|
|
yolo_path = os.path.join(MODEL_CACHE, "yolov9_license_plate", "yolov9-256-license-plates.onnx")
|
|
rec_path = os.path.join(MODEL_CACHE, "paddleocr-onnx", "recognition_v4.onnx")
|
|
keys_path = os.path.join(MODEL_CACHE, "paddleocr-onnx", "ppocr_keys_v1.txt")
|
|
|
|
if os.path.exists(yolo_path):
|
|
self._yolo = ort.InferenceSession(yolo_path, providers=providers)
|
|
log.info("YOLOv9 plate detector loaded")
|
|
else:
|
|
log.warning(f"YOLOv9 model not found at {yolo_path}")
|
|
|
|
if os.path.exists(rec_path):
|
|
self._rec = ort.InferenceSession(rec_path, providers=providers)
|
|
log.info("PaddleOCR recognition model loaded")
|
|
|
|
if os.path.exists(keys_path):
|
|
with open(keys_path) as f:
|
|
self._keys = f.read().splitlines()
|
|
log.info(f"Loaded {len(self._keys)} OCR characters")
|
|
|
|
except ImportError:
|
|
log.warning("onnxruntime not installed — LPR disabled")
|
|
except Exception as e:
|
|
log.error(f"LPR model load error: {e}")
|
|
|
|
def detect_plates(self, frame: np.ndarray) -> list[tuple]:
|
|
"""Returns [(x1, y1, x2, y2, conf), ...] in original frame coordinates."""
|
|
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]
|
|
|
|
out = self._yolo.run(None, {"images": inp})[0] # [N, 7]
|
|
|
|
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)))
|
|
|
|
return sorted(boxes, key=lambda b: -b[4])
|
|
|
|
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 "", 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]
|
|
|
|
chars: list[str] = []
|
|
confs: list[float] = []
|
|
prev_idx = 0
|
|
|
|
for step in out:
|
|
idx = int(np.argmax(step))
|
|
conf = float(step[idx])
|
|
if idx != prev_idx and idx != 0 and idx <= len(self._keys):
|
|
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 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."""
|
|
plate_boxes = self.detect_plates(frame)
|
|
if not plate_boxes:
|
|
return "", 0.0, None
|
|
|
|
x1, y1, x2, y2, plate_conf = plate_boxes[0]
|
|
pad = 8
|
|
cx1 = max(0, int(x1) - pad)
|
|
cy1 = max(0, int(y1) - pad)
|
|
cx2 = min(frame.shape[1], int(x2) + pad)
|
|
cy2 = min(frame.shape[0], int(y2) + pad)
|
|
|
|
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))
|
|
|
|
return text, (plate_conf + ocr_conf) / 2.0, (int(x1), int(y1), int(x2), int(y2))
|