Files
camwatch/app/watcher.py
T
percoandClaude Sonnet 4.6 8c2af7dfab Fix couleur véhicule, supprime filtre caméra, améliore debug
- 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>
2026-06-02 16:19:29 +02:00

221 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import time
import uuid
import shutil
import logging
import subprocess
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")
EVENTS_DIR = os.environ.get("EVENTS_DIR", "/data/events")
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"))
TOP_FRAMES = int(os.environ.get("TOP_FRAMES", "10"))
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()
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 _vehicle_color(frame: np.ndarray, plate_bbox: tuple | None) -> tuple[str, str]:
"""Extract dominant color from the vehicle body (above the plate, or center frame)."""
h, w = frame.shape[:2]
if plate_bbox:
px1, py1, px2, py2 = plate_bbox
pw = px2 - px1
ph = py2 - py1
# Vehicle body: above plate, same horizontal span expanded ×3
crop_x1 = max(0, px1 - pw * 2)
crop_x2 = min(w, px2 + pw * 2)
crop_y2 = max(0, py1 - 5)
crop_y1 = max(0, py1 - ph * 10) # 10× plate height above plate
if crop_y2 > crop_y1 and crop_x2 > crop_x1:
crop = frame[int(crop_y1):int(crop_y2), int(crop_x1):int(crop_x2)]
return extract_dominant_color_from_frame(crop)
# Fallback: center 50% of image (excludes sky at top, road at bottom)
cy1 = h // 4
cy2 = 3 * h // 4
cx1 = w // 4
cx2 = 3 * w // 4
return extract_dominant_color_from_frame(frame[cy1:cy2, cx1:cx2])
def _frame_score(frame: np.ndarray, plates: list) -> float:
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 _process_event():
event_id = str(uuid.uuid4())
event_dir = os.path.join(EVENTS_DIR, event_id)
os.makedirs(event_dir, exist_ok=True)
url = _rtsp_url()
clip_path = os.path.join(event_dir, "clip.mp4")
frames_pattern = os.path.join(event_dir, "frame_%04d.jpg")
log.info(f"Capturing {CAPTURE_DURATION}s at {CAPTURE_FPS}fps — event {event_id[:8]}")
# Step 1: capture clip
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')}")
shutil.rmtree(event_dir, ignore_errors=True)
return
# Step 2: extract frames
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(event_dir, "frame_*.jpg")))
log.info(f"Extracted {len(frame_files)} frames")
if not frame_files:
shutil.rmtree(event_dir, ignore_errors=True)
return
# Step 3: score and select top frames
scored: list[tuple[float, np.ndarray, str]] = []
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)
scored.append((score, frame, path))
scored.sort(key=lambda x: -x[0])
# Delete frames below TOP_FRAMES
for _, _, path in scored[TOP_FRAMES:]:
os.remove(path)
# Rename kept frames to sorted order (best first = frame_0001)
kept = scored[:TOP_FRAMES]
for i, (_, _, old_path) in enumerate(kept):
new_path = os.path.join(event_dir, f"frame_{i+1:04d}.jpg")
if old_path != new_path:
os.rename(old_path, new_path)
best_frame = kept[0][1] if kept else None
if best_frame is None:
shutil.rmtree(event_dir, ignore_errors=True)
return
# Step 4: run LPR on best frame
plate, conf, plate_bbox = ("", 0.0, None)
if _analyzer:
plate, conf, plate_bbox = _analyzer.read_plate(best_frame)
log.info(f"LPR: plate={plate!r} conf={conf:.2f} bbox={plate_bbox}")
# Step 5: save thumbnail (best frame)
snapshot_file = f"{event_id}.jpg"
snapshot_path = os.path.join(SNAPSHOTS_DIR, snapshot_file)
cv2.imwrite(snapshot_path, best_frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
# Color: crop vehicle body above plate (avoids sky/road)
hex_color, color_name = _vehicle_color(best_frame, plate_bbox)
insert_event(
event_id, CAMERA_NAME, int(time.time()),
f"snapshots/{snapshot_file}",
f"events/{event_id}/clip.mp4",
plate or None, hex_color, color_name,
)
log.info(f"Stored: {event_id[:8]} | plate={plate} | color={color_name} | frames={len(kept)}")
def run_watcher():
global _last_event_time, _analyzer
os.makedirs(EVENTS_DIR, exist_ok=True)
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!")
_process_event()
except Exception as e:
log.error(f"Watcher loop error: {e}", exc_info=True)
time.sleep(POLL_INTERVAL)