diff --git a/.gitignore b/.gitignore index ac481ac..514d682 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ data/ __pycache__/ *.pyc +models/ diff --git a/Dockerfile b/Dockerfile index 4cab83e..3ff60b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM python:3.11-slim RUN apt-get update && apt-get install -y --no-install-recommends \ - libgl1 libglib2.0-0 \ + libgl1 libglib2.0-0 libgomp1 \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/app/analyzer.py b/app/analyzer.py index 4883cfd..efbfb8d 100644 --- a/app/analyzer.py +++ b/app/analyzer.py @@ -3,11 +3,22 @@ import numpy as np import colorsys +def extract_dominant_color_from_frame(frame: np.ndarray, bbox: dict | None = None) -> tuple[str, str]: + """Returns (hex_color, color_name) from a BGR numpy frame.""" + if frame is None: + return "#808080", "Inconnu" + return _dominant_color(frame, bbox) + + def extract_dominant_color(image_path: str, bbox: dict | None = None) -> tuple[str, str]: - """Returns (hex_color, color_name) from image, optionally cropped to bbox.""" + """Returns (hex_color, color_name) from image path, optionally cropped to bbox.""" img = cv2.imread(image_path) if img is None: return "#808080", "Inconnu" + return _dominant_color(img, bbox) + + +def _dominant_color(img: np.ndarray, bbox: dict | None = None) -> tuple[str, str]: if bbox: h, w = img.shape[:2] diff --git a/app/lpr.py b/app/lpr.py new file mode 100644 index 0000000..8a871c9 --- /dev/null +++ b/app/lpr.py @@ -0,0 +1,135 @@ +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]: + """Detect plate in frame, read text. Returns (plate_text, confidence).""" + plate_boxes = self.detect_plates(frame) + if not plate_boxes: + return "", 0.0 + + 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 + + return text, (plate_conf + ocr_conf) / 2.0 diff --git a/app/requirements.txt b/app/requirements.txt index bf37b7a..4884a42 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -5,3 +5,4 @@ python-multipart==0.0.9 requests==2.32.3 opencv-python-headless==4.10.0.84 numpy==1.26.4 +onnxruntime==1.20.0 diff --git a/app/templates/index.html b/app/templates/index.html index d0e57e2..79907cc 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -54,7 +54,7 @@
đź“·

Aucun passage enregistré

-

En attente de détections Frigate…

+

En attente de détections véhicules…

{% endif %} diff --git a/app/watcher.py b/app/watcher.py index c5a741d..53a15ae 100644 --- a/app/watcher.py +++ b/app/watcher.py @@ -1,94 +1,164 @@ import os import time +import uuid import logging import requests -import shutil -from database import event_exists, insert_event -from analyzer import extract_dominant_color +import cv2 +import numpy as np +from urllib.parse import quote + +from database import insert_event +from analyzer import extract_dominant_color_from_frame +from lpr import PlateAnalyzer log = logging.getLogger("watcher") -FRIGATE_URL = os.environ.get("FRIGATE_URL", "http://frigate:5000") +CAMERA_URL = os.environ.get("CAMERA_URL", "http://192.168.1.44") +CAMERA_USER = os.environ.get("CAMERA_USER", "admin") +CAMERA_PASS = os.environ.get("CAMERA_PASS", "") +CAMERA_RTSP = os.environ.get("CAMERA_RTSP", "") +CAMERA_NAME = os.environ.get("CAMERA_NAME", "portail") SNAPSHOTS_DIR = os.environ.get("SNAPSHOTS_DIR", "/data/snapshots") -POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "30")) +POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL", "2")) +CAPTURE_DURATION = int(os.environ.get("CAPTURE_DURATION", "20")) +COOLDOWN = int(os.environ.get("COOLDOWN", "60")) + +_token: str | None = None +_token_time = 0.0 +_last_event_time = 0.0 +_analyzer: PlateAnalyzer | None = None -def fetch_new_events() -> list[dict]: +def _rtsp_url() -> str: + if CAMERA_RTSP: + return CAMERA_RTSP + host = CAMERA_URL.replace("http://", "").replace("https://", "").split(":")[0] + return f"rtsp://{quote(CAMERA_USER, safe='')}:{quote(CAMERA_PASS, safe='')}@{host}/h264Preview_01_main" + + +def _login() -> str | None: + global _token, _token_time + if _token and (time.time() - _token_time) < 3000: + return _token try: - resp = requests.get( - f"{FRIGATE_URL}/api/events", - params={"labels": "car", "has_snapshot": "1", "limit": "50"}, - timeout=10 + resp = requests.post( + f"{CAMERA_URL}/api.cgi?cmd=Login", + json=[{"cmd": "Login", "param": {"User": {"userName": CAMERA_USER, "password": CAMERA_PASS}}}], + timeout=5, ) - resp.raise_for_status() - return resp.json() + data = resp.json() + if data[0]["code"] == 0: + _token = data[0]["value"]["Token"]["name"] + _token_time = time.time() + log.debug("Camera login OK") + return _token except Exception as e: - log.warning(f"Frigate fetch error: {e}") - return [] + log.warning(f"Login error: {e}") + return None -def download_snapshot(event_id: str, dest_path: str) -> bool: +def _get_ai_state() -> dict | None: + token = _login() + if not token: + return None try: - resp = requests.get( - f"{FRIGATE_URL}/api/events/{event_id}/snapshot.jpg", - params={"bbox": "1", "crop": "1", "quality": "95"}, - timeout=15, stream=True + resp = requests.post( + f"{CAMERA_URL}/api.cgi?cmd=GetAiState&token={token}", + json=[{"cmd": "GetAiState", "action": 0, "param": {"channel": 0}}], + timeout=5, ) - resp.raise_for_status() - with open(dest_path, "wb") as f: - shutil.copyfileobj(resp.raw, f) - return True + data = resp.json() + if data[0]["code"] == 0: + return data[0]["value"] except Exception as e: - log.warning(f"Snapshot download error for {event_id}: {e}") - return False + log.warning(f"GetAiState error: {e}") + return None -def process_event(ev: dict): - event_id = ev.get("id", "") - if not event_id or event_exists(event_id): +def _frame_score(frame: np.ndarray, plates: list) -> float: + """Score a frame: prefer large, high-confidence plates. Fallback to sharpness.""" + if plates: + x1, y1, x2, y2, conf = plates[0] + return (x2 - x1) * (y2 - y1) * conf + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + return cv2.Laplacian(gray, cv2.CV_64F).var() * 0.001 + + +def _capture_best_frame() -> np.ndarray | None: + url = _rtsp_url() + log.info(f"Capturing RTSP for {CAPTURE_DURATION}s...") + cap = cv2.VideoCapture(url) + if not cap.isOpened(): + log.error("Cannot open RTSP stream") + return None + + best_frame: np.ndarray | None = None + best_score = -1.0 + start = time.time() + + try: + while time.time() - start < CAPTURE_DURATION: + ret, frame = cap.read() + if not ret: + log.warning("RTSP read failed, retrying...") + time.sleep(0.5) + continue + plates = _analyzer.detect_plates(frame) if _analyzer else [] + score = _frame_score(frame, plates) + if score > best_score: + best_score = score + best_frame = frame.copy() + time.sleep(0.8) + finally: + cap.release() + + log.info(f"Capture done. Best frame score: {best_score:.2f}") + return best_frame + + +def _process_event(): + frame = _capture_best_frame() + if frame is None: + log.warning("No frame captured") return - camera = ev.get("camera", "unknown") - start_time = int(ev.get("start_time", time.time())) - - # Plate from Frigate LPR - plate = None - data = ev.get("data", {}) - if data.get("sub_label"): - plate = data["sub_label"] - if isinstance(plate, list): - plate = plate[0] if plate else None - - # Download snapshot + event_id = str(uuid.uuid4()) snapshot_file = f"{event_id}.jpg" snapshot_path = os.path.join(SNAPSHOTS_DIR, snapshot_file) - if not download_snapshot(event_id, snapshot_path): - return + cv2.imwrite(snapshot_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 90]) - # Extract color from vehicle bounding box - bbox = None - if ev.get("box"): - b = ev["box"] - bbox = {"x": b[0], "y": b[1], "width": b[2] - b[0], "height": b[3] - b[1]} - elif data.get("box"): - b = data["box"] - if len(b) == 4: - bbox = {"x": b[0], "y": b[1], "width": b[2] - b[0], "height": b[3] - b[1]} + plate, conf = "", 0.0 + if _analyzer: + plate, conf = _analyzer.read_plate(frame) - hex_color, color_name = extract_dominant_color(snapshot_path, bbox) + log.info(f"LPR: plate={plate!r} conf={conf:.2f}") - insert_event(event_id, camera, start_time, f"snapshots/{snapshot_file}", - plate, hex_color, color_name) - log.info(f"Processed event {event_id} | plate={plate} | color={color_name} | camera={camera}") + hex_color, color_name = extract_dominant_color_from_frame(frame) + insert_event( + event_id, CAMERA_NAME, int(time.time()), + f"snapshots/{snapshot_file}", plate or None, hex_color, color_name, + ) + log.info(f"Stored: {event_id} | plate={plate} | color={color_name}") def run_watcher(): - log.info(f"Watcher started — polling Frigate every {POLL_INTERVAL}s") + global _last_event_time, _analyzer + + log.info("Loading plate analyzer...") + _analyzer = PlateAnalyzer() + + log.info(f"Watcher started — polling {CAMERA_URL} every {POLL_INTERVAL}s") while True: - events = fetch_new_events() - for ev in events: - try: - process_event(ev) - except Exception as e: - log.error(f"Error processing event: {e}") + try: + state = _get_ai_state() + if state: + vehicle = state.get("vehicle", {}) + if vehicle.get("alarm_state") == 1: + now = time.time() + if now - _last_event_time > COOLDOWN: + _last_event_time = now + log.info("Vehicle detected! Triggering capture...") + _process_event() + except Exception as e: + log.error(f"Watcher loop error: {e}", exc_info=True) time.sleep(POLL_INTERVAL) diff --git a/docker-compose.yml b/docker-compose.yml index 4a6eb1a..28cd9e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,11 +5,18 @@ services: restart: unless-stopped volumes: - ./data:/data + - ./models:/models:ro environment: - - FRIGATE_URL=http://frigate:5000 + - CAMERA_URL=http://192.168.1.44 + - CAMERA_USER=admin + - CAMERA_PASS=Wxcvbn99--$$$$ + - CAMERA_NAME=portail + - MODEL_CACHE=/models - DB_PATH=/data/camwatch.db - SNAPSHOTS_DIR=/data/snapshots - - POLL_INTERVAL=30 + - POLL_INTERVAL=2 + - CAPTURE_DURATION=20 + - COOLDOWN=60 labels: - traefik.enable=true - traefik.http.routers.camwatch.rule=Host(`camwatch.nas.percolouco.com`)