mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-31 16:12:19 +03:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1769dac5e | ||
|
|
c35cee2d2f | ||
|
|
1a01513223 | ||
|
|
06ad72860c | ||
|
|
03d0139497 | ||
|
|
4772e6a2ab | ||
|
|
909b40ba96 | ||
|
|
0cf9d7d5b1 |
@@ -16,7 +16,7 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const maintainers = ['blakeblackshear', 'NickM-27', 'hawkeye217'];
|
||||
const maintainers = ['blakeblackshear', 'NickM-27', 'hawkeye217', 'dependabot[bot]'];
|
||||
const author = context.payload.pull_request.user.login;
|
||||
|
||||
if (maintainers.includes(author)) {
|
||||
|
||||
Generated
+3
-3
@@ -15952,9 +15952,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
|
||||
"integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
|
||||
@@ -613,6 +613,34 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
|
||||
try:
|
||||
config = FrigateConfig.parse(new_raw_config)
|
||||
except ValidationError as e:
|
||||
with open(config_file, "w") as f:
|
||||
f.write(old_raw_config)
|
||||
f.close()
|
||||
logger.error(
|
||||
f"Config Validation Error:\n\n{str(traceback.format_exc())}"
|
||||
)
|
||||
error_messages = []
|
||||
for err in e.errors():
|
||||
msg = err.get("msg", "")
|
||||
# Strip pydantic "Value error, " prefix for cleaner display
|
||||
if msg.startswith("Value error, "):
|
||||
msg = msg[len("Value error, ") :]
|
||||
error_messages.append(msg)
|
||||
message = (
|
||||
"; ".join(error_messages)
|
||||
if error_messages
|
||||
else "Check logs for error message."
|
||||
)
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"Error saving config: {message}",
|
||||
}
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
except Exception:
|
||||
with open(config_file, "w") as f:
|
||||
f.write(old_raw_config)
|
||||
|
||||
+23
-22
@@ -1,26 +1,27 @@
|
||||
import multiprocessing as mp
|
||||
from multiprocessing.managers import SyncManager
|
||||
import queue
|
||||
from multiprocessing.managers import SyncManager, ValueProxy
|
||||
from multiprocessing.sharedctypes import Synchronized
|
||||
from multiprocessing.synchronize import Event
|
||||
|
||||
|
||||
class CameraMetrics:
|
||||
camera_fps: Synchronized
|
||||
detection_fps: Synchronized
|
||||
detection_frame: Synchronized
|
||||
process_fps: Synchronized
|
||||
skipped_fps: Synchronized
|
||||
read_start: Synchronized
|
||||
audio_rms: Synchronized
|
||||
audio_dBFS: Synchronized
|
||||
camera_fps: ValueProxy[float]
|
||||
detection_fps: ValueProxy[float]
|
||||
detection_frame: ValueProxy[float]
|
||||
process_fps: ValueProxy[float]
|
||||
skipped_fps: ValueProxy[float]
|
||||
read_start: ValueProxy[float]
|
||||
audio_rms: ValueProxy[float]
|
||||
audio_dBFS: ValueProxy[float]
|
||||
|
||||
frame_queue: mp.Queue
|
||||
frame_queue: queue.Queue
|
||||
|
||||
process_pid: Synchronized
|
||||
capture_process_pid: Synchronized
|
||||
ffmpeg_pid: Synchronized
|
||||
reconnects_last_hour: Synchronized
|
||||
stalls_last_hour: Synchronized
|
||||
process_pid: ValueProxy[int]
|
||||
capture_process_pid: ValueProxy[int]
|
||||
ffmpeg_pid: ValueProxy[int]
|
||||
reconnects_last_hour: ValueProxy[int]
|
||||
stalls_last_hour: ValueProxy[int]
|
||||
|
||||
def __init__(self, manager: SyncManager):
|
||||
self.camera_fps = manager.Value("d", 0)
|
||||
@@ -56,14 +57,14 @@ class PTZMetrics:
|
||||
reset: Event
|
||||
|
||||
def __init__(self, *, autotracker_enabled: bool):
|
||||
self.autotracker_enabled = mp.Value("i", autotracker_enabled)
|
||||
self.autotracker_enabled = mp.Value("i", autotracker_enabled) # type: ignore[assignment]
|
||||
|
||||
self.start_time = mp.Value("d", 0)
|
||||
self.stop_time = mp.Value("d", 0)
|
||||
self.frame_time = mp.Value("d", 0)
|
||||
self.zoom_level = mp.Value("d", 0)
|
||||
self.max_zoom = mp.Value("d", 0)
|
||||
self.min_zoom = mp.Value("d", 0)
|
||||
self.start_time = mp.Value("d", 0) # type: ignore[assignment]
|
||||
self.stop_time = mp.Value("d", 0) # type: ignore[assignment]
|
||||
self.frame_time = mp.Value("d", 0) # type: ignore[assignment]
|
||||
self.zoom_level = mp.Value("d", 0) # type: ignore[assignment]
|
||||
self.max_zoom = mp.Value("d", 0) # type: ignore[assignment]
|
||||
self.min_zoom = mp.Value("d", 0) # type: ignore[assignment]
|
||||
|
||||
self.tracking_active = mp.Event()
|
||||
self.motor_stopped = mp.Event()
|
||||
|
||||
@@ -37,6 +37,9 @@ class CameraActivityManager:
|
||||
self.__init_camera(camera_config)
|
||||
|
||||
def __init_camera(self, camera_config: CameraConfig) -> None:
|
||||
if camera_config.name is None:
|
||||
return
|
||||
|
||||
self.last_camera_activity[camera_config.name] = {}
|
||||
self.camera_all_object_counts[camera_config.name] = Counter()
|
||||
self.camera_active_object_counts[camera_config.name] = Counter()
|
||||
@@ -114,7 +117,7 @@ class CameraActivityManager:
|
||||
self.last_camera_activity = new_activity
|
||||
|
||||
def compare_camera_activity(
|
||||
self, camera: str, new_activity: dict[str, Any]
|
||||
self, camera: str, new_activity: list[dict[str, Any]]
|
||||
) -> None:
|
||||
all_objects = Counter(
|
||||
obj["label"].replace("-verified", "") for obj in new_activity
|
||||
@@ -175,6 +178,9 @@ class AudioActivityManager:
|
||||
self.__init_camera(camera_config)
|
||||
|
||||
def __init_camera(self, camera_config: CameraConfig) -> None:
|
||||
if camera_config.name is None:
|
||||
return
|
||||
|
||||
self.current_audio_detections[camera_config.name] = {}
|
||||
|
||||
def update_activity(self, new_activity: dict[str, dict[str, Any]]) -> None:
|
||||
@@ -202,7 +208,7 @@ class AudioActivityManager:
|
||||
|
||||
def compare_audio_activity(
|
||||
self, camera: str, new_detections: list[tuple[str, float]], now: float
|
||||
) -> None:
|
||||
) -> bool:
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return False
|
||||
|
||||
@@ -102,7 +102,7 @@ class CameraMaintainer(threading.Thread):
|
||||
f"recommend increasing it to at least {shm_stats['min_shm']}MB."
|
||||
)
|
||||
|
||||
return shm_stats["shm_frame_count"]
|
||||
return int(shm_stats["shm_frame_count"])
|
||||
|
||||
def __start_camera_processor(
|
||||
self, name: str, config: CameraConfig, runtime: bool = False
|
||||
@@ -152,10 +152,10 @@ class CameraMaintainer(threading.Thread):
|
||||
camera_stop_event,
|
||||
self.config.logger,
|
||||
)
|
||||
self.camera_processes[config.name] = camera_process
|
||||
self.camera_processes[name] = camera_process
|
||||
camera_process.start()
|
||||
self.camera_metrics[config.name].process_pid.value = camera_process.pid
|
||||
logger.info(f"Camera processor started for {config.name}: {camera_process.pid}")
|
||||
self.camera_metrics[name].process_pid.value = camera_process.pid
|
||||
logger.info(f"Camera processor started for {name}: {camera_process.pid}")
|
||||
|
||||
def __start_camera_capture(
|
||||
self, name: str, config: CameraConfig, runtime: bool = False
|
||||
@@ -219,7 +219,7 @@ class CameraMaintainer(threading.Thread):
|
||||
logger.info(f"Closing frame queue for {camera}")
|
||||
empty_and_close_queue(self.camera_metrics[camera].frame_queue)
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
self.__init_historical_regions()
|
||||
|
||||
# start camera processes
|
||||
|
||||
+37
-30
@@ -31,26 +31,26 @@ logger = logging.getLogger(__name__)
|
||||
class CameraState:
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
name: str,
|
||||
config: FrigateConfig,
|
||||
frame_manager: SharedMemoryFrameManager,
|
||||
ptz_autotracker_thread: PtzAutoTrackerThread,
|
||||
):
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.config = config
|
||||
self.camera_config = config.cameras[name]
|
||||
self.frame_manager = frame_manager
|
||||
self.best_objects: dict[str, TrackedObject] = {}
|
||||
self.tracked_objects: dict[str, TrackedObject] = {}
|
||||
self.frame_cache = {}
|
||||
self.zone_objects = defaultdict(list)
|
||||
self.frame_cache: dict[float, dict[str, Any]] = {}
|
||||
self.zone_objects: defaultdict[str, list[Any]] = defaultdict(list)
|
||||
self._current_frame = np.zeros(self.camera_config.frame_shape_yuv, np.uint8)
|
||||
self.current_frame_lock = threading.Lock()
|
||||
self.current_frame_time = 0.0
|
||||
self.motion_boxes = []
|
||||
self.regions = []
|
||||
self.previous_frame_id = None
|
||||
self.callbacks = defaultdict(list)
|
||||
self.motion_boxes: list[tuple[int, int, int, int]] = []
|
||||
self.regions: list[tuple[int, int, int, int]] = []
|
||||
self.previous_frame_id: str | None = None
|
||||
self.callbacks: defaultdict[str, list[Callable]] = defaultdict(list)
|
||||
self.ptz_autotracker_thread = ptz_autotracker_thread
|
||||
self.prev_enabled = self.camera_config.enabled
|
||||
|
||||
@@ -62,10 +62,10 @@ class CameraState:
|
||||
motion_boxes = self.motion_boxes.copy()
|
||||
regions = self.regions.copy()
|
||||
|
||||
frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420)
|
||||
frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420) # type: ignore[assignment]
|
||||
# draw on the frame
|
||||
if draw_options.get("mask"):
|
||||
mask_overlay = np.where(self.camera_config.motion.rasterized_mask == [0])
|
||||
mask_overlay = np.where(self.camera_config.motion.rasterized_mask == [0]) # type: ignore[attr-defined]
|
||||
frame_copy[mask_overlay] = [0, 0, 0]
|
||||
|
||||
if draw_options.get("bounding_boxes"):
|
||||
@@ -97,7 +97,7 @@ class CameraState:
|
||||
and obj["id"]
|
||||
== self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||
self.name
|
||||
].obj_data["id"]
|
||||
].obj_data["id"] # type: ignore[attr-defined]
|
||||
and obj["frame_time"] == frame_time
|
||||
):
|
||||
thickness = 5
|
||||
@@ -109,10 +109,12 @@ class CameraState:
|
||||
if (
|
||||
self.camera_config.onvif.autotracking.zooming
|
||||
!= ZoomingModeEnum.disabled
|
||||
and self.camera_config.detect.width is not None
|
||||
and self.camera_config.detect.height is not None
|
||||
):
|
||||
max_target_box = self.ptz_autotracker_thread.ptz_autotracker.tracked_object_metrics[
|
||||
self.name
|
||||
]["max_target_box"]
|
||||
]["max_target_box"] # type: ignore[index]
|
||||
side_length = max_target_box * (
|
||||
max(
|
||||
self.camera_config.detect.width,
|
||||
@@ -221,14 +223,14 @@ class CameraState:
|
||||
)
|
||||
|
||||
if draw_options.get("timestamp"):
|
||||
color = self.camera_config.timestamp_style.color
|
||||
ts_color = self.camera_config.timestamp_style.color
|
||||
draw_timestamp(
|
||||
frame_copy,
|
||||
frame_time,
|
||||
self.camera_config.timestamp_style.format,
|
||||
font_effect=self.camera_config.timestamp_style.effect,
|
||||
font_thickness=self.camera_config.timestamp_style.thickness,
|
||||
font_color=(color.blue, color.green, color.red),
|
||||
font_color=(ts_color.blue, ts_color.green, ts_color.red),
|
||||
position=self.camera_config.timestamp_style.position,
|
||||
)
|
||||
|
||||
@@ -273,10 +275,10 @@ class CameraState:
|
||||
|
||||
return frame_copy
|
||||
|
||||
def finished(self, obj_id):
|
||||
def finished(self, obj_id: str) -> None:
|
||||
del self.tracked_objects[obj_id]
|
||||
|
||||
def on(self, event_type: str, callback: Callable):
|
||||
def on(self, event_type: str, callback: Callable[..., Any]) -> None:
|
||||
self.callbacks[event_type].append(callback)
|
||||
|
||||
def update(
|
||||
@@ -286,7 +288,7 @@ class CameraState:
|
||||
current_detections: dict[str, dict[str, Any]],
|
||||
motion_boxes: list[tuple[int, int, int, int]],
|
||||
regions: list[tuple[int, int, int, int]],
|
||||
):
|
||||
) -> None:
|
||||
current_frame = self.frame_manager.get(
|
||||
frame_name, self.camera_config.frame_shape_yuv
|
||||
)
|
||||
@@ -313,7 +315,7 @@ class CameraState:
|
||||
f"{self.name}: New object, adding {frame_time} to frame cache for {id}"
|
||||
)
|
||||
self.frame_cache[frame_time] = {
|
||||
"frame": np.copy(current_frame),
|
||||
"frame": np.copy(current_frame), # type: ignore[arg-type]
|
||||
"object_id": id,
|
||||
}
|
||||
|
||||
@@ -356,7 +358,8 @@ class CameraState:
|
||||
if thumb_update and current_frame is not None:
|
||||
# ensure this frame is stored in the cache
|
||||
if (
|
||||
updated_obj.thumbnail_data["frame_time"] == frame_time
|
||||
updated_obj.thumbnail_data is not None
|
||||
and updated_obj.thumbnail_data["frame_time"] == frame_time
|
||||
and frame_time not in self.frame_cache
|
||||
):
|
||||
logger.debug(
|
||||
@@ -397,7 +400,7 @@ class CameraState:
|
||||
|
||||
# TODO: can i switch to looking this up and only changing when an event ends?
|
||||
# maintain best objects
|
||||
camera_activity: dict[str, list[Any]] = {
|
||||
camera_activity: dict[str, Any] = {
|
||||
"motion": len(motion_boxes) > 0,
|
||||
"objects": [],
|
||||
}
|
||||
@@ -411,10 +414,7 @@ class CameraState:
|
||||
sub_label = None
|
||||
|
||||
if obj.obj_data.get("sub_label"):
|
||||
if (
|
||||
obj.obj_data.get("sub_label")[0]
|
||||
in self.config.model.all_attributes
|
||||
):
|
||||
if obj.obj_data["sub_label"][0] in self.config.model.all_attributes:
|
||||
label = obj.obj_data["sub_label"][0]
|
||||
else:
|
||||
label = f"{object_type}-verified"
|
||||
@@ -449,14 +449,19 @@ class CameraState:
|
||||
# if the object is a higher score than the current best score
|
||||
# or the current object is older than desired, use the new object
|
||||
if (
|
||||
is_better_thumbnail(
|
||||
current_best.thumbnail_data is not None
|
||||
and obj.thumbnail_data is not None
|
||||
and is_better_thumbnail(
|
||||
object_type,
|
||||
current_best.thumbnail_data,
|
||||
obj.thumbnail_data,
|
||||
self.camera_config.frame_shape,
|
||||
)
|
||||
or (now - current_best.thumbnail_data["frame_time"])
|
||||
> self.camera_config.best_image_timeout
|
||||
or (
|
||||
current_best.thumbnail_data is not None
|
||||
and (now - current_best.thumbnail_data["frame_time"])
|
||||
> self.camera_config.best_image_timeout
|
||||
)
|
||||
):
|
||||
self.send_mqtt_snapshot(obj, object_type)
|
||||
else:
|
||||
@@ -472,7 +477,9 @@ class CameraState:
|
||||
if obj.thumbnail_data is not None
|
||||
}
|
||||
current_best_frames = {
|
||||
obj.thumbnail_data["frame_time"] for obj in self.best_objects.values()
|
||||
obj.thumbnail_data["frame_time"]
|
||||
for obj in self.best_objects.values()
|
||||
if obj.thumbnail_data is not None
|
||||
}
|
||||
thumb_frames_to_delete = [
|
||||
t
|
||||
@@ -540,7 +547,7 @@ class CameraState:
|
||||
with open(
|
||||
os.path.join(
|
||||
CLIPS_DIR,
|
||||
f"{self.camera_config.name}-{event_id}-clean.webp",
|
||||
f"{self.name}-{event_id}-clean.webp",
|
||||
),
|
||||
"wb",
|
||||
) as p:
|
||||
@@ -549,7 +556,7 @@ class CameraState:
|
||||
# create thumbnail with max height of 175 and save
|
||||
width = int(175 * img_frame.shape[1] / img_frame.shape[0])
|
||||
thumb = cv2.resize(img_frame, dsize=(width, 175), interpolation=cv2.INTER_AREA)
|
||||
thumb_path = os.path.join(THUMB_DIR, self.camera_config.name)
|
||||
thumb_path = os.path.join(THUMB_DIR, self.name)
|
||||
os.makedirs(thumb_path, exist_ok=True)
|
||||
cv2.imwrite(os.path.join(thumb_path, f"{event_id}.webp"), thumb)
|
||||
|
||||
|
||||
@@ -542,9 +542,9 @@ class WebPushClient(Communicator):
|
||||
|
||||
self.check_registrations()
|
||||
|
||||
reasoning: str = payload.get("reasoning", "")
|
||||
text: str = payload.get("message") or payload.get("reasoning", "")
|
||||
title = f"{camera_name}: Monitoring Alert"
|
||||
message = (reasoning[:197] + "...") if len(reasoning) > 200 else reasoning
|
||||
message = (text[:197] + "...") if len(text) > 200 else text
|
||||
|
||||
logger.debug(f"Sending camera monitoring push notification for {camera_name}")
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ class DetectConfig(FrigateBaseModel):
|
||||
default=None,
|
||||
title="Minimum initialization frames",
|
||||
description="Number of consecutive detection hits required before creating a tracked object. Increase to reduce false initializations. Default value is fps divided by 2.",
|
||||
ge=2,
|
||||
)
|
||||
max_disappeared: Optional[int] = Field(
|
||||
default=None,
|
||||
|
||||
@@ -188,7 +188,7 @@ class ReviewConfig(FrigateBaseModel):
|
||||
detections: DetectionsConfig = Field(
|
||||
default_factory=DetectionsConfig,
|
||||
title="Detections config",
|
||||
description="Settings for creating detection events (non-alert) and how long to keep them.",
|
||||
description="Settings for which tracked objects generate detections (non-alert) and how detections are retained.",
|
||||
)
|
||||
genai: GenAIReviewConfig = Field(
|
||||
default_factory=GenAIReviewConfig,
|
||||
|
||||
+21
-10
@@ -614,6 +614,21 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if self.ffmpeg.hwaccel_args == "auto":
|
||||
self.ffmpeg.hwaccel_args = auto_detect_hwaccel()
|
||||
|
||||
# Populate global audio filters for all audio labels
|
||||
all_audio_labels = {
|
||||
label
|
||||
for label in load_labels("/audio-labelmap.txt", prefill=521).values()
|
||||
if label
|
||||
}
|
||||
|
||||
if self.audio.filters is None:
|
||||
self.audio.filters = {}
|
||||
|
||||
for key in sorted(all_audio_labels - self.audio.filters.keys()):
|
||||
self.audio.filters[key] = AudioFilterConfig()
|
||||
|
||||
self.audio.filters = dict(sorted(self.audio.filters.items()))
|
||||
|
||||
# Global config to propagate down to camera level
|
||||
global_config = self.model_dump(
|
||||
include={
|
||||
@@ -672,12 +687,6 @@ class FrigateConfig(FrigateBaseModel):
|
||||
detector_config.model = model
|
||||
self.detectors[key] = detector_config
|
||||
|
||||
all_audio_labels = {
|
||||
label
|
||||
for label in load_labels("/audio-labelmap.txt", prefill=521).values()
|
||||
if label
|
||||
}
|
||||
|
||||
for name, camera in self.cameras.items():
|
||||
modified_global_config = global_config.copy()
|
||||
|
||||
@@ -755,7 +764,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
)
|
||||
|
||||
# Default min_initialized configuration
|
||||
min_initialized = int(camera_config.detect.fps / 2)
|
||||
min_initialized = max(int(camera_config.detect.fps / 2), 2)
|
||||
if camera_config.detect.min_initialized is None:
|
||||
camera_config.detect.min_initialized = min_initialized
|
||||
|
||||
@@ -801,11 +810,13 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if camera_config.audio.filters is None:
|
||||
camera_config.audio.filters = {}
|
||||
|
||||
audio_keys = all_audio_labels
|
||||
audio_keys = audio_keys - camera_config.audio.filters.keys()
|
||||
for key in audio_keys:
|
||||
for key in sorted(all_audio_labels - camera_config.audio.filters.keys()):
|
||||
camera_config.audio.filters[key] = AudioFilterConfig()
|
||||
|
||||
camera_config.audio.filters = dict(
|
||||
sorted(camera_config.audio.filters.items())
|
||||
)
|
||||
|
||||
# Add default filters
|
||||
object_keys = camera_config.objects.track
|
||||
if camera_config.objects.filters is None:
|
||||
|
||||
@@ -53,7 +53,7 @@ class AudioTranscriptionModelRunner:
|
||||
self.downloader = ModelDownloader(
|
||||
model_name="sherpa-onnx",
|
||||
download_path=download_path,
|
||||
file_names=self.model_files.keys(),
|
||||
file_names=list(self.model_files.keys()),
|
||||
download_func=self.__download_models,
|
||||
)
|
||||
self.downloader.ensure_model_files()
|
||||
|
||||
@@ -21,7 +21,7 @@ class FaceRecognizer(ABC):
|
||||
|
||||
def __init__(self, config: FrigateConfig) -> None:
|
||||
self.config = config
|
||||
self.landmark_detector: cv2.face.FacemarkLBF = None
|
||||
self.landmark_detector: cv2.face.Facemark | None = None
|
||||
self.init_landmark_detector()
|
||||
|
||||
@abstractmethod
|
||||
@@ -38,13 +38,14 @@ class FaceRecognizer(ABC):
|
||||
def classify(self, face_image: np.ndarray) -> tuple[str, float] | None:
|
||||
pass
|
||||
|
||||
@redirect_output_to_logger(logger, logging.DEBUG)
|
||||
@redirect_output_to_logger(logger, logging.DEBUG) # type: ignore[misc]
|
||||
def init_landmark_detector(self) -> None:
|
||||
landmark_model = os.path.join(MODEL_CACHE_DIR, "facedet/landmarkdet.yaml")
|
||||
|
||||
if os.path.exists(landmark_model):
|
||||
self.landmark_detector = cv2.face.createFacemarkLBF()
|
||||
self.landmark_detector.loadModel(landmark_model)
|
||||
landmark_detector = cv2.face.createFacemarkLBF()
|
||||
landmark_detector.loadModel(landmark_model)
|
||||
self.landmark_detector = landmark_detector
|
||||
|
||||
def align_face(
|
||||
self,
|
||||
@@ -52,8 +53,10 @@ class FaceRecognizer(ABC):
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
) -> np.ndarray:
|
||||
# landmark is run on grayscale images
|
||||
if not self.landmark_detector:
|
||||
raise ValueError("Landmark detector not initialized")
|
||||
|
||||
# landmark is run on grayscale images
|
||||
if image.ndim == 3:
|
||||
land_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
else:
|
||||
@@ -131,8 +134,11 @@ class FaceRecognizer(ABC):
|
||||
|
||||
|
||||
def similarity_to_confidence(
|
||||
cosine_similarity: float, median=0.3, range_width=0.6, slope_factor=12
|
||||
):
|
||||
cosine_similarity: float,
|
||||
median: float = 0.3,
|
||||
range_width: float = 0.6,
|
||||
slope_factor: float = 12,
|
||||
) -> float:
|
||||
"""
|
||||
Default sigmoid function to map cosine similarity to confidence.
|
||||
|
||||
@@ -151,14 +157,14 @@ def similarity_to_confidence(
|
||||
bias = median
|
||||
|
||||
# Calculate confidence
|
||||
confidence = 1 / (1 + np.exp(-slope * (cosine_similarity - bias)))
|
||||
confidence: float = 1 / (1 + np.exp(-slope * (cosine_similarity - bias)))
|
||||
return confidence
|
||||
|
||||
|
||||
class FaceNetRecognizer(FaceRecognizer):
|
||||
def __init__(self, config: FrigateConfig):
|
||||
super().__init__(config)
|
||||
self.mean_embs: dict[int, np.ndarray] = {}
|
||||
self.mean_embs: dict[str, np.ndarray] = {}
|
||||
self.face_embedder: FaceNetEmbedding = FaceNetEmbedding()
|
||||
self.model_builder_queue: queue.Queue | None = None
|
||||
|
||||
@@ -168,7 +174,7 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
def run_build_task(self) -> None:
|
||||
self.model_builder_queue = queue.Queue()
|
||||
|
||||
def build_model():
|
||||
def build_model() -> None:
|
||||
face_embeddings_map: dict[str, list[np.ndarray]] = {}
|
||||
idx = 0
|
||||
|
||||
@@ -187,7 +193,7 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
img = cv2.imread(os.path.join(face_folder, image))
|
||||
|
||||
if img is None:
|
||||
continue
|
||||
continue # type: ignore[unreachable]
|
||||
|
||||
img = self.align_face(img, img.shape[1], img.shape[0])
|
||||
emb = self.face_embedder([img])[0].squeeze()
|
||||
@@ -195,12 +201,13 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
|
||||
idx += 1
|
||||
|
||||
assert self.model_builder_queue is not None
|
||||
self.model_builder_queue.put(face_embeddings_map)
|
||||
|
||||
thread = threading.Thread(target=build_model, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def build(self):
|
||||
def build(self) -> None:
|
||||
if not self.landmark_detector:
|
||||
self.init_landmark_detector()
|
||||
return None
|
||||
@@ -226,7 +233,7 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
|
||||
logger.debug("Finished building ArcFace model")
|
||||
|
||||
def classify(self, face_image):
|
||||
def classify(self, face_image: np.ndarray) -> tuple[str, float] | None:
|
||||
if not self.landmark_detector:
|
||||
return None
|
||||
|
||||
@@ -245,7 +252,7 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
img = self.align_face(face_image, face_image.shape[1], face_image.shape[0])
|
||||
embedding = self.face_embedder([img])[0].squeeze()
|
||||
|
||||
score = 0
|
||||
score: float = 0
|
||||
label = ""
|
||||
|
||||
for name, mean_emb in self.mean_embs.items():
|
||||
@@ -268,7 +275,7 @@ class FaceNetRecognizer(FaceRecognizer):
|
||||
class ArcFaceRecognizer(FaceRecognizer):
|
||||
def __init__(self, config: FrigateConfig):
|
||||
super().__init__(config)
|
||||
self.mean_embs: dict[int, np.ndarray] = {}
|
||||
self.mean_embs: dict[str, np.ndarray] = {}
|
||||
self.face_embedder: ArcfaceEmbedding = ArcfaceEmbedding(config.face_recognition)
|
||||
self.model_builder_queue: queue.Queue | None = None
|
||||
|
||||
@@ -278,7 +285,7 @@ class ArcFaceRecognizer(FaceRecognizer):
|
||||
def run_build_task(self) -> None:
|
||||
self.model_builder_queue = queue.Queue()
|
||||
|
||||
def build_model():
|
||||
def build_model() -> None:
|
||||
face_embeddings_map: dict[str, list[np.ndarray]] = {}
|
||||
idx = 0
|
||||
|
||||
@@ -297,20 +304,21 @@ class ArcFaceRecognizer(FaceRecognizer):
|
||||
img = cv2.imread(os.path.join(face_folder, image))
|
||||
|
||||
if img is None:
|
||||
continue
|
||||
continue # type: ignore[unreachable]
|
||||
|
||||
img = self.align_face(img, img.shape[1], img.shape[0])
|
||||
emb = self.face_embedder([img])[0].squeeze()
|
||||
emb = self.face_embedder([img])[0].squeeze() # type: ignore[arg-type]
|
||||
face_embeddings_map[name].append(emb)
|
||||
|
||||
idx += 1
|
||||
|
||||
assert self.model_builder_queue is not None
|
||||
self.model_builder_queue.put(face_embeddings_map)
|
||||
|
||||
thread = threading.Thread(target=build_model, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def build(self):
|
||||
def build(self) -> None:
|
||||
if not self.landmark_detector:
|
||||
self.init_landmark_detector()
|
||||
return None
|
||||
@@ -336,7 +344,7 @@ class ArcFaceRecognizer(FaceRecognizer):
|
||||
|
||||
logger.debug("Finished building ArcFace model")
|
||||
|
||||
def classify(self, face_image):
|
||||
def classify(self, face_image: np.ndarray) -> tuple[str, float] | None:
|
||||
if not self.landmark_detector:
|
||||
return None
|
||||
|
||||
@@ -353,9 +361,9 @@ class ArcFaceRecognizer(FaceRecognizer):
|
||||
|
||||
# align face and run recognition
|
||||
img = self.align_face(face_image, face_image.shape[1], face_image.shape[0])
|
||||
embedding = self.face_embedder([img])[0].squeeze()
|
||||
embedding = self.face_embedder([img])[0].squeeze() # type: ignore[arg-type]
|
||||
|
||||
score = 0
|
||||
score: float = 0
|
||||
label = ""
|
||||
|
||||
for name, mean_emb in self.mean_embs.items():
|
||||
|
||||
@@ -10,7 +10,7 @@ import random
|
||||
import re
|
||||
import string
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -22,19 +22,35 @@ from frigate.comms.event_metadata_updater import (
|
||||
EventMetadataPublisher,
|
||||
EventMetadataTypeEnum,
|
||||
)
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import LicensePlateRecognitionConfig
|
||||
from frigate.const import CLIPS_DIR, MODEL_CACHE_DIR
|
||||
from frigate.data_processing.common.license_plate.model import LicensePlateModelRunner
|
||||
from frigate.embeddings.onnx.lpr_embedding import LPR_EMBEDDING_SIZE
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
|
||||
from frigate.util.image import area
|
||||
|
||||
from ...types import DataProcessorMetrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WRITE_DEBUG_IMAGES = False
|
||||
|
||||
|
||||
class LicensePlateProcessingMixin:
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Attributes expected from consuming classes (set before super().__init__)
|
||||
config: FrigateConfig
|
||||
metrics: DataProcessorMetrics
|
||||
model_runner: LicensePlateModelRunner
|
||||
lpr_config: LicensePlateRecognitionConfig
|
||||
requestor: InterProcessRequestor
|
||||
detected_license_plates: dict[str, dict[str, Any]]
|
||||
camera_current_cars: dict[str, list[str]]
|
||||
sub_label_publisher: EventMetadataPublisher
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.plate_rec_speed = InferenceSpeed(self.metrics.alpr_speed)
|
||||
self.plates_rec_second = EventsPerSecond()
|
||||
@@ -97,7 +113,7 @@ class LicensePlateProcessingMixin:
|
||||
)
|
||||
|
||||
try:
|
||||
outputs = self.model_runner.detection_model([normalized_image])[0]
|
||||
outputs = self.model_runner.detection_model([normalized_image])[0] # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LPR box detection model: {e}")
|
||||
return []
|
||||
@@ -105,18 +121,18 @@ class LicensePlateProcessingMixin:
|
||||
outputs = outputs[0, :, :]
|
||||
|
||||
if False:
|
||||
current_time = int(datetime.datetime.now().timestamp())
|
||||
current_time = int(datetime.datetime.now().timestamp()) # type: ignore[unreachable]
|
||||
cv2.imwrite(
|
||||
f"debug/frames/probability_map_{current_time}.jpg",
|
||||
(outputs * 255).astype(np.uint8),
|
||||
)
|
||||
|
||||
boxes, _ = self._boxes_from_bitmap(outputs, outputs > self.mask_thresh, w, h)
|
||||
return self._filter_polygon(boxes, (h, w))
|
||||
return self._filter_polygon(boxes, (h, w)) # type: ignore[return-value,arg-type]
|
||||
|
||||
def _classify(
|
||||
self, images: List[np.ndarray]
|
||||
) -> Tuple[List[np.ndarray], List[Tuple[str, float]]]:
|
||||
) -> Tuple[List[np.ndarray], List[Tuple[str, float]]] | None:
|
||||
"""
|
||||
Classify the orientation or category of each detected license plate.
|
||||
|
||||
@@ -138,15 +154,15 @@ class LicensePlateProcessingMixin:
|
||||
norm_images.append(norm_img)
|
||||
|
||||
try:
|
||||
outputs = self.model_runner.classification_model(norm_images)
|
||||
outputs = self.model_runner.classification_model(norm_images) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LPR classification model: {e}")
|
||||
return
|
||||
return None
|
||||
|
||||
return self._process_classification_output(images, outputs)
|
||||
|
||||
def _recognize(
|
||||
self, camera: string, images: List[np.ndarray]
|
||||
self, camera: str, images: List[np.ndarray]
|
||||
) -> Tuple[List[str], List[List[float]]]:
|
||||
"""
|
||||
Recognize the characters on the detected license plates using the recognition model.
|
||||
@@ -179,7 +195,7 @@ class LicensePlateProcessingMixin:
|
||||
norm_images.append(norm_image)
|
||||
|
||||
try:
|
||||
outputs = self.model_runner.recognition_model(norm_images)
|
||||
outputs = self.model_runner.recognition_model(norm_images) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LPR recognition model: {e}")
|
||||
return [], []
|
||||
@@ -410,7 +426,8 @@ class LicensePlateProcessingMixin:
|
||||
)
|
||||
|
||||
if sorted_data:
|
||||
return map(list, zip(*sorted_data))
|
||||
plates, confs, areas_list = zip(*sorted_data)
|
||||
return list(plates), list(confs), list(areas_list)
|
||||
|
||||
return [], [], []
|
||||
|
||||
@@ -532,7 +549,7 @@ class LicensePlateProcessingMixin:
|
||||
# Add the last box
|
||||
merged_boxes.append(current_box)
|
||||
|
||||
return np.array(merged_boxes, dtype=np.int32)
|
||||
return np.array(merged_boxes, dtype=np.int32) # type: ignore[return-value]
|
||||
|
||||
def _boxes_from_bitmap(
|
||||
self, output: np.ndarray, mask: np.ndarray, dest_width: int, dest_height: int
|
||||
@@ -560,38 +577,42 @@ class LicensePlateProcessingMixin:
|
||||
boxes = []
|
||||
scores = []
|
||||
|
||||
for index in range(len(contours)):
|
||||
contour = contours[index]
|
||||
for index in range(len(contours)): # type: ignore[arg-type]
|
||||
contour = contours[index] # type: ignore[index]
|
||||
|
||||
# get minimum bounding box (rotated rectangle) around the contour and the smallest side length.
|
||||
points, sside = self._get_min_boxes(contour)
|
||||
if sside < self.min_size:
|
||||
continue
|
||||
|
||||
points = np.array(points, dtype=np.float32)
|
||||
points = np.array(points, dtype=np.float32) # type: ignore[assignment]
|
||||
|
||||
score = self._box_score(output, contour)
|
||||
if self.box_thresh > score:
|
||||
continue
|
||||
|
||||
points = self._expand_box(points)
|
||||
points = self._expand_box(points) # type: ignore[assignment]
|
||||
|
||||
# Get the minimum area rectangle again after expansion
|
||||
points, sside = self._get_min_boxes(points.reshape(-1, 1, 2))
|
||||
points, sside = self._get_min_boxes(points.reshape(-1, 1, 2)) # type: ignore[attr-defined]
|
||||
if sside < self.min_size + 2:
|
||||
continue
|
||||
|
||||
points = np.array(points, dtype=np.float32)
|
||||
points = np.array(points, dtype=np.float32) # type: ignore[assignment]
|
||||
|
||||
# normalize and clip box coordinates to fit within the destination image size.
|
||||
points[:, 0] = np.clip(
|
||||
np.round(points[:, 0] / width * dest_width), 0, dest_width
|
||||
points[:, 0] = np.clip( # type: ignore[call-overload]
|
||||
np.round(points[:, 0] / width * dest_width), # type: ignore[call-overload]
|
||||
0,
|
||||
dest_width,
|
||||
)
|
||||
points[:, 1] = np.clip(
|
||||
np.round(points[:, 1] / height * dest_height), 0, dest_height
|
||||
points[:, 1] = np.clip( # type: ignore[call-overload]
|
||||
np.round(points[:, 1] / height * dest_height), # type: ignore[call-overload]
|
||||
0,
|
||||
dest_height,
|
||||
)
|
||||
|
||||
boxes.append(points.astype("int32"))
|
||||
boxes.append(points.astype("int32")) # type: ignore[attr-defined]
|
||||
scores.append(score)
|
||||
|
||||
return np.array(boxes, dtype="int32"), scores
|
||||
@@ -632,7 +653,7 @@ class LicensePlateProcessingMixin:
|
||||
x1, y1 = np.clip(contour.min(axis=0), 0, [w - 1, h - 1])
|
||||
x2, y2 = np.clip(contour.max(axis=0), 0, [w - 1, h - 1])
|
||||
mask = np.zeros((y2 - y1 + 1, x2 - x1 + 1), dtype=np.uint8)
|
||||
cv2.fillPoly(mask, [contour - [x1, y1]], 1)
|
||||
cv2.fillPoly(mask, [contour - [x1, y1]], 1) # type: ignore[call-overload]
|
||||
return cv2.mean(bitmap[y1 : y2 + 1, x1 : x2 + 1], mask)[0]
|
||||
|
||||
@staticmethod
|
||||
@@ -690,7 +711,7 @@ class LicensePlateProcessingMixin:
|
||||
Returns:
|
||||
bool: Whether the polygon is valid or not.
|
||||
"""
|
||||
return (
|
||||
return bool(
|
||||
point[:, 0].min() >= 0
|
||||
and point[:, 0].max() < width
|
||||
and point[:, 1].min() >= 0
|
||||
@@ -735,7 +756,7 @@ class LicensePlateProcessingMixin:
|
||||
return np.array([tl, tr, br, bl])
|
||||
|
||||
@staticmethod
|
||||
def _sort_boxes(boxes):
|
||||
def _sort_boxes(boxes: list[np.ndarray]) -> list[np.ndarray]:
|
||||
"""
|
||||
Sort polygons based on their position in the image. If boxes are close in vertical
|
||||
position (within 5 pixels), sort them by horizontal position.
|
||||
@@ -837,16 +858,16 @@ class LicensePlateProcessingMixin:
|
||||
results = [["", 0.0]] * len(images)
|
||||
indices = np.argsort(np.array([x.shape[1] / x.shape[0] for x in images]))
|
||||
|
||||
outputs = np.stack(outputs)
|
||||
stacked_outputs = np.stack(outputs)
|
||||
|
||||
outputs = [
|
||||
(labels[idx], outputs[i, idx])
|
||||
for i, idx in enumerate(outputs.argmax(axis=1))
|
||||
stacked_outputs = [
|
||||
(labels[idx], stacked_outputs[i, idx])
|
||||
for i, idx in enumerate(stacked_outputs.argmax(axis=1))
|
||||
]
|
||||
|
||||
for i in range(0, len(images), self.batch_size):
|
||||
for j in range(len(outputs)):
|
||||
label, score = outputs[j]
|
||||
for j in range(len(stacked_outputs)):
|
||||
label, score = stacked_outputs[j]
|
||||
results[indices[i + j]] = [label, score]
|
||||
# make sure we have high confidence if we need to flip a box
|
||||
if "180" in label and score >= 0.7:
|
||||
@@ -854,10 +875,10 @@ class LicensePlateProcessingMixin:
|
||||
images[indices[i + j]], cv2.ROTATE_180
|
||||
)
|
||||
|
||||
return images, results
|
||||
return images, results # type: ignore[return-value]
|
||||
|
||||
def _preprocess_recognition_image(
|
||||
self, camera: string, image: np.ndarray, max_wh_ratio: float
|
||||
self, camera: str, image: np.ndarray, max_wh_ratio: float
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Preprocess an image for recognition by dynamically adjusting its width.
|
||||
@@ -925,7 +946,7 @@ class LicensePlateProcessingMixin:
|
||||
input_w = int(input_h * max_wh_ratio)
|
||||
|
||||
# check for model-specific input width
|
||||
model_input_w = self.model_runner.recognition_model.runner.get_input_width()
|
||||
model_input_w = self.model_runner.recognition_model.runner.get_input_width() # type: ignore[union-attr]
|
||||
if isinstance(model_input_w, int) and model_input_w > 0:
|
||||
input_w = model_input_w
|
||||
|
||||
@@ -945,7 +966,7 @@ class LicensePlateProcessingMixin:
|
||||
padded_image[:, :, :resized_w] = resized_image
|
||||
|
||||
if False:
|
||||
current_time = int(datetime.datetime.now().timestamp() * 1000)
|
||||
current_time = int(datetime.datetime.now().timestamp() * 1000) # type: ignore[unreachable]
|
||||
cv2.imwrite(
|
||||
f"debug/frames/preprocessed_recognition_{current_time}.jpg",
|
||||
image,
|
||||
@@ -983,8 +1004,9 @@ class LicensePlateProcessingMixin:
|
||||
np.linalg.norm(points[1] - points[2]),
|
||||
)
|
||||
)
|
||||
pts_std = np.float32(
|
||||
[[0, 0], [crop_width, 0], [crop_width, crop_height], [0, crop_height]]
|
||||
pts_std = np.array(
|
||||
[[0, 0], [crop_width, 0], [crop_width, crop_height], [0, crop_height]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
matrix = cv2.getPerspectiveTransform(points, pts_std)
|
||||
image = cv2.warpPerspective(
|
||||
@@ -1000,15 +1022,15 @@ class LicensePlateProcessingMixin:
|
||||
return image
|
||||
|
||||
def _detect_license_plate(
|
||||
self, camera: string, input: np.ndarray
|
||||
) -> tuple[int, int, int, int]:
|
||||
self, camera: str, input: np.ndarray
|
||||
) -> tuple[int, int, int, int] | None:
|
||||
"""
|
||||
Use a lightweight YOLOv9 model to detect license plates for users without Frigate+
|
||||
|
||||
Return the dimensions of the detected plate as [x1, y1, x2, y2].
|
||||
"""
|
||||
try:
|
||||
predictions = self.model_runner.yolov9_detection_model(input)
|
||||
predictions = self.model_runner.yolov9_detection_model(input) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running YOLOv9 license plate detection model: {e}")
|
||||
return None
|
||||
@@ -1073,7 +1095,7 @@ class LicensePlateProcessingMixin:
|
||||
logger.debug(
|
||||
f"{camera}: Found license plate. Bounding box: {expanded_box.astype(int)}"
|
||||
)
|
||||
return tuple(expanded_box.astype(int))
|
||||
return tuple(expanded_box.astype(int)) # type: ignore[return-value]
|
||||
else:
|
||||
return None # No detection above the threshold
|
||||
|
||||
@@ -1097,7 +1119,7 @@ class LicensePlateProcessingMixin:
|
||||
f" Variant {i + 1}: '{p['plate']}' (conf: {p['conf']:.3f}, area: {p['area']})"
|
||||
)
|
||||
|
||||
clusters = []
|
||||
clusters: list[list[dict[str, Any]]] = []
|
||||
for i, plate in enumerate(plates):
|
||||
merged = False
|
||||
for j, cluster in enumerate(clusters):
|
||||
@@ -1132,7 +1154,7 @@ class LicensePlateProcessingMixin:
|
||||
)
|
||||
|
||||
# Best cluster: largest size, tiebroken by max conf
|
||||
def cluster_score(c):
|
||||
def cluster_score(c: list[dict[str, Any]]) -> tuple[int, float]:
|
||||
return (len(c), max(v["conf"] for v in c))
|
||||
|
||||
best_cluster_idx = max(
|
||||
@@ -1178,7 +1200,7 @@ class LicensePlateProcessingMixin:
|
||||
|
||||
def lpr_process(
|
||||
self, obj_data: dict[str, Any], frame: np.ndarray, dedicated_lpr: bool = False
|
||||
):
|
||||
) -> None:
|
||||
"""Look for license plates in image."""
|
||||
self.metrics.alpr_pps.value = self.plates_rec_second.eps()
|
||||
self.metrics.yolov9_lpr_pps.value = self.plates_det_second.eps()
|
||||
@@ -1195,7 +1217,7 @@ class LicensePlateProcessingMixin:
|
||||
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
|
||||
|
||||
# apply motion mask
|
||||
rgb[self.config.cameras[obj_data].motion.rasterized_mask == 0] = [0, 0, 0]
|
||||
rgb[self.config.cameras[camera].motion.rasterized_mask == 0] = [0, 0, 0] # type: ignore[attr-defined]
|
||||
|
||||
if WRITE_DEBUG_IMAGES:
|
||||
cv2.imwrite(
|
||||
@@ -1261,7 +1283,7 @@ class LicensePlateProcessingMixin:
|
||||
"stationary", False
|
||||
):
|
||||
logger.debug(
|
||||
f"{camera}: Skipping LPR for non-stationary {obj_data['label']} object {id} with no position changes. (Detected in {self.config.cameras[camera].detect.min_initialized + 1} concurrent frames, threshold to run is {self.config.cameras[camera].detect.min_initialized + 2} frames)"
|
||||
f"{camera}: Skipping LPR for non-stationary {obj_data['label']} object {id} with no position changes. (Detected in {self.config.cameras[camera].detect.min_initialized + 1} concurrent frames, threshold to run is {self.config.cameras[camera].detect.min_initialized + 2} frames)" # type: ignore[operator]
|
||||
)
|
||||
return
|
||||
|
||||
@@ -1288,7 +1310,7 @@ class LicensePlateProcessingMixin:
|
||||
if time_since_stationary > self.stationary_scan_duration:
|
||||
return
|
||||
|
||||
license_plate: Optional[dict[str, Any]] = None
|
||||
license_plate = None
|
||||
|
||||
if "license_plate" not in self.config.cameras[camera].objects.track:
|
||||
logger.debug(f"{camera}: Running manual license_plate detection.")
|
||||
@@ -1301,7 +1323,7 @@ class LicensePlateProcessingMixin:
|
||||
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
|
||||
|
||||
# apply motion mask
|
||||
rgb[self.config.cameras[camera].motion.rasterized_mask == 0] = [0, 0, 0]
|
||||
rgb[self.config.cameras[camera].motion.rasterized_mask == 0] = [0, 0, 0] # type: ignore[attr-defined]
|
||||
|
||||
left, top, right, bottom = car_box
|
||||
car = rgb[top:bottom, left:right]
|
||||
@@ -1378,10 +1400,10 @@ class LicensePlateProcessingMixin:
|
||||
if attr.get("label") != "license_plate":
|
||||
continue
|
||||
|
||||
if license_plate is None or attr.get(
|
||||
if license_plate is None or attr.get( # type: ignore[unreachable]
|
||||
"score", 0.0
|
||||
) > license_plate.get("score", 0.0):
|
||||
license_plate = attr
|
||||
license_plate = attr # type: ignore[assignment]
|
||||
|
||||
# no license plates detected in this frame
|
||||
if not license_plate:
|
||||
@@ -1389,9 +1411,9 @@ class LicensePlateProcessingMixin:
|
||||
|
||||
# we are using dedicated lpr with frigate+
|
||||
if obj_data.get("label") == "license_plate":
|
||||
license_plate = obj_data
|
||||
license_plate = obj_data # type: ignore[assignment]
|
||||
|
||||
license_plate_box = license_plate.get("box")
|
||||
license_plate_box = license_plate.get("box") # type: ignore[attr-defined]
|
||||
|
||||
# check that license plate is valid
|
||||
if (
|
||||
@@ -1420,7 +1442,7 @@ class LicensePlateProcessingMixin:
|
||||
0, [license_plate_frame.shape[1], license_plate_frame.shape[0]] * 2
|
||||
)
|
||||
|
||||
plate_box = tuple(int(x) for x in expanded_box)
|
||||
plate_box = tuple(int(x) for x in expanded_box) # type: ignore[assignment]
|
||||
|
||||
# Crop using the expanded box
|
||||
license_plate_frame = license_plate_frame[
|
||||
@@ -1596,7 +1618,7 @@ class LicensePlateProcessingMixin:
|
||||
sub_label = next(
|
||||
(
|
||||
label
|
||||
for label, plates_list in self.lpr_config.known_plates.items()
|
||||
for label, plates_list in self.lpr_config.known_plates.items() # type: ignore[union-attr]
|
||||
if any(
|
||||
re.match(f"^{plate}$", rep_plate)
|
||||
or Levenshtein.distance(plate, rep_plate)
|
||||
@@ -1649,14 +1671,16 @@ class LicensePlateProcessingMixin:
|
||||
frame_bgr = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
|
||||
_, encoded_img = cv2.imencode(".jpg", frame_bgr)
|
||||
self.sub_label_publisher.publish(
|
||||
(base64.b64encode(encoded_img).decode("ASCII"), id, camera),
|
||||
(base64.b64encode(encoded_img.tobytes()).decode("ASCII"), id, camera),
|
||||
EventMetadataTypeEnum.save_lpr_snapshot.value,
|
||||
)
|
||||
|
||||
def handle_request(self, topic, request_data) -> dict[str, Any] | None:
|
||||
return
|
||||
def handle_request(
|
||||
self, topic: str, request_data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
def lpr_expire(self, object_id: str, camera: str):
|
||||
def lpr_expire(self, object_id: str, camera: str) -> None:
|
||||
if object_id in self.detected_license_plates:
|
||||
self.detected_license_plates.pop(object_id)
|
||||
|
||||
@@ -1673,7 +1697,7 @@ class CTCDecoder:
|
||||
for each decoded character sequence.
|
||||
"""
|
||||
|
||||
def __init__(self, character_dict_path=None):
|
||||
def __init__(self, character_dict_path: str | None = None) -> None:
|
||||
"""
|
||||
Initializes the CTCDecoder.
|
||||
:param character_dict_path: Path to the character dictionary file.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.embeddings.onnx.lpr_embedding import (
|
||||
LicensePlateDetector,
|
||||
PaddleOCRClassification,
|
||||
@@ -9,7 +10,12 @@ from ...types import DataProcessorModelRunner
|
||||
|
||||
|
||||
class LicensePlateModelRunner(DataProcessorModelRunner):
|
||||
def __init__(self, requestor, device: str = "CPU", model_size: str = "small"):
|
||||
def __init__(
|
||||
self,
|
||||
requestor: InterProcessRequestor,
|
||||
device: str = "CPU",
|
||||
model_size: str = "small",
|
||||
):
|
||||
super().__init__(requestor, device, model_size)
|
||||
self.detection_model = PaddleOCRDetection(
|
||||
model_size=model_size, requestor=requestor, device=device
|
||||
|
||||
@@ -17,7 +17,7 @@ class PostProcessorApi(ABC):
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
metrics: DataProcessorMetrics,
|
||||
model_runner: DataProcessorModelRunner,
|
||||
model_runner: DataProcessorModelRunner | None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.metrics = metrics
|
||||
@@ -41,7 +41,7 @@ class PostProcessorApi(ABC):
|
||||
@abstractmethod
|
||||
def handle_request(
|
||||
self, topic: str, request_data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
) -> dict[str, Any] | str | None:
|
||||
"""Handle metadata requests.
|
||||
Args:
|
||||
request_data (dict): containing data about requested change to process.
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from peewee import DoesNotExist
|
||||
|
||||
@@ -17,6 +17,7 @@ from frigate.const import (
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
)
|
||||
from frigate.data_processing.types import PostProcessDataEnum
|
||||
from frigate.embeddings.embeddings import Embeddings
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.audio import get_audio_from_recording
|
||||
|
||||
@@ -31,7 +32,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
requestor: InterProcessRequestor,
|
||||
embeddings,
|
||||
embeddings: Embeddings,
|
||||
metrics: DataProcessorMetrics,
|
||||
):
|
||||
super().__init__(config, metrics, None)
|
||||
@@ -40,7 +41,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
self.embeddings = embeddings
|
||||
self.recognizer = None
|
||||
self.transcription_lock = threading.Lock()
|
||||
self.transcription_thread = None
|
||||
self.transcription_thread: threading.Thread | None = None
|
||||
self.transcription_running = False
|
||||
|
||||
# faster-whisper handles model downloading automatically
|
||||
@@ -69,7 +70,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
self.recognizer = None
|
||||
|
||||
def process_data(
|
||||
self, data: dict[str, any], data_type: PostProcessDataEnum
|
||||
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
||||
) -> None:
|
||||
"""Transcribe audio from a recording.
|
||||
|
||||
@@ -141,13 +142,13 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
except Exception as e:
|
||||
logger.error(f"Error in audio transcription post-processing: {e}")
|
||||
|
||||
def __transcribe_audio(self, audio_data: bytes) -> Optional[tuple[str, float]]:
|
||||
def __transcribe_audio(self, audio_data: bytes) -> Optional[str]:
|
||||
"""Transcribe WAV audio data using faster-whisper."""
|
||||
if not self.recognizer:
|
||||
logger.debug("Recognizer not initialized")
|
||||
return None
|
||||
|
||||
try:
|
||||
try: # type: ignore[unreachable]
|
||||
# Save audio data to a temporary wav (faster-whisper expects a file)
|
||||
temp_wav = os.path.join(CACHE_DIR, f"temp_audio_{int(time.time())}.wav")
|
||||
with open(temp_wav, "wb") as f:
|
||||
@@ -176,7 +177,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
logger.error(f"Error transcribing audio: {e}")
|
||||
return None
|
||||
|
||||
def _transcription_wrapper(self, event: dict[str, any]) -> None:
|
||||
def _transcription_wrapper(self, event: dict[str, Any]) -> None:
|
||||
"""Wrapper to run transcription and reset running flag when done."""
|
||||
try:
|
||||
self.process_data(
|
||||
@@ -194,7 +195,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
|
||||
self.requestor.send_data(UPDATE_AUDIO_TRANSCRIPTION_STATE, "idle")
|
||||
|
||||
def handle_request(self, topic: str, request_data: dict[str, any]) -> str | None:
|
||||
def handle_request(self, topic: str, request_data: dict[str, Any]) -> str | None:
|
||||
if topic == "transcribe_audio":
|
||||
event = request_data["event"]
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ from .api import PostProcessorApi
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
|
||||
class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): # type: ignore[misc]
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
@@ -71,7 +71,7 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
|
||||
# don't run LPR post processing for now
|
||||
return
|
||||
|
||||
event_id = data["event_id"]
|
||||
event_id = data["event_id"] # type: ignore[unreachable]
|
||||
camera_name = data["camera"]
|
||||
|
||||
if data_type == PostProcessDataEnum.recording:
|
||||
@@ -225,7 +225,7 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
|
||||
logger.debug(f"Post processing plate: {event_id}, {frame_time}")
|
||||
self.lpr_process(keyframe_obj_data, frame)
|
||||
|
||||
def handle_request(self, topic, request_data) -> dict[str, Any] | None:
|
||||
def handle_request(self, topic: str, request_data: dict) -> dict[str, Any] | None:
|
||||
if topic == EmbeddingsRequestEnum.reprocess_plate.value:
|
||||
event = request_data["event"]
|
||||
|
||||
@@ -242,3 +242,5 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi):
|
||||
"message": "Successfully requested reprocessing of license plate.",
|
||||
"success": True,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@@ -24,7 +24,7 @@ from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_ima
|
||||
from frigate.util.image import create_thumbnail, ensure_jpeg_bytes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.embeddings import Embeddings
|
||||
from frigate.embeddings.embeddings import Embeddings
|
||||
|
||||
from ..post.api import PostProcessorApi
|
||||
from ..types import DataProcessorMetrics
|
||||
@@ -139,7 +139,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
):
|
||||
self._process_genai_description(event, camera_config, thumbnail)
|
||||
else:
|
||||
self.cleanup_event(event.id)
|
||||
self.cleanup_event(str(event.id))
|
||||
|
||||
def __regenerate_description(self, event_id: str, source: str, force: bool) -> None:
|
||||
"""Regenerate the description for an event."""
|
||||
@@ -149,17 +149,17 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
logger.error(f"Event {event_id} not found for description regeneration")
|
||||
return
|
||||
|
||||
if self.genai_client is None:
|
||||
logger.error("GenAI not enabled")
|
||||
return
|
||||
|
||||
camera_config = self.config.cameras[event.camera]
|
||||
camera_config = self.config.cameras[str(event.camera)]
|
||||
if not camera_config.objects.genai.enabled and not force:
|
||||
logger.error(f"GenAI not enabled for camera {event.camera}")
|
||||
return
|
||||
|
||||
thumbnail = get_event_thumbnail_bytes(event)
|
||||
|
||||
if thumbnail is None:
|
||||
logger.error("No thumbnail available for %s", event.id)
|
||||
return
|
||||
|
||||
# ensure we have a jpeg to pass to the model
|
||||
thumbnail = ensure_jpeg_bytes(thumbnail)
|
||||
|
||||
@@ -187,7 +187,9 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
)
|
||||
)
|
||||
|
||||
self._genai_embed_description(event, embed_image)
|
||||
self._genai_embed_description(
|
||||
event, [img for img in embed_image if img is not None]
|
||||
)
|
||||
|
||||
def process_data(self, frame_data: dict, data_type: PostProcessDataEnum) -> None:
|
||||
"""Process a frame update."""
|
||||
@@ -241,7 +243,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
# Crop snapshot based on region
|
||||
# provide full image if region doesn't exist (manual events)
|
||||
height, width = img.shape[:2]
|
||||
x1_rel, y1_rel, width_rel, height_rel = event.data.get(
|
||||
x1_rel, y1_rel, width_rel, height_rel = event.data.get( # type: ignore[attr-defined]
|
||||
"region", [0, 0, 1, 1]
|
||||
)
|
||||
x1, y1 = int(x1_rel * width), int(y1_rel * height)
|
||||
@@ -258,14 +260,16 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
return None
|
||||
|
||||
def _process_genai_description(
|
||||
self, event: Event, camera_config: CameraConfig, thumbnail
|
||||
self, event: Event, camera_config: CameraConfig, thumbnail: bytes
|
||||
) -> None:
|
||||
event_id = str(event.id)
|
||||
|
||||
if event.has_snapshot and camera_config.objects.genai.use_snapshot:
|
||||
snapshot_image = self._read_and_crop_snapshot(event)
|
||||
if not snapshot_image:
|
||||
return
|
||||
|
||||
num_thumbnails = len(self.tracked_events.get(event.id, []))
|
||||
num_thumbnails = len(self.tracked_events.get(event_id, []))
|
||||
|
||||
# ensure we have a jpeg to pass to the model
|
||||
thumbnail = ensure_jpeg_bytes(thumbnail)
|
||||
@@ -277,7 +281,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
else (
|
||||
[
|
||||
data["thumbnail"][:] if data.get("thumbnail") else None
|
||||
for data in self.tracked_events[event.id]
|
||||
for data in self.tracked_events[event_id]
|
||||
if data.get("thumbnail")
|
||||
]
|
||||
if num_thumbnails > 0
|
||||
@@ -286,22 +290,22 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
)
|
||||
|
||||
if camera_config.objects.genai.debug_save_thumbnails and num_thumbnails > 0:
|
||||
logger.debug(f"Saving {num_thumbnails} thumbnails for event {event.id}")
|
||||
logger.debug(f"Saving {num_thumbnails} thumbnails for event {event_id}")
|
||||
|
||||
Path(os.path.join(CLIPS_DIR, f"genai-requests/{event.id}")).mkdir(
|
||||
Path(os.path.join(CLIPS_DIR, f"genai-requests/{event_id}")).mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
|
||||
for idx, data in enumerate(self.tracked_events[event.id], 1):
|
||||
for idx, data in enumerate(self.tracked_events[event_id], 1):
|
||||
jpg_bytes: bytes | None = data["thumbnail"]
|
||||
|
||||
if jpg_bytes is None:
|
||||
logger.warning(f"Unable to save thumbnail {idx} for {event.id}.")
|
||||
logger.warning(f"Unable to save thumbnail {idx} for {event_id}.")
|
||||
else:
|
||||
with open(
|
||||
os.path.join(
|
||||
CLIPS_DIR,
|
||||
f"genai-requests/{event.id}/{idx}.jpg",
|
||||
f"genai-requests/{event_id}/{idx}.jpg",
|
||||
),
|
||||
"wb",
|
||||
) as j:
|
||||
@@ -310,7 +314,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
# Generate the description. Call happens in a thread since it is network bound.
|
||||
threading.Thread(
|
||||
target=self._genai_embed_description,
|
||||
name=f"_genai_embed_description_{event.id}",
|
||||
name=f"_genai_embed_description_{event_id}",
|
||||
daemon=True,
|
||||
args=(
|
||||
event,
|
||||
@@ -319,12 +323,12 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
).start()
|
||||
|
||||
# Clean up tracked events and early request state
|
||||
self.cleanup_event(event.id)
|
||||
self.cleanup_event(event_id)
|
||||
|
||||
def _genai_embed_description(self, event: Event, thumbnails: list[bytes]) -> None:
|
||||
"""Embed the description for an event."""
|
||||
start = datetime.datetime.now().timestamp()
|
||||
camera_config = self.config.cameras[event.camera]
|
||||
camera_config = self.config.cameras[str(event.camera)]
|
||||
description = self.genai_client.generate_object_description(
|
||||
camera_config, thumbnails, event
|
||||
)
|
||||
@@ -346,7 +350,7 @@ class ObjectDescriptionProcessor(PostProcessorApi):
|
||||
|
||||
# Embed the description
|
||||
if self.config.semantic_search.enabled:
|
||||
self.embeddings.embed_description(event.id, description)
|
||||
self.embeddings.embed_description(str(event.id), description)
|
||||
|
||||
# Check semantic trigger for this description
|
||||
if self.semantic_trigger_processor is not None:
|
||||
|
||||
@@ -48,8 +48,8 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
self.metrics = metrics
|
||||
self.genai_client = client
|
||||
self.review_desc_speed = InferenceSpeed(self.metrics.review_desc_speed)
|
||||
self.review_descs_dps = EventsPerSecond()
|
||||
self.review_descs_dps.start()
|
||||
self.review_desc_dps = EventsPerSecond()
|
||||
self.review_desc_dps.start()
|
||||
|
||||
def calculate_frame_count(
|
||||
self,
|
||||
@@ -59,7 +59,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
) -> int:
|
||||
"""Calculate optimal number of frames based on context size, image source, and resolution.
|
||||
|
||||
Token usage varies by resolution: larger images (ultrawide aspect ratios) use more tokens.
|
||||
Token usage varies by resolution: larger images (ultra-wide aspect ratios) use more tokens.
|
||||
Estimates ~1 token per 1250 pixels. Targets 98% context utilization with safety margin.
|
||||
Capped at 20 frames.
|
||||
"""
|
||||
@@ -68,7 +68,11 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
|
||||
detect_width = camera_config.detect.width
|
||||
detect_height = camera_config.detect.height
|
||||
aspect_ratio = detect_width / detect_height
|
||||
|
||||
if not detect_width or not detect_height:
|
||||
aspect_ratio = 16 / 9
|
||||
else:
|
||||
aspect_ratio = detect_width / detect_height
|
||||
|
||||
if image_source == ImageSourceEnum.recordings:
|
||||
if aspect_ratio >= 1:
|
||||
@@ -99,8 +103,10 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
|
||||
return min(max(max_frames, 3), 20)
|
||||
|
||||
def process_data(self, data, data_type):
|
||||
self.metrics.review_desc_dps.value = self.review_descs_dps.eps()
|
||||
def process_data(
|
||||
self, data: dict[str, Any], data_type: PostProcessDataEnum
|
||||
) -> None:
|
||||
self.metrics.review_desc_dps.value = self.review_desc_dps.eps()
|
||||
|
||||
if data_type != PostProcessDataEnum.review:
|
||||
return
|
||||
@@ -186,7 +192,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
)
|
||||
|
||||
# kickoff analysis
|
||||
self.review_descs_dps.update()
|
||||
self.review_desc_dps.update()
|
||||
threading.Thread(
|
||||
target=run_analysis,
|
||||
args=(
|
||||
@@ -202,7 +208,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
),
|
||||
).start()
|
||||
|
||||
def handle_request(self, topic, request_data):
|
||||
def handle_request(self, topic: str, request_data: dict[str, Any]) -> str | None:
|
||||
if topic == EmbeddingsRequestEnum.summarize_review.value:
|
||||
start_ts = request_data["start_ts"]
|
||||
end_ts = request_data["end_ts"]
|
||||
@@ -327,7 +333,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
file_start = f"preview_{camera}-"
|
||||
start_file = f"{file_start}{start_time}.webp"
|
||||
end_file = f"{file_start}{end_time}.webp"
|
||||
all_frames = []
|
||||
all_frames: list[str] = []
|
||||
|
||||
for file in sorted(os.listdir(preview_dir)):
|
||||
if not file.startswith(file_start):
|
||||
@@ -465,7 +471,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
thumb_data = cv2.imread(thumb_path)
|
||||
|
||||
if thumb_data is None:
|
||||
logger.warning(
|
||||
logger.warning( # type: ignore[unreachable]
|
||||
"Could not read preview frame at %s, skipping", thumb_path
|
||||
)
|
||||
continue
|
||||
@@ -488,13 +494,12 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
return thumbs
|
||||
|
||||
|
||||
@staticmethod
|
||||
def run_analysis(
|
||||
requestor: InterProcessRequestor,
|
||||
genai_client: GenAIClient,
|
||||
review_inference_speed: InferenceSpeed,
|
||||
camera_config: CameraConfig,
|
||||
final_data: dict[str, str],
|
||||
final_data: dict[str, Any],
|
||||
thumbs: list[bytes],
|
||||
genai_config: GenAIReviewConfig,
|
||||
labelmap_objects: list[str],
|
||||
|
||||
@@ -19,6 +19,7 @@ from frigate.config import FrigateConfig
|
||||
from frigate.const import CONFIG_DIR
|
||||
from frigate.data_processing.types import PostProcessDataEnum
|
||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
from frigate.embeddings.embeddings import Embeddings
|
||||
from frigate.embeddings.util import ZScoreNormalization
|
||||
from frigate.models import Event, Trigger
|
||||
from frigate.util.builtin import cosine_distance
|
||||
@@ -40,8 +41,8 @@ class SemanticTriggerProcessor(PostProcessorApi):
|
||||
requestor: InterProcessRequestor,
|
||||
sub_label_publisher: EventMetadataPublisher,
|
||||
metrics: DataProcessorMetrics,
|
||||
embeddings,
|
||||
):
|
||||
embeddings: Embeddings,
|
||||
) -> None:
|
||||
super().__init__(config, metrics, None)
|
||||
self.db = db
|
||||
self.embeddings = embeddings
|
||||
@@ -236,11 +237,14 @@ class SemanticTriggerProcessor(PostProcessorApi):
|
||||
return
|
||||
|
||||
# Skip the event if not an object
|
||||
if event.data.get("type") != "object":
|
||||
if event.data.get("type") != "object": # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
thumbnail_bytes = get_event_thumbnail_bytes(event)
|
||||
|
||||
if thumbnail_bytes is None:
|
||||
return
|
||||
|
||||
nparr = np.frombuffer(thumbnail_bytes, np.uint8)
|
||||
thumbnail = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
|
||||
@@ -262,8 +266,10 @@ class SemanticTriggerProcessor(PostProcessorApi):
|
||||
thumbnail,
|
||||
)
|
||||
|
||||
def handle_request(self, topic, request_data):
|
||||
def handle_request(
|
||||
self, topic: str, request_data: dict[str, Any]
|
||||
) -> dict[str, Any] | str | None:
|
||||
return None
|
||||
|
||||
def expire_object(self, object_id, camera):
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
pass
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -39,11 +39,11 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.config = config
|
||||
self.camera_config = camera_config
|
||||
self.requestor = requestor
|
||||
self.stream = None
|
||||
self.whisper_model = None
|
||||
self.stream: Any = None
|
||||
self.whisper_model: FasterWhisperASR | None = None
|
||||
self.model_runner = model_runner
|
||||
self.transcription_segments = []
|
||||
self.audio_queue = queue.Queue()
|
||||
self.transcription_segments: list[str] = []
|
||||
self.audio_queue: queue.Queue[tuple[dict[str, Any], np.ndarray]] = queue.Queue()
|
||||
self.stop_event = stop_event
|
||||
|
||||
def __build_recognizer(self) -> None:
|
||||
@@ -142,10 +142,10 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
|
||||
logger.error(f"Error processing audio stream: {e}")
|
||||
return None
|
||||
|
||||
def process_frame(self, obj_data: dict[str, any], frame: np.ndarray) -> None:
|
||||
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None:
|
||||
pass
|
||||
|
||||
def process_audio(self, obj_data: dict[str, any], audio: np.ndarray) -> bool | None:
|
||||
def process_audio(self, obj_data: dict[str, Any], audio: np.ndarray) -> bool | None:
|
||||
if audio is None or audio.size == 0:
|
||||
logger.debug("No audio data provided for transcription")
|
||||
return None
|
||||
@@ -269,13 +269,13 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
|
||||
def handle_request(
|
||||
self, topic: str, request_data: dict[str, any]
|
||||
) -> dict[str, any] | None:
|
||||
self, topic: str, request_data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
if topic == "clear_audio_recognizer":
|
||||
self.stream = None
|
||||
self.__build_recognizer()
|
||||
return {"message": "Audio recognizer cleared and rebuilt", "success": True}
|
||||
return None
|
||||
|
||||
def expire_object(self, object_id: str) -> None:
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
pass
|
||||
|
||||
@@ -14,7 +14,7 @@ from frigate.comms.event_metadata_updater import (
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.log import suppress_stderr_during
|
||||
from frigate.util.object import calculate_region
|
||||
from frigate.util.image import calculate_region
|
||||
|
||||
from ..types import DataProcessorMetrics
|
||||
from .api import RealTimeProcessorApi
|
||||
@@ -35,10 +35,10 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
||||
metrics: DataProcessorMetrics,
|
||||
):
|
||||
super().__init__(config, metrics)
|
||||
self.interpreter: Interpreter = None
|
||||
self.interpreter: Interpreter | None = None
|
||||
self.sub_label_publisher = sub_label_publisher
|
||||
self.tensor_input_details: dict[str, Any] = None
|
||||
self.tensor_output_details: dict[str, Any] = None
|
||||
self.tensor_input_details: list[dict[str, Any]] | None = None
|
||||
self.tensor_output_details: list[dict[str, Any]] | None = None
|
||||
self.detected_birds: dict[str, float] = {}
|
||||
self.labelmap: dict[int, str] = {}
|
||||
|
||||
@@ -61,7 +61,7 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.downloader = ModelDownloader(
|
||||
model_name="bird",
|
||||
download_path=download_path,
|
||||
file_names=self.model_files.keys(),
|
||||
file_names=list(self.model_files.keys()),
|
||||
download_func=self.__download_models,
|
||||
complete_func=self.__build_detector,
|
||||
)
|
||||
@@ -102,8 +102,12 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
||||
i += 1
|
||||
line = f.readline()
|
||||
|
||||
def process_frame(self, obj_data, frame):
|
||||
if not self.interpreter:
|
||||
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None:
|
||||
if (
|
||||
not self.interpreter
|
||||
or not self.tensor_input_details
|
||||
or not self.tensor_output_details
|
||||
):
|
||||
return
|
||||
|
||||
if obj_data["label"] != "bird":
|
||||
@@ -145,7 +149,7 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.tensor_output_details[0]["index"]
|
||||
)[0]
|
||||
probs = res / res.sum(axis=0)
|
||||
best_id = np.argmax(probs)
|
||||
best_id = int(np.argmax(probs))
|
||||
|
||||
if best_id == 964:
|
||||
logger.debug("No bird classification was detected.")
|
||||
@@ -179,9 +183,11 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.config.classification = payload
|
||||
logger.debug("Bird classification config updated dynamically")
|
||||
|
||||
def handle_request(self, topic, request_data):
|
||||
def handle_request(
|
||||
self, topic: str, request_data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
def expire_object(self, object_id, camera):
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
if object_id in self.detected_birds:
|
||||
self.detected_birds.pop(object_id)
|
||||
|
||||
@@ -24,7 +24,8 @@ from frigate.const import CLIPS_DIR, MODEL_CACHE_DIR
|
||||
from frigate.log import suppress_stderr_during
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, load_labels
|
||||
from frigate.util.object import box_overlaps, calculate_region
|
||||
from frigate.util.image import calculate_region
|
||||
from frigate.util.object import box_overlaps
|
||||
|
||||
from ..types import DataProcessorMetrics
|
||||
from .api import RealTimeProcessorApi
|
||||
@@ -49,12 +50,16 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
):
|
||||
super().__init__(config, metrics)
|
||||
self.model_config = model_config
|
||||
|
||||
if not self.model_config.name:
|
||||
raise ValueError("Custom classification model name must be set.")
|
||||
|
||||
self.requestor = requestor
|
||||
self.model_dir = os.path.join(MODEL_CACHE_DIR, self.model_config.name)
|
||||
self.train_dir = os.path.join(CLIPS_DIR, self.model_config.name, "train")
|
||||
self.interpreter: Interpreter = None
|
||||
self.tensor_input_details: dict[str, Any] | None = None
|
||||
self.tensor_output_details: dict[str, Any] | None = None
|
||||
self.interpreter: Interpreter | None = None
|
||||
self.tensor_input_details: list[dict[str, Any]] | None = None
|
||||
self.tensor_output_details: list[dict[str, Any]] | None = None
|
||||
self.labelmap: dict[int, str] = {}
|
||||
self.classifications_per_second = EventsPerSecond()
|
||||
self.state_history: dict[str, dict[str, Any]] = {}
|
||||
@@ -63,7 +68,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
self.metrics
|
||||
and self.model_config.name in self.metrics.classification_speeds
|
||||
):
|
||||
self.inference_speed = InferenceSpeed(
|
||||
self.inference_speed: InferenceSpeed | None = InferenceSpeed(
|
||||
self.metrics.classification_speeds[self.model_config.name]
|
||||
)
|
||||
else:
|
||||
@@ -172,12 +177,20 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
|
||||
return None
|
||||
|
||||
def process_frame(self, frame_data: dict[str, Any], frame: np.ndarray):
|
||||
def process_frame(self, frame_data: dict[str, Any], frame: np.ndarray) -> None:
|
||||
if (
|
||||
not self.model_config.name
|
||||
or not self.model_config.state_config
|
||||
or not self.tensor_input_details
|
||||
or not self.tensor_output_details
|
||||
):
|
||||
return
|
||||
|
||||
if self.metrics and self.model_config.name in self.metrics.classification_cps:
|
||||
self.metrics.classification_cps[
|
||||
self.model_config.name
|
||||
].value = self.classifications_per_second.eps()
|
||||
camera = frame_data.get("camera")
|
||||
camera = str(frame_data.get("camera"))
|
||||
|
||||
if camera not in self.model_config.state_config.cameras:
|
||||
return
|
||||
@@ -283,7 +296,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
logger.debug(
|
||||
f"{self.model_config.name} Ran state classification with probabilities: {probs}"
|
||||
)
|
||||
best_id = np.argmax(probs)
|
||||
best_id = int(np.argmax(probs))
|
||||
score = round(probs[best_id], 2)
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||
|
||||
@@ -319,7 +332,9 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
verified_state,
|
||||
)
|
||||
|
||||
def handle_request(self, topic, request_data):
|
||||
def handle_request(
|
||||
self, topic: str, request_data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
if topic == EmbeddingsRequestEnum.reload_classification_model.value:
|
||||
if request_data.get("model_name") == self.model_config.name:
|
||||
self.__build_detector()
|
||||
@@ -335,7 +350,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
else:
|
||||
return None
|
||||
|
||||
def expire_object(self, object_id, camera):
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@@ -350,13 +365,17 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
):
|
||||
super().__init__(config, metrics)
|
||||
self.model_config = model_config
|
||||
|
||||
if not self.model_config.name:
|
||||
raise ValueError("Custom classification model name must be set.")
|
||||
|
||||
self.model_dir = os.path.join(MODEL_CACHE_DIR, self.model_config.name)
|
||||
self.train_dir = os.path.join(CLIPS_DIR, self.model_config.name, "train")
|
||||
self.interpreter: Interpreter = None
|
||||
self.interpreter: Interpreter | None = None
|
||||
self.sub_label_publisher = sub_label_publisher
|
||||
self.requestor = requestor
|
||||
self.tensor_input_details: dict[str, Any] | None = None
|
||||
self.tensor_output_details: dict[str, Any] | None = None
|
||||
self.tensor_input_details: list[dict[str, Any]] | None = None
|
||||
self.tensor_output_details: list[dict[str, Any]] | None = None
|
||||
self.classification_history: dict[str, list[tuple[str, float, float]]] = {}
|
||||
self.labelmap: dict[int, str] = {}
|
||||
self.classifications_per_second = EventsPerSecond()
|
||||
@@ -365,7 +384,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
self.metrics
|
||||
and self.model_config.name in self.metrics.classification_speeds
|
||||
):
|
||||
self.inference_speed = InferenceSpeed(
|
||||
self.inference_speed: InferenceSpeed | None = InferenceSpeed(
|
||||
self.metrics.classification_speeds[self.model_config.name]
|
||||
)
|
||||
else:
|
||||
@@ -431,8 +450,8 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
return None, 0.0
|
||||
|
||||
label_counts = {}
|
||||
label_scores = {}
|
||||
label_counts: dict[str, int] = {}
|
||||
label_scores: dict[str, list[float]] = {}
|
||||
total_attempts = len(history)
|
||||
|
||||
for label, score, timestamp in history:
|
||||
@@ -443,7 +462,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
label_counts[label] += 1
|
||||
label_scores[label].append(score)
|
||||
|
||||
best_label = max(label_counts, key=label_counts.get)
|
||||
best_label = max(label_counts, key=lambda k: label_counts[k])
|
||||
best_count = label_counts[best_label]
|
||||
|
||||
consensus_threshold = total_attempts * 0.6
|
||||
@@ -470,7 +489,15 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
return best_label, avg_score
|
||||
|
||||
def process_frame(self, obj_data, frame):
|
||||
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None:
|
||||
if (
|
||||
not self.model_config.name
|
||||
or not self.model_config.object_config
|
||||
or not self.tensor_input_details
|
||||
or not self.tensor_output_details
|
||||
):
|
||||
return
|
||||
|
||||
if self.metrics and self.model_config.name in self.metrics.classification_cps:
|
||||
self.metrics.classification_cps[
|
||||
self.model_config.name
|
||||
@@ -555,7 +582,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
logger.debug(
|
||||
f"{self.model_config.name} Ran object classification with probabilities: {probs}"
|
||||
)
|
||||
best_id = np.argmax(probs)
|
||||
best_id = int(np.argmax(probs))
|
||||
score = round(probs[best_id], 2)
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||
|
||||
@@ -650,7 +677,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
),
|
||||
)
|
||||
|
||||
def handle_request(self, topic, request_data):
|
||||
def handle_request(self, topic: str, request_data: dict) -> dict | None:
|
||||
if topic == EmbeddingsRequestEnum.reload_classification_model.value:
|
||||
if request_data.get("model_name") == self.model_config.name:
|
||||
self.__build_detector()
|
||||
@@ -666,12 +693,11 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
else:
|
||||
return None
|
||||
|
||||
def expire_object(self, object_id, camera):
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
if object_id in self.classification_history:
|
||||
self.classification_history.pop(object_id)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def write_classification_attempt(
|
||||
folder: str,
|
||||
frame: np.ndarray,
|
||||
|
||||
@@ -52,11 +52,11 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.face_config = config.face_recognition
|
||||
self.requestor = requestor
|
||||
self.sub_label_publisher = sub_label_publisher
|
||||
self.face_detector: cv2.FaceDetectorYN = None
|
||||
self.face_detector: cv2.FaceDetectorYN | None = None
|
||||
self.requires_face_detection = "face" not in self.config.objects.all_objects
|
||||
self.person_face_history: dict[str, list[tuple[str, float, int]]] = {}
|
||||
self.camera_current_people: dict[str, list[str]] = {}
|
||||
self.recognizer: FaceRecognizer | None = None
|
||||
self.recognizer: FaceRecognizer
|
||||
self.faces_per_second = EventsPerSecond()
|
||||
self.inference_speed = InferenceSpeed(self.metrics.face_rec_speed)
|
||||
|
||||
@@ -78,7 +78,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.downloader = ModelDownloader(
|
||||
model_name="facedet",
|
||||
download_path=download_path,
|
||||
file_names=self.model_files.keys(),
|
||||
file_names=list(self.model_files.keys()),
|
||||
download_func=self.__download_models,
|
||||
complete_func=self.__build_detector,
|
||||
)
|
||||
@@ -134,7 +134,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
def __detect_face(
|
||||
self, input: np.ndarray, threshold: float
|
||||
) -> tuple[int, int, int, int]:
|
||||
) -> tuple[int, int, int, int] | None:
|
||||
"""Detect faces in input image."""
|
||||
if not self.face_detector:
|
||||
return None
|
||||
@@ -153,7 +153,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
faces = self.face_detector.detect(input)
|
||||
|
||||
if faces is None or faces[1] is None:
|
||||
return None
|
||||
return None # type: ignore[unreachable]
|
||||
|
||||
face = None
|
||||
|
||||
@@ -168,7 +168,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
h: int = int(raw_bbox[3] / scale_factor)
|
||||
bbox = (x, y, x + w, y + h)
|
||||
|
||||
if face is None or area(bbox) > area(face):
|
||||
if face is None or area(bbox) > area(face): # type: ignore[unreachable]
|
||||
face = bbox
|
||||
|
||||
return face
|
||||
@@ -177,7 +177,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.faces_per_second.update()
|
||||
self.inference_speed.update(duration)
|
||||
|
||||
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray):
|
||||
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None:
|
||||
"""Look for faces in image."""
|
||||
self.metrics.face_rec_fps.value = self.faces_per_second.eps()
|
||||
camera = obj_data["camera"]
|
||||
@@ -349,7 +349,9 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - start)
|
||||
|
||||
def handle_request(self, topic, request_data) -> dict[str, Any] | None:
|
||||
def handle_request(
|
||||
self, topic: str, request_data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
if topic == EmbeddingsRequestEnum.clear_face_classifier.value:
|
||||
self.recognizer.clear()
|
||||
return {"success": True, "message": "Face classifier cleared."}
|
||||
@@ -432,7 +434,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
img = cv2.imread(current_file)
|
||||
|
||||
if img is None:
|
||||
return {
|
||||
return { # type: ignore[unreachable]
|
||||
"message": "Invalid image file.",
|
||||
"success": False,
|
||||
}
|
||||
@@ -469,7 +471,9 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
"score": score,
|
||||
}
|
||||
|
||||
def expire_object(self, object_id: str, camera: str):
|
||||
return None
|
||||
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
if object_id in self.person_face_history:
|
||||
self.person_face_history.pop(object_id)
|
||||
|
||||
@@ -478,7 +482,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
def weighted_average(
|
||||
self, results_list: list[tuple[str, float, int]], max_weight: int = 4000
|
||||
):
|
||||
) -> tuple[str | None, float]:
|
||||
"""
|
||||
Calculates a robust weighted average, capping the area weight and giving more weight to higher scores.
|
||||
|
||||
@@ -493,8 +497,8 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
return None, 0.0
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
weighted_scores: dict[str, int] = {}
|
||||
total_weights: dict[str, int] = {}
|
||||
weighted_scores: dict[str, float] = {}
|
||||
total_weights: dict[str, float] = {}
|
||||
|
||||
for name, score, face_area in results_list:
|
||||
if name == "unknown":
|
||||
@@ -509,7 +513,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
counts[name] += 1
|
||||
|
||||
# Capped weight based on face area
|
||||
weight = min(face_area, max_weight)
|
||||
weight: float = min(face_area, max_weight)
|
||||
|
||||
# Score-based weighting (higher scores get more weight)
|
||||
weight *= (score - self.face_config.unknown_score) * 10
|
||||
@@ -519,7 +523,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
if not weighted_scores:
|
||||
return None, 0.0
|
||||
|
||||
best_name = max(weighted_scores, key=weighted_scores.get)
|
||||
best_name = max(weighted_scores, key=lambda k: weighted_scores[k])
|
||||
|
||||
# If the number of faces for this person < min_faces, we are not confident it is a correct result
|
||||
if counts[best_name] < self.face_config.min_faces:
|
||||
|
||||
@@ -61,14 +61,16 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess
|
||||
self,
|
||||
obj_data: dict[str, Any],
|
||||
frame: np.ndarray,
|
||||
dedicated_lpr: bool | None = False,
|
||||
):
|
||||
dedicated_lpr: bool = False,
|
||||
) -> None:
|
||||
"""Look for license plates in image."""
|
||||
self.lpr_process(obj_data, frame, dedicated_lpr)
|
||||
|
||||
def handle_request(self, topic, request_data) -> dict[str, Any] | None:
|
||||
return
|
||||
def handle_request(
|
||||
self, topic: str, request_data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
def expire_object(self, object_id: str, camera: str):
|
||||
def expire_object(self, object_id: str, camera: str) -> None:
|
||||
"""Expire lpr objects."""
|
||||
self.lpr_expire(object_id, camera)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Embeddings types."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from multiprocessing.managers import SyncManager
|
||||
from multiprocessing.sharedctypes import Synchronized
|
||||
from multiprocessing.managers import DictProxy, SyncManager, ValueProxy
|
||||
from typing import Any
|
||||
|
||||
import sherpa_onnx
|
||||
|
||||
@@ -10,22 +12,22 @@ from frigate.data_processing.real_time.whisper_online import FasterWhisperASR
|
||||
|
||||
|
||||
class DataProcessorMetrics:
|
||||
image_embeddings_speed: Synchronized
|
||||
image_embeddings_eps: Synchronized
|
||||
text_embeddings_speed: Synchronized
|
||||
text_embeddings_eps: Synchronized
|
||||
face_rec_speed: Synchronized
|
||||
face_rec_fps: Synchronized
|
||||
alpr_speed: Synchronized
|
||||
alpr_pps: Synchronized
|
||||
yolov9_lpr_speed: Synchronized
|
||||
yolov9_lpr_pps: Synchronized
|
||||
review_desc_speed: Synchronized
|
||||
review_desc_dps: Synchronized
|
||||
object_desc_speed: Synchronized
|
||||
object_desc_dps: Synchronized
|
||||
classification_speeds: dict[str, Synchronized]
|
||||
classification_cps: dict[str, Synchronized]
|
||||
image_embeddings_speed: ValueProxy[float]
|
||||
image_embeddings_eps: ValueProxy[float]
|
||||
text_embeddings_speed: ValueProxy[float]
|
||||
text_embeddings_eps: ValueProxy[float]
|
||||
face_rec_speed: ValueProxy[float]
|
||||
face_rec_fps: ValueProxy[float]
|
||||
alpr_speed: ValueProxy[float]
|
||||
alpr_pps: ValueProxy[float]
|
||||
yolov9_lpr_speed: ValueProxy[float]
|
||||
yolov9_lpr_pps: ValueProxy[float]
|
||||
review_desc_speed: ValueProxy[float]
|
||||
review_desc_dps: ValueProxy[float]
|
||||
object_desc_speed: ValueProxy[float]
|
||||
object_desc_dps: ValueProxy[float]
|
||||
classification_speeds: DictProxy[str, ValueProxy[float]]
|
||||
classification_cps: DictProxy[str, ValueProxy[float]]
|
||||
|
||||
def __init__(self, manager: SyncManager, custom_classification_models: list[str]):
|
||||
self.image_embeddings_speed = manager.Value("d", 0.0)
|
||||
@@ -52,7 +54,7 @@ class DataProcessorMetrics:
|
||||
|
||||
|
||||
class DataProcessorModelRunner:
|
||||
def __init__(self, requestor, device: str = "CPU", model_size: str = "large"):
|
||||
def __init__(self, requestor: Any, device: str = "CPU", model_size: str = "large"):
|
||||
self.requestor = requestor
|
||||
self.device = device
|
||||
self.model_size = model_size
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
|
||||
class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
def __init__(self, *args, load_vec_extension: bool = False, **kwargs) -> None:
|
||||
def __init__(
|
||||
self, *args: Any, load_vec_extension: bool = False, **kwargs: Any
|
||||
) -> None:
|
||||
self.load_vec_extension: bool = load_vec_extension
|
||||
# no extension necessary, sqlite will load correctly for each platform
|
||||
self.sqlite_vec_path = "/usr/local/lib/vec0"
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _connect(self, *args, **kwargs) -> sqlite3.Connection:
|
||||
conn: sqlite3.Connection = super()._connect(*args, **kwargs)
|
||||
def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection:
|
||||
conn: sqlite3.Connection = super()._connect(*args, **kwargs) # type: ignore[misc]
|
||||
if self.load_vec_extension:
|
||||
self._load_vec_extension(conn)
|
||||
|
||||
@@ -27,7 +30,7 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
conn.enable_load_extension(False)
|
||||
|
||||
def _register_regexp(self, conn: sqlite3.Connection) -> None:
|
||||
def regexp(expr: str, item: str) -> bool:
|
||||
def regexp(expr: str, item: str | None) -> bool:
|
||||
if item is None:
|
||||
return False
|
||||
try:
|
||||
|
||||
@@ -47,7 +47,7 @@ class ModelTypeEnum(str, Enum):
|
||||
class ModelConfig(BaseModel):
|
||||
path: Optional[str] = Field(
|
||||
None,
|
||||
title="Custom Object detection model path",
|
||||
title="Custom object detector model path",
|
||||
description="Path to a custom detection model file (or plus://<model_id> for Frigate+ models).",
|
||||
)
|
||||
labelmap_path: Optional[str] = Field(
|
||||
|
||||
+35
-20
@@ -2,17 +2,19 @@
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from multiprocessing.managers import DictProxy
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Tuple
|
||||
from typing import Any, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import CameraConfig, CameraInput, FfmpegConfig, FrigateConfig
|
||||
from frigate.config import CameraConfig, CameraInput, FrigateConfig
|
||||
from frigate.config.camera.ffmpeg import CameraFfmpegConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
@@ -35,8 +37,7 @@ from frigate.data_processing.real_time.audio_transcription import (
|
||||
)
|
||||
from frigate.ffmpeg_presets import parse_preset_input
|
||||
from frigate.log import LogPipe, suppress_stderr_during
|
||||
from frigate.object_detection.base import load_labels
|
||||
from frigate.util.builtin import get_ffmpeg_arg_list
|
||||
from frigate.util.builtin import get_ffmpeg_arg_list, load_labels
|
||||
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
|
||||
from frigate.util.process import FrigateProcess
|
||||
|
||||
@@ -49,7 +50,7 @@ except ModuleNotFoundError:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_ffmpeg_command(ffmpeg: FfmpegConfig) -> list[str]:
|
||||
def get_ffmpeg_command(ffmpeg: CameraFfmpegConfig) -> list[str]:
|
||||
ffmpeg_input: CameraInput = [i for i in ffmpeg.inputs if "audio" in i.roles][0]
|
||||
input_args = get_ffmpeg_arg_list(ffmpeg.global_args) + (
|
||||
parse_preset_input(ffmpeg_input.input_args, 1)
|
||||
@@ -102,9 +103,11 @@ class AudioProcessor(FrigateProcess):
|
||||
threading.current_thread().name = "process:audio_manager"
|
||||
|
||||
if self.config.audio_transcription.enabled:
|
||||
self.transcription_model_runner = AudioTranscriptionModelRunner(
|
||||
self.config.audio_transcription.device,
|
||||
self.config.audio_transcription.model_size,
|
||||
self.transcription_model_runner: AudioTranscriptionModelRunner | None = (
|
||||
AudioTranscriptionModelRunner(
|
||||
self.config.audio_transcription.device or "AUTO",
|
||||
self.config.audio_transcription.model_size,
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.transcription_model_runner = None
|
||||
@@ -118,7 +121,7 @@ class AudioProcessor(FrigateProcess):
|
||||
self.config,
|
||||
self.camera_metrics,
|
||||
self.transcription_model_runner,
|
||||
self.stop_event,
|
||||
self.stop_event, # type: ignore[arg-type]
|
||||
)
|
||||
audio_threads.append(audio_thread)
|
||||
audio_thread.start()
|
||||
@@ -162,7 +165,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.logger = logging.getLogger(f"audio.{self.camera_config.name}")
|
||||
self.ffmpeg_cmd = get_ffmpeg_command(self.camera_config.ffmpeg)
|
||||
self.logpipe = LogPipe(f"ffmpeg.{self.camera_config.name}.audio")
|
||||
self.audio_listener = None
|
||||
self.audio_listener: subprocess.Popen[Any] | None = None
|
||||
self.audio_transcription_model_runner = audio_transcription_model_runner
|
||||
self.transcription_processor = None
|
||||
self.transcription_thread = None
|
||||
@@ -171,7 +174,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.requestor = InterProcessRequestor()
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
None,
|
||||
{self.camera_config.name: self.camera_config},
|
||||
{str(self.camera_config.name): self.camera_config},
|
||||
[
|
||||
CameraConfigUpdateEnum.audio,
|
||||
CameraConfigUpdateEnum.enabled,
|
||||
@@ -180,7 +183,10 @@ class AudioEventMaintainer(threading.Thread):
|
||||
)
|
||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio.value)
|
||||
|
||||
if self.config.audio_transcription.enabled:
|
||||
if (
|
||||
self.config.audio_transcription.enabled
|
||||
and self.audio_transcription_model_runner is not None
|
||||
):
|
||||
# init the transcription processor for this camera
|
||||
self.transcription_processor = AudioTranscriptionRealTimeProcessor(
|
||||
config=self.config,
|
||||
@@ -200,11 +206,11 @@ class AudioEventMaintainer(threading.Thread):
|
||||
|
||||
self.was_enabled = camera.enabled
|
||||
|
||||
def detect_audio(self, audio) -> None:
|
||||
def detect_audio(self, audio: np.ndarray) -> None:
|
||||
if not self.camera_config.audio.enabled or self.stop_event.is_set():
|
||||
return
|
||||
|
||||
audio_as_float = audio.astype(np.float32)
|
||||
audio_as_float: np.ndarray = audio.astype(np.float32)
|
||||
rms, dBFS = self.calculate_audio_levels(audio_as_float)
|
||||
|
||||
self.camera_metrics[self.camera_config.name].audio_rms.value = rms
|
||||
@@ -261,7 +267,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
else:
|
||||
self.transcription_processor.check_unload_model()
|
||||
|
||||
def calculate_audio_levels(self, audio_as_float: np.float32) -> Tuple[float, float]:
|
||||
def calculate_audio_levels(self, audio_as_float: np.ndarray) -> Tuple[float, float]:
|
||||
# Calculate RMS (Root-Mean-Square) which represents the average signal amplitude
|
||||
# Note: np.float32 isn't serializable, we must use np.float64 to publish the message
|
||||
rms = np.sqrt(np.mean(np.absolute(np.square(audio_as_float))))
|
||||
@@ -296,6 +302,10 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.logpipe.dump()
|
||||
self.start_or_restart_ffmpeg()
|
||||
|
||||
if self.audio_listener is None or self.audio_listener.stdout is None:
|
||||
log_and_restart()
|
||||
return
|
||||
|
||||
try:
|
||||
chunk = self.audio_listener.stdout.read(self.chunk_size)
|
||||
|
||||
@@ -341,7 +351,10 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.requestor.send_data(
|
||||
EXPIRE_AUDIO_ACTIVITY, self.camera_config.name
|
||||
)
|
||||
stop_ffmpeg(self.audio_listener, self.logger)
|
||||
|
||||
if self.audio_listener:
|
||||
stop_ffmpeg(self.audio_listener, self.logger)
|
||||
|
||||
self.audio_listener = None
|
||||
self.was_enabled = enabled
|
||||
continue
|
||||
@@ -367,7 +380,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
|
||||
|
||||
class AudioTfl:
|
||||
def __init__(self, stop_event: threading.Event, num_threads=2):
|
||||
def __init__(self, stop_event: threading.Event, num_threads: int = 2) -> None:
|
||||
self.stop_event = stop_event
|
||||
self.num_threads = num_threads
|
||||
self.labels = load_labels("/audio-labelmap.txt", prefill=521)
|
||||
@@ -382,7 +395,7 @@ class AudioTfl:
|
||||
self.tensor_input_details = self.interpreter.get_input_details()
|
||||
self.tensor_output_details = self.interpreter.get_output_details()
|
||||
|
||||
def _detect_raw(self, tensor_input):
|
||||
def _detect_raw(self, tensor_input: np.ndarray) -> np.ndarray:
|
||||
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
|
||||
self.interpreter.invoke()
|
||||
detections = np.zeros((20, 6), np.float32)
|
||||
@@ -410,8 +423,10 @@ class AudioTfl:
|
||||
|
||||
return detections
|
||||
|
||||
def detect(self, tensor_input, threshold=AUDIO_MIN_CONFIDENCE):
|
||||
detections = []
|
||||
def detect(
|
||||
self, tensor_input: np.ndarray, threshold: float = AUDIO_MIN_CONFIDENCE
|
||||
) -> list[tuple[str, float, tuple[float, float, float, float]]]:
|
||||
detections: list[tuple[str, float, tuple[float, float, float, float]]] = []
|
||||
|
||||
if self.stop_event.is_set():
|
||||
return detections
|
||||
|
||||
+15
-13
@@ -29,7 +29,7 @@ class EventCleanup(threading.Thread):
|
||||
self.stop_event = stop_event
|
||||
self.db = db
|
||||
self.camera_keys = list(self.config.cameras.keys())
|
||||
self.removed_camera_labels: list[str] = None
|
||||
self.removed_camera_labels: list[Event] | None = None
|
||||
self.camera_labels: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def get_removed_camera_labels(self) -> list[Event]:
|
||||
@@ -37,7 +37,7 @@ class EventCleanup(threading.Thread):
|
||||
if self.removed_camera_labels is None:
|
||||
self.removed_camera_labels = list(
|
||||
Event.select(Event.label)
|
||||
.where(Event.camera.not_in(self.camera_keys))
|
||||
.where(Event.camera.not_in(self.camera_keys)) # type: ignore[arg-type,call-arg,misc]
|
||||
.distinct()
|
||||
.execute()
|
||||
)
|
||||
@@ -61,7 +61,7 @@ class EventCleanup(threading.Thread):
|
||||
),
|
||||
}
|
||||
|
||||
return self.camera_labels[camera]["labels"]
|
||||
return self.camera_labels[camera]["labels"] # type: ignore[no-any-return]
|
||||
|
||||
def expire_snapshots(self) -> list[str]:
|
||||
## Expire events from unlisted cameras based on the global config
|
||||
@@ -74,7 +74,9 @@ class EventCleanup(threading.Thread):
|
||||
# loop over object types in db
|
||||
for event in distinct_labels:
|
||||
# get expiration time for this label
|
||||
expire_days = retain_config.objects.get(event.label, retain_config.default)
|
||||
expire_days = retain_config.objects.get(
|
||||
str(event.label), retain_config.default
|
||||
)
|
||||
|
||||
expire_after = (
|
||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||
@@ -87,7 +89,7 @@ class EventCleanup(threading.Thread):
|
||||
Event.thumbnail,
|
||||
)
|
||||
.where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
@@ -109,16 +111,16 @@ class EventCleanup(threading.Thread):
|
||||
|
||||
# update the clips attribute for the db entry
|
||||
query = Event.select(Event.id).where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.start_time < expire_after,
|
||||
Event.label == event.label,
|
||||
Event.retain_indefinitely == False,
|
||||
)
|
||||
|
||||
events_to_update = []
|
||||
events_to_update: list[str] = []
|
||||
|
||||
for event in query.iterator():
|
||||
events_to_update.append(event.id)
|
||||
events_to_update.append(str(event.id))
|
||||
if len(events_to_update) >= CHUNK_SIZE:
|
||||
logger.debug(
|
||||
f"Updating {update_params} for {len(events_to_update)} events"
|
||||
@@ -150,7 +152,7 @@ class EventCleanup(threading.Thread):
|
||||
for event in distinct_labels:
|
||||
# get expiration time for this label
|
||||
expire_days = retain_config.objects.get(
|
||||
event.label, retain_config.default
|
||||
str(event.label), retain_config.default
|
||||
)
|
||||
|
||||
expire_after = (
|
||||
@@ -177,7 +179,7 @@ class EventCleanup(threading.Thread):
|
||||
# only snapshots are stored in /clips
|
||||
# so no need to delete mp4 files
|
||||
for event in expired_events:
|
||||
events_to_update.append(event.id)
|
||||
events_to_update.append(str(event.id))
|
||||
deleted = delete_event_snapshot(event)
|
||||
|
||||
if not deleted:
|
||||
@@ -214,7 +216,7 @@ class EventCleanup(threading.Thread):
|
||||
Event.camera,
|
||||
)
|
||||
.where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.start_time < expire_after,
|
||||
Event.retain_indefinitely == False,
|
||||
)
|
||||
@@ -245,7 +247,7 @@ class EventCleanup(threading.Thread):
|
||||
|
||||
# update the clips attribute for the db entry
|
||||
query = Event.select(Event.id).where(
|
||||
Event.camera.not_in(self.camera_keys),
|
||||
Event.camera.not_in(self.camera_keys), # type: ignore[arg-type,call-arg,misc]
|
||||
Event.start_time < expire_after,
|
||||
Event.retain_indefinitely == False,
|
||||
)
|
||||
@@ -358,7 +360,7 @@ class EventCleanup(threading.Thread):
|
||||
|
||||
logger.debug(f"Found {len(events_to_delete)} events that can be expired")
|
||||
if len(events_to_delete) > 0:
|
||||
ids_to_delete = [e.id for e in events_to_delete]
|
||||
ids_to_delete = [str(e.id) for e in events_to_delete]
|
||||
for i in range(0, len(ids_to_delete), CHUNK_SIZE):
|
||||
chunk = ids_to_delete[i : i + CHUNK_SIZE]
|
||||
logger.debug(f"Deleting {len(chunk)} events from the database")
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
import threading
|
||||
from multiprocessing import Queue
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Dict
|
||||
from typing import Any, Dict
|
||||
|
||||
from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber
|
||||
from frigate.config import FrigateConfig
|
||||
@@ -15,7 +15,7 @@ from frigate.util.builtin import to_relative_box
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def should_update_db(prev_event: Event, current_event: Event) -> bool:
|
||||
def should_update_db(prev_event: dict[str, Any], current_event: dict[str, Any]) -> bool:
|
||||
"""If current_event has updated fields and (clip or snapshot)."""
|
||||
# If event is ending and was previously saved, always update to set end_time
|
||||
# This ensures events are properly ended even when alerts/detections are disabled
|
||||
@@ -47,7 +47,9 @@ def should_update_db(prev_event: Event, current_event: Event) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def should_update_state(prev_event: Event, current_event: Event) -> bool:
|
||||
def should_update_state(
|
||||
prev_event: dict[str, Any], current_event: dict[str, Any]
|
||||
) -> bool:
|
||||
"""If current event should update state, but not necessarily update the db."""
|
||||
if prev_event["stationary"] != current_event["stationary"]:
|
||||
return True
|
||||
@@ -74,7 +76,7 @@ class EventProcessor(threading.Thread):
|
||||
super().__init__(name="event_processor")
|
||||
self.config = config
|
||||
self.timeline_queue = timeline_queue
|
||||
self.events_in_process: Dict[str, Event] = {}
|
||||
self.events_in_process: Dict[str, dict[str, Any]] = {}
|
||||
self.stop_event = stop_event
|
||||
|
||||
self.event_receiver = EventUpdateSubscriber()
|
||||
@@ -92,7 +94,7 @@ class EventProcessor(threading.Thread):
|
||||
if update == None:
|
||||
continue
|
||||
|
||||
source_type, event_type, camera, _, event_data = update
|
||||
source_type, event_type, camera, _, event_data = update # type: ignore[misc]
|
||||
|
||||
logger.debug(
|
||||
f"Event received: {source_type} {event_type} {camera} {event_data['id']}"
|
||||
@@ -140,7 +142,7 @@ class EventProcessor(threading.Thread):
|
||||
self,
|
||||
event_type: str,
|
||||
camera: str,
|
||||
event_data: Event,
|
||||
event_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""handle tracked object event updates."""
|
||||
updated_db = False
|
||||
@@ -150,8 +152,13 @@ class EventProcessor(threading.Thread):
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return
|
||||
|
||||
width = camera_config.detect.width
|
||||
height = camera_config.detect.height
|
||||
|
||||
if width is None or height is None:
|
||||
return
|
||||
|
||||
first_detector = list(self.config.detectors.values())[0]
|
||||
|
||||
start_time = event_data["start_time"]
|
||||
@@ -222,8 +229,12 @@ class EventProcessor(threading.Thread):
|
||||
Event.thumbnail: event_data.get("thumbnail"),
|
||||
Event.has_clip: event_data["has_clip"],
|
||||
Event.has_snapshot: event_data["has_snapshot"],
|
||||
Event.model_hash: first_detector.model.model_hash,
|
||||
Event.model_type: first_detector.model.model_type,
|
||||
Event.model_hash: first_detector.model.model_hash
|
||||
if first_detector.model
|
||||
else None,
|
||||
Event.model_type: first_detector.model.model_type
|
||||
if first_detector.model
|
||||
else None,
|
||||
Event.detector_type: first_detector.type,
|
||||
Event.data: {
|
||||
"box": box,
|
||||
@@ -287,10 +298,10 @@ class EventProcessor(threading.Thread):
|
||||
|
||||
if event_type == EventStateEnum.end:
|
||||
del self.events_in_process[event_data["id"]]
|
||||
self.event_end_publisher.publish((event_data["id"], camera, updated_db))
|
||||
self.event_end_publisher.publish((event_data["id"], camera, updated_db)) # type: ignore[arg-type]
|
||||
|
||||
def handle_external_detection(
|
||||
self, event_type: EventStateEnum, event_data: Event
|
||||
self, event_type: EventStateEnum, event_data: dict[str, Any]
|
||||
) -> None:
|
||||
# Skip replay cameras
|
||||
if event_data.get("camera", "").startswith(REPLAY_CAMERA_PREFIX):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
from frigate.const import (
|
||||
FFMPEG_HVC1_ARGS,
|
||||
@@ -215,7 +215,7 @@ def parse_preset_hardware_acceleration_decode(
|
||||
width: int,
|
||||
height: int,
|
||||
gpu: int,
|
||||
) -> list[str]:
|
||||
) -> Optional[list[str]]:
|
||||
"""Return the correct preset if in preset format otherwise return None."""
|
||||
if not isinstance(arg, str):
|
||||
return None
|
||||
@@ -242,9 +242,9 @@ def parse_preset_hardware_acceleration_scale(
|
||||
else:
|
||||
scale = PRESETS_HW_ACCEL_SCALE.get(arg, PRESETS_HW_ACCEL_SCALE["default"])
|
||||
|
||||
scale = scale.format(fps, width, height).split(" ")
|
||||
scale.extend(detect_args)
|
||||
return scale
|
||||
scale_args = scale.format(fps, width, height).split(" ")
|
||||
scale_args.extend(detect_args)
|
||||
return scale_args
|
||||
|
||||
|
||||
class EncodeTypeEnum(str, Enum):
|
||||
@@ -420,7 +420,7 @@ PRESETS_INPUT = {
|
||||
}
|
||||
|
||||
|
||||
def parse_preset_input(arg: Any, detect_fps: int) -> list[str]:
|
||||
def parse_preset_input(arg: Any, detect_fps: int) -> Optional[list[str]]:
|
||||
"""Return the correct preset if in preset format otherwise return None."""
|
||||
if not isinstance(arg, str):
|
||||
return None
|
||||
@@ -465,6 +465,16 @@ PRESETS_RECORD_OUTPUT = {
|
||||
"-c:a",
|
||||
"aac",
|
||||
],
|
||||
# NOTE: This preset originally used "-c:a copy" to pass through audio
|
||||
# without re-encoding. FFmpeg 7.x introduced a threaded pipeline where
|
||||
# demuxing, encoding, and muxing run in parallel via a Scheduler. This
|
||||
# broke audio streamcopy from RTSP sources: packets are demuxed correctly
|
||||
# but silently dropped before reaching the muxer (0 bytes written). The
|
||||
# issue is specific to RTSP + streamcopy; file inputs and transcoding both
|
||||
# work. Transcoding AAC audio is very lightweight (~30KiB per 10s segment)
|
||||
# and adds negligible CPU overhead, so this is an acceptable workaround.
|
||||
# The benefits of FFmpeg 7.x — particularly the removal of gamma correction
|
||||
# hacks required by earlier versions — outweigh this trade-off.
|
||||
"preset-record-generic-audio-copy": [
|
||||
"-f",
|
||||
"segment",
|
||||
@@ -476,8 +486,10 @@ PRESETS_RECORD_OUTPUT = {
|
||||
"1",
|
||||
"-strftime",
|
||||
"1",
|
||||
"-c",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
],
|
||||
"preset-record-mjpeg": [
|
||||
"-f",
|
||||
@@ -530,7 +542,9 @@ PRESETS_RECORD_OUTPUT = {
|
||||
}
|
||||
|
||||
|
||||
def parse_preset_output_record(arg: Any, force_record_hvc1: bool) -> list[str]:
|
||||
def parse_preset_output_record(
|
||||
arg: Any, force_record_hvc1: bool
|
||||
) -> Optional[list[str]]:
|
||||
"""Return the correct preset if in preset format otherwise return None."""
|
||||
if not isinstance(arg, str):
|
||||
return None
|
||||
|
||||
@@ -50,9 +50,9 @@ class GeminiClient(GenAIClient):
|
||||
response_format: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to Gemini."""
|
||||
contents = [
|
||||
contents = [prompt] + [
|
||||
types.Part.from_bytes(data=img, mime_type="image/jpeg") for img in images
|
||||
] + [prompt]
|
||||
]
|
||||
try:
|
||||
# Merge runtime_options into generation_config if provided
|
||||
generation_config_dict: dict[str, Any] = {"candidate_count": 1}
|
||||
|
||||
@@ -44,7 +44,12 @@ class OpenAIClient(GenAIClient):
|
||||
) -> Optional[str]:
|
||||
"""Submit a request to OpenAI."""
|
||||
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
|
||||
messages_content = []
|
||||
messages_content: list[dict] = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": prompt,
|
||||
}
|
||||
]
|
||||
for image in encoded_images:
|
||||
messages_content.append(
|
||||
{
|
||||
@@ -55,12 +60,6 @@ class OpenAIClient(GenAIClient):
|
||||
},
|
||||
}
|
||||
)
|
||||
messages_content.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": prompt,
|
||||
}
|
||||
)
|
||||
try:
|
||||
request_params = {
|
||||
"model": self.genai_config.model,
|
||||
|
||||
+49
-12
@@ -25,6 +25,9 @@ logger = logging.getLogger(__name__)
|
||||
_MIN_INTERVAL = 1
|
||||
_MAX_INTERVAL = 300
|
||||
|
||||
# Minimum seconds between VLM iterations when woken by detections (no zone filter)
|
||||
_DETECTION_COOLDOWN_WITHOUT_ZONE = 10
|
||||
|
||||
# Max user/assistant turn pairs to keep in conversation history
|
||||
_MAX_HISTORY = 10
|
||||
|
||||
@@ -40,6 +43,7 @@ class VLMWatchJob(Job):
|
||||
labels: list = field(default_factory=list)
|
||||
zones: list = field(default_factory=list)
|
||||
last_reasoning: str = ""
|
||||
notification_message: str = ""
|
||||
iteration_count: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -196,6 +200,7 @@ class VLMWatchRunner(threading.Thread):
|
||||
min(_MAX_INTERVAL, int(parsed.get("next_run_in", 30))),
|
||||
)
|
||||
reasoning = str(parsed.get("reasoning", ""))
|
||||
notification_message = str(parsed.get("notification_message", ""))
|
||||
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
||||
logger.warning(
|
||||
"VLM watch job %s: failed to parse VLM response: %s", self.job.id, e
|
||||
@@ -203,6 +208,7 @@ class VLMWatchRunner(threading.Thread):
|
||||
return 30
|
||||
|
||||
self.job.last_reasoning = reasoning
|
||||
self.job.notification_message = notification_message
|
||||
self.job.iteration_count += 1
|
||||
self._broadcast_status()
|
||||
|
||||
@@ -213,19 +219,35 @@ class VLMWatchRunner(threading.Thread):
|
||||
self.job.camera,
|
||||
reasoning,
|
||||
)
|
||||
self._send_notification(reasoning)
|
||||
self._send_notification(notification_message or reasoning)
|
||||
self.job.status = JobStatusTypesEnum.success
|
||||
return 0
|
||||
|
||||
return next_run_in
|
||||
|
||||
def _wait_for_trigger(self, max_wait: float) -> None:
|
||||
"""Wait up to max_wait seconds, returning early if a relevant detection fires on the target camera."""
|
||||
deadline = time.time() + max_wait
|
||||
"""Wait up to max_wait seconds, returning early if a relevant detection fires on the target camera.
|
||||
|
||||
With zones configured, a matching detection wakes immediately (events
|
||||
are already filtered). Without zones, detections are frequent so a
|
||||
cooldown is enforced: messages are continuously drained to prevent
|
||||
queue backup, but the loop only exits once a match has been seen
|
||||
*and* the cooldown period has elapsed.
|
||||
"""
|
||||
now = time.time()
|
||||
deadline = now + max_wait
|
||||
use_cooldown = not self.job.zones
|
||||
earliest_wake = now + _DETECTION_COOLDOWN_WITHOUT_ZONE if use_cooldown else 0
|
||||
triggered = False
|
||||
|
||||
while not self.cancel_event.is_set():
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
if triggered and time.time() >= earliest_wake:
|
||||
break
|
||||
|
||||
result = self.detection_subscriber.check_for_update(
|
||||
timeout=min(1.0, remaining)
|
||||
)
|
||||
@@ -250,12 +272,22 @@ class VLMWatchRunner(threading.Thread):
|
||||
if cam != self.job.camera or not tracked_objects:
|
||||
continue
|
||||
if self._detection_matches_filters(tracked_objects):
|
||||
logger.debug(
|
||||
"VLM watch job %s: woken early by detection event on %s",
|
||||
self.job.id,
|
||||
self.job.camera,
|
||||
)
|
||||
break
|
||||
if not use_cooldown:
|
||||
logger.debug(
|
||||
"VLM watch job %s: woken early by detection event on %s",
|
||||
self.job.id,
|
||||
self.job.camera,
|
||||
)
|
||||
break
|
||||
|
||||
if not triggered:
|
||||
logger.debug(
|
||||
"VLM watch job %s: detection match on %s, draining for %.0fs",
|
||||
self.job.id,
|
||||
self.job.camera,
|
||||
max(0, earliest_wake - time.time()),
|
||||
)
|
||||
triggered = True
|
||||
|
||||
def _detection_matches_filters(self, tracked_objects: list) -> bool:
|
||||
"""Return True if any tracked object passes the label and zone filters."""
|
||||
@@ -284,7 +316,11 @@ class VLMWatchRunner(threading.Thread):
|
||||
f"You will receive a sequence of frames over time. Use the conversation history to understand "
|
||||
f"what is stationary vs. actively changing.\n\n"
|
||||
f"For each frame respond with JSON only:\n"
|
||||
f'{{"condition_met": <true/false>, "next_run_in": <integer seconds 1-300>, "reasoning": "<brief explanation>"}}\n\n'
|
||||
f'{{"condition_met": <true/false>, "next_run_in": <integer seconds 1-300>, "reasoning": "<brief explanation>", "notification_message": "<natural language notification>"}}\n\n'
|
||||
f"Guidelines for notification_message:\n"
|
||||
f"- Only required when condition_met is true.\n"
|
||||
f"- Write a short, natural notification a user would want to receive on their phone.\n"
|
||||
f'- Example: "Your package has been delivered to the front porch."\n\n'
|
||||
f"Guidelines for next_run_in:\n"
|
||||
f"- Scene is empty / nothing of interest visible: 60-300.\n"
|
||||
f"- Relevant object(s) visible anywhere in frame (even outside the target zone): 3-10. "
|
||||
@@ -294,12 +330,13 @@ class VLMWatchRunner(threading.Thread):
|
||||
f"- Keep reasoning to 1-2 sentences."
|
||||
)
|
||||
|
||||
def _send_notification(self, reasoning: str) -> None:
|
||||
def _send_notification(self, message: str) -> None:
|
||||
"""Publish a camera_monitoring event so downstream handlers (web push, MQTT) can notify users."""
|
||||
payload = {
|
||||
"camera": self.job.camera,
|
||||
"condition": self.job.condition,
|
||||
"reasoning": reasoning,
|
||||
"message": message,
|
||||
"reasoning": self.job.last_reasoning,
|
||||
"job_id": self.job.id,
|
||||
}
|
||||
|
||||
|
||||
+27
-52
@@ -22,68 +22,43 @@ warn_unreachable = true
|
||||
no_implicit_reexport = true
|
||||
|
||||
[mypy-frigate.*]
|
||||
ignore_errors = false
|
||||
|
||||
# Third-party code imported from https://github.com/ufal/whisper_streaming
|
||||
[mypy-frigate.data_processing.real_time.whisper_online]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.__main__]
|
||||
ignore_errors = false
|
||||
disallow_untyped_calls = false
|
||||
# TODO: Remove ignores for these modules as they are updated with type annotations.
|
||||
|
||||
[mypy-frigate.app]
|
||||
ignore_errors = false
|
||||
disallow_untyped_calls = false
|
||||
[mypy-frigate.api.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.const]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.config.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.comms.*]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.debug_replay]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.events]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.detectors.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.genai.*]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.embeddings.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.jobs.*]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.http]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.motion.*]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.ptz.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.object_detection.*]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.stats.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.output.*]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.test.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.ptz]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.util.*]
|
||||
ignore_errors = true
|
||||
|
||||
[mypy-frigate.log]
|
||||
ignore_errors = false
|
||||
|
||||
[mypy-frigate.models]
|
||||
ignore_errors = false
|
||||
|
||||
[mypy-frigate.plus]
|
||||
ignore_errors = false
|
||||
|
||||
[mypy-frigate.stats]
|
||||
ignore_errors = false
|
||||
|
||||
[mypy-frigate.track.*]
|
||||
ignore_errors = false
|
||||
|
||||
[mypy-frigate.types]
|
||||
ignore_errors = false
|
||||
|
||||
[mypy-frigate.version]
|
||||
ignore_errors = false
|
||||
|
||||
[mypy-frigate.watchdog]
|
||||
ignore_errors = false
|
||||
disallow_untyped_calls = false
|
||||
|
||||
|
||||
[mypy-frigate.service_manager.*]
|
||||
ignore_errors = false
|
||||
[mypy-frigate.video.*]
|
||||
ignore_errors = true
|
||||
|
||||
+12
-10
@@ -7,6 +7,7 @@ import os
|
||||
import threading
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
|
||||
@@ -60,7 +61,9 @@ class RecordingCleanup(threading.Thread):
|
||||
db.execute_sql("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
db.close()
|
||||
|
||||
def expire_review_segments(self, config: CameraConfig, now: datetime) -> set[Path]:
|
||||
def expire_review_segments(
|
||||
self, config: CameraConfig, now: datetime.datetime
|
||||
) -> set[Path]:
|
||||
"""Delete review segments that are expired"""
|
||||
alert_expire_date = (
|
||||
now - datetime.timedelta(days=config.record.alerts.retain.days)
|
||||
@@ -68,7 +71,7 @@ class RecordingCleanup(threading.Thread):
|
||||
detection_expire_date = (
|
||||
now - datetime.timedelta(days=config.record.detections.retain.days)
|
||||
).timestamp()
|
||||
expired_reviews: ReviewSegment = (
|
||||
expired_reviews = (
|
||||
ReviewSegment.select(ReviewSegment.id, ReviewSegment.thumb_path)
|
||||
.where(ReviewSegment.camera == config.name)
|
||||
.where(
|
||||
@@ -109,13 +112,13 @@ class RecordingCleanup(threading.Thread):
|
||||
continuous_expire_date: float,
|
||||
motion_expire_date: float,
|
||||
config: CameraConfig,
|
||||
reviews: ReviewSegment,
|
||||
reviews: list[Any],
|
||||
) -> set[Path]:
|
||||
"""Delete recordings for existing camera based on retention config."""
|
||||
# Get the timestamp for cutoff of retained days
|
||||
|
||||
# Get recordings to check for expiration
|
||||
recordings: Recordings = (
|
||||
recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.start_time,
|
||||
@@ -148,13 +151,12 @@ class RecordingCleanup(threading.Thread):
|
||||
review_start = 0
|
||||
deleted_recordings = set()
|
||||
kept_recordings: list[tuple[float, float]] = []
|
||||
recording: Recordings
|
||||
for recording in recordings:
|
||||
keep = False
|
||||
mode = None
|
||||
# Now look for a reason to keep this recording segment
|
||||
for idx in range(review_start, len(reviews)):
|
||||
review: ReviewSegment = reviews[idx]
|
||||
review = reviews[idx]
|
||||
severity = review.severity
|
||||
pre_capture = config.record.get_review_pre_capture(severity)
|
||||
post_capture = config.record.get_review_post_capture(severity)
|
||||
@@ -214,7 +216,7 @@ class RecordingCleanup(threading.Thread):
|
||||
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
||||
).execute()
|
||||
|
||||
previews: list[Previews] = (
|
||||
previews = (
|
||||
Previews.select(
|
||||
Previews.id,
|
||||
Previews.start_time,
|
||||
@@ -290,13 +292,13 @@ class RecordingCleanup(threading.Thread):
|
||||
expire_before = (
|
||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||
).timestamp()
|
||||
no_camera_recordings: Recordings = (
|
||||
no_camera_recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.path,
|
||||
)
|
||||
.where(
|
||||
Recordings.camera.not_in(list(self.config.cameras.keys())),
|
||||
Recordings.camera.not_in(list(self.config.cameras.keys())), # type: ignore[call-arg, arg-type, misc]
|
||||
Recordings.end_time < expire_before,
|
||||
)
|
||||
.namedtuples()
|
||||
@@ -341,7 +343,7 @@ class RecordingCleanup(threading.Thread):
|
||||
).timestamp()
|
||||
|
||||
# Get all the reviews to check against
|
||||
reviews: ReviewSegment = (
|
||||
reviews = (
|
||||
ReviewSegment.select(
|
||||
ReviewSegment.start_time,
|
||||
ReviewSegment.end_time,
|
||||
|
||||
@@ -85,10 +85,6 @@ def validate_ffmpeg_args(args: str) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
|
||||
def lower_priority():
|
||||
os.nice(PROCESS_PRIORITY_LOW)
|
||||
|
||||
|
||||
class PlaybackSourceEnum(str, Enum):
|
||||
recordings = "recordings"
|
||||
preview = "preview"
|
||||
@@ -150,7 +146,7 @@ class RecordingExporter(threading.Thread):
|
||||
):
|
||||
# has preview mp4
|
||||
try:
|
||||
preview: Previews = (
|
||||
preview = (
|
||||
Previews.select(
|
||||
Previews.camera,
|
||||
Previews.path,
|
||||
@@ -231,20 +227,19 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
def get_record_export_command(
|
||||
self, video_path: str, use_hwaccel: bool = True
|
||||
) -> list[str]:
|
||||
) -> tuple[list[str], str | list[str]]:
|
||||
# handle case where internal port is a string with ip:port
|
||||
internal_port = self.config.networking.listen.internal
|
||||
if type(internal_port) is str:
|
||||
internal_port = int(internal_port.split(":")[-1])
|
||||
|
||||
playlist_lines: list[str] = []
|
||||
if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS:
|
||||
playlist_lines = f"http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
|
||||
playlist_url = f"http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
|
||||
ffmpeg_input = (
|
||||
f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_lines}"
|
||||
f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_url}"
|
||||
)
|
||||
else:
|
||||
playlist_lines = []
|
||||
|
||||
# get full set of recordings
|
||||
export_recordings = (
|
||||
Recordings.select(
|
||||
@@ -305,7 +300,7 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
def get_preview_export_command(
|
||||
self, video_path: str, use_hwaccel: bool = True
|
||||
) -> list[str]:
|
||||
) -> tuple[list[str], list[str]]:
|
||||
playlist_lines = []
|
||||
codec = "-c copy"
|
||||
|
||||
@@ -355,7 +350,6 @@ class RecordingExporter(threading.Thread):
|
||||
.iterator()
|
||||
)
|
||||
|
||||
preview: Previews
|
||||
for preview in export_previews:
|
||||
playlist_lines.append(f"file '{preview.path}'")
|
||||
|
||||
@@ -441,10 +435,9 @@ class RecordingExporter(threading.Thread):
|
||||
return
|
||||
|
||||
p = sp.run(
|
||||
ffmpeg_cmd,
|
||||
["nice", "-n", str(PROCESS_PRIORITY_LOW)] + ffmpeg_cmd,
|
||||
input="\n".join(playlist_lines),
|
||||
encoding="ascii",
|
||||
preexec_fn=lower_priority,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
@@ -469,10 +462,9 @@ class RecordingExporter(threading.Thread):
|
||||
)
|
||||
|
||||
p = sp.run(
|
||||
ffmpeg_cmd,
|
||||
["nice", "-n", str(PROCESS_PRIORITY_LOW)] + ffmpeg_cmd,
|
||||
input="\n".join(playlist_lines),
|
||||
encoding="ascii",
|
||||
preexec_fn=lower_priority,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
@@ -493,7 +485,7 @@ class RecordingExporter(threading.Thread):
|
||||
logger.debug(f"Finished exporting {video_path}")
|
||||
|
||||
|
||||
def migrate_exports(ffmpeg: FfmpegConfig, camera_names: list[str]):
|
||||
def migrate_exports(ffmpeg: FfmpegConfig, camera_names: list[str]) -> None:
|
||||
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
|
||||
|
||||
exports = []
|
||||
|
||||
@@ -266,7 +266,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
|
||||
# get all reviews with the end time after the start of the oldest cache file
|
||||
# or with end_time None
|
||||
reviews: ReviewSegment = (
|
||||
reviews = (
|
||||
ReviewSegment.select(
|
||||
ReviewSegment.start_time,
|
||||
ReviewSegment.end_time,
|
||||
@@ -301,7 +301,9 @@ class RecordingMaintainer(threading.Thread):
|
||||
RecordingsDataTypeEnum.saved.value,
|
||||
)
|
||||
|
||||
recordings_to_insert: list[Optional[Recordings]] = await asyncio.gather(*tasks)
|
||||
recordings_to_insert: list[Optional[dict[str, Any]]] = await asyncio.gather(
|
||||
*tasks
|
||||
)
|
||||
|
||||
# fire and forget recordings entries
|
||||
self.requestor.send_data(
|
||||
@@ -314,8 +316,8 @@ class RecordingMaintainer(threading.Thread):
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
|
||||
async def validate_and_move_segment(
|
||||
self, camera: str, reviews: list[ReviewSegment], recording: dict[str, Any]
|
||||
) -> Optional[Recordings]:
|
||||
self, camera: str, reviews: Any, recording: dict[str, Any]
|
||||
) -> Optional[dict[str, Any]]:
|
||||
cache_path: str = recording["cache_path"]
|
||||
start_time: datetime.datetime = recording["start_time"]
|
||||
|
||||
@@ -456,6 +458,8 @@ class RecordingMaintainer(threading.Thread):
|
||||
if end_time < retain_cutoff:
|
||||
self.drop_segment(cache_path)
|
||||
|
||||
return None
|
||||
|
||||
def _compute_motion_heatmap(
|
||||
self, camera: str, motion_boxes: list[tuple[int, int, int, int]]
|
||||
) -> dict[str, int] | None:
|
||||
@@ -481,7 +485,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
frame_width = camera_config.detect.width
|
||||
frame_height = camera_config.detect.height
|
||||
|
||||
if frame_width <= 0 or frame_height <= 0:
|
||||
if not frame_width or frame_width <= 0 or not frame_height or frame_height <= 0:
|
||||
return None
|
||||
|
||||
GRID_SIZE = 16
|
||||
@@ -575,13 +579,13 @@ class RecordingMaintainer(threading.Thread):
|
||||
duration: float,
|
||||
cache_path: str,
|
||||
store_mode: RetainModeEnum,
|
||||
) -> Optional[Recordings]:
|
||||
) -> Optional[dict[str, Any]]:
|
||||
segment_info = self.segment_stats(camera, start_time, end_time)
|
||||
|
||||
# check if the segment shouldn't be stored
|
||||
if segment_info.should_discard_segment(store_mode):
|
||||
self.drop_segment(cache_path)
|
||||
return
|
||||
return None
|
||||
|
||||
# directory will be in utc due to start_time being in utc
|
||||
directory = os.path.join(
|
||||
@@ -620,7 +624,8 @@ class RecordingMaintainer(threading.Thread):
|
||||
|
||||
if p.returncode != 0:
|
||||
logger.error(f"Unable to convert {cache_path} to {file_path}")
|
||||
logger.error((await p.stderr.read()).decode("ascii"))
|
||||
if p.stderr:
|
||||
logger.error((await p.stderr.read()).decode("ascii"))
|
||||
return None
|
||||
else:
|
||||
logger.debug(
|
||||
@@ -684,11 +689,16 @@ class RecordingMaintainer(threading.Thread):
|
||||
stale_frame_count_threshold = 10
|
||||
# empty the object recordings info queue
|
||||
while True:
|
||||
(topic, data) = self.detection_subscriber.check_for_update(
|
||||
result = self.detection_subscriber.check_for_update(
|
||||
timeout=FAST_QUEUE_TIMEOUT
|
||||
)
|
||||
|
||||
if not topic:
|
||||
if not result:
|
||||
break
|
||||
|
||||
topic, data = result
|
||||
|
||||
if not topic or not data:
|
||||
break
|
||||
|
||||
if topic == DetectionTypeEnum.video.value:
|
||||
|
||||
@@ -31,7 +31,7 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.models import ReviewSegment
|
||||
from frigate.review.types import SeverityEnum
|
||||
from frigate.track.object_processing import ManualEventState, TrackedObject
|
||||
from frigate.track.object_processing import ManualEventState
|
||||
from frigate.util.image import SharedMemoryFrameManager, calculate_16_9_crop
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -69,7 +69,9 @@ class PendingReviewSegment:
|
||||
self.last_alert_time = frame_time
|
||||
|
||||
# thumbnail
|
||||
self._frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8)
|
||||
self._frame: np.ndarray[Any, Any] = np.zeros(
|
||||
(THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8
|
||||
)
|
||||
self.has_frame = False
|
||||
self.frame_active_count = 0
|
||||
self.frame_path = os.path.join(
|
||||
@@ -77,8 +79,11 @@ class PendingReviewSegment:
|
||||
)
|
||||
|
||||
def update_frame(
|
||||
self, camera_config: CameraConfig, frame, objects: list[TrackedObject]
|
||||
):
|
||||
self,
|
||||
camera_config: CameraConfig,
|
||||
frame: np.ndarray,
|
||||
objects: list[dict[str, Any]],
|
||||
) -> None:
|
||||
min_x = camera_config.frame_shape[1]
|
||||
min_y = camera_config.frame_shape[0]
|
||||
max_x = 0
|
||||
@@ -114,7 +119,7 @@ class PendingReviewSegment:
|
||||
self.frame_path, self._frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60]
|
||||
)
|
||||
|
||||
def save_full_frame(self, camera_config: CameraConfig, frame):
|
||||
def save_full_frame(self, camera_config: CameraConfig, frame: np.ndarray) -> None:
|
||||
color_frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
|
||||
width = int(THUMB_HEIGHT * color_frame.shape[1] / color_frame.shape[0])
|
||||
self._frame = cv2.resize(
|
||||
@@ -165,13 +170,13 @@ class ActiveObjects:
|
||||
self,
|
||||
frame_time: float,
|
||||
camera_config: CameraConfig,
|
||||
all_objects: list[TrackedObject],
|
||||
all_objects: list[dict[str, Any]],
|
||||
):
|
||||
self.camera_config = camera_config
|
||||
|
||||
# get current categorization of objects to know if
|
||||
# these objects are currently being categorized
|
||||
self.categorized_objects = {
|
||||
self.categorized_objects: dict[str, list[dict[str, Any]]] = {
|
||||
"alerts": [],
|
||||
"detections": [],
|
||||
}
|
||||
@@ -250,7 +255,7 @@ class ActiveObjects:
|
||||
|
||||
return False
|
||||
|
||||
def get_all_objects(self) -> list[TrackedObject]:
|
||||
def get_all_objects(self) -> list[dict[str, Any]]:
|
||||
return (
|
||||
self.categorized_objects["alerts"] + self.categorized_objects["detections"]
|
||||
)
|
||||
@@ -309,7 +314,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
"reviews",
|
||||
json.dumps(review_update),
|
||||
)
|
||||
self.review_publisher.publish(review_update, segment.camera)
|
||||
self.review_publisher.publish(review_update, segment.camera) # type: ignore[arg-type]
|
||||
self.requestor.send_data(
|
||||
f"{segment.camera}/review_status", segment.severity.value.upper()
|
||||
)
|
||||
@@ -318,8 +323,8 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
self,
|
||||
segment: PendingReviewSegment,
|
||||
camera_config: CameraConfig,
|
||||
frame,
|
||||
objects: list[TrackedObject],
|
||||
frame: Optional[np.ndarray],
|
||||
objects: list[dict[str, Any]],
|
||||
prev_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update segment."""
|
||||
@@ -337,7 +342,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
"reviews",
|
||||
json.dumps(review_update),
|
||||
)
|
||||
self.review_publisher.publish(review_update, segment.camera)
|
||||
self.review_publisher.publish(review_update, segment.camera) # type: ignore[arg-type]
|
||||
self.requestor.send_data(
|
||||
f"{segment.camera}/review_status", segment.severity.value.upper()
|
||||
)
|
||||
@@ -346,7 +351,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
self,
|
||||
segment: PendingReviewSegment,
|
||||
prev_data: dict[str, Any],
|
||||
) -> float:
|
||||
) -> Any:
|
||||
"""End segment."""
|
||||
final_data = segment.get_data(ended=True)
|
||||
end_time = final_data[ReviewSegment.end_time.name]
|
||||
@@ -360,24 +365,25 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
"reviews",
|
||||
json.dumps(review_update),
|
||||
)
|
||||
self.review_publisher.publish(review_update, segment.camera)
|
||||
self.review_publisher.publish(review_update, segment.camera) # type: ignore[arg-type]
|
||||
self.requestor.send_data(f"{segment.camera}/review_status", "NONE")
|
||||
self.active_review_segments[segment.camera] = None
|
||||
return end_time
|
||||
|
||||
def forcibly_end_segment(self, camera: str) -> float:
|
||||
def forcibly_end_segment(self, camera: str) -> Any:
|
||||
"""Forcibly end the pending segment for a camera."""
|
||||
segment = self.active_review_segments.get(camera)
|
||||
if segment:
|
||||
prev_data = segment.get_data(False)
|
||||
return self._publish_segment_end(segment, prev_data)
|
||||
return None
|
||||
|
||||
def update_existing_segment(
|
||||
self,
|
||||
segment: PendingReviewSegment,
|
||||
frame_name: str,
|
||||
frame_time: float,
|
||||
objects: list[TrackedObject],
|
||||
objects: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Validate if existing review segment should continue."""
|
||||
camera_config = self.config.cameras[segment.camera]
|
||||
@@ -492,8 +498,11 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
if segment.severity == SeverityEnum.alert and frame_time > (
|
||||
segment.last_alert_time + camera_config.review.alerts.cutoff_time
|
||||
if (
|
||||
segment.severity == SeverityEnum.alert
|
||||
and segment.last_alert_time is not None
|
||||
and frame_time
|
||||
> (segment.last_alert_time + camera_config.review.alerts.cutoff_time)
|
||||
):
|
||||
needs_new_detection = (
|
||||
segment.last_detection_time > segment.last_alert_time
|
||||
@@ -516,23 +525,18 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
new_zones.update(o["current_zones"])
|
||||
|
||||
if new_detections:
|
||||
self.active_review_segments[activity.camera_config.name] = (
|
||||
PendingReviewSegment(
|
||||
activity.camera_config.name,
|
||||
end_time,
|
||||
SeverityEnum.detection,
|
||||
new_detections,
|
||||
sub_labels={},
|
||||
audio=set(),
|
||||
zones=list(new_zones),
|
||||
)
|
||||
new_segment = PendingReviewSegment(
|
||||
segment.camera,
|
||||
end_time,
|
||||
SeverityEnum.detection,
|
||||
new_detections,
|
||||
sub_labels={},
|
||||
audio=set(),
|
||||
zones=list(new_zones),
|
||||
)
|
||||
self._publish_segment_start(
|
||||
self.active_review_segments[activity.camera_config.name]
|
||||
)
|
||||
self.active_review_segments[
|
||||
activity.camera_config.name
|
||||
].last_detection_time = last_detection_time
|
||||
self.active_review_segments[segment.camera] = new_segment
|
||||
self._publish_segment_start(new_segment)
|
||||
new_segment.last_detection_time = last_detection_time
|
||||
elif segment.severity == SeverityEnum.detection and frame_time > (
|
||||
segment.last_detection_time
|
||||
+ camera_config.review.detections.cutoff_time
|
||||
@@ -544,7 +548,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
camera: str,
|
||||
frame_name: str,
|
||||
frame_time: float,
|
||||
objects: list[TrackedObject],
|
||||
objects: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Check if a new review segment should be created."""
|
||||
camera_config = self.config.cameras[camera]
|
||||
@@ -581,7 +585,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
zones.append(zone)
|
||||
|
||||
if severity:
|
||||
self.active_review_segments[camera] = PendingReviewSegment(
|
||||
new_segment = PendingReviewSegment(
|
||||
camera,
|
||||
frame_time,
|
||||
severity,
|
||||
@@ -590,6 +594,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
audio=set(),
|
||||
zones=zones,
|
||||
)
|
||||
self.active_review_segments[camera] = new_segment
|
||||
|
||||
try:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
@@ -600,11 +605,11 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
logger.debug(f"Failed to get frame {frame_name} from SHM")
|
||||
return
|
||||
|
||||
self.active_review_segments[camera].update_frame(
|
||||
new_segment.update_frame(
|
||||
camera_config, yuv_frame, activity.get_all_objects()
|
||||
)
|
||||
self.frame_manager.close(frame_name)
|
||||
self._publish_segment_start(self.active_review_segments[camera])
|
||||
self._publish_segment_start(new_segment)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
@@ -621,9 +626,14 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
for camera in updated_topics["enabled"]:
|
||||
self.forcibly_end_segment(camera)
|
||||
|
||||
(topic, data) = self.detection_subscriber.check_for_update(timeout=1)
|
||||
result = self.detection_subscriber.check_for_update(timeout=1)
|
||||
|
||||
if not topic:
|
||||
if not result:
|
||||
continue
|
||||
|
||||
topic, data = result
|
||||
|
||||
if not topic or not data:
|
||||
continue
|
||||
|
||||
if topic == DetectionTypeEnum.video.value:
|
||||
@@ -712,10 +722,13 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
if topic == DetectionTypeEnum.api:
|
||||
# manual_info["label"] contains 'label: sub_label'
|
||||
# so split out the label without modifying manual_info
|
||||
det_labels = self.config.cameras[
|
||||
camera
|
||||
].review.detections.labels
|
||||
if (
|
||||
self.config.cameras[camera].review.detections.enabled
|
||||
and manual_info["label"].split(": ")[0]
|
||||
in self.config.cameras[camera].review.detections.labels
|
||||
and det_labels is not None
|
||||
and manual_info["label"].split(": ")[0] in det_labels
|
||||
):
|
||||
current_segment.last_detection_time = manual_info[
|
||||
"end_time"
|
||||
@@ -744,14 +757,15 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
):
|
||||
# manual_info["label"] contains 'label: sub_label'
|
||||
# so split out the label without modifying manual_info
|
||||
det_labels = self.config.cameras[
|
||||
camera
|
||||
].review.detections.labels
|
||||
if (
|
||||
not self.config.cameras[
|
||||
camera
|
||||
].review.detections.enabled
|
||||
or manual_info["label"].split(": ")[0]
|
||||
not in self.config.cameras[
|
||||
camera
|
||||
].review.detections.labels
|
||||
or det_labels is None
|
||||
or manual_info["label"].split(": ")[0] not in det_labels
|
||||
):
|
||||
current_segment.severity = SeverityEnum.alert
|
||||
elif (
|
||||
@@ -828,17 +842,18 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
severity = None
|
||||
# manual_info["label"] contains 'label: sub_label'
|
||||
# so split out the label without modifying manual_info
|
||||
det_labels = self.config.cameras[camera].review.detections.labels
|
||||
if (
|
||||
self.config.cameras[camera].review.detections.enabled
|
||||
and manual_info["label"].split(": ")[0]
|
||||
in self.config.cameras[camera].review.detections.labels
|
||||
and det_labels is not None
|
||||
and manual_info["label"].split(": ")[0] in det_labels
|
||||
):
|
||||
severity = SeverityEnum.detection
|
||||
elif self.config.cameras[camera].review.alerts.enabled:
|
||||
severity = SeverityEnum.alert
|
||||
|
||||
if severity:
|
||||
self.active_review_segments[camera] = PendingReviewSegment(
|
||||
api_segment = PendingReviewSegment(
|
||||
camera,
|
||||
frame_time,
|
||||
severity,
|
||||
@@ -847,32 +862,25 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
[],
|
||||
set(),
|
||||
)
|
||||
self.active_review_segments[camera] = api_segment
|
||||
|
||||
if manual_info["state"] == ManualEventState.start:
|
||||
self.indefinite_events[camera][manual_info["event_id"]] = (
|
||||
manual_info["label"]
|
||||
)
|
||||
# temporarily make it so this event can not end
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_alert_time = sys.maxsize
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_detection_time = sys.maxsize
|
||||
api_segment.last_alert_time = sys.maxsize
|
||||
api_segment.last_detection_time = sys.maxsize
|
||||
elif manual_info["state"] == ManualEventState.complete:
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_alert_time = manual_info["end_time"]
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_detection_time = manual_info["end_time"]
|
||||
api_segment.last_alert_time = manual_info["end_time"]
|
||||
api_segment.last_detection_time = manual_info["end_time"]
|
||||
else:
|
||||
logger.warning(
|
||||
f"Manual event API has been called for {camera}, but alerts and detections are disabled. This manual event will not appear as an alert or detection."
|
||||
)
|
||||
elif topic == DetectionTypeEnum.lpr:
|
||||
if self.config.cameras[camera].review.detections.enabled:
|
||||
self.active_review_segments[camera] = PendingReviewSegment(
|
||||
lpr_segment = PendingReviewSegment(
|
||||
camera,
|
||||
frame_time,
|
||||
SeverityEnum.detection,
|
||||
@@ -881,25 +889,18 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
[],
|
||||
set(),
|
||||
)
|
||||
self.active_review_segments[camera] = lpr_segment
|
||||
|
||||
if manual_info["state"] == ManualEventState.start:
|
||||
self.indefinite_events[camera][manual_info["event_id"]] = (
|
||||
manual_info["label"]
|
||||
)
|
||||
# temporarily make it so this event can not end
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_alert_time = sys.maxsize
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_detection_time = sys.maxsize
|
||||
lpr_segment.last_alert_time = sys.maxsize
|
||||
lpr_segment.last_detection_time = sys.maxsize
|
||||
elif manual_info["state"] == ManualEventState.complete:
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_alert_time = manual_info["end_time"]
|
||||
self.active_review_segments[
|
||||
camera
|
||||
].last_detection_time = manual_info["end_time"]
|
||||
lpr_segment.last_alert_time = manual_info["end_time"]
|
||||
lpr_segment.last_detection_time = manual_info["end_time"]
|
||||
else:
|
||||
logger.warning(
|
||||
f"Dedicated LPR camera API has been called for {camera}, but detections are disabled. LPR events will not appear as a detection."
|
||||
|
||||
@@ -19,6 +19,7 @@ from frigate.types import StatsTrackingTypes
|
||||
from frigate.util.services import (
|
||||
calculate_shm_requirements,
|
||||
get_amd_gpu_stats,
|
||||
get_axcl_npu_stats,
|
||||
get_bandwidth_stats,
|
||||
get_cpu_stats,
|
||||
get_fs_type,
|
||||
@@ -324,6 +325,10 @@ async def set_npu_usages(config: FrigateConfig, all_stats: dict[str, Any]) -> No
|
||||
# OpenVINO NPU usage
|
||||
ov_usage = get_openvino_npu_stats()
|
||||
stats["openvino"] = ov_usage
|
||||
elif detector.type == "axengine":
|
||||
# AXERA NPU usage
|
||||
axcl_usage = get_axcl_npu_stats()
|
||||
stats["axengine"] = axcl_usage
|
||||
|
||||
if stats:
|
||||
all_stats["npu_usages"] = stats
|
||||
|
||||
+6
-5
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
import shutil
|
||||
import threading
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from pathlib import Path
|
||||
|
||||
from peewee import SQL, fn
|
||||
@@ -23,7 +24,7 @@ MAX_CALCULATED_BANDWIDTH = 10000 # 10Gb/hr
|
||||
class StorageMaintainer(threading.Thread):
|
||||
"""Maintain frigates recording storage."""
|
||||
|
||||
def __init__(self, config: FrigateConfig, stop_event) -> None:
|
||||
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
|
||||
super().__init__(name="storage_maintainer")
|
||||
self.config = config
|
||||
self.stop_event = stop_event
|
||||
@@ -114,7 +115,7 @@ class StorageMaintainer(threading.Thread):
|
||||
logger.debug(
|
||||
f"Storage cleanup check: {hourly_bandwidth} hourly with remaining storage: {remaining_storage}."
|
||||
)
|
||||
return remaining_storage < hourly_bandwidth
|
||||
return remaining_storage < float(hourly_bandwidth)
|
||||
|
||||
def reduce_storage_consumption(self) -> None:
|
||||
"""Remove oldest hour of recordings."""
|
||||
@@ -124,7 +125,7 @@ class StorageMaintainer(threading.Thread):
|
||||
[b["bandwidth"] for b in self.camera_storage_stats.values()]
|
||||
)
|
||||
|
||||
recordings: Recordings = (
|
||||
recordings = (
|
||||
Recordings.select(
|
||||
Recordings.id,
|
||||
Recordings.camera,
|
||||
@@ -138,7 +139,7 @@ class StorageMaintainer(threading.Thread):
|
||||
.iterator()
|
||||
)
|
||||
|
||||
retained_events: Event = (
|
||||
retained_events = (
|
||||
Event.select(
|
||||
Event.start_time,
|
||||
Event.end_time,
|
||||
@@ -278,7 +279,7 @@ class StorageMaintainer(threading.Thread):
|
||||
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
||||
).execute()
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
"""Check every 5 minutes if storage needs to be cleaned up."""
|
||||
if self.config.safe_mode:
|
||||
logger.info("Safe mode enabled, skipping storage maintenance")
|
||||
|
||||
+10
-6
@@ -8,7 +8,7 @@ from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.events.maintainer import EventStateEnum, EventTypeEnum
|
||||
from frigate.events.types import EventStateEnum, EventTypeEnum
|
||||
from frigate.models import Timeline
|
||||
from frigate.util.builtin import to_relative_box
|
||||
|
||||
@@ -28,7 +28,7 @@ class TimelineProcessor(threading.Thread):
|
||||
self.config = config
|
||||
self.queue = queue
|
||||
self.stop_event = stop_event
|
||||
self.pre_event_cache: dict[str, list[dict[str, Any]]] = {}
|
||||
self.pre_event_cache: dict[str, list[dict[Any, Any]]] = {}
|
||||
|
||||
def run(self) -> None:
|
||||
while not self.stop_event.is_set():
|
||||
@@ -56,7 +56,7 @@ class TimelineProcessor(threading.Thread):
|
||||
|
||||
def insert_or_save(
|
||||
self,
|
||||
entry: dict[str, Any],
|
||||
entry: dict[Any, Any],
|
||||
prev_event_data: dict[Any, Any],
|
||||
event_data: dict[Any, Any],
|
||||
) -> None:
|
||||
@@ -84,11 +84,15 @@ class TimelineProcessor(threading.Thread):
|
||||
event_type: str,
|
||||
prev_event_data: dict[Any, Any],
|
||||
event_data: dict[Any, Any],
|
||||
) -> bool:
|
||||
) -> None:
|
||||
"""Handle object detection."""
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
return False
|
||||
if (
|
||||
camera_config is None
|
||||
or camera_config.detect.width is None
|
||||
or camera_config.detect.height is None
|
||||
):
|
||||
return
|
||||
event_id = event_data["id"]
|
||||
|
||||
# Base timeline entry data that all entries will share
|
||||
|
||||
@@ -67,8 +67,8 @@ class TrackedObject:
|
||||
self.has_snapshot = False
|
||||
self.top_score = self.computed_score = 0.0
|
||||
self.thumbnail_data: dict[str, Any] | None = None
|
||||
self.last_updated = 0
|
||||
self.last_published = 0
|
||||
self.last_updated: float = 0
|
||||
self.last_published: float = 0
|
||||
self.frame = None
|
||||
self.active = True
|
||||
self.pending_loitering = False
|
||||
|
||||
@@ -12,7 +12,7 @@ import shlex
|
||||
import struct
|
||||
import urllib.parse
|
||||
from collections.abc import Mapping
|
||||
from multiprocessing.sharedctypes import Synchronized
|
||||
from multiprocessing.managers import ValueProxy
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
@@ -64,7 +64,7 @@ class EventsPerSecond:
|
||||
|
||||
|
||||
class InferenceSpeed:
|
||||
def __init__(self, metric: Synchronized) -> None:
|
||||
def __init__(self, metric: ValueProxy[float]) -> None:
|
||||
self.__metric = metric
|
||||
self.__initialized = False
|
||||
|
||||
|
||||
@@ -488,6 +488,43 @@ def get_rockchip_npu_stats() -> Optional[dict[str, float | str]]:
|
||||
return stats
|
||||
|
||||
|
||||
def get_axcl_npu_stats() -> Optional[dict[str, str | float]]:
|
||||
"""Get NPU stats using axcl."""
|
||||
# Check if axcl-smi exists
|
||||
axcl_smi_path = "/usr/bin/axcl/axcl-smi"
|
||||
if not os.path.exists(axcl_smi_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
# Run axcl-smi command to get NPU stats
|
||||
axcl_command = [axcl_smi_path, "sh", "cat", "/proc/ax_proc/npu/top"]
|
||||
p = sp.run(
|
||||
axcl_command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if p.returncode != 0:
|
||||
pass
|
||||
else:
|
||||
utilization = None
|
||||
|
||||
for line in p.stdout.strip().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("utilization:"):
|
||||
match = re.search(r"utilization:(\d+)%", line)
|
||||
if match:
|
||||
utilization = float(match.group(1))
|
||||
|
||||
if utilization is not None:
|
||||
stats: dict[str, str | float] = {"npu": utilization, "mem": "-%"}
|
||||
return stats
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def try_get_info(f, h, default="N/A", sensor=None):
|
||||
try:
|
||||
if h:
|
||||
|
||||
@@ -529,7 +529,7 @@
|
||||
},
|
||||
"detections": {
|
||||
"label": "Detections config",
|
||||
"description": "Settings for creating detection events (non-alert) and how long to keep them.",
|
||||
"description": "Settings for which tracked objects generate detections (non-alert) and how detections are retained.",
|
||||
"enabled": {
|
||||
"label": "Enable detections",
|
||||
"description": "Enable or disable detection events for this camera."
|
||||
|
||||
@@ -293,7 +293,7 @@
|
||||
"label": "Detector specific model configuration",
|
||||
"description": "Detector-specific model configuration options (path, input size, etc.).",
|
||||
"path": {
|
||||
"label": "Custom Object detection model path",
|
||||
"label": "Custom object detector model path",
|
||||
"description": "Path to a custom detection model file (or plus://<model_id> for Frigate+ models)."
|
||||
},
|
||||
"labelmap_path": {
|
||||
@@ -466,7 +466,7 @@
|
||||
"label": "Detection model",
|
||||
"description": "Settings to configure a custom object detection model and its input shape.",
|
||||
"path": {
|
||||
"label": "Custom Object detection model path",
|
||||
"label": "Custom object detector model path",
|
||||
"description": "Path to a custom detection model file (or plus://<model_id> for Frigate+ models)."
|
||||
},
|
||||
"labelmap_path": {
|
||||
@@ -1044,7 +1044,7 @@
|
||||
},
|
||||
"detections": {
|
||||
"label": "Detections config",
|
||||
"description": "Settings for creating detection events (non-alert) and how long to keep them.",
|
||||
"description": "Settings for which tracked objects generate detections (non-alert) and how detections are retained.",
|
||||
"enabled": {
|
||||
"label": "Enable detections",
|
||||
"description": "Enable or disable detection events for all cameras; can be overridden per-camera."
|
||||
|
||||
@@ -1431,6 +1431,11 @@
|
||||
"summary": "{{count}} object types selected",
|
||||
"empty": "No object labels available"
|
||||
},
|
||||
"reviewLabels": {
|
||||
"summary": "{{count}} labels selected",
|
||||
"empty": "No labels available",
|
||||
"allNonAlertDetections": "All non-alert activity will be included as detections."
|
||||
},
|
||||
"filters": {
|
||||
"objectFieldLabel": "{{field}} for {{label}}"
|
||||
},
|
||||
@@ -1474,7 +1479,8 @@
|
||||
"timestamp_style": {
|
||||
"title": "Timestamp Settings"
|
||||
},
|
||||
"searchPlaceholder": "Search..."
|
||||
"searchPlaceholder": "Search...",
|
||||
"addCustomLabel": "Add custom label..."
|
||||
},
|
||||
"globalConfig": {
|
||||
"title": "Global Configuration",
|
||||
@@ -1521,6 +1527,8 @@
|
||||
"noOverrides": "No overrides",
|
||||
"cameraCount_one": "{{count}} camera",
|
||||
"cameraCount_other": "{{count}} cameras",
|
||||
"columnCamera": "Camera",
|
||||
"columnOverrides": "Profile Overrides",
|
||||
"baseConfig": "Base Config",
|
||||
"addProfile": "Add Profile",
|
||||
"newProfile": "New Profile",
|
||||
|
||||
@@ -23,7 +23,7 @@ const record: SectionConfigOverrides = {
|
||||
uiSchema: {
|
||||
export: {
|
||||
hwaccel_args: {
|
||||
"ui:options": { size: "lg" },
|
||||
"ui:options": { suppressMultiSchema: true, size: "lg" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,14 +3,16 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const review: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/review",
|
||||
fieldDocs: {
|
||||
"alerts.labels": "/configuration/review/#alerts-and-detections",
|
||||
"detections.labels": "/configuration/review/#alerts-and-detections",
|
||||
},
|
||||
restartRequired: [],
|
||||
fieldOrder: ["alerts", "detections", "genai"],
|
||||
fieldGroups: {},
|
||||
hiddenFields: [
|
||||
"enabled_in_config",
|
||||
"alerts.labels",
|
||||
"alerts.enabled_in_config",
|
||||
"detections.labels",
|
||||
"detections.enabled_in_config",
|
||||
"genai.enabled_in_config",
|
||||
],
|
||||
@@ -18,20 +20,35 @@ const review: SectionConfigOverrides = {
|
||||
uiSchema: {
|
||||
alerts: {
|
||||
"ui:before": { render: "CameraReviewStatusToggles" },
|
||||
labels: {
|
||||
"ui:widget": "reviewLabels",
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
},
|
||||
},
|
||||
required_zones: {
|
||||
"ui:widget": "hidden",
|
||||
},
|
||||
},
|
||||
detections: {
|
||||
labels: {
|
||||
"ui:widget": "reviewLabels",
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
emptySelectionHintKey:
|
||||
"configForm.reviewLabels.allNonAlertDetections",
|
||||
},
|
||||
},
|
||||
required_zones: {
|
||||
"ui:widget": "hidden",
|
||||
},
|
||||
},
|
||||
genai: {
|
||||
additional_concerns: {
|
||||
"ui:widget": "textarea",
|
||||
"ui:widget": "ArrayAsTextWidget",
|
||||
"ui:options": {
|
||||
size: "full",
|
||||
multiline: true,
|
||||
},
|
||||
},
|
||||
activity_context_prompt: {
|
||||
|
||||
@@ -152,6 +152,10 @@ export interface BaseSectionProps {
|
||||
profileBorderColor?: string;
|
||||
/** Callback to delete the current profile's overrides for this section */
|
||||
onDeleteProfileSection?: () => void;
|
||||
/** Whether a SaveAll operation is in progress (disables individual Save) */
|
||||
isSavingAll?: boolean;
|
||||
/** Callback when this section's saving state changes */
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
}
|
||||
|
||||
export interface CreateSectionOptions {
|
||||
@@ -186,6 +190,8 @@ export function ConfigSection({
|
||||
profileFriendlyName,
|
||||
profileBorderColor,
|
||||
onDeleteProfileSection,
|
||||
isSavingAll = false,
|
||||
onSavingChange,
|
||||
}: ConfigSectionProps) {
|
||||
// For replay level, treat as camera-level config access
|
||||
const effectiveLevel = level === "replay" ? "camera" : level;
|
||||
@@ -246,6 +252,7 @@ export function ConfigSection({
|
||||
[onPendingDataChange, effectiveSectionPath, cameraName],
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isResettingToDefault, setIsResettingToDefault] = useState(false);
|
||||
const [hasValidationErrors, setHasValidationErrors] = useState(false);
|
||||
const [extraHasChanges, setExtraHasChanges] = useState(false);
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
@@ -577,6 +584,7 @@ export function ConfigSection({
|
||||
if (!pendingData) return;
|
||||
|
||||
setIsSaving(true);
|
||||
onSavingChange?.(true);
|
||||
try {
|
||||
const basePath =
|
||||
effectiveLevel === "camera" && cameraName
|
||||
@@ -699,6 +707,7 @@ export function ConfigSection({
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
onSavingChange?.(false);
|
||||
}
|
||||
}, [
|
||||
sectionPath,
|
||||
@@ -718,12 +727,14 @@ export function ConfigSection({
|
||||
setPendingData,
|
||||
requiresRestartForOverrides,
|
||||
skipSave,
|
||||
onSavingChange,
|
||||
]);
|
||||
|
||||
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
|
||||
const handleResetToGlobal = useCallback(async () => {
|
||||
if (effectiveLevel === "camera" && !cameraName) return;
|
||||
|
||||
setIsResettingToDefault(true);
|
||||
try {
|
||||
const basePath =
|
||||
effectiveLevel === "camera" && cameraName
|
||||
@@ -758,6 +769,8 @@ export function ConfigSection({
|
||||
defaultValue: "Failed to reset settings",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsResettingToDefault(false);
|
||||
}
|
||||
}, [
|
||||
effectiveSectionPath,
|
||||
@@ -945,9 +958,12 @@ export function ConfigSection({
|
||||
<Button
|
||||
onClick={() => setIsResetDialogOpen(true)}
|
||||
variant="outline"
|
||||
disabled={isSaving || disabled}
|
||||
disabled={isSaving || isResettingToDefault || disabled}
|
||||
className="flex flex-1 gap-2"
|
||||
>
|
||||
{isResettingToDefault && (
|
||||
<ActivityIndicator className="h-4 w-4" />
|
||||
)}
|
||||
{effectiveLevel === "global"
|
||||
? t("button.resetToDefault", {
|
||||
ns: "common",
|
||||
@@ -980,7 +996,7 @@ export function ConfigSection({
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
variant="outline"
|
||||
disabled={isSaving || disabled}
|
||||
disabled={isSaving || isSavingAll || disabled}
|
||||
className="flex min-w-36 flex-1 gap-2"
|
||||
>
|
||||
{t("button.undo", { ns: "common", defaultValue: "Undo" })}
|
||||
@@ -990,7 +1006,11 @@ export function ConfigSection({
|
||||
onClick={handleSave}
|
||||
variant="select"
|
||||
disabled={
|
||||
!hasChanges || hasValidationErrors || isSaving || disabled
|
||||
!hasChanges ||
|
||||
hasValidationErrors ||
|
||||
isSaving ||
|
||||
isSavingAll ||
|
||||
disabled
|
||||
}
|
||||
className="flex min-w-36 flex-1 gap-2"
|
||||
>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { TextareaWidget } from "./widgets/TextareaWidget";
|
||||
import { SwitchesWidget } from "./widgets/SwitchesWidget";
|
||||
import { ObjectLabelSwitchesWidget } from "./widgets/ObjectLabelSwitchesWidget";
|
||||
import { AudioLabelSwitchesWidget } from "./widgets/AudioLabelSwitchesWidget";
|
||||
import { ReviewLabelSwitchesWidget } from "./widgets/ReviewLabelSwitchesWidget";
|
||||
import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget";
|
||||
import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget";
|
||||
import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
|
||||
@@ -76,6 +77,7 @@ export const frigateTheme: FrigateTheme = {
|
||||
switches: SwitchesWidget,
|
||||
objectLabels: ObjectLabelSwitchesWidget,
|
||||
audioLabels: AudioLabelSwitchesWidget,
|
||||
reviewLabels: ReviewLabelSwitchesWidget,
|
||||
zoneNames: ZoneSwitchesWidget,
|
||||
timezoneSelect: TimezoneSelectWidget,
|
||||
optionalField: OptionalFieldWidget,
|
||||
|
||||
@@ -1,33 +1,101 @@
|
||||
// Widget that displays an array as a concatenated text string
|
||||
// Widget that displays an array as editable text.
|
||||
// Single-line mode (default): space-separated in an Input.
|
||||
// Multiline mode (options.multiline): one item per line in a Textarea.
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useCallback } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
function arrayToText(value: unknown, multiline: boolean): string {
|
||||
const sep = multiline ? "\n" : " ";
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
return value.join(sep);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function textToArray(text: string, multiline: boolean): string[] {
|
||||
if (text.trim() === "") {
|
||||
return [];
|
||||
}
|
||||
return multiline
|
||||
? text.split("\n").filter((line) => line.trim() !== "")
|
||||
: text.trim().split(/\s+/);
|
||||
}
|
||||
|
||||
export function ArrayAsTextWidget(props: WidgetProps) {
|
||||
const { value, onChange, disabled, readonly, placeholder } = props;
|
||||
const {
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder,
|
||||
schema,
|
||||
options,
|
||||
} = props;
|
||||
|
||||
// Convert array or string to text
|
||||
let textValue = "";
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
textValue = value;
|
||||
} else if (Array.isArray(value) && value.length > 0) {
|
||||
textValue = value.join(" ");
|
||||
}
|
||||
const multiline = !!(options.multiline as boolean);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newText = event.target.value;
|
||||
// Convert space-separated string back to array
|
||||
const newArray = newText.trim() ? newText.trim().split(/\s+/) : [];
|
||||
onChange(newArray);
|
||||
// Local state keeps raw text so newlines aren't stripped mid-typing
|
||||
const [text, setText] = useState(() => arrayToText(value, multiline));
|
||||
|
||||
useEffect(() => {
|
||||
setText(arrayToText(value, multiline));
|
||||
}, [value, multiline]);
|
||||
|
||||
const fieldClassName = multiline
|
||||
? getSizedFieldClassName(options, "md")
|
||||
: undefined;
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const raw = e.target.value;
|
||||
setText(raw);
|
||||
onChange(textToArray(raw, multiline));
|
||||
},
|
||||
[onChange],
|
||||
[onChange, multiline],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
// Clean up: strip empty entries and sync
|
||||
const cleaned = textToArray(e.target.value, multiline);
|
||||
onChange(cleaned);
|
||||
setText(arrayToText(cleaned, multiline));
|
||||
onBlur?.(id, e.target.value);
|
||||
},
|
||||
[id, onChange, onBlur, multiline],
|
||||
);
|
||||
|
||||
if (multiline) {
|
||||
return (
|
||||
<Textarea
|
||||
id={id}
|
||||
className={cn("text-md", fieldClassName)}
|
||||
value={text}
|
||||
disabled={disabled || readonly}
|
||||
rows={(options.rows as number) || 3}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={(e) => onFocus?.(id, e.target.value)}
|
||||
aria-label={schema.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={textValue}
|
||||
onChange={handleChange}
|
||||
value={text}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
readOnly={readonly}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Review Label Switches Widget - For selecting review alert/detection labels via switches.
|
||||
// Combines object labels (from objects.track) and audio labels (from audio.listen)
|
||||
// since review labels can include both types.
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { SwitchesWidget } from "./SwitchesWidget";
|
||||
import type { FormContext } from "./SwitchesWidget";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { JsonObject } from "@/types/configForm";
|
||||
|
||||
function getReviewLabels(context: FormContext): string[] {
|
||||
const labels = new Set<string>();
|
||||
const fullConfig = context.fullConfig as FrigateConfig | undefined;
|
||||
const fullCameraConfig = context.fullCameraConfig;
|
||||
|
||||
// Object labels from tracked objects (camera-level, falling back to global)
|
||||
const trackLabels =
|
||||
fullCameraConfig?.objects?.track ?? fullConfig?.objects?.track;
|
||||
if (Array.isArray(trackLabels)) {
|
||||
trackLabels.forEach((label: string) => labels.add(label));
|
||||
}
|
||||
|
||||
// Audio labels from listen config, only if audio detection is enabled
|
||||
const audioEnabled =
|
||||
fullCameraConfig?.audio?.enabled_in_config ??
|
||||
fullConfig?.audio?.enabled_in_config;
|
||||
if (audioEnabled) {
|
||||
const audioLabels =
|
||||
fullCameraConfig?.audio?.listen ?? fullConfig?.audio?.listen;
|
||||
if (Array.isArray(audioLabels)) {
|
||||
audioLabels.forEach((label: string) => labels.add(label));
|
||||
}
|
||||
}
|
||||
|
||||
// Include any labels already in the review form data (alerts + detections)
|
||||
// so that previously saved labels remain visible even if tracking config changed
|
||||
if (context.formData && typeof context.formData === "object") {
|
||||
const formData = context.formData as JsonObject;
|
||||
for (const section of ["alerts", "detections"] as const) {
|
||||
const sectionData = formData[section];
|
||||
if (sectionData && typeof sectionData === "object") {
|
||||
const sectionLabels = (sectionData as JsonObject).labels;
|
||||
if (Array.isArray(sectionLabels)) {
|
||||
sectionLabels.forEach((label) => {
|
||||
if (typeof label === "string") {
|
||||
labels.add(label);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...labels].sort();
|
||||
}
|
||||
|
||||
function getReviewLabelDisplayName(
|
||||
label: string,
|
||||
context?: FormContext,
|
||||
): string {
|
||||
const fullCameraConfig = context?.fullCameraConfig;
|
||||
const fullConfig = context?.fullConfig as FrigateConfig | undefined;
|
||||
const audioLabels =
|
||||
fullCameraConfig?.audio?.listen ?? fullConfig?.audio?.listen;
|
||||
const isAudio = Array.isArray(audioLabels) && audioLabels.includes(label);
|
||||
return getTranslatedLabel(label, isAudio ? "audio" : "object");
|
||||
}
|
||||
|
||||
export function ReviewLabelSwitchesWidget(props: WidgetProps) {
|
||||
return (
|
||||
<SwitchesWidget
|
||||
{...props}
|
||||
options={{
|
||||
...props.options,
|
||||
getEntities: getReviewLabels,
|
||||
getDisplayLabel: getReviewLabelDisplayName,
|
||||
i18nKey: "reviewLabels",
|
||||
allowCustomEntries: true,
|
||||
listClassName:
|
||||
"relative max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Generic Switches Widget - Reusable component for selecting from any list of entities
|
||||
import { WidgetProps } from "@rjsf/utils";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -43,6 +43,10 @@ export type SwitchesWidgetOptions = {
|
||||
listClassName?: string;
|
||||
/** Enable search input to filter the list */
|
||||
enableSearch?: boolean;
|
||||
/** Allow users to add custom entries not in the predefined list */
|
||||
allowCustomEntries?: boolean;
|
||||
/** i18n key for a hint shown when no entities are selected */
|
||||
emptySelectionHintKey?: string;
|
||||
};
|
||||
|
||||
function normalizeValue(value: unknown): string[] {
|
||||
@@ -122,20 +126,51 @@ export function SwitchesWidget(props: WidgetProps) {
|
||||
[props.options],
|
||||
);
|
||||
|
||||
const allowCustomEntries = useMemo(
|
||||
() => props.options?.allowCustomEntries as boolean | undefined,
|
||||
[props.options],
|
||||
);
|
||||
|
||||
const emptySelectionHintKey = useMemo(
|
||||
() => props.options?.emptySelectionHintKey as string | undefined,
|
||||
[props.options],
|
||||
);
|
||||
|
||||
const selectedEntities = useMemo(() => normalizeValue(value), [value]);
|
||||
const [isOpen, setIsOpen] = useState(selectedEntities.length > 0);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [customEntries, setCustomEntries] = useState<string[]>([]);
|
||||
const [customInput, setCustomInput] = useState("");
|
||||
|
||||
const allEntities = useMemo(() => {
|
||||
if (customEntries.length === 0) {
|
||||
return availableEntities;
|
||||
}
|
||||
const merged = new Set([...availableEntities, ...customEntries]);
|
||||
return [...merged].sort();
|
||||
}, [availableEntities, customEntries]);
|
||||
|
||||
const filteredEntities = useMemo(() => {
|
||||
if (!enableSearch || !searchTerm.trim()) {
|
||||
return availableEntities;
|
||||
return allEntities;
|
||||
}
|
||||
const term = searchTerm.toLowerCase();
|
||||
return availableEntities.filter((entity) => {
|
||||
return allEntities.filter((entity) => {
|
||||
const displayLabel = getDisplayLabel(entity, context);
|
||||
return displayLabel.toLowerCase().includes(term);
|
||||
});
|
||||
}, [availableEntities, searchTerm, enableSearch, getDisplayLabel, context]);
|
||||
}, [allEntities, searchTerm, enableSearch, getDisplayLabel, context]);
|
||||
|
||||
const addCustomEntry = useCallback(() => {
|
||||
const trimmed = customInput.trim().toLowerCase();
|
||||
if (!trimmed || allEntities.includes(trimmed)) {
|
||||
setCustomInput("");
|
||||
return;
|
||||
}
|
||||
setCustomEntries((prev) => [...prev, trimmed]);
|
||||
onChange([...selectedEntities, trimmed]);
|
||||
setCustomInput("");
|
||||
}, [customInput, allEntities, selectedEntities, onChange]);
|
||||
|
||||
const toggleEntity = (entity: string, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
@@ -163,7 +198,7 @@ export function SwitchesWidget(props: WidgetProps) {
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -180,8 +215,14 @@ export function SwitchesWidget(props: WidgetProps) {
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="rounded-lg bg-secondary p-2 pr-0 md:max-w-md">
|
||||
{availableEntities.length === 0 ? (
|
||||
{emptySelectionHintKey && selectedEntities.length === 0 && t && (
|
||||
<div className="mt-0 pb-2 text-sm text-success">
|
||||
{t(emptySelectionHintKey, { ns: namespace })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CollapsibleContent className="rounded-lg border border-input bg-secondary pb-1 pr-0 pt-2 md:max-w-md">
|
||||
{allEntities.length === 0 && !allowCustomEntries ? (
|
||||
<div className="text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -223,6 +264,26 @@ export function SwitchesWidget(props: WidgetProps) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{allowCustomEntries && !disabled && !readonly && (
|
||||
<div className="mx-2 mt-2 pb-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t?.("configForm.addCustomLabel", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Add custom label...",
|
||||
})}
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addCustomEntry();
|
||||
}
|
||||
}}
|
||||
onBlur={addCustomEntry}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
|
||||
@@ -9,15 +9,16 @@ import { toast } from "sonner";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { LuExternalLink, LuInfo, LuMinus, LuPlus } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ANNOTATION_OFFSET_MAX,
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
ANNOTATION_OFFSET_STEP,
|
||||
} from "@/lib/const";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const OFFSET_MIN = -2500;
|
||||
const OFFSET_MAX = 2500;
|
||||
const OFFSET_STEP = 50;
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
@@ -43,7 +44,10 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
(delta: number) => {
|
||||
setAnnotationOffset((prev) => {
|
||||
const next = prev + delta;
|
||||
return Math.max(OFFSET_MIN, Math.min(OFFSET_MAX, next));
|
||||
return Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, next),
|
||||
);
|
||||
});
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
@@ -114,17 +118,17 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="-50ms"
|
||||
onClick={() => stepOffset(-OFFSET_STEP)}
|
||||
disabled={annotationOffset <= OFFSET_MIN}
|
||||
onClick={() => stepOffset(-ANNOTATION_OFFSET_STEP)}
|
||||
disabled={annotationOffset <= ANNOTATION_OFFSET_MIN}
|
||||
>
|
||||
<LuMinus className="size-4" />
|
||||
</Button>
|
||||
<div className="w-full flex-1 landscape:flex">
|
||||
<Slider
|
||||
value={[annotationOffset]}
|
||||
min={OFFSET_MIN}
|
||||
max={OFFSET_MAX}
|
||||
step={OFFSET_STEP}
|
||||
min={ANNOTATION_OFFSET_MIN}
|
||||
max={ANNOTATION_OFFSET_MAX}
|
||||
step={ANNOTATION_OFFSET_STEP}
|
||||
onValueChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
@@ -134,8 +138,8 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="+50ms"
|
||||
onClick={() => stepOffset(OFFSET_STEP)}
|
||||
disabled={annotationOffset >= OFFSET_MAX}
|
||||
onClick={() => stepOffset(ANNOTATION_OFFSET_STEP)}
|
||||
disabled={annotationOffset >= ANNOTATION_OFFSET_MAX}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -13,10 +13,11 @@ import { Slider } from "@/components/ui/slider";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
|
||||
const OFFSET_MIN = -2500;
|
||||
const OFFSET_MAX = 2500;
|
||||
const OFFSET_STEP = 50;
|
||||
import {
|
||||
ANNOTATION_OFFSET_MAX,
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
ANNOTATION_OFFSET_STEP,
|
||||
} from "@/lib/const";
|
||||
|
||||
type AnnotationSettingsPaneProps = {
|
||||
event: Event;
|
||||
@@ -49,7 +50,10 @@ export function AnnotationSettingsPane({
|
||||
(delta: number) => {
|
||||
setAnnotationOffset((prev) => {
|
||||
const next = prev + delta;
|
||||
return Math.max(OFFSET_MIN, Math.min(OFFSET_MAX, next));
|
||||
return Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, next),
|
||||
);
|
||||
});
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
@@ -128,16 +132,16 @@ export function AnnotationSettingsPane({
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="-50ms"
|
||||
onClick={() => stepOffset(-OFFSET_STEP)}
|
||||
disabled={annotationOffset <= OFFSET_MIN}
|
||||
onClick={() => stepOffset(-ANNOTATION_OFFSET_STEP)}
|
||||
disabled={annotationOffset <= ANNOTATION_OFFSET_MIN}
|
||||
>
|
||||
<LuMinus className="size-4" />
|
||||
</Button>
|
||||
<Slider
|
||||
value={[annotationOffset]}
|
||||
min={OFFSET_MIN}
|
||||
max={OFFSET_MAX}
|
||||
step={OFFSET_STEP}
|
||||
min={ANNOTATION_OFFSET_MIN}
|
||||
max={ANNOTATION_OFFSET_MAX}
|
||||
step={ANNOTATION_OFFSET_STEP}
|
||||
onValueChange={handleSliderChange}
|
||||
className="flex-1"
|
||||
/>
|
||||
@@ -147,8 +151,8 @@ export function AnnotationSettingsPane({
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="+50ms"
|
||||
onClick={() => stepOffset(OFFSET_STEP)}
|
||||
disabled={annotationOffset >= OFFSET_MAX}
|
||||
onClick={() => stepOffset(ANNOTATION_OFFSET_STEP)}
|
||||
disabled={annotationOffset >= ANNOTATION_OFFSET_MAX}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/** ONNX embedding models that require local model downloads. GenAI providers are not in this list. */
|
||||
export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const;
|
||||
|
||||
export const ANNOTATION_OFFSET_MIN = -10000;
|
||||
export const ANNOTATION_OFFSET_MAX = 5000;
|
||||
export const ANNOTATION_OFFSET_STEP = 50;
|
||||
|
||||
export const supportedLanguageKeys = [
|
||||
"en",
|
||||
"es",
|
||||
|
||||
@@ -724,6 +724,7 @@ export default function Settings() {
|
||||
|
||||
// Save All state
|
||||
const [isSavingAll, setIsSavingAll] = useState(false);
|
||||
const [isAnySectionSaving, setIsAnySectionSaving] = useState(false);
|
||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||
const { send: sendRestart } = useRestart();
|
||||
const { data: fullSchema } = useSWR<RJSFSchema>("config/schema.json");
|
||||
@@ -1299,6 +1300,10 @@ export default function Settings() {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSectionSavingChange = useCallback((saving: boolean) => {
|
||||
setIsAnySectionSaving(saving);
|
||||
}, []);
|
||||
|
||||
// The active profile being edited for the selected camera
|
||||
const activeEditingProfile = selectedCamera
|
||||
? (editingProfile[selectedCamera] ?? null)
|
||||
@@ -1508,7 +1513,7 @@ export default function Settings() {
|
||||
onClick={handleUndoAll}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isSavingAll}
|
||||
disabled={isSavingAll || isAnySectionSaving}
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
>
|
||||
{t("button.undoAll", {
|
||||
@@ -1520,7 +1525,11 @@ export default function Settings() {
|
||||
onClick={handleSaveAll}
|
||||
variant="select"
|
||||
size="sm"
|
||||
disabled={isSavingAll || hasPendingValidationErrors}
|
||||
disabled={
|
||||
isSavingAll ||
|
||||
isAnySectionSaving ||
|
||||
hasPendingValidationErrors
|
||||
}
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
>
|
||||
{isSavingAll ? (
|
||||
@@ -1606,6 +1615,8 @@ export default function Settings() {
|
||||
}
|
||||
profilesUIEnabled={profilesUIEnabled}
|
||||
setProfilesUIEnabled={setProfilesUIEnabled}
|
||||
isSavingAll={isSavingAll}
|
||||
onSectionSavingChange={handleSectionSavingChange}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
@@ -1674,7 +1685,7 @@ export default function Settings() {
|
||||
onClick={handleUndoAll}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isSavingAll}
|
||||
disabled={isSavingAll || isAnySectionSaving}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
{t("button.undoAll", {
|
||||
@@ -1686,7 +1697,11 @@ export default function Settings() {
|
||||
variant="select"
|
||||
size="sm"
|
||||
onClick={handleSaveAll}
|
||||
disabled={isSavingAll || hasPendingValidationErrors}
|
||||
disabled={
|
||||
isSavingAll ||
|
||||
isAnySectionSaving ||
|
||||
hasPendingValidationErrors
|
||||
}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSavingAll ? (
|
||||
@@ -1843,6 +1858,8 @@ export default function Settings() {
|
||||
onDeleteProfileSection={handleDeleteProfileForCurrentSection}
|
||||
profilesUIEnabled={profilesUIEnabled}
|
||||
setProfilesUIEnabled={setProfilesUIEnabled}
|
||||
isSavingAll={isSavingAll}
|
||||
onSectionSavingChange={handleSectionSavingChange}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -542,6 +542,20 @@ export default function ProfilesView({
|
||||
<CollapsibleContent>
|
||||
{cameras.length > 0 ? (
|
||||
<div className="mx-4 mb-3 ml-11 border-l border-border/50 pl-4">
|
||||
<div className="flex items-baseline gap-3 border-b border-border/30 pb-1.5">
|
||||
<span className="min-w-[120px] shrink-0 text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t("profiles.columnCamera", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Camera",
|
||||
})}
|
||||
</span>
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t("profiles.columnOverrides", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Profile Overrides",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{cameras.map((camera) => {
|
||||
const sections = cameraData[camera];
|
||||
return (
|
||||
|
||||
@@ -39,6 +39,10 @@ export type SettingsPageProps = {
|
||||
onDeleteProfileSection?: (profileName: string) => void;
|
||||
profilesUIEnabled?: boolean;
|
||||
setProfilesUIEnabled?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
/** Whether a SaveAll operation is in progress */
|
||||
isSavingAll?: boolean;
|
||||
/** Callback when a section's saving state changes */
|
||||
onSectionSavingChange?: (isSaving: boolean) => void;
|
||||
};
|
||||
|
||||
export type SectionStatus = {
|
||||
@@ -73,6 +77,8 @@ export function SingleSectionPage({
|
||||
onPendingDataChange,
|
||||
profileState,
|
||||
onDeleteProfileSection,
|
||||
isSavingAll,
|
||||
onSectionSavingChange,
|
||||
}: SingleSectionPageProps) {
|
||||
const sectionNamespace =
|
||||
level === "camera" ? "config/cameras" : "config/global";
|
||||
@@ -95,9 +101,10 @@ export function SingleSectionPage({
|
||||
? getLocaleDocUrl(resolvedSectionConfig.sectionDocs)
|
||||
: undefined;
|
||||
|
||||
const currentEditingProfile = selectedCamera
|
||||
? (profileState?.editingProfile[selectedCamera] ?? null)
|
||||
: null;
|
||||
const currentEditingProfile =
|
||||
level === "camera" && selectedCamera
|
||||
? (profileState?.editingProfile[selectedCamera] ?? null)
|
||||
: null;
|
||||
|
||||
const profileColor = useMemo(
|
||||
() =>
|
||||
@@ -273,6 +280,8 @@ export function SingleSectionPage({
|
||||
onDeleteProfileSection={
|
||||
currentEditingProfile ? handleDeleteProfileSection : undefined
|
||||
}
|
||||
isSavingAll={isSavingAll}
|
||||
onSavingChange={onSectionSavingChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user