- Remplace OpenCV VideoCapture par ffmpeg pour la capture RTSP (plus fiable) - Capture 15s → extrait ~75 frames à 5fps via ffmpeg - Le LPR analyse toutes les frames et garde la meilleure (plaque la plus grande/confiante) - Ajoute CAPTURE_FPS env var Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
192 lines
6.1 KiB
Python
192 lines
6.1 KiB
Python
import os
|
|
import time
|
|
import uuid
|
|
import logging
|
|
import subprocess
|
|
import tempfile
|
|
import glob
|
|
import requests
|
|
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")
|
|
|
|
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 = float(os.environ.get("POLL_INTERVAL", "2"))
|
|
CAPTURE_DURATION = int(os.environ.get("CAPTURE_DURATION", "15"))
|
|
CAPTURE_FPS = int(os.environ.get("CAPTURE_FPS", "5"))
|
|
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 _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.post(
|
|
f"{CAMERA_URL}/api.cgi?cmd=Login",
|
|
json=[{"cmd": "Login", "param": {"User": {"userName": CAMERA_USER, "password": CAMERA_PASS}}}],
|
|
timeout=5,
|
|
)
|
|
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"Login error: {e}")
|
|
return None
|
|
|
|
|
|
def _get_ai_state() -> dict | None:
|
|
token = _login()
|
|
if not token:
|
|
return None
|
|
try:
|
|
resp = requests.post(
|
|
f"{CAMERA_URL}/api.cgi?cmd=GetAiState&token={token}",
|
|
json=[{"cmd": "GetAiState", "action": 0, "param": {"channel": 0}}],
|
|
timeout=5,
|
|
)
|
|
data = resp.json()
|
|
if data[0]["code"] == 0:
|
|
return data[0]["value"]
|
|
except Exception as e:
|
|
log.warning(f"GetAiState error: {e}")
|
|
return None
|
|
|
|
|
|
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 {CAPTURE_DURATION}s at {CAPTURE_FPS}fps via ffmpeg...")
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
clip_path = os.path.join(tmpdir, "clip.mp4")
|
|
frames_pattern = os.path.join(tmpdir, "frame_%04d.jpg")
|
|
|
|
# Step 1: capture clip with ffmpeg (TCP for reliability)
|
|
ret = subprocess.run([
|
|
"ffmpeg", "-y",
|
|
"-rtsp_transport", "tcp",
|
|
"-i", url,
|
|
"-t", str(CAPTURE_DURATION),
|
|
"-c", "copy",
|
|
clip_path,
|
|
], capture_output=True, timeout=CAPTURE_DURATION + 10)
|
|
|
|
if not os.path.exists(clip_path) or os.path.getsize(clip_path) < 1000:
|
|
log.error(f"ffmpeg capture failed: {ret.stderr[-200:].decode(errors='ignore')}")
|
|
return None
|
|
|
|
# Step 2: extract frames at CAPTURE_FPS
|
|
subprocess.run([
|
|
"ffmpeg", "-y",
|
|
"-i", clip_path,
|
|
"-vf", f"fps={CAPTURE_FPS}",
|
|
"-q:v", "2",
|
|
frames_pattern,
|
|
], capture_output=True, timeout=30)
|
|
|
|
frame_files = sorted(glob.glob(os.path.join(tmpdir, "frame_*.jpg")))
|
|
log.info(f"Extracted {len(frame_files)} frames")
|
|
|
|
if not frame_files:
|
|
return None
|
|
|
|
# Step 3: score each frame, keep the best
|
|
best_frame: np.ndarray | None = None
|
|
best_score = -1.0
|
|
|
|
for path in frame_files:
|
|
frame = cv2.imread(path)
|
|
if frame is None:
|
|
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()
|
|
|
|
log.info(f"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
|
|
|
|
event_id = str(uuid.uuid4())
|
|
snapshot_file = f"{event_id}.jpg"
|
|
snapshot_path = os.path.join(SNAPSHOTS_DIR, snapshot_file)
|
|
cv2.imwrite(snapshot_path, frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
|
|
|
plate, conf = "", 0.0
|
|
if _analyzer:
|
|
plate, conf = _analyzer.read_plate(frame)
|
|
|
|
log.info(f"LPR: plate={plate!r} conf={conf:.2f}")
|
|
|
|
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():
|
|
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:
|
|
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)
|