Compare commits

...
Author SHA1 Message Date
dependabot[bot]andGitHub b1769dac5e Bump node-forge from 1.3.3 to 1.4.0 in /docs
Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.3 to 1.4.0.
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.3...v1.4.0)

---
updated-dependencies:
- dependency-name: node-forge
  dependency-version: 1.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-29 02:29:54 +00:00
Josh HawkinsandGitHub c35cee2d2f Review labels widget (#22664)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* add review labels widget

* register widget and add to review section

* i18n

* add border to switches widget

* padding tweaks

* don't show audio labels if audio is not enabled

* add docs links

* ability to add custom labels to review

* add hint for empty selection in review labels and SwitchesWidget

* language consistency
2026-03-27 08:45:50 -06:00
Nicolas MowenandGitHub 1a01513223 Improve chat features (#22663)
* Improve notification messaging

* Improve wake behavior when a zone is not specified

* Fix prompt ordering for generate calls
2026-03-27 08:48:50 -05:00
GuoQing LiuandGitHub 06ad72860c feat: add axera npu load (#22662) 2026-03-27 05:07:07 -06:00
Nicolas MowenandGitHub 03d0139497 More mypy cleanup (#22658)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / ARM Extra Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
* Halfway point for fixing data processing

* Fix mixin types missing

* Cleanup LPR mypy

* Cleanup audio mypy

* Cleanup bird mypy

* Cleanup mypy for custom classification

* remove whisper

* Fix DB typing

* Cleanup events mypy

* Clenaup

* fix type evaluation

* Cleanup

* Fix broken imports
2026-03-26 12:54:12 -06:00
Josh HawkinsandGitHub 4772e6a2ab Tweaks (#22656)
* tweak language

* show validation errors in json response

* fix export hwaccel args field in UI

* increase annotation offset consts

* fix save button race conditions, add reset spinner, and fix enrichments profile leak

- Disable both Save and SaveAll buttons while either operation is in progress so users cannot trigger concurrent saves
- Show activity indicator on Reset to Default/Global button during the API call
- Enrichments panes (semantic search, genai, face recognition) now always show base config fields regardless of profile selection in the header dropdown

* fix genai additional_concerns validation error with textarea array widget

The additional_concerns field is list[str] in the backend but was using the textarea widget which produces a string value, causing validation errors.
Created a TextareaArrayWidget that converts between array (one item per line) and textarea display, and switched additional_concerns to use it

* populate and sort global audio filters for all audio labels

* add column labels in profiles view

* enforce a minimum value of 2 for min_initialized

* reuse widget and refactor for multiline

* fix

* change record copy preset to transcode audio to aac
2026-03-26 13:47:24 -05:00
Ivan ShvedunovandGitHub 909b40ba96 Fix export deadlock by replacing preexec_fn with nice command (#22641)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
subprocess.run() with preexec_fn forces Python to use fork() instead
of posix_spawn(). In Frigate's main process (75+ threads), fork()
creates a child that inherits locked mutexes from other threads. The
child may deadlocks e.g. on a pysqlite3 mutex before it can exec()
ffmpeg.

Replace preexec_fn=lower_priority (which calls os.nice(19)) with
prefixing the ffmpeg command with "nice -n 19", achieving the same
priority reduction without requiring preexec_fn. This allows Python
to use posix_spawn() which is safe in multithreaded processes.

Fixes both the primary export path and the CPU fallback retry path.
2026-03-26 05:34:28 -06:00
Nicolas MowenandGitHub 0cf9d7d5b1 Inverse mypy and more mypy fixes (#22645)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* organize

* Improve storage mypy

* Cleanup timeline mypy

* Cleanup recording mypy

* Improve review mypy

* Add review mypy

* Inverse mypy

* Fix ffmpeg presets

* fix template thing

* Cleanup camera
2026-03-25 19:30:59 -05:00
Josh HawkinsandGitHub c0124938b3 Tweaks (#22630)
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
* fix stage overlay size

* add audio filter config and load audio labels

* remove add button from object and audio labels in settings

* tests

* update classification docs

* tweak wording

* don't require restart for timestamp_style changes

* add optional i18n prefix for select widgets

* use i18n enum prefix for timestamp position

* add i18n for all presets
2026-03-25 13:14:32 -06:00
Nicolas MowenandGitHub b1c410bc3e Optimize more mypy classes (#22637)
* Cleanup motion mypy

* Cleanup object detection mypy

* Update output mypy

* Cleanup
2026-03-25 12:53:19 -06:00
Nicolas MowenandGitHub 80c4ce2b5d Increase mypy coverage and fixes (#22632) 2026-03-25 09:28:48 -06:00
89 changed files with 1692 additions and 864 deletions
+1 -1
View File
@@ -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)) {
@@ -102,8 +102,19 @@ If examples for some of your classes do not appear in the grid, you can continue
### Improving the Model
:::tip Diversity matters far more than volume
Selecting dozens of nearly identical images is one of the fastest ways to degrade model performance. MobileNetV2 can overfit quickly when trained on homogeneous data — the model learns what *that exact moment* looked like rather than what actually defines the class. **This is why Frigate does not implement bulk training in the UI.**
For more detail, see [Frigate Tip: Best Practices for Training Face and Custom Classification Models](https://github.com/blakeblackshear/frigate/discussions/21374).
:::
- **Start small and iterate**: Begin with a small, representative set of images per class. Models often begin working well with surprisingly few examples and improve naturally over time.
- **Favor hard examples**: When images appear in the Recent Classifications tab, prioritize images scoring below 90100% or those captured under new lighting, weather, or distance conditions.
- **Avoid bulk training similar images**: Training large batches of images that already score 100% (or close) adds little new information and increases the risk of overfitting.
- **The wizard is just the starting point**: You dont need to find and label every class upfront. Missing classes will naturally appear in Recent Classifications, and those images tend to be more valuable because they represent new conditions and edge cases.
- **Problem framing**: Keep classes visually distinct and relevant to the chosen object types.
- **Data collection**: Use the models Recent Classification tab to gather balanced examples across times of day, weather, and distances.
- **Preprocessing**: Ensure examples reflect object crops similar to Frigates boxes; keep the subject centered.
- **Labels**: Keep label names short and consistent; include a `none` class if you plan to ignore uncertain predictions for sub labels.
- **Threshold**: Tune `threshold` per model to reduce false assignments. Start at `0.8` and adjust based on validation.
@@ -70,10 +70,21 @@ Once some images are assigned, training will begin automatically.
### Improving the Model
:::tip Diversity matters far more than volume
Selecting dozens of nearly identical images is one of the fastest ways to degrade model performance. MobileNetV2 can overfit quickly when trained on homogeneous data — the model learns what *that exact moment* looked like rather than what actually defines the state. This often leads to models that work perfectly under the original conditions but become unstable when day turns to night, weather changes, or seasonal lighting shifts. **This is why Frigate does not implement bulk training in the UI.**
For more detail, see [Frigate Tip: Best Practices for Training Face and Custom Classification Models](https://github.com/blakeblackshear/frigate/discussions/21374).
:::
- **Start small and iterate**: Begin with a small, representative set of images per class. Models often begin working well with surprisingly few examples and improve naturally over time.
- **Problem framing**: Keep classes visually distinct and state-focused (e.g., `open`, `closed`, `unknown`). Avoid combining object identity with state in a single model unless necessary.
- **Data collection**: Use the model's Recent Classifications tab to gather balanced examples across times of day and weather.
- **When to train**: Focus on cases where the model is entirely incorrect or flips between states when it should not. There's no need to train additional images when the model is already working consistently.
- **Selecting training images**: Images scoring below 100% due to new conditions (e.g., first snow of the year, seasonal changes) or variations (e.g., objects temporarily in view, insects at night) are good candidates for training, as they represent scenarios different from the default state. Training these lower-scoring images that differ from existing training data helps prevent overfitting. Avoid training large quantities of images that look very similar, especially if they already score 100% as this can lead to overfitting.
- **Favor hard examples**: When images appear in the Recent Classifications tab, prioritize images scoring below 90100% or those captured under new conditions (e.g., first snow of the year, seasonal changes, objects temporarily in view, insects at night). These represent scenarios different from the default state and help prevent overfitting.
- **Avoid bulk training similar images**: Training large batches of images that already score 100% (or close) adds little new information and increases the risk of overfitting.
- **The wizard is just the starting point**: You don't need to find and label every state upfront. Missing states will naturally appear in Recent Classifications, and those images tend to be more valuable because they represent new conditions and edge cases.
## Debugging Classification Models
+3 -3
View File
@@ -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"
+28
View File
@@ -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
View File
@@ -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()
+8 -2
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -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)
+2 -2
View File
@@ -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}")
+1
View File
@@ -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,
+1 -1
View File
@@ -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,
+3
View File
@@ -32,6 +32,7 @@ class CameraConfigUpdateEnum(str, Enum):
face_recognition = "face_recognition"
lpr = "lpr"
snapshots = "snapshots"
timestamp_style = "timestamp_style"
zones = "zones"
@@ -133,6 +134,8 @@ class CameraConfigUpdateSubscriber:
config.snapshots = updated_config
elif update_type == CameraConfigUpdateEnum.onvif:
config.onvif = updated_config
elif update_type == CameraConfigUpdateEnum.timestamp_style:
config.timestamp_style = updated_config
elif update_type == CameraConfigUpdateEnum.zones:
config.zones = updated_config
+29 -3
View File
@@ -25,6 +25,7 @@ from frigate.plus import PlusApi
from frigate.util.builtin import (
deep_merge,
get_ffmpeg_arg_list,
load_labels,
)
from frigate.util.config import (
CURRENT_CONFIG_VERSION,
@@ -40,7 +41,7 @@ from frigate.util.services import auto_detect_hwaccel
from .auth import AuthConfig
from .base import FrigateBaseModel
from .camera import CameraConfig, CameraLiveConfig
from .camera.audio import AudioConfig
from .camera.audio import AudioConfig, AudioFilterConfig
from .camera.birdseye import BirdseyeConfig
from .camera.detect import DetectConfig
from .camera.ffmpeg import FfmpegConfig
@@ -473,7 +474,7 @@ class FrigateConfig(FrigateBaseModel):
live: CameraLiveConfig = Field(
default_factory=CameraLiveConfig,
title="Live playback",
description="Settings used by the Web UI to control live stream resolution and quality.",
description="Settings to control the jsmpeg live stream resolution and quality. This does not affect restreamed cameras that use go2rtc for live view.",
)
motion: Optional[MotionConfig] = Field(
default=None,
@@ -613,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={
@@ -748,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
@@ -791,6 +807,16 @@ class FrigateConfig(FrigateBaseModel):
camera_config.review.genai.enabled
)
if camera_config.audio.filters is None:
camera_config.audio.filters = {}
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()
+30 -22
View File
@@ -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
+2 -2
View File
@@ -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
+16 -10
View File
@@ -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,
+19 -15
View File
@@ -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)
+21 -19
View File
@@ -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
+7 -4
View File
@@ -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:
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -317,7 +317,7 @@ class MemryXDetector(DetectionApi):
f"Failed to remove downloaded zip {zip_path}: {e}"
)
def send_input(self, connection_id, tensor_input: np.ndarray):
def send_input(self, connection_id, tensor_input: np.ndarray) -> None:
"""Pre-process (if needed) and send frame to MemryX input queue"""
if tensor_input is None:
raise ValueError("[send_input] No image data provided for inference")
+35 -20
View File
@@ -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
View File
@@ -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")
+21 -10
View File
@@ -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):
+22 -8
View File
@@ -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
+6 -6
View File
@@ -5,7 +5,7 @@ import importlib
import logging
import os
import re
from typing import Any, Optional
from typing import Any, Callable, Optional
import numpy as np
from playhouse.shortcuts import model_to_dict
@@ -31,10 +31,10 @@ __all__ = [
PROVIDERS = {}
def register_genai_provider(key: GenAIProviderEnum):
def register_genai_provider(key: GenAIProviderEnum) -> Callable:
"""Register a GenAI provider."""
def decorator(cls):
def decorator(cls: type) -> type:
PROVIDERS[key] = cls
return cls
@@ -297,7 +297,7 @@ Guidelines:
"""Generate a description for the frame."""
try:
prompt = camera_config.objects.genai.object_prompts.get(
event.label,
str(event.label),
camera_config.objects.genai.prompt,
).format(**model_to_dict(event))
except KeyError as e:
@@ -307,7 +307,7 @@ Guidelines:
logger.debug(f"Sending images to genai provider with prompt: {prompt}")
return self._send(prompt, thumbnails)
def _init_provider(self):
def _init_provider(self) -> Any:
"""Initialize the client."""
return None
@@ -402,7 +402,7 @@ Guidelines:
}
def load_providers():
def load_providers() -> None:
package_dir = os.path.dirname(__file__)
for filename in os.listdir(package_dir):
if filename.endswith(".py") and filename != "__init__.py":
+7 -7
View File
@@ -3,7 +3,7 @@
import base64
import json
import logging
from typing import Any, Optional
from typing import Any, AsyncGenerator, Optional
from urllib.parse import parse_qs, urlparse
from openai import AzureOpenAI
@@ -20,10 +20,10 @@ class OpenAIClient(GenAIClient):
provider: AzureOpenAI
def _init_provider(self):
def _init_provider(self) -> AzureOpenAI | None:
"""Initialize the client."""
try:
parsed_url = urlparse(self.genai_config.base_url)
parsed_url = urlparse(self.genai_config.base_url or "")
query_params = parse_qs(parsed_url.query)
api_version = query_params.get("api-version", [None])[0]
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
@@ -79,7 +79,7 @@ class OpenAIClient(GenAIClient):
logger.warning("Azure OpenAI returned an error: %s", str(e))
return None
if len(result.choices) > 0:
return result.choices[0].message.content.strip()
return str(result.choices[0].message.content.strip())
return None
def get_context_size(self) -> int:
@@ -113,7 +113,7 @@ class OpenAIClient(GenAIClient):
if openai_tool_choice is not None:
request_params["tool_choice"] = openai_tool_choice
result = self.provider.chat.completions.create(**request_params)
result = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
if (
result is None
@@ -181,7 +181,7 @@ class OpenAIClient(GenAIClient):
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
):
) -> AsyncGenerator[tuple[str, Any], None]:
"""
Stream chat with tools; yields content deltas then final message.
@@ -214,7 +214,7 @@ class OpenAIClient(GenAIClient):
tool_calls_by_index: dict[int, dict[str, Any]] = {}
finish_reason = "stop"
stream = self.provider.chat.completions.create(**request_params)
stream = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
for chunk in stream:
if not chunk or not chunk.choices:
+47 -24
View File
@@ -2,10 +2,11 @@
import json
import logging
from typing import Any, Optional
from typing import Any, AsyncGenerator, Optional
from google import genai
from google.genai import errors, types
from google.genai.types import FunctionCallingConfigMode
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
@@ -19,10 +20,10 @@ class GeminiClient(GenAIClient):
provider: genai.Client
def _init_provider(self):
def _init_provider(self) -> genai.Client:
"""Initialize the client."""
# Merge provider_options into HttpOptions
http_options_dict = {
http_options_dict: dict[str, Any] = {
"timeout": int(self.timeout * 1000), # requires milliseconds
"retry_options": types.HttpRetryOptions(
attempts=3,
@@ -49,12 +50,12 @@ 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 = {"candidate_count": 1}
generation_config_dict: dict[str, Any] = {"candidate_count": 1}
generation_config_dict.update(self.genai_config.runtime_options)
if response_format and response_format.get("type") == "json_schema":
@@ -65,7 +66,7 @@ class GeminiClient(GenAIClient):
response = self.provider.models.generate_content(
model=self.genai_config.model,
contents=contents,
contents=contents, # type: ignore[arg-type]
config=types.GenerateContentConfig(
**generation_config_dict,
),
@@ -78,6 +79,8 @@ class GeminiClient(GenAIClient):
return None
try:
if response.text is None:
return None
description = response.text.strip()
except (ValueError, AttributeError):
# No description was generated
@@ -102,7 +105,7 @@ class GeminiClient(GenAIClient):
"""
try:
# Convert messages to Gemini format
gemini_messages = []
gemini_messages: list[types.Content] = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
@@ -110,7 +113,11 @@ class GeminiClient(GenAIClient):
# Map roles to Gemini format
if role == "system":
# Gemini doesn't have system role, prepend to first user message
if gemini_messages and gemini_messages[0].role == "user":
if (
gemini_messages
and gemini_messages[0].role == "user"
and gemini_messages[0].parts
):
gemini_messages[0].parts[
0
].text = f"{content}\n\n{gemini_messages[0].parts[0].text}"
@@ -136,7 +143,7 @@ class GeminiClient(GenAIClient):
types.Content(
role="function",
parts=[
types.Part.from_function_response(function_response)
types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type]
],
)
)
@@ -171,19 +178,25 @@ class GeminiClient(GenAIClient):
if tool_choice:
if tool_choice == "none":
tool_config = types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(mode="NONE")
function_calling_config=types.FunctionCallingConfig(
mode=FunctionCallingConfigMode.NONE
)
)
elif tool_choice == "auto":
tool_config = types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(mode="AUTO")
function_calling_config=types.FunctionCallingConfig(
mode=FunctionCallingConfigMode.AUTO
)
)
elif tool_choice == "required":
tool_config = types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(mode="ANY")
function_calling_config=types.FunctionCallingConfig(
mode=FunctionCallingConfigMode.ANY
)
)
# Build request config
config_params = {"candidate_count": 1}
config_params: dict[str, Any] = {"candidate_count": 1}
if gemini_tools:
config_params["tools"] = gemini_tools
@@ -197,7 +210,7 @@ class GeminiClient(GenAIClient):
response = self.provider.models.generate_content(
model=self.genai_config.model,
contents=gemini_messages,
contents=gemini_messages, # type: ignore[arg-type]
config=types.GenerateContentConfig(**config_params),
)
@@ -291,7 +304,7 @@ class GeminiClient(GenAIClient):
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
):
) -> AsyncGenerator[tuple[str, Any], None]:
"""
Stream chat with tools; yields content deltas then final message.
@@ -299,7 +312,7 @@ class GeminiClient(GenAIClient):
"""
try:
# Convert messages to Gemini format
gemini_messages = []
gemini_messages: list[types.Content] = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
@@ -307,7 +320,11 @@ class GeminiClient(GenAIClient):
# Map roles to Gemini format
if role == "system":
# Gemini doesn't have system role, prepend to first user message
if gemini_messages and gemini_messages[0].role == "user":
if (
gemini_messages
and gemini_messages[0].role == "user"
and gemini_messages[0].parts
):
gemini_messages[0].parts[
0
].text = f"{content}\n\n{gemini_messages[0].parts[0].text}"
@@ -333,7 +350,7 @@ class GeminiClient(GenAIClient):
types.Content(
role="function",
parts=[
types.Part.from_function_response(function_response)
types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type]
],
)
)
@@ -368,19 +385,25 @@ class GeminiClient(GenAIClient):
if tool_choice:
if tool_choice == "none":
tool_config = types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(mode="NONE")
function_calling_config=types.FunctionCallingConfig(
mode=FunctionCallingConfigMode.NONE
)
)
elif tool_choice == "auto":
tool_config = types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(mode="AUTO")
function_calling_config=types.FunctionCallingConfig(
mode=FunctionCallingConfigMode.AUTO
)
)
elif tool_choice == "required":
tool_config = types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(mode="ANY")
function_calling_config=types.FunctionCallingConfig(
mode=FunctionCallingConfigMode.ANY
)
)
# Build request config
config_params = {"candidate_count": 1}
config_params: dict[str, Any] = {"candidate_count": 1}
if gemini_tools:
config_params["tools"] = gemini_tools
@@ -399,7 +422,7 @@ class GeminiClient(GenAIClient):
stream = await self.provider.aio.models.generate_content_stream(
model=self.genai_config.model,
contents=gemini_messages,
contents=gemini_messages, # type: ignore[arg-type]
config=types.GenerateContentConfig(**config_params),
)
+8 -8
View File
@@ -4,7 +4,7 @@ import base64
import io
import json
import logging
from typing import Any, Optional
from typing import Any, AsyncGenerator, Optional
import httpx
import numpy as np
@@ -23,7 +23,7 @@ def _to_jpeg(img_bytes: bytes) -> bytes | None:
try:
img = Image.open(io.BytesIO(img_bytes))
if img.mode != "RGB":
img = img.convert("RGB")
img = img.convert("RGB") # type: ignore[assignment]
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
return buf.getvalue()
@@ -36,10 +36,10 @@ def _to_jpeg(img_bytes: bytes) -> bytes | None:
class LlamaCppClient(GenAIClient):
"""Generative AI client for Frigate using llama.cpp server."""
provider: str # base_url
provider: str | None # base_url
provider_options: dict[str, Any]
def _init_provider(self):
def _init_provider(self) -> str | None:
"""Initialize the client."""
self.provider_options = {
**self.genai_config.provider_options,
@@ -75,7 +75,7 @@ class LlamaCppClient(GenAIClient):
content.append(
{
"type": "image_url",
"image_url": {
"image_url": { # type: ignore[dict-item]
"url": f"data:image/jpeg;base64,{encoded_image}",
},
}
@@ -111,7 +111,7 @@ class LlamaCppClient(GenAIClient):
):
choice = result["choices"][0]
if "message" in choice and "content" in choice["message"]:
return choice["message"]["content"].strip()
return str(choice["message"]["content"].strip())
return None
except Exception as e:
logger.warning("llama.cpp returned an error: %s", str(e))
@@ -229,7 +229,7 @@ class LlamaCppClient(GenAIClient):
content.append(
{
"prompt_string": "<__media__>\n",
"multimodal_data": [encoded],
"multimodal_data": [encoded], # type: ignore[dict-item]
}
)
@@ -367,7 +367,7 @@ class LlamaCppClient(GenAIClient):
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
):
) -> AsyncGenerator[tuple[str, Any], None]:
"""Stream chat with tools via OpenAI-compatible streaming API."""
if self.provider is None:
logger.warning(
+6 -6
View File
@@ -2,7 +2,7 @@
import json
import logging
from typing import Any, Optional
from typing import Any, AsyncGenerator, Optional
from httpx import RemoteProtocolError, TimeoutException
from ollama import AsyncClient as OllamaAsyncClient
@@ -28,10 +28,10 @@ class OllamaClient(GenAIClient):
},
}
provider: ApiClient
provider: ApiClient | None
provider_options: dict[str, Any]
def _init_provider(self):
def _init_provider(self) -> ApiClient | None:
"""Initialize the client."""
self.provider_options = {
**self.LOCAL_OPTIMIZED_OPTIONS,
@@ -73,7 +73,7 @@ class OllamaClient(GenAIClient):
"exclusiveMinimum",
"exclusiveMaximum",
}
result = {}
result: dict[str, Any] = {}
for key, value in schema.items():
if not _is_properties and key in STRIP_KEYS:
continue
@@ -122,7 +122,7 @@ class OllamaClient(GenAIClient):
logger.debug(
f"Ollama tokens used: eval_count={result.get('eval_count')}, prompt_eval_count={result.get('prompt_eval_count')}"
)
return result["response"].strip()
return str(result["response"]).strip()
except (
TimeoutException,
ResponseError,
@@ -263,7 +263,7 @@ class OllamaClient(GenAIClient):
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
):
) -> AsyncGenerator[tuple[str, Any], None]:
"""Stream chat with tools; yields content deltas then final message.
When tools are provided, Ollama streaming does not include tool_calls
+12 -13
View File
@@ -3,7 +3,7 @@
import base64
import json
import logging
from typing import Any, Optional
from typing import Any, AsyncGenerator, Optional
from httpx import TimeoutException
from openai import OpenAI
@@ -21,7 +21,7 @@ class OpenAIClient(GenAIClient):
provider: OpenAI
context_size: Optional[int] = None
def _init_provider(self):
def _init_provider(self) -> OpenAI:
"""Initialize the client."""
# Extract context_size from provider_options as it's not a valid OpenAI client parameter
# It will be used in get_context_size() instead
@@ -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,
@@ -81,7 +80,7 @@ class OpenAIClient(GenAIClient):
and hasattr(result, "choices")
and len(result.choices) > 0
):
return result.choices[0].message.content.strip()
return str(result.choices[0].message.content.strip())
return None
except (TimeoutException, Exception) as e:
logger.warning("OpenAI returned an error: %s", str(e))
@@ -171,7 +170,7 @@ class OpenAIClient(GenAIClient):
}
request_params.update(provider_opts)
result = self.provider.chat.completions.create(**request_params)
result = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
if (
result is None
@@ -245,7 +244,7 @@ class OpenAIClient(GenAIClient):
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "auto",
):
) -> AsyncGenerator[tuple[str, Any], None]:
"""
Stream chat with tools; yields content deltas then final message.
@@ -287,7 +286,7 @@ class OpenAIClient(GenAIClient):
tool_calls_by_index: dict[int, dict[str, Any]] = {}
finish_reason = "stop"
stream = self.provider.chat.completions.create(**request_params)
stream = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
for chunk in stream:
if not chunk or not chunk.choices:
+4 -4
View File
@@ -5,7 +5,7 @@ import os
import threading
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from typing import Optional, cast
from frigate.comms.inter_process import InterProcessRequestor
from frigate.const import CONFIG_DIR, UPDATE_JOB_STATE
@@ -122,7 +122,7 @@ def start_media_sync_job(
if job_is_running("media_sync"):
current = get_current_job("media_sync")
logger.warning(
f"Media sync job {current.id} is already running. Rejecting new request."
f"Media sync job {current.id if current else 'unknown'} is already running. Rejecting new request."
)
return None
@@ -146,9 +146,9 @@ def start_media_sync_job(
def get_current_media_sync_job() -> Optional[MediaSyncJob]:
"""Get the current running/queued media sync job, if any."""
return get_current_job("media_sync")
return cast(Optional[MediaSyncJob], get_current_job("media_sync"))
def get_media_sync_job_by_id(job_id: str) -> Optional[MediaSyncJob]:
"""Get media sync job by ID. Currently only tracks the current job."""
return get_job_by_id("media_sync", job_id)
return cast(Optional[MediaSyncJob], get_job_by_id("media_sync", job_id))
+24 -15
View File
@@ -6,7 +6,7 @@ import threading
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
from dataclasses import asdict, dataclass, field
from datetime import datetime
from typing import Any, Optional
from typing import Any, Optional, cast
import cv2
import numpy as np
@@ -96,7 +96,7 @@ def create_polygon_mask(
dtype=np.int32,
)
mask = np.zeros((frame_height, frame_width), dtype=np.uint8)
cv2.fillPoly(mask, [motion_points], 255)
cv2.fillPoly(mask, [motion_points], (255,))
return mask
@@ -116,7 +116,7 @@ def compute_roi_bbox_normalized(
def heatmap_overlaps_roi(
heatmap: dict[str, int], roi_bbox: tuple[float, float, float, float]
heatmap: object, roi_bbox: tuple[float, float, float, float]
) -> bool:
"""Check if a sparse motion heatmap has any overlap with the ROI bounding box.
@@ -155,9 +155,9 @@ def segment_passes_activity_gate(recording: Recordings) -> bool:
Returns True if any of motion, objects, or regions is non-zero/non-null.
Returns True if all are null (old segments without data).
"""
motion = recording.motion
objects = recording.objects
regions = recording.regions
motion: Any = recording.motion
objects: Any = recording.objects
regions: Any = recording.regions
# Old segments without metadata - pass through (conservative)
if motion is None and objects is None and regions is None:
@@ -278,6 +278,9 @@ class MotionSearchRunner(threading.Thread):
frame_width = camera_config.detect.width
frame_height = camera_config.detect.height
if frame_width is None or frame_height is None:
raise ValueError(f"Camera {camera_name} detect dimensions not configured")
# Create polygon mask
polygon_mask = create_polygon_mask(
self.job.polygon_points, frame_width, frame_height
@@ -415,11 +418,13 @@ class MotionSearchRunner(threading.Thread):
if self._should_stop():
break
rec_start: float = recording.start_time # type: ignore[assignment]
rec_end: float = recording.end_time # type: ignore[assignment]
future = executor.submit(
self._process_recording_for_motion,
recording.path,
recording.start_time,
recording.end_time,
str(recording.path),
rec_start,
rec_end,
self.job.start_time_range,
self.job.end_time_range,
polygon_mask,
@@ -524,10 +529,12 @@ class MotionSearchRunner(threading.Thread):
break
try:
rec_start: float = recording.start_time # type: ignore[assignment]
rec_end: float = recording.end_time # type: ignore[assignment]
results, frames = self._process_recording_for_motion(
recording.path,
recording.start_time,
recording.end_time,
str(recording.path),
rec_start,
rec_end,
self.job.start_time_range,
self.job.end_time_range,
polygon_mask,
@@ -672,7 +679,9 @@ class MotionSearchRunner(threading.Thread):
# Handle frame dimension changes
if gray.shape != polygon_mask.shape:
resized_mask = cv2.resize(
polygon_mask, (gray.shape[1], gray.shape[0]), cv2.INTER_NEAREST
polygon_mask,
(gray.shape[1], gray.shape[0]),
interpolation=cv2.INTER_NEAREST,
)
current_bbox = cv2.boundingRect(resized_mask)
else:
@@ -698,7 +707,7 @@ class MotionSearchRunner(threading.Thread):
)
if prev_frame_gray is not None:
diff = cv2.absdiff(prev_frame_gray, masked_gray)
diff = cv2.absdiff(prev_frame_gray, masked_gray) # type: ignore[unreachable]
diff_blurred = cv2.GaussianBlur(diff, (3, 3), 0)
_, thresh = cv2.threshold(
diff_blurred, threshold, 255, cv2.THRESH_BINARY
@@ -825,7 +834,7 @@ def get_motion_search_job(job_id: str) -> Optional[MotionSearchJob]:
if job_entry:
return job_entry[0]
# Check completed jobs via manager
return get_job_by_id("motion_search", job_id)
return cast(Optional[MotionSearchJob], get_job_by_id("motion_search", job_id))
def cancel_motion_search_job(job_id: str) -> bool:
+59 -19
View File
@@ -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]:
@@ -54,9 +58,9 @@ class VLMWatchRunner(threading.Thread):
job: VLMWatchJob,
config: FrigateConfig,
cancel_event: threading.Event,
frame_processor,
genai_manager,
dispatcher,
frame_processor: Any,
genai_manager: Any,
dispatcher: Any,
) -> None:
super().__init__(daemon=True, name=f"vlm_watch_{job.id}")
self.job = job
@@ -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,22 +219,41 @@ 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
topic, payload = self.detection_subscriber.check_for_update(
if triggered and time.time() >= earliest_wake:
break
result = self.detection_subscriber.check_for_update(
timeout=min(1.0, remaining)
)
if result is None:
continue
topic, payload = result
if topic is None or payload is None:
continue
# payload = (camera, frame_name, frame_time, tracked_objects, motion_boxes, regions)
@@ -247,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."""
@@ -281,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. "
@@ -291,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,
}
@@ -328,9 +368,9 @@ def start_vlm_watch_job(
condition: str,
max_duration_minutes: int,
config: FrigateConfig,
frame_processor,
genai_manager,
dispatcher,
frame_processor: Any,
genai_manager: Any,
dispatcher: Any,
labels: list[str] | None = None,
zones: list[str] | None = None,
) -> str:
+6 -6
View File
@@ -13,10 +13,10 @@ class MotionDetector(ABC):
frame_shape: Tuple[int, int, int],
config: MotionConfig,
fps: int,
improve_contrast,
threshold,
contour_area,
):
improve_contrast: bool,
threshold: int,
contour_area: int | None,
) -> None:
pass
@abstractmethod
@@ -25,7 +25,7 @@ class MotionDetector(ABC):
pass
@abstractmethod
def is_calibrating(self):
def is_calibrating(self) -> bool:
"""Return if motion is recalibrating."""
pass
@@ -35,6 +35,6 @@ class MotionDetector(ABC):
pass
@abstractmethod
def stop(self):
def stop(self) -> None:
"""Stop any ongoing work and processes."""
pass
+16 -13
View File
@@ -1,7 +1,9 @@
from typing import Any
import cv2
import numpy as np
from frigate.config import MotionConfig
from frigate.config.config import RuntimeMotionConfig
from frigate.motion import MotionDetector
from frigate.util.image import grab_cv2_contours
@@ -9,19 +11,20 @@ from frigate.util.image import grab_cv2_contours
class FrigateMotionDetector(MotionDetector):
def __init__(
self,
frame_shape,
config: MotionConfig,
frame_shape: tuple[int, ...],
config: RuntimeMotionConfig,
fps: int,
improve_contrast,
threshold,
contour_area,
):
improve_contrast: Any,
threshold: Any,
contour_area: Any,
) -> None:
self.config = config
self.frame_shape = frame_shape
self.resize_factor = frame_shape[0] / config.frame_height
frame_height = config.frame_height or frame_shape[0]
self.resize_factor = frame_shape[0] / frame_height
self.motion_frame_size = (
config.frame_height,
config.frame_height * frame_shape[1] // frame_shape[0],
frame_height,
frame_height * frame_shape[1] // frame_shape[0],
)
self.avg_frame = np.zeros(self.motion_frame_size, np.float32)
self.avg_delta = np.zeros(self.motion_frame_size, np.float32)
@@ -38,10 +41,10 @@ class FrigateMotionDetector(MotionDetector):
self.threshold = threshold
self.contour_area = contour_area
def is_calibrating(self):
def is_calibrating(self) -> bool:
return False
def detect(self, frame):
def detect(self, frame: np.ndarray) -> list:
motion_boxes = []
gray = frame[0 : self.frame_shape[0], 0 : self.frame_shape[1]]
@@ -99,7 +102,7 @@ class FrigateMotionDetector(MotionDetector):
# dilate the thresholded image to fill in holes, then find contours
# on thresholded image
thresh_dilated = cv2.dilate(thresh, None, iterations=2)
thresh_dilated = cv2.dilate(thresh, None, iterations=2) # type: ignore[call-overload]
contours = cv2.findContours(
thresh_dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
+23 -21
View File
@@ -1,11 +1,12 @@
import logging
from typing import Optional
import cv2
import numpy as np
from scipy.ndimage import gaussian_filter
from frigate.camera import PTZMetrics
from frigate.config import MotionConfig
from frigate.config.config import RuntimeMotionConfig
from frigate.motion import MotionDetector
from frigate.util.image import grab_cv2_contours
@@ -15,22 +16,23 @@ logger = logging.getLogger(__name__)
class ImprovedMotionDetector(MotionDetector):
def __init__(
self,
frame_shape,
config: MotionConfig,
frame_shape: tuple[int, ...],
config: RuntimeMotionConfig,
fps: int,
ptz_metrics: PTZMetrics = None,
name="improved",
blur_radius=1,
interpolation=cv2.INTER_NEAREST,
contrast_frame_history=50,
):
ptz_metrics: Optional[PTZMetrics] = None,
name: str = "improved",
blur_radius: int = 1,
interpolation: int = cv2.INTER_NEAREST,
contrast_frame_history: int = 50,
) -> None:
self.name = name
self.config = config
self.frame_shape = frame_shape
self.resize_factor = frame_shape[0] / config.frame_height
frame_height = config.frame_height or frame_shape[0]
self.resize_factor = frame_shape[0] / frame_height
self.motion_frame_size = (
config.frame_height,
config.frame_height * frame_shape[1] // frame_shape[0],
frame_height,
frame_height * frame_shape[1] // frame_shape[0],
)
self.avg_frame = np.zeros(self.motion_frame_size, np.float32)
self.motion_frame_count = 0
@@ -44,20 +46,20 @@ class ImprovedMotionDetector(MotionDetector):
self.contrast_values[:, 1:2] = 255
self.contrast_values_index = 0
self.ptz_metrics = ptz_metrics
self.last_stop_time = None
self.last_stop_time: float | None = None
def is_calibrating(self):
def is_calibrating(self) -> bool:
return self.calibrating
def detect(self, frame):
motion_boxes = []
def detect(self, frame: np.ndarray) -> list[tuple[int, int, int, int]]:
motion_boxes: list[tuple[int, int, int, int]] = []
if not self.config.enabled:
return motion_boxes
# if ptz motor is moving from autotracking, quickly return
# a single box that is 80% of the frame
if (
if self.ptz_metrics is not None and (
self.ptz_metrics.autotracker_enabled.value
and not self.ptz_metrics.motor_stopped.is_set()
):
@@ -130,19 +132,19 @@ class ImprovedMotionDetector(MotionDetector):
# dilate the thresholded image to fill in holes, then find contours
# on thresholded image
thresh_dilated = cv2.dilate(thresh, None, iterations=1)
thresh_dilated = cv2.dilate(thresh, None, iterations=1) # type: ignore[call-overload]
contours = cv2.findContours(
thresh_dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
contours = grab_cv2_contours(contours)
# loop over the contours
total_contour_area = 0
total_contour_area: float = 0
for c in contours:
# if the contour is big enough, count it as motion
contour_area = cv2.contourArea(c)
total_contour_area += contour_area
if contour_area > self.config.contour_area:
if contour_area > (self.config.contour_area or 0):
x, y, w, h = cv2.boundingRect(c)
motion_boxes.append(
(
@@ -159,7 +161,7 @@ class ImprovedMotionDetector(MotionDetector):
# check if the motor has just stopped from autotracking
# if so, reassign the average to the current frame so we begin with a new baseline
if (
if self.ptz_metrics is not None and (
# ensure we only do this for cameras with autotracking enabled
self.ptz_metrics.autotracker_enabled.value
and self.ptz_metrics.motor_stopped.is_set()
+27 -34
View File
@@ -22,50 +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.log]
ignore_errors = false
[mypy-frigate.embeddings.*]
ignore_errors = true
[mypy-frigate.models]
ignore_errors = false
[mypy-frigate.http]
ignore_errors = true
[mypy-frigate.plus]
ignore_errors = false
[mypy-frigate.ptz.*]
ignore_errors = true
[mypy-frigate.stats]
ignore_errors = false
[mypy-frigate.stats.*]
ignore_errors = true
[mypy-frigate.track.*]
ignore_errors = false
[mypy-frigate.test.*]
ignore_errors = true
[mypy-frigate.types]
ignore_errors = false
[mypy-frigate.util.*]
ignore_errors = true
[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
+50 -40
View File
@@ -7,6 +7,7 @@ from abc import ABC, abstractmethod
from collections import deque
from multiprocessing import Queue, Value
from multiprocessing.synchronize import Event as MpEvent
from typing import Any, Optional
import numpy as np
import zmq
@@ -34,26 +35,25 @@ logger = logging.getLogger(__name__)
class ObjectDetector(ABC):
@abstractmethod
def detect(self, tensor_input, threshold: float = 0.4):
def detect(self, tensor_input: np.ndarray, threshold: float = 0.4) -> list:
pass
class BaseLocalDetector(ObjectDetector):
def __init__(
self,
detector_config: BaseDetectorConfig = None,
labels: str = None,
stop_event: MpEvent = None,
):
detector_config: Optional[BaseDetectorConfig] = None,
labels: Optional[str] = None,
stop_event: Optional[MpEvent] = None,
) -> None:
self.fps = EventsPerSecond()
if labels is None:
self.labels = {}
self.labels: dict[int, str] = {}
else:
self.labels = load_labels(labels)
if detector_config:
if detector_config and detector_config.model:
self.input_transform = tensor_transform(detector_config.model.input_tensor)
self.dtype = detector_config.model.input_dtype
else:
self.input_transform = None
@@ -77,10 +77,10 @@ class BaseLocalDetector(ObjectDetector):
return tensor_input
def detect(self, tensor_input: np.ndarray, threshold=0.4):
def detect(self, tensor_input: np.ndarray, threshold: float = 0.4) -> list:
detections = []
raw_detections = self.detect_raw(tensor_input)
raw_detections = self.detect_raw(tensor_input) # type: ignore[attr-defined]
for d in raw_detections:
if int(d[0]) < 0 or int(d[0]) >= len(self.labels):
@@ -96,28 +96,28 @@ class BaseLocalDetector(ObjectDetector):
class LocalObjectDetector(BaseLocalDetector):
def detect_raw(self, tensor_input: np.ndarray):
def detect_raw(self, tensor_input: np.ndarray) -> np.ndarray:
tensor_input = self._transform_input(tensor_input)
return self.detect_api.detect_raw(tensor_input=tensor_input)
return self.detect_api.detect_raw(tensor_input=tensor_input) # type: ignore[no-any-return]
class AsyncLocalObjectDetector(BaseLocalDetector):
def async_send_input(self, tensor_input: np.ndarray, connection_id: str):
def async_send_input(self, tensor_input: np.ndarray, connection_id: str) -> None:
tensor_input = self._transform_input(tensor_input)
return self.detect_api.send_input(connection_id, tensor_input)
self.detect_api.send_input(connection_id, tensor_input)
def async_receive_output(self):
def async_receive_output(self) -> Any:
return self.detect_api.receive_output()
class DetectorRunner(FrigateProcess):
def __init__(
self,
name,
name: str,
detection_queue: Queue,
cameras: list[str],
avg_speed: Value,
start_time: Value,
avg_speed: Any,
start_time: Any,
config: FrigateConfig,
detector_config: BaseDetectorConfig,
stop_event: MpEvent,
@@ -129,11 +129,11 @@ class DetectorRunner(FrigateProcess):
self.start_time = start_time
self.config = config
self.detector_config = detector_config
self.outputs: dict = {}
self.outputs: dict[str, Any] = {}
def create_output_shm(self, name: str):
def create_output_shm(self, name: str) -> None:
out_shm = UntrackedSharedMemory(name=f"out-{name}", create=False)
out_np = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf)
out_np: np.ndarray = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf)
self.outputs[name] = {"shm": out_shm, "np": out_np}
def run(self) -> None:
@@ -155,8 +155,8 @@ class DetectorRunner(FrigateProcess):
connection_id,
(
1,
self.detector_config.model.height,
self.detector_config.model.width,
self.detector_config.model.height, # type: ignore[union-attr]
self.detector_config.model.width, # type: ignore[union-attr]
3,
),
)
@@ -187,11 +187,11 @@ class DetectorRunner(FrigateProcess):
class AsyncDetectorRunner(FrigateProcess):
def __init__(
self,
name,
name: str,
detection_queue: Queue,
cameras: list[str],
avg_speed: Value,
start_time: Value,
avg_speed: Any,
start_time: Any,
config: FrigateConfig,
detector_config: BaseDetectorConfig,
stop_event: MpEvent,
@@ -203,15 +203,15 @@ class AsyncDetectorRunner(FrigateProcess):
self.start_time = start_time
self.config = config
self.detector_config = detector_config
self.outputs: dict = {}
self.outputs: dict[str, Any] = {}
self._frame_manager: SharedMemoryFrameManager | None = None
self._publisher: ObjectDetectorPublisher | None = None
self._detector: AsyncLocalObjectDetector | None = None
self.send_times = deque()
self.send_times: deque[float] = deque()
def create_output_shm(self, name: str):
def create_output_shm(self, name: str) -> None:
out_shm = UntrackedSharedMemory(name=f"out-{name}", create=False)
out_np = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf)
out_np: np.ndarray = np.ndarray((20, 6), dtype=np.float32, buffer=out_shm.buf)
self.outputs[name] = {"shm": out_shm, "np": out_np}
def _detect_worker(self) -> None:
@@ -222,12 +222,13 @@ class AsyncDetectorRunner(FrigateProcess):
except queue.Empty:
continue
assert self._frame_manager is not None
input_frame = self._frame_manager.get(
connection_id,
(
1,
self.detector_config.model.height,
self.detector_config.model.width,
self.detector_config.model.height, # type: ignore[union-attr]
self.detector_config.model.width, # type: ignore[union-attr]
3,
),
)
@@ -238,11 +239,13 @@ class AsyncDetectorRunner(FrigateProcess):
# mark start time and send to accelerator
self.send_times.append(time.perf_counter())
assert self._detector is not None
self._detector.async_send_input(input_frame, connection_id)
def _result_worker(self) -> None:
logger.info("Starting Result Worker Thread")
while not self.stop_event.is_set():
assert self._detector is not None
connection_id, detections = self._detector.async_receive_output()
# Handle timeout case (queue.Empty) - just continue
@@ -256,6 +259,7 @@ class AsyncDetectorRunner(FrigateProcess):
duration = time.perf_counter() - ts
# release input buffer
assert self._frame_manager is not None
self._frame_manager.close(connection_id)
if connection_id not in self.outputs:
@@ -264,6 +268,7 @@ class AsyncDetectorRunner(FrigateProcess):
# write results and publish
if detections is not None:
self.outputs[connection_id]["np"][:] = detections[:]
assert self._publisher is not None
self._publisher.publish(connection_id)
# update timers
@@ -330,11 +335,14 @@ class ObjectDetectProcess:
self.stop_event = stop_event
self.start_or_restart()
def stop(self):
def stop(self) -> None:
# if the process has already exited on its own, just return
if self.detect_process and self.detect_process.exitcode:
return
if self.detect_process is None:
return
logging.info("Waiting for detection process to exit gracefully...")
self.detect_process.join(timeout=30)
if self.detect_process.exitcode is None:
@@ -343,8 +351,8 @@ class ObjectDetectProcess:
self.detect_process.join()
logging.info("Detection process has exited...")
def start_or_restart(self):
self.detection_start.value = 0.0
def start_or_restart(self) -> None:
self.detection_start.value = 0.0 # type: ignore[attr-defined]
if (self.detect_process is not None) and self.detect_process.is_alive():
self.stop()
@@ -389,17 +397,19 @@ class RemoteObjectDetector:
self.detection_queue = detection_queue
self.stop_event = stop_event
self.shm = UntrackedSharedMemory(name=self.name, create=False)
self.np_shm = np.ndarray(
self.np_shm: np.ndarray = np.ndarray(
(1, model_config.height, model_config.width, 3),
dtype=np.uint8,
buffer=self.shm.buf,
)
self.out_shm = UntrackedSharedMemory(name=f"out-{self.name}", create=False)
self.out_np_shm = np.ndarray((20, 6), dtype=np.float32, buffer=self.out_shm.buf)
self.out_np_shm: np.ndarray = np.ndarray(
(20, 6), dtype=np.float32, buffer=self.out_shm.buf
)
self.detector_subscriber = ObjectDetectorSubscriber(name)
def detect(self, tensor_input, threshold=0.4):
detections = []
def detect(self, tensor_input: np.ndarray, threshold: float = 0.4) -> list:
detections: list = []
if self.stop_event.is_set():
return detections
@@ -431,7 +441,7 @@ class RemoteObjectDetector:
self.fps.update()
return detections
def cleanup(self):
def cleanup(self) -> None:
self.detector_subscriber.stop()
self.shm.unlink()
self.out_shm.unlink()
+11 -7
View File
@@ -13,10 +13,10 @@ class RequestStore:
A thread-safe hash-based response store that handles creating requests.
"""
def __init__(self):
def __init__(self) -> None:
self.request_counter = 0
self.request_counter_lock = threading.Lock()
self.input_queue = queue.Queue()
self.input_queue: queue.Queue[tuple[int, ndarray]] = queue.Queue()
def __get_request_id(self) -> int:
with self.request_counter_lock:
@@ -45,17 +45,19 @@ class ResponseStore:
their request's result appears.
"""
def __init__(self):
self.responses = {} # Maps request_id -> (original_input, infer_results)
def __init__(self) -> None:
self.responses: dict[
int, ndarray
] = {} # Maps request_id -> (original_input, infer_results)
self.lock = threading.Lock()
self.cond = threading.Condition(self.lock)
def put(self, request_id: int, response: ndarray):
def put(self, request_id: int, response: ndarray) -> None:
with self.cond:
self.responses[request_id] = response
self.cond.notify_all()
def get(self, request_id: int, timeout=None) -> ndarray:
def get(self, request_id: int, timeout: float | None = None) -> ndarray:
with self.cond:
if not self.cond.wait_for(
lambda: request_id in self.responses, timeout=timeout
@@ -65,7 +67,9 @@ class ResponseStore:
return self.responses.pop(request_id)
def tensor_transform(desired_shape: InputTensorEnum):
def tensor_transform(
desired_shape: InputTensorEnum,
) -> tuple[int, int, int, int] | None:
# Currently this function only supports BHWC permutations
if desired_shape == InputTensorEnum.nhwc:
return None
+50 -38
View File
@@ -4,13 +4,13 @@ import datetime
import glob
import logging
import math
import multiprocessing as mp
import os
import queue
import subprocess as sp
import threading
import time
import traceback
from multiprocessing.synchronize import Event as MpEvent
from typing import Any, Optional
import cv2
@@ -74,25 +74,25 @@ class Canvas:
self,
canvas_width: int,
canvas_height: int,
scaling_factor: int,
scaling_factor: float,
) -> None:
self.scaling_factor = scaling_factor
gcd = math.gcd(canvas_width, canvas_height)
self.aspect = get_standard_aspect_ratio(
(canvas_width / gcd), (canvas_height / gcd)
int(canvas_width / gcd), int(canvas_height / gcd)
)
self.width = canvas_width
self.height = (self.width * self.aspect[1]) / self.aspect[0]
self.coefficient_cache: dict[int, int] = {}
self.height: float = (self.width * self.aspect[1]) / self.aspect[0]
self.coefficient_cache: dict[int, float] = {}
self.aspect_cache: dict[str, tuple[int, int]] = {}
def get_aspect(self, coefficient: int) -> tuple[int, int]:
def get_aspect(self, coefficient: float) -> tuple[float, float]:
return (self.aspect[0] * coefficient, self.aspect[1] * coefficient)
def get_coefficient(self, camera_count: int) -> int:
def get_coefficient(self, camera_count: int) -> float:
return self.coefficient_cache.get(camera_count, self.scaling_factor)
def set_coefficient(self, camera_count: int, coefficient: int) -> None:
def set_coefficient(self, camera_count: int, coefficient: float) -> None:
self.coefficient_cache[camera_count] = coefficient
def get_camera_aspect(
@@ -105,7 +105,7 @@ class Canvas:
gcd = math.gcd(camera_width, camera_height)
camera_aspect = get_standard_aspect_ratio(
camera_width / gcd, camera_height / gcd
int(camera_width / gcd), int(camera_height / gcd)
)
self.aspect_cache[cam_name] = camera_aspect
return camera_aspect
@@ -116,7 +116,7 @@ class FFMpegConverter(threading.Thread):
self,
ffmpeg: FfmpegConfig,
input_queue: queue.Queue,
stop_event: mp.Event,
stop_event: MpEvent,
in_width: int,
in_height: int,
out_width: int,
@@ -128,7 +128,7 @@ class FFMpegConverter(threading.Thread):
self.camera = "birdseye"
self.input_queue = input_queue
self.stop_event = stop_event
self.bd_pipe = None
self.bd_pipe: int | None = None
if birdseye_rtsp:
self.recreate_birdseye_pipe()
@@ -181,7 +181,8 @@ class FFMpegConverter(threading.Thread):
os.close(stdin)
self.reading_birdseye = False
def __write(self, b) -> None:
def __write(self, b: bytes) -> None:
assert self.process.stdin is not None
self.process.stdin.write(b)
if self.bd_pipe:
@@ -200,13 +201,13 @@ class FFMpegConverter(threading.Thread):
return
def read(self, length):
def read(self, length: int) -> Any:
try:
return self.process.stdout.read1(length)
return self.process.stdout.read1(length) # type: ignore[union-attr]
except ValueError:
return False
def exit(self):
def exit(self) -> None:
if self.bd_pipe:
os.close(self.bd_pipe)
@@ -233,8 +234,8 @@ class BroadcastThread(threading.Thread):
self,
camera: str,
converter: FFMpegConverter,
websocket_server,
stop_event: mp.Event,
websocket_server: Any,
stop_event: MpEvent,
):
super().__init__()
self.camera = camera
@@ -242,7 +243,7 @@ class BroadcastThread(threading.Thread):
self.websocket_server = websocket_server
self.stop_event = stop_event
def run(self):
def run(self) -> None:
while not self.stop_event.is_set():
buf = self.converter.read(65536)
if buf:
@@ -270,16 +271,16 @@ class BirdsEyeFrameManager:
def __init__(
self,
config: FrigateConfig,
stop_event: mp.Event,
stop_event: MpEvent,
):
self.config = config
width, height = get_canvas_shape(config.birdseye.width, config.birdseye.height)
self.frame_shape = (height, width)
self.yuv_shape = (height * 3 // 2, width)
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
self.frame: np.ndarray = np.ndarray(self.yuv_shape, dtype=np.uint8)
self.canvas = Canvas(width, height, config.birdseye.layout.scaling_factor)
self.stop_event = stop_event
self.last_refresh_time = 0
self.last_refresh_time: float = 0
# initialize the frame as black and with the Frigate logo
self.blank_frame = np.zeros(self.yuv_shape, np.uint8)
@@ -323,15 +324,15 @@ class BirdsEyeFrameManager:
self.frame[:] = self.blank_frame
self.cameras = {}
self.cameras: dict[str, Any] = {}
for camera in self.config.cameras.keys():
self.add_camera(camera)
self.camera_layout = []
self.active_cameras = set()
self.camera_layout: list[Any] = []
self.active_cameras: set[str] = set()
self.last_output_time = 0.0
def add_camera(self, cam: str):
def add_camera(self, cam: str) -> None:
"""Add a camera to self.cameras with the correct structure."""
settings = self.config.cameras[cam]
# precalculate the coordinates for all the channels
@@ -361,16 +362,21 @@ class BirdsEyeFrameManager:
},
}
def remove_camera(self, cam: str):
def remove_camera(self, cam: str) -> None:
"""Remove a camera from self.cameras."""
if cam in self.cameras:
del self.cameras[cam]
def clear_frame(self):
def clear_frame(self) -> None:
logger.debug("Clearing the birdseye frame")
self.frame[:] = self.blank_frame
def copy_to_position(self, position, camera=None, frame: np.ndarray = None):
def copy_to_position(
self,
position: Any,
camera: Optional[str] = None,
frame: Optional[np.ndarray] = None,
) -> None:
if camera is None:
frame = None
channel_dims = None
@@ -389,7 +395,9 @@ class BirdsEyeFrameManager:
channel_dims,
)
def camera_active(self, mode, object_box_count, motion_box_count):
def camera_active(
self, mode: Any, object_box_count: int, motion_box_count: int
) -> bool:
if mode == BirdseyeModeEnum.continuous:
return True
@@ -399,6 +407,8 @@ class BirdsEyeFrameManager:
if mode == BirdseyeModeEnum.objects and object_box_count > 0:
return True
return False
def get_camera_coordinates(self) -> dict[str, dict[str, int]]:
"""Return the coordinates of each camera in the current layout."""
coordinates = {}
@@ -451,7 +461,7 @@ class BirdsEyeFrameManager:
- self.cameras[active_camera]["last_active_frame"]
),
)
active_cameras = limited_active_cameras[:max_cameras]
active_cameras = set(limited_active_cameras[:max_cameras])
max_camera_refresh = True
self.last_refresh_time = now
@@ -510,7 +520,7 @@ class BirdsEyeFrameManager:
# center camera view in canvas and ensure that it fits
if scaled_width < self.canvas.width:
coefficient = 1
coefficient: float = 1
x_offset = int((self.canvas.width - scaled_width) / 2)
else:
coefficient = self.canvas.width / scaled_width
@@ -557,7 +567,7 @@ class BirdsEyeFrameManager:
calculating = False
self.canvas.set_coefficient(len(active_cameras), coefficient)
self.camera_layout = layout_candidate
self.camera_layout = layout_candidate or []
frame_changed = True
# Draw the layout
@@ -577,10 +587,12 @@ class BirdsEyeFrameManager:
self,
cameras_to_add: list[str],
coefficient: float,
) -> tuple[Any]:
) -> Optional[list[list[Any]]]:
"""Calculate the optimal layout for 2+ cameras."""
def map_layout(camera_layout: list[list[Any]], row_height: int):
def map_layout(
camera_layout: list[list[Any]], row_height: int
) -> tuple[int, int, Optional[list[list[Any]]]]:
"""Map the calculated layout."""
candidate_layout = []
starting_x = 0
@@ -777,11 +789,11 @@ class Birdseye:
def __init__(
self,
config: FrigateConfig,
stop_event: mp.Event,
websocket_server,
stop_event: MpEvent,
websocket_server: Any,
) -> None:
self.config = config
self.input = queue.Queue(maxsize=10)
self.input: queue.Queue[bytes] = queue.Queue(maxsize=10)
self.converter = FFMpegConverter(
config.ffmpeg,
self.input,
@@ -806,7 +818,7 @@ class Birdseye:
)
if config.birdseye.restream:
self.birdseye_buffer = self.frame_manager.create(
self.birdseye_buffer: Any = self.frame_manager.create(
"birdseye",
self.birdseye_manager.yuv_shape[0] * self.birdseye_manager.yuv_shape[1],
)
+16 -14
View File
@@ -1,10 +1,11 @@
"""Handle outputting individual cameras via jsmpeg."""
import logging
import multiprocessing as mp
import queue
import subprocess as sp
import threading
from multiprocessing.synchronize import Event as MpEvent
from typing import Any
from frigate.config import CameraConfig, FfmpegConfig
@@ -17,7 +18,7 @@ class FFMpegConverter(threading.Thread):
camera: str,
ffmpeg: FfmpegConfig,
input_queue: queue.Queue,
stop_event: mp.Event,
stop_event: MpEvent,
in_width: int,
in_height: int,
out_width: int,
@@ -64,16 +65,17 @@ class FFMpegConverter(threading.Thread):
start_new_session=True,
)
def __write(self, b) -> None:
def __write(self, b: bytes) -> None:
assert self.process.stdin is not None
self.process.stdin.write(b)
def read(self, length):
def read(self, length: int) -> Any:
try:
return self.process.stdout.read1(length)
return self.process.stdout.read1(length) # type: ignore[union-attr]
except ValueError:
return False
def exit(self):
def exit(self) -> None:
self.process.terminate()
try:
@@ -98,8 +100,8 @@ class BroadcastThread(threading.Thread):
self,
camera: str,
converter: FFMpegConverter,
websocket_server,
stop_event: mp.Event,
websocket_server: Any,
stop_event: MpEvent,
):
super().__init__()
self.camera = camera
@@ -107,7 +109,7 @@ class BroadcastThread(threading.Thread):
self.websocket_server = websocket_server
self.stop_event = stop_event
def run(self):
def run(self) -> None:
while not self.stop_event.is_set():
buf = self.converter.read(65536)
if buf:
@@ -133,15 +135,15 @@ class BroadcastThread(threading.Thread):
class JsmpegCamera:
def __init__(
self, config: CameraConfig, stop_event: mp.Event, websocket_server
self, config: CameraConfig, stop_event: MpEvent, websocket_server: Any
) -> None:
self.config = config
self.input = queue.Queue(maxsize=config.detect.fps)
self.input: queue.Queue[bytes] = queue.Queue(maxsize=config.detect.fps)
width = int(
config.live.height * (config.frame_shape[1] / config.frame_shape[0])
)
self.converter = FFMpegConverter(
config.name,
config.name or "",
config.ffmpeg,
self.input,
stop_event,
@@ -152,13 +154,13 @@ class JsmpegCamera:
config.live.quality,
)
self.broadcaster = BroadcastThread(
config.name, self.converter, websocket_server, stop_event
config.name or "", self.converter, websocket_server, stop_event
)
self.converter.start()
self.broadcaster.start()
def write_frame(self, frame_bytes) -> None:
def write_frame(self, frame_bytes: bytes) -> None:
try:
self.input.put_nowait(frame_bytes)
except queue.Full:
+28 -15
View File
@@ -61,6 +61,12 @@ def check_disabled_camera_update(
# last camera update was more than 1 second ago
# need to send empty data to birdseye because current
# frame is now out of date
cam_width = config.cameras[camera].detect.width
cam_height = config.cameras[camera].detect.height
if cam_width is None or cam_height is None:
raise ValueError(f"Camera {camera} detect dimensions not configured")
if birdseye and offline_time < 10:
# we only need to send blank frames to birdseye at the beginning of a camera being offline
birdseye.write_data(
@@ -68,10 +74,7 @@ def check_disabled_camera_update(
[],
[],
now,
get_blank_yuv_frame(
config.cameras[camera].detect.width,
config.cameras[camera].detect.height,
),
get_blank_yuv_frame(cam_width, cam_height),
)
if not has_enabled_camera and birdseye:
@@ -173,7 +176,7 @@ class OutputProcess(FrigateProcess):
birdseye_config_subscriber.check_for_update()
)
if update_topic is not None:
if update_topic is not None and birdseye_config is not None:
previous_global_mode = self.config.birdseye.mode
self.config.birdseye = birdseye_config
@@ -198,7 +201,10 @@ class OutputProcess(FrigateProcess):
birdseye,
)
(topic, data) = detection_subscriber.check_for_update(timeout=1)
_result = detection_subscriber.check_for_update(timeout=1)
if _result is None:
continue
(topic, data) = _result
now = datetime.datetime.now().timestamp()
if now - last_disabled_cam_check > 5:
@@ -208,7 +214,7 @@ class OutputProcess(FrigateProcess):
self.config, birdseye, preview_recorders, preview_write_times
)
if not topic:
if not topic or data is None:
continue
(
@@ -262,11 +268,15 @@ class OutputProcess(FrigateProcess):
jsmpeg_cameras[camera].write_frame(frame.tobytes())
# send output data to birdseye if websocket is connected or restreaming
if self.config.birdseye.enabled and (
self.config.birdseye.restream
or any(
ws.environ["PATH_INFO"].endswith("birdseye")
for ws in websocket_server.manager
if (
self.config.birdseye.enabled
and birdseye is not None
and (
self.config.birdseye.restream
or any(
ws.environ["PATH_INFO"].endswith("birdseye")
for ws in websocket_server.manager
)
)
):
birdseye.write_data(
@@ -282,9 +292,12 @@ class OutputProcess(FrigateProcess):
move_preview_frames("clips")
while True:
(topic, data) = detection_subscriber.check_for_update(timeout=0)
_cleanup_result = detection_subscriber.check_for_update(timeout=0)
if _cleanup_result is None:
break
(topic, data) = _cleanup_result
if not topic:
if not topic or data is None:
break
(
@@ -322,7 +335,7 @@ class OutputProcess(FrigateProcess):
logger.info("exiting output process...")
def move_preview_frames(loc: str):
def move_preview_frames(loc: str) -> None:
preview_holdover = os.path.join(CLIPS_DIR, "preview_restart_cache")
preview_cache = os.path.join(CACHE_DIR, "preview_frames")
+30 -27
View File
@@ -22,7 +22,6 @@ from frigate.ffmpeg_presets import (
parse_preset_hardware_acceleration_encode,
)
from frigate.models import Previews
from frigate.track.object_processing import TrackedObject
from frigate.util.image import copy_yuv_to_position, get_blank_yuv_frame, get_yuv_crop
logger = logging.getLogger(__name__)
@@ -66,7 +65,9 @@ def get_cache_image_name(camera: str, frame_time: float) -> str:
)
def get_most_recent_preview_frame(camera: str, before: float = None) -> str | None:
def get_most_recent_preview_frame(
camera: str, before: float | None = None
) -> str | None:
"""Get the most recent preview frame for a camera."""
if not os.path.exists(PREVIEW_CACHE_DIR):
return None
@@ -147,12 +148,12 @@ class FFMpegConverter(threading.Thread):
if t_idx == item_count - 1:
# last frame does not get a duration
playlist.append(
f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'"
f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'" # type: ignore[arg-type]
)
continue
playlist.append(
f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'"
f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'" # type: ignore[arg-type]
)
playlist.append(
f"duration {self.frame_times[t_idx + 1] - self.frame_times[t_idx]}"
@@ -199,30 +200,33 @@ class FFMpegConverter(threading.Thread):
# unlink files from cache
# don't delete last frame as it will be used as first frame in next segment
for t in self.frame_times[0:-1]:
Path(get_cache_image_name(self.config.name, t)).unlink(missing_ok=True)
Path(get_cache_image_name(self.config.name, t)).unlink(missing_ok=True) # type: ignore[arg-type]
class PreviewRecorder:
def __init__(self, config: CameraConfig) -> None:
self.config = config
self.start_time = 0
self.last_output_time = 0
self.camera_name: str = config.name or ""
self.start_time: float = 0
self.last_output_time: float = 0
self.offline = False
self.output_frames = []
self.output_frames: list[float] = []
if config.detect.width > config.detect.height:
if config.detect.width is None or config.detect.height is None:
raise ValueError("Detect width and height must be set for previews.")
self.detect_width: int = config.detect.width
self.detect_height: int = config.detect.height
if self.detect_width > self.detect_height:
self.out_height = PREVIEW_HEIGHT
self.out_width = (
int((config.detect.width / config.detect.height) * self.out_height)
// 4
* 4
int((self.detect_width / self.detect_height) * self.out_height) // 4 * 4
)
else:
self.out_width = PREVIEW_HEIGHT
self.out_height = (
int((config.detect.height / config.detect.width) * self.out_width)
// 4
* 4
int((self.detect_height / self.detect_width) * self.out_width) // 4 * 4
)
# create communication for finished previews
@@ -302,7 +306,7 @@ class PreviewRecorder:
)
self.start_time = frame_time
self.last_output_time = frame_time
self.output_frames: list[float] = []
self.output_frames = []
def should_write_frame(
self,
@@ -342,7 +346,9 @@ class PreviewRecorder:
def write_frame_to_cache(self, frame_time: float, frame: np.ndarray) -> None:
# resize yuv frame
small_frame = np.zeros((self.out_height * 3 // 2, self.out_width), np.uint8)
small_frame: np.ndarray = np.zeros(
(self.out_height * 3 // 2, self.out_width), np.uint8
)
copy_yuv_to_position(
small_frame,
(0, 0),
@@ -356,7 +362,7 @@ class PreviewRecorder:
cv2.COLOR_YUV2BGR_I420,
)
cv2.imwrite(
get_cache_image_name(self.config.name, frame_time),
get_cache_image_name(self.camera_name, frame_time),
small_frame,
[
int(cv2.IMWRITE_WEBP_QUALITY),
@@ -396,7 +402,7 @@ class PreviewRecorder:
).start()
else:
logger.debug(
f"Not saving preview for {self.config.name} because there are no saved frames."
f"Not saving preview for {self.camera_name} because there are no saved frames."
)
self.reset_frame_cache(frame_time)
@@ -416,9 +422,7 @@ class PreviewRecorder:
if not self.offline:
self.write_frame_to_cache(
frame_time,
get_blank_yuv_frame(
self.config.detect.width, self.config.detect.height
),
get_blank_yuv_frame(self.detect_width, self.detect_height),
)
self.offline = True
@@ -431,9 +435,9 @@ class PreviewRecorder:
return
old_frame_path = get_cache_image_name(
self.config.name, self.output_frames[-1]
self.camera_name, self.output_frames[-1]
)
new_frame_path = get_cache_image_name(self.config.name, frame_time)
new_frame_path = get_cache_image_name(self.camera_name, frame_time)
shutil.copy(old_frame_path, new_frame_path)
# save last frame to ensure consistent duration
@@ -447,13 +451,12 @@ class PreviewRecorder:
self.reset_frame_cache(frame_time)
def stop(self) -> None:
self.config_subscriber.stop()
self.requestor.stop()
def get_active_objects(
frame_time: float, camera_config: CameraConfig, all_objects: list[TrackedObject]
) -> list[TrackedObject]:
frame_time: float, camera_config: CameraConfig, all_objects: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""get active objects for detection."""
return [
o
+12 -10
View File
@@ -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,
+9 -17
View File
@@ -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 = []
+20 -10
View File
@@ -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:
+75 -74
View File
@@ -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."
+5
View File
@@ -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
View File
@@ -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")
+60 -1
View File
@@ -10,7 +10,7 @@ from ruamel.yaml.constructor import DuplicateKeyError
from frigate.config import BirdseyeModeEnum, FrigateConfig
from frigate.const import MODEL_CACHE_DIR
from frigate.detectors import DetectorTypeEnum
from frigate.util.builtin import deep_merge
from frigate.util.builtin import deep_merge, load_labels
class TestConfig(unittest.TestCase):
@@ -288,6 +288,65 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**config)
assert "dog" in frigate_config.cameras["back"].objects.filters
def test_default_audio_filters(self):
config = {
"mqtt": {"host": "mqtt"},
"audio": {"listen": ["speech", "yell"]},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
all_audio_labels = {
label
for label in load_labels("/audio-labelmap.txt", prefill=521).values()
if label
}
assert all_audio_labels.issubset(
set(frigate_config.cameras["back"].audio.filters.keys())
)
def test_override_audio_filters(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
"audio": {
"listen": ["speech", "yell"],
"filters": {"speech": {"threshold": 0.9}},
},
}
},
}
frigate_config = FrigateConfig(**config)
assert "speech" in frigate_config.cameras["back"].audio.filters
assert frigate_config.cameras["back"].audio.filters["speech"].threshold == 0.9
assert "babbling" in frigate_config.cameras["back"].audio.filters
def test_inherit_object_filters(self):
config = {
"mqtt": {"host": "mqtt"},
+10 -6
View File
@@ -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
+1
View File
@@ -81,6 +81,7 @@ class TrackedObjectProcessor(threading.Thread):
CameraConfigUpdateEnum.motion,
CameraConfigUpdateEnum.objects,
CameraConfigUpdateEnum.remove,
CameraConfigUpdateEnum.timestamp_style,
CameraConfigUpdateEnum.zones,
],
)
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+37
View File
@@ -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:
+1 -1
View File
@@ -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."
+4 -4
View File
@@ -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": {
@@ -752,7 +752,7 @@
},
"live": {
"label": "Live playback",
"description": "Settings used by the Web UI to control live stream resolution and quality.",
"description": "Settings to control the jsmpeg live stream resolution and quality. This does not affect restreamed cameras that use go2rtc for live view.",
"streams": {
"label": "Live stream names",
"description": "Mapping of configured stream names to restream/go2rtc names used for live playback."
@@ -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."
+31 -2
View File
@@ -825,6 +825,12 @@
"area": "Area"
}
},
"timestampPosition": {
"tl": "Top left",
"tr": "Top right",
"bl": "Bottom left",
"br": "Bottom right"
},
"users": {
"title": "Users",
"management": {
@@ -1342,7 +1348,22 @@
"preset-nvidia": "NVIDIA GPU",
"preset-jetson-h264": "NVIDIA Jetson (H.264)",
"preset-jetson-h265": "NVIDIA Jetson (H.265)",
"preset-rkmpp": "Rockchip RKMPP"
"preset-rkmpp": "Rockchip RKMPP",
"preset-http-jpeg-generic": "HTTP JPEG (Generic)",
"preset-http-mjpeg-generic": "HTTP MJPEG (Generic)",
"preset-http-reolink": "HTTP - Reolink Cameras",
"preset-rtmp-generic": "RTMP (Generic)",
"preset-rtsp-generic": "RTSP (Generic)",
"preset-rtsp-restream": "RTSP - Restream from go2rtc",
"preset-rtsp-restream-low-latency": "RTSP - Restream from go2rtc (Low Latency)",
"preset-rtsp-udp": "RTSP - UDP",
"preset-rtsp-blue-iris": "RTSP - Blue Iris",
"preset-record-generic": "Record (Generic, no audio)",
"preset-record-generic-audio-copy": "Record (Generic + Copy Audio)",
"preset-record-generic-audio-aac": "Record (Generic + Audio to AAC)",
"preset-record-mjpeg": "Record - MJPEG Cameras",
"preset-record-jpeg": "Record - JPEG Cameras",
"preset-record-ubiquiti": "Record - Ubiquiti Cameras"
}
},
"cameraInputs": {
@@ -1410,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}}"
},
@@ -1453,7 +1479,8 @@
"timestamp_style": {
"title": "Timestamp Settings"
},
"searchPlaceholder": "Search..."
"searchPlaceholder": "Search...",
"addCustomLabel": "Add custom label..."
},
"globalConfig": {
"title": "Global Configuration",
@@ -1500,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",
@@ -19,6 +19,16 @@ const audio: SectionConfigOverrides = {
hiddenFields: ["enabled_in_config"],
advancedFields: ["min_volume", "max_not_heard", "num_threads"],
uiSchema: {
filters: {
"ui:options": {
expandable: false,
},
},
"filters.*": {
"ui:options": {
additionalPropertyKeyReadonly: true,
},
},
listen: {
"ui:widget": "audioLabels",
},
@@ -29,6 +29,11 @@ const objects: SectionConfigOverrides = {
],
advancedFields: ["genai"],
uiSchema: {
filters: {
"ui:options": {
expandable: false,
},
},
"filters.*.min_area": {
"ui:options": {
suppressMultiSchema: true,
@@ -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: {
@@ -4,12 +4,13 @@ const timestampStyle: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/reference",
restartRequired: [],
fieldOrder: ["position", "format", "color", "thickness"],
fieldOrder: ["position", "format", "thickness", "color"],
hiddenFields: ["effect", "enabled_in_config"],
advancedFields: [],
uiSchema: {
position: {
"ui:size": "xs",
"ui:options": { enumI18nPrefix: "timestampPosition" },
},
format: {
"ui:size": "xs",
@@ -17,7 +18,7 @@ const timestampStyle: SectionConfigOverrides = {
},
},
global: {
restartRequired: ["position", "format", "color", "thickness", "effect"],
restartRequired: [],
},
camera: {
restartRequired: [],
@@ -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,5 +1,6 @@
// Select Widget - maps to shadcn/ui Select
import type { WidgetProps } from "@rjsf/utils";
import { useTranslation } from "react-i18next";
import {
Select,
SelectContent,
@@ -21,9 +22,18 @@ export function SelectWidget(props: WidgetProps) {
schema,
} = props;
const { t } = useTranslation(["views/settings"]);
const { enumOptions = [] } = options;
const enumI18nPrefix = options["enumI18nPrefix"] as string | undefined;
const fieldClassName = getSizedFieldClassName(options, "sm");
const getLabel = (option: { value: unknown; label: string }) => {
if (enumI18nPrefix) {
return t(`${enumI18nPrefix}.${option.value}`);
}
return option.label;
};
return (
<Select
value={value?.toString() ?? ""}
@@ -42,7 +52,7 @@ export function SelectWidget(props: WidgetProps) {
<SelectContent>
{enumOptions.map((option: { value: unknown; label: string }) => (
<SelectItem key={String(option.value)} value={String(option.value)}>
{option.label}
{getLabel(option)}
</SelectItem>
))}
</SelectContent>
@@ -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>
+4
View File
@@ -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",
+21 -4
View File
@@ -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}
/>
);
})()}
+11 -2
View File
@@ -707,14 +707,23 @@ export default function LiveCameraView({
}}
>
<div
className={`relative flex flex-col items-center justify-center ${growClassName}`}
className={cn(
"flex flex-col items-center justify-center",
growClassName,
)}
ref={clickOverlayRef}
style={{
aspectRatio: constrainedAspectRatio,
}}
>
{clickOverlay && overlaySize.width > 0 && (
<div className="absolute inset-0 z-40 cursor-crosshair">
<div
className="absolute z-40 cursor-crosshair"
style={{
width: overlaySize.width,
height: overlaySize.height,
}}
>
<Stage
width={overlaySize.width}
height={overlaySize.height}
+14
View File
@@ -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 (
+12 -3
View File
@@ -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>
);