Files
camwatch/app/watcher.py
T
percoandClaude Sonnet 4.6 6aac633862 Integrate PlateRecognizer API as primary ANPR engine
Adds call_platerecognizer() to lpr.py which sends frames to the
PlateRecognizer cloud API (regions=fr) with a 1920px cap to stay
within API limits. In watcher.py, switches to a two-pass frame
scoring strategy: Laplacian sharpness on all frames first, then
YOLO only on the top-10 sharpest frames, then PlateRecognizer on
the top-3 by score. Falls back to local YOLO+Tesseract/PaddleOCR
if the API returns no result.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 19:05:34 +02:00

305 lines
11 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"))
PLATERECOGNIZER_KEY = os.environ.get("PLATERECOGNIZER_API_KEY", "")
_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
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)
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)
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_clip(event_id: str, event_dir: str, clip_path: str, camera_name: str = None):
"""Process an existing clip: extract frames at full res, transcode for browser, LPR, store in DB."""
if camera_name is None:
camera_name = CAMERA_NAME
frames_pattern = os.path.join(event_dir, "frame_%04d.jpg")
# Extract frames at full original resolution BEFORE any transcode
subprocess.run([
"ffmpeg", "-y", "-i", clip_path,
"-vf", f"fps={CAPTURE_FPS}", "-q:v", "2", frames_pattern,
], capture_output=True, timeout=60)
# Transcode to H.264 baseline 720p for browser (after frame extraction)
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",
"-an",
"-movflags", "+faststart",
web_clip,
], capture_output=True, timeout=120)
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 None
# Pass 1: rank all frames by sharpness (fast, no LPR)
sharpness: list[tuple[float, np.ndarray, str]] = []
for path in frame_files:
frame = cv2.imread(path)
if frame is None:
continue
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
lap = cv2.Laplacian(gray, cv2.CV_64F).var()
sharpness.append((lap, frame, path))
sharpness.sort(key=lambda x: -x[0])
# Pass 2: run YOLO only on top 10 sharpest frames → LPR-based score
top10 = sharpness[:10]
scored: list[tuple[float, np.ndarray, str]] = []
for _, frame, path in top10:
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])
# Merge: LPR-ranked top10 first, then remaining by sharpness
lpr_paths = {path for _, _, path in scored}
rest = [(lap * 0.001, frame, path) for lap, frame, path in sharpness if path not in lpr_paths]
full_sorted = scored + rest
for i, (_, _, old_path) in enumerate(full_sorted):
os.rename(old_path, old_path + ".tmp")
for i, (_, _, old_path) in enumerate(full_sorted):
os.rename(old_path + ".tmp", os.path.join(event_dir, f"frame_{i+1:04d}.jpg"))
best_frame = full_sorted[0][1] if full_sorted else None
if best_frame is None:
shutil.rmtree(event_dir, ignore_errors=True)
return None
# LPR: PlateRecognizer API (top 3 frames) → fallback to local
plate, conf, plate_bbox, plate_corrected = ("", 0.0, None, None)
if PLATERECOGNIZER_KEY:
from lpr import call_platerecognizer
for _, frame, _ in scored[:3]:
p, c = call_platerecognizer(frame, PLATERECOGNIZER_KEY)
if p and c > conf:
plate, conf = p, c
if conf >= 0.7:
break
if plate:
# Get local perspective-corrected crop for display
if _analyzer:
plates = _analyzer.detect_plates(best_frame)
if plates:
x1, y1, x2, y2, _ = plates[0]
quad = _analyzer._find_plate_quad(best_frame, int(x1), int(y1), int(x2), int(y2))
plate_bbox = (int(x1), int(y1), int(x2), int(y2))
plate_corrected = _analyzer._perspective_correct(best_frame, quad) if quad else None
else:
log.info("PlateRecognizer returned no result, falling back to local LPR")
if _analyzer:
plate, conf, plate_bbox, plate_corrected = _analyzer.read_plate(best_frame)
elif _analyzer:
plate, conf, plate_bbox, plate_corrected = _analyzer.read_plate(best_frame)
log.info(f"LPR: plate={plate!r} conf={conf:.2f} bbox={plate_bbox}")
# Save raw plate crop (4× upscale) and the perspective-corrected OCR crop
if plate_bbox:
px1, py1, px2, py2 = plate_bbox
fh, fw = best_frame.shape[:2]
pad = max(8, int((py2 - py1) * 0.5))
cx1, cy1 = max(0, px1 - pad), max(0, py1 - pad)
cx2, cy2 = min(fw, px2 + pad), min(fh, py2 + pad)
plate_crop = best_frame[cy1:cy2, cx1:cx2]
if plate_crop.size > 0:
ph, pw = plate_crop.shape[:2]
big = cv2.resize(plate_crop, (pw * 4, ph * 4), interpolation=cv2.INTER_CUBIC)
cv2.imwrite(os.path.join(event_dir, "plate_crop.jpg"), big, [cv2.IMWRITE_JPEG_QUALITY, 95])
if plate_corrected is not None and plate_corrected.size > 0:
enhanced = _analyzer._enhance_crop(plate_corrected)
ch, cw = enhanced.shape[:2]
if cw < 300: # upscale small crops for display
enhanced = cv2.resize(enhanced, (300, int(300 * ch / cw)), interpolation=cv2.INTER_CUBIC)
cv2.imwrite(os.path.join(event_dir, "plate_ocr.jpg"), enhanced, [cv2.IMWRITE_JPEG_QUALITY, 95])
# Save thumbnail
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])
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(frame_files)}")
return event_id
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")
log.info(f"Capturing {CAPTURE_DURATION}s at {CAPTURE_FPS}fps — event {event_id[:8]}")
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
_process_clip(event_id, event_dir, clip_path)
def process_uploaded_clip(src_path: str) -> str:
"""Create a new event from an uploaded MP4. Returns event_id."""
event_id = str(uuid.uuid4())
event_dir = os.path.join(EVENTS_DIR, event_id)
os.makedirs(event_dir, exist_ok=True)
clip_path = os.path.join(event_dir, "clip.mp4")
shutil.copy2(src_path, clip_path)
log.info(f"Processing uploaded clip — event {event_id[:8]}")
result = _process_clip(event_id, event_dir, clip_path, camera_name="upload")
if result is None:
raise RuntimeError("Processing failed: no frames extracted")
return event_id
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)