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`)