feat: initial CamWatch service

Polls Frigate events API, extracts dominant vehicle color (KMeans),
reads LPR plate from Frigate sub_label. Responsive dark UI with
filter by plate/camera/date and auto-refresh. FastAPI + SQLite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
perco
2026-06-02 14:10:05 +02:00
co-authored by Claude Sonnet 4.6
commit f6422b0e7e
9 changed files with 534 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
import cv2
import numpy as np
import colorsys
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."""
img = cv2.imread(image_path)
if img is None:
return "#808080", "Inconnu"
if bbox:
h, w = img.shape[:2]
x1 = max(0, int(bbox.get("x", 0) * w))
y1 = max(0, int(bbox.get("y", 0) * h))
x2 = min(w, int((bbox.get("x", 0) + bbox.get("width", 1)) * w))
y2 = min(h, int((bbox.get("y", 0) + bbox.get("height", 1)) * h))
if x2 > x1 and y2 > y1:
img = img[y1:y2, x1:x2]
small = cv2.resize(img, (60, 60), interpolation=cv2.INTER_AREA)
pixels = small.reshape(-1, 3).astype(np.float32)
k = min(4, len(pixels))
_, labels, centers = cv2.kmeans(
pixels, k, None,
(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 20, 1.0),
10, cv2.KMEANS_RANDOM_CENTERS
)
counts = np.bincount(labels.flatten())
dominant = centers[np.argmax(counts)]
b, g, r = int(dominant[0]), int(dominant[1]), int(dominant[2])
hex_color = f"#{r:02x}{g:02x}{b:02x}"
return hex_color, _name_color(r, g, b)
def _name_color(r: int, g: int, b: int) -> str:
h, s, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
if v < 0.18:
return "Noir"
if v > 0.82 and s < 0.18:
return "Blanc"
if s < 0.18:
return "Gris"
hue = h * 360
if hue < 15 or hue >= 345:
return "Rouge"
if hue < 45:
return "Orange"
if hue < 75:
return "Jaune"
if hue < 150:
return "Vert"
if hue < 195:
return "Cyan"
if hue < 255:
return "Bleu"
if hue < 290:
return "Violet"
return "Rose"