Passe de Frigate polling à GetAiState Reolink + LPR ONNX local
- Remplace la source Frigate par l'API GetAiState de la caméra Reolink - Capture RTSP en temps réel (~20s) quand véhicule détecté, garde la meilleure frame - LPR avec YOLOv9 (détection plaque) + PaddleOCR v4 (lecture texte) via ONNX - Modèles partagés avec Frigate (volume local ./models/) - Cooldown 60s entre événements pour éviter les doublons Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f6422b0e7e
commit
65b74ce46d
@@ -1,3 +1,4 @@
|
|||||||
data/
|
data/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
models/
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
+12
-1
@@ -3,11 +3,22 @@ import numpy as np
|
|||||||
import colorsys
|
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]:
|
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)
|
img = cv2.imread(image_path)
|
||||||
if img is None:
|
if img is None:
|
||||||
return "#808080", "Inconnu"
|
return "#808080", "Inconnu"
|
||||||
|
return _dominant_color(img, bbox)
|
||||||
|
|
||||||
|
|
||||||
|
def _dominant_color(img: np.ndarray, bbox: dict | None = None) -> tuple[str, str]:
|
||||||
|
|
||||||
if bbox:
|
if bbox:
|
||||||
h, w = img.shape[:2]
|
h, w = img.shape[:2]
|
||||||
|
|||||||
+135
@@ -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
|
||||||
@@ -5,3 +5,4 @@ python-multipart==0.0.9
|
|||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
opencv-python-headless==4.10.0.84
|
opencv-python-headless==4.10.0.84
|
||||||
numpy==1.26.4
|
numpy==1.26.4
|
||||||
|
onnxruntime==1.20.0
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
<div class="text-center py-20 text-slate-500">
|
<div class="text-center py-20 text-slate-500">
|
||||||
<div class="text-5xl mb-4">📷</div>
|
<div class="text-5xl mb-4">📷</div>
|
||||||
<p class="text-lg">Aucun passage enregistré</p>
|
<p class="text-lg">Aucun passage enregistré</p>
|
||||||
<p class="text-sm mt-1">En attente de détections Frigate…</p>
|
<p class="text-sm mt-1">En attente de détections véhicules…</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
+132
-62
@@ -1,94 +1,164 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
import shutil
|
import cv2
|
||||||
from database import event_exists, insert_event
|
import numpy as np
|
||||||
from analyzer import extract_dominant_color
|
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")
|
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")
|
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:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.post(
|
||||||
f"{FRIGATE_URL}/api/events",
|
f"{CAMERA_URL}/api.cgi?cmd=Login",
|
||||||
params={"labels": "car", "has_snapshot": "1", "limit": "50"},
|
json=[{"cmd": "Login", "param": {"User": {"userName": CAMERA_USER, "password": CAMERA_PASS}}}],
|
||||||
timeout=10
|
timeout=5,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
data = resp.json()
|
||||||
return 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:
|
except Exception as e:
|
||||||
log.warning(f"Frigate fetch error: {e}")
|
log.warning(f"Login error: {e}")
|
||||||
return []
|
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:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.post(
|
||||||
f"{FRIGATE_URL}/api/events/{event_id}/snapshot.jpg",
|
f"{CAMERA_URL}/api.cgi?cmd=GetAiState&token={token}",
|
||||||
params={"bbox": "1", "crop": "1", "quality": "95"},
|
json=[{"cmd": "GetAiState", "action": 0, "param": {"channel": 0}}],
|
||||||
timeout=15, stream=True
|
timeout=5,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
data = resp.json()
|
||||||
with open(dest_path, "wb") as f:
|
if data[0]["code"] == 0:
|
||||||
shutil.copyfileobj(resp.raw, f)
|
return data[0]["value"]
|
||||||
return True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"Snapshot download error for {event_id}: {e}")
|
log.warning(f"GetAiState error: {e}")
|
||||||
return False
|
return None
|
||||||
|
|
||||||
|
|
||||||
def process_event(ev: dict):
|
def _frame_score(frame: np.ndarray, plates: list) -> float:
|
||||||
event_id = ev.get("id", "")
|
"""Score a frame: prefer large, high-confidence plates. Fallback to sharpness."""
|
||||||
if not event_id or event_exists(event_id):
|
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
|
return
|
||||||
|
|
||||||
camera = ev.get("camera", "unknown")
|
event_id = str(uuid.uuid4())
|
||||||
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
|
|
||||||
snapshot_file = f"{event_id}.jpg"
|
snapshot_file = f"{event_id}.jpg"
|
||||||
snapshot_path = os.path.join(SNAPSHOTS_DIR, snapshot_file)
|
snapshot_path = os.path.join(SNAPSHOTS_DIR, snapshot_file)
|
||||||
if not download_snapshot(event_id, snapshot_path):
|
cv2.imwrite(snapshot_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
||||||
return
|
|
||||||
|
|
||||||
# Extract color from vehicle bounding box
|
plate, conf = "", 0.0
|
||||||
bbox = None
|
if _analyzer:
|
||||||
if ev.get("box"):
|
plate, conf = _analyzer.read_plate(frame)
|
||||||
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]}
|
|
||||||
|
|
||||||
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}",
|
hex_color, color_name = extract_dominant_color_from_frame(frame)
|
||||||
plate, hex_color, color_name)
|
insert_event(
|
||||||
log.info(f"Processed event {event_id} | plate={plate} | color={color_name} | camera={camera}")
|
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():
|
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:
|
while True:
|
||||||
events = fetch_new_events()
|
try:
|
||||||
for ev in events:
|
state = _get_ai_state()
|
||||||
try:
|
if state:
|
||||||
process_event(ev)
|
vehicle = state.get("vehicle", {})
|
||||||
except Exception as e:
|
if vehicle.get("alarm_state") == 1:
|
||||||
log.error(f"Error processing event: {e}")
|
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)
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|||||||
+9
-2
@@ -5,11 +5,18 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
- ./models:/models:ro
|
||||||
environment:
|
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
|
- DB_PATH=/data/camwatch.db
|
||||||
- SNAPSHOTS_DIR=/data/snapshots
|
- SNAPSHOTS_DIR=/data/snapshots
|
||||||
- POLL_INTERVAL=30
|
- POLL_INTERVAL=2
|
||||||
|
- CAPTURE_DURATION=20
|
||||||
|
- COOLDOWN=60
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.camwatch.rule=Host(`camwatch.nas.percolouco.com`)
|
- traefik.http.routers.camwatch.rule=Host(`camwatch.nas.percolouco.com`)
|
||||||
|
|||||||
Reference in New Issue
Block a user