Files
camwatch/app/watcher.py
T
percoandClaude Sonnet 4.6 d333a29c4c Fix encodage clip : H.264 baseline 720p CRF28 pour compatibilité navigateur
- Transcode clip en H.264 baseline level 3.1 (compatible tous navigateurs)
- Downsample à 720p pour réduire la taille (17MB raw → ~2.5MB encodé)
- CRF 28 + preset fast = bon compromis qualité/vitesse
- Sans audio (inutile pour surveillance)
- -movflags +faststart pour lecture streaming sans téléchargement complet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:42:52 +02:00

231 lines
8.1 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"))
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 all frames
subprocess.run([
"ffmpeg", "-y", "-i", clip_path,
"-vf", f"fps={CAPTURE_FPS}", "-q:v", "2", frames_pattern,
], capture_output=True, timeout=30)
# Step 2b: transcode to H.264 baseline for browser compatibility
web_clip = os.path.join(event_dir, "clip_web.mp4")
subprocess.run([
"ffmpeg", "-y", "-i", clip_path,
"-c:v", "libx264", "-profile:v", "baseline", "-level", "3.1",
"-preset", "fast", "-crf", "28",
"-vf", "scale=-2:720", # downsample to 720p — enough for review
"-an", # no audio needed for surveillance
"-movflags", "+faststart", # moov atom at front for streaming
web_clip,
], capture_output=True, timeout=60)
if os.path.exists(web_clip):
os.replace(web_clip, clip_path)
else:
log.warning("Transcode failed, keeping raw clip")
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 ALL frames, rename sorted (best = frame_0001)
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])
# Rename to sorted order (temp names to avoid collisions)
for i, (_, _, old_path) in enumerate(scored):
os.rename(old_path, old_path + ".tmp")
for i, (_, _, old_path) in enumerate(scored):
os.rename(old_path + ".tmp", os.path.join(event_dir, f"frame_{i+1:04d}.jpg"))
best_frame = scored[0][1] if scored 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)