Compare commits

..
2 Commits
55 changed files with 939 additions and 1229 deletions
+1 -1
View File
@@ -125,4 +125,4 @@ jobs:
- name: Check API spec is up to date
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check"
- name: Run unit tests in devcontainer
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m frigate.test"
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest"
+4 -4
View File
@@ -36,7 +36,7 @@ When reviewing code, do NOT comment on:
- **Formatting**: Ruff (configured in `pyproject.toml`)
- **Linting**: Ruff with rules defined in project config
- **Type Checking**: Use type hints consistently
- **Testing**: unittest framework - use `python3 -u -m frigate.test` to run the suite in parallel groups
- **Testing**: unittest framework - use `python3 -u -m unittest` to run tests
- **Language**: American English for all code, comments, and documentation
- **Punctuation**: Do not use em dashes in documentation, comments, or strings; reword with standard punctuation (commas, colons, parentheses, or separate sentences)
@@ -196,7 +196,7 @@ Key rules enforced:
### Backend Testing
- **Framework**: Python unittest
- **Run Command**: `python3 -u -m frigate.test` (parallel groups; plain `python3 -u -m unittest` runs serially)
- **Run Command**: `python3 -u -m unittest`
- **Location**: `frigate/test/`
- **Coverage**: Aim for comprehensive test coverage of core functionality
- **Pattern**: Use `TestCase` classes with descriptive test method names
@@ -219,8 +219,8 @@ Key rules enforced:
### Python Backend
```bash
# Run all tests in parallel groups (-j to set the number of jobs)
python3 -u -m frigate.test
# Run all tests
python3 -u -m unittest
# Run specific test file
python3 -u -m unittest frigate.test.test_ffmpeg_presets
+2 -2
View File
@@ -64,7 +64,7 @@ Pull requests that appear to be unreviewed AI output will be closed without revi
- **Python** — use modern language features (type hints, pattern matching, f-strings, dataclasses)
- **Formatting**: Ruff (configured in `pyproject.toml`)
- **Linting**: Ruff
- **Testing**: `python3 -u -m frigate.test` (runs the suite in parallel groups)
- **Testing**: `python3 -u -m unittest`
- **Logging**: Use module-level `logger = logging.getLogger(__name__)` with lazy formatting
- **Async**: All external I/O must be async. No blocking calls in async functions.
- **Error handling**: Use specific exception types. Keep try blocks minimal.
@@ -82,7 +82,7 @@ Pull requests that appear to be unreviewed AI output will be closed without revi
```bash
# Python
python3 -u -m frigate.test # Run all tests in parallel groups
python3 -u -m unittest # Run all tests
python3 -u -m unittest frigate.test.test_ffmpeg_presets # Run specific test
ruff format frigate # Format
ruff check frigate # Lint
+1 -1
View File
@@ -54,7 +54,7 @@ run: local
run_tests: local
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \
python3 -u -m frigate.test
python3 -u -m unittest
docker run --rm --workdir=/opt/frigate --entrypoint= frigate:latest \
python3 -u -m mypy --config-file frigate/mypy.ini frigate
@@ -504,6 +504,9 @@ review:
# Presets adjust the tone and level of detail of the user-facing title,
# summary, and scene description; "default" leaves the built-in prompt unchanged.
response_style: default
# Optional: Let the model think before writing the description (default: shown below)
# Only applies to models with a per-request thinking toggle (ollama and llamacpp providers).
enable_thinking: False
# Optional: Save thumbnails sent to the GenAI provider for review/debugging purposes (default: shown below)
debug_save_thumbnails: False
@@ -578,9 +581,6 @@ notifications:
email: "admin@example.com"
# Optional: Cooldown time for notifications in seconds (default: shown below)
cooldown: 0
# Optional: Seconds the push service keeps retrying a notification sent while an alert is active (default: shown below)
# NOTE: A non-zero value can deliver late notifications that still link to the live view
ttl: 0
# Optional: Record configuration
# NOTE: Can be overridden at the camera level
+8 -9
View File
@@ -75,12 +75,11 @@ Running Generative AI models on CPU is not recommended, as high inference times
You must use a vision-capable model with Frigate. The following models are recommended for local deployment of the `descriptions` and `chat` roles:
| Model | Review [frame mode](/configuration/genai/genai_review#frame-mode) | Review [response style](/configuration/genai/genai_review#response-style) | Notes |
| ------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `qwen3-vl` | `frames` | `natural` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. Follows a sequence of frames on its own. |
| `qwen3.6`/`qwen3.8` | `frames` | `natural` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
| `gemma4` | `annotated_frames` | `default` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. Loses track of activity that repeats or reverses, so it benefits from annotated frames. |
| `lfm3-vl` | `annotated_frames` | `concise` | Very efficient with fast inference, making it a good choice for less powerful hardware or high volumes of review items. |
| Model | Review [frame mode](/configuration/genai/genai_review#frame-mode) | Notes |
| ------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `qwen3-vl` | `frames` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. Follows a sequence of frames on its own. |
| `qwen3.6`/`qwen3.8` | `frames` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
| `gemma4` | `annotated_frames` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. Loses track of activity that repeats or reverses, so it benefits from annotated frames. |
#### Embedding models
@@ -96,7 +95,7 @@ The `embeddings` role needs a different kind of model. Text queries are matched
The `transcribe` role needs a model that accepts audio input. A text-only or vision-only model cannot serve this role. The following are recommended for local deployment of the `transcribe` role:
| Model | Notes |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `qwen3-asr` | Dedicated speech recognition model covering 30 languages, and the better choice for transcription quality. It only transcribes, so it cannot be shared with the `descriptions` or `chat` roles. |
| `gemma4` | General multimodal model that accepts audio as well as images, so one served model can cover `transcribe` alongside the other roles. Transcript quality is below `qwen3-asr`, particularly on noisy audio. |
@@ -120,7 +119,7 @@ Vision-language models come in **instruct** variants (fine-tuned to follow instr
Frigate manages reasoning per task automatically:
- **Description tasks** (object descriptions, review descriptions, review summaries) are synthesis-only and benefit from concise, direct output, so Frigate disables thinking for these calls when the model exposes a per-request toggle.
- **Description tasks** (object descriptions, review descriptions, review summaries) are synthesis-only and benefit from concise, direct output, so Frigate disables thinking for these calls when the model exposes a per-request toggle. Review descriptions can opt back in with [`enable_thinking`](/configuration/genai/genai_review#thinking) for models trained to reason over video.
- **Chat** lets you toggle thinking on or off from the composer when the configured model supports it.
You can use a pure instruct, hybrid, or thinking-capable model with Frigate. No extra configuration is required to disable thinking for descriptions.
@@ -511,7 +510,7 @@ Frigate's Generative AI features are configured and enabled separately. [Review
5. Save the exact images and prompts that were sent to your provider.
- Turn on **Save thumbnails** for the feature you are debugging (`review.genai.debug_save_thumbnails` or `objects.genai.debug_save_thumbnails`). Both features write to `/media/frigate/clips/genai-requests/`, and these files are admin-only.
- Review descriptions write `genai-requests/<review_id>/` containing the numbered frames that were sent, plus `prompt.txt` and `response.txt` with the exact prompt and the raw, unparsed model response.
- Review descriptions write `genai-requests/<review_id>/` containing the numbered frames that were sent, plus `prompt.txt` and `response.txt` with the exact prompt and the raw, unparsed model response. When the provider returns a reasoning trace (for example with [thinking](/configuration/genai/genai_review#thinking) enabled), it is saved to `reasoning.txt`.
- Review summary reports write `genai-requests/<start_ts>-<end_ts>/prompt.txt` and `response.txt`. No images are involved, since a report summarizes existing review descriptions.
- Object descriptions write `genai-requests/<event_id>/` containing the numbered thumbnails. The prompt for object descriptions is not written to a file, it is only visible in the debug logs from step 4.
- Look at the saved images before blaming the model. If the object is small, blurry, or out of frame, no prompt will fix the result. For object descriptions, consider turning on **Use snapshots** (`objects.genai.use_snapshot`) to send a higher quality image. For review items, consider setting **Review image source** (`review.genai.image_source`) to `recordings` for 480p frames instead of the lower resolution preview frames.
@@ -264,6 +264,36 @@ review:
</TabItem>
</ConfigTabs>
### Thinking
By default, Frigate turns thinking off for review descriptions, since most models write better descriptions without spending tokens on a reasoning trace. Some models are trained to reason over video, and may follow complex scenes more accurately when allowed to think first. The `enable_thinking` option lets you turn thinking on for review descriptions to evaluate this with your model.
`enable_thinking` only applies to models that expose a per-request thinking toggle with the `ollama` or `llamacpp` providers. For llama.cpp, the model's chat template must use the `enable_thinking` variable. Other models keep their default behavior. With `openai`, use `runtime_options` (for example `reasoning_effort`) to control reasoning instead.
Thinking makes each review description take longer and use more tokens. If responses come back empty, the model may be using its whole output budget on reasoning.
When **Save thumbnails** (`debug_save_thumbnails`) is enabled, the reasoning trace is saved to `reasoning.txt` alongside `prompt.txt` and `response.txt`, so you can see what the model considered before answering. The trace is saved even when the model returns no answer. To compare a saved example with thinking on and off, use `testing-scripts/genai_review_tester.py` in the Frigate repository.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > Review" />.
- Enable **GenAI config > Enable thinking**. This option is only shown when the provider assigned the descriptions role is `ollama` or `llamacpp`.
</TabItem>
<TabItem value="yaml">
```yaml {4}
review:
genai:
enabled: true
enable_thinking: true
```
</TabItem>
</ConfigTabs>
## Review Reports
Along with individual review item summaries, Generative AI can also produce a single report of review items from all cameras marked "suspicious" over a specified time period (for example, a daily summary of suspicious activity while you're on vacation).
+1 -24
View File
@@ -43,7 +43,7 @@ Notifications will be prevented if either:
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Notifications" />.
1. Navigate to <NavPath path="Settings > Notifications > Notifications" />.
- Set **Email** to your email address
- Enable notifications for the desired cameras
@@ -108,29 +108,6 @@ Currently, only Chrome supports images in notifications. Safari and Firefox will
Different platforms handle notifications differently, some settings changes may be required to get optimal notification delivery.
### Notification TTL
By default, Frigate sends notifications for an active alert with a TTL of `0`, so the push service either delivers them immediately or drops them. If your device is briefly unreachable (asleep, switching from Wi-Fi to cellular, or on a weak signal), those notifications are lost and you only receive the notification sent when the alert ends, which is always kept for up to an hour.
Setting `ttl` tells the push service to keep retrying notifications sent while an alert is active for that many seconds. This makes delivery more reliable, but a notification delivered late will still say an object is detected, even if the object has already left. Keep the value small.
<ConfigTabs>
<TabItem value="ui">
1. Navigate to <NavPath path="Settings > Notifications" />.
- Expand **Advanced Settings** and set **Notification TTL** to the number of seconds to keep retrying (e.g. `10`)
</TabItem>
<TabItem value="yaml">
```yaml
notifications:
ttl: 10 # retry an undelivered notification for up to 10 seconds
```
</TabItem>
</ConfigTabs>
### Android
Most Android phones have battery optimization settings. To get reliable Notification delivery the browser (Chrome, Firefox) should have battery optimizations disabled. If Frigate is running as a PWA then the Frigate app should have battery optimizations disabled as well.
+2 -2
View File
@@ -94,7 +94,7 @@ After closing VS Code, you may still have containers running. To close everythin
GitHub will execute unit tests on new PRs. You must ensure that all tests pass.
```shell
python3 -u -m frigate.test
python3 -u -m unittest
```
#### FFMPEG Hardware Acceleration
@@ -207,7 +207,7 @@ npm run lint
- Ensure the backend [unit tests](#unit-tests) pass. Your PR cannot be merged unless tests pass.
```shell
python3 -u -m frigate.test
python3 -u -m unittest
```
- Ensure the end-to-end tests pass. They run in Playwright against a production build with mocked API data, so they don't need a running Frigate instance. Add or update tests in `web/e2e/specs/` when you change UI behavior.
+1 -2
View File
@@ -934,8 +934,7 @@ def profile(request: Request):
)
def logout(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth
base_path = request.headers.get("x-ingress-path", "").rstrip("/")
response = RedirectResponse(f"{base_path}/login", status_code=303)
response = RedirectResponse("/login", status_code=303)
response.delete_cookie(auth_config.cookie_name)
return response
+1 -1
View File
@@ -623,7 +623,7 @@ class WebPushClient(Communicator):
# if event is ongoing open to live view otherwise open to recordings view
direct_url = f"/review?id={reviewId}" if ended else f"/#{camera}"
ttl = 3600 if ended else self.config.notifications.ttl
ttl = 3600 if ended else 0
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
-6
View File
@@ -24,12 +24,6 @@ class NotificationConfig(FrigateBaseModel):
title="Cooldown period",
description="Cooldown (seconds) between notifications to avoid spamming recipients.",
)
ttl: int = Field(
default=0,
ge=0,
title="Notification TTL",
description="Seconds the push service keeps retrying a notification sent while an alert is active if your device can't be reached. 0 delivers immediately or drops it. The notification sent when an alert ends is always kept for up to an hour.",
)
enabled_in_config: bool | None = Field(
default=None,
title="Original notifications state",
+5
View File
@@ -171,6 +171,11 @@ class GenAIReviewConfig(FrigateBaseModel):
title="Response style",
description="Writing style preset for generated review descriptions. Presets adjust the tone and level of detail of the user-facing title, summary, and scene description; 'default' leaves the built-in prompt unchanged.",
)
enable_thinking: bool = Field(
default=False,
title="Enable thinking",
description="Let the model reason before writing the review description. Only applies to models with a per-request thinking toggle (Ollama and llama.cpp providers); other models keep their default behavior. Thinking increases response time and token usage. When thumbnail saving is enabled, the reasoning is saved alongside the response.",
)
activity_context_prompt: str = Field(
default="""### Normal Activity Indicators (Level 0)
- Known/verified people in any zone at any time
@@ -12,6 +12,8 @@ import math
from collections.abc import Sequence
from typing import Any
from titlecase import titlecase
from frigate.models import Event, Timeline
logger = logging.getLogger(__name__)
@@ -78,7 +80,13 @@ def describe_heading(dx: float, dy: float) -> str:
def event_name(event: dict[str, Any]) -> str:
"""Name an object for the notes, e.g. 'a person' or 'waste bin "Compost"'.
"""Name an object for the notes, e.g. 'a person' or 'Nick'.
A recognized sub_label is used on its own, in the same title case as
"Objects in Scene", so the notes name the subject the way the response
should. The object type is left out: leading with it makes models copy
"person" and drop the name from titles. An unrecognized face is stored as
"unknown" and stays unnamed.
Objects are never numbered or given track identifiers. Frigate opens a new
tracked object whenever a subject is re-detected, so the tracking data
@@ -86,10 +94,10 @@ def event_name(event: dict[str, Any]) -> str:
ambiguous rather than implying either answer.
"""
label = str(event["label"]).replace("_", " ").replace("-verified", "")
sub_label = event.get("sub_label")
sub_label = str(event.get("sub_label") or "").strip()
if sub_label:
return f'{label} "{sub_label}"'
if sub_label and sub_label.lower() != "unknown":
return titlecase(sub_label.replace("_", " "))
article = "an" if label[:1].lower() in "aeiou" else "a"
return f"{article} {label}"
@@ -387,11 +395,10 @@ def build_frame_captions(
# audio and manual review items can have state changes but no tracked objects
if events:
# No source prefix. A tag placed before the name is read as the
# subject's identity.
timeline.extend(
(timestamp, f"[tracker] {note}")
for timestamp, note in build_timeline(
events, span_end, get_state_changes(detection_ids)
)
build_timeline(events, span_end, get_state_changes(detection_ids))
)
timeline.extend(
@@ -813,6 +813,7 @@ def run_analysis(
genai_config.activity_context_prompt,
genai_config.response_style,
frame_captions,
genai_config.enable_thinking,
)
review_inference_speed.update(datetime.datetime.now().timestamp() - start)
+25 -315
View File
@@ -1,13 +1,8 @@
import logging
import math
import os
import cv2
import numpy as np
from frigate.detectors.detector_config import ModelConfig, ModelTypeEnum
from frigate.util.model import xyxy_to_xywh_for_nms
try:
from tflite_runtime.interpreter import Interpreter, load_delegate
except ModuleNotFoundError:
@@ -17,324 +12,39 @@ except ModuleNotFoundError:
logger = logging.getLogger(__name__)
class TFLiteDetectionModel:
"""Object detection pre and post processing for a TFLite interpreter.
def tflite_init(self, interpreter):
self.interpreter = interpreter
Every TFLite detector shares this, differing only in how it builds the
interpreter (on the CPU, or with the EdgeTPU or Teflon delegate).
"""
self.interpreter.allocate_tensors()
supported_models = [
ModelTypeEnum.ssd,
ModelTypeEnum.yologeneric,
]
self.tensor_input_details = self.interpreter.get_input_details()
self.tensor_output_details = self.interpreter.get_output_details()
def __init__(
self, interpreter: Interpreter, model_config: ModelConfig, detector_key: str
):
"""Prepare the interpreter and work out the model's output layout.
Args:
interpreter: The TFLite interpreter, with any delegate loaded
model_config: The model being run
detector_key: The detector's type, for error messages
"""
self.model_type = model_config.model_type
def tflite_detect_raw(self, tensor_input):
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
self.interpreter.invoke()
if self.model_type not in self.supported_models:
raise ValueError(
f"{self.model_type.value} is currently not supported for {detector_key}. See the docs for more info on supported models."
)
boxes = self.interpreter.tensor(self.tensor_output_details[0]["index"])()[0]
class_ids = self.interpreter.tensor(self.tensor_output_details[1]["index"])()[0]
scores = self.interpreter.tensor(self.tensor_output_details[2]["index"])()[0]
count = int(self.interpreter.tensor(self.tensor_output_details[3]["index"])()[0])
self.interpreter = interpreter
detections = np.zeros((20, 6), np.float32)
try:
self.interpreter.allocate_tensors()
except RuntimeError as e:
# an EdgeTPU compiled model wraps its graph in a custom op that only
# the EdgeTPU delegate can run
if "edgetpu-custom-op" in str(e):
logger.error(
"%s is compiled for the EdgeTPU and can only be run by an edgetpu detector",
model_config.path,
)
raise
for i in range(count):
if scores[i] < 0.4 or i == 20:
break
detections[i] = [
class_ids[i],
float(scores[i]),
boxes[i][0],
boxes[i][1],
boxes[i][2],
boxes[i][3],
]
self.tensor_input_details = self.interpreter.get_input_details()
self.tensor_output_details = self.interpreter.get_output_details()
self.model_width = model_config.width
self.model_height = model_config.height
self.min_score = 0.4
self.max_detections = 20
self.model_requires_int8 = self.tensor_input_details[0]["dtype"] == np.int8
if self.model_type == ModelTypeEnum.yologeneric:
logger.debug("Using YOLO preprocessing/postprocessing")
if len(self.tensor_output_details) not in [2, 3]:
raise ValueError(
f"Invalid count of output tensors in YOLO model. Found {len(self.tensor_output_details)}, expecting 2 or 3."
)
self.reg_max = 16 # = 64 dfl_channels // 4 # YOLO standard
self.min_logit_value = np.log(
self.min_score / (1 - self.min_score)
) # for filtering
self._generate_anchors_and_strides() # decode bounding box DFL
self.project = np.arange(
self.reg_max, dtype=np.float32
) # for decoding bounding box DFL information
# Determine YOLO tensor indices and quantization scales for
# boxes and class_scores the tensor ordering and names are
# not reliable, so use tensor shape to detect which tensor
# holds boxes or class scores.
# The tensors have shapes (B, N, C)
# where N is the number of candidates (=2100 for 320x320)
# this may guess wrong if the number of classes is exactly 64
output_boxes_index = None
output_classes_index = None
for i, x in enumerate(self.tensor_output_details):
# the nominal index seems to start at 1 instead of 0
if len(x["shape"]) == 3 and x["shape"][2] == 64:
output_boxes_index = i
elif len(x["shape"]) == 3 and x["shape"][2] > 1:
# require the number of classes to be more than 1
# to differentiate from (not used) max score tensor
output_classes_index = i
if output_boxes_index is None or output_classes_index is None:
logger.warning("Unrecognized model output, unexpected tensor shapes.")
output_classes_index = (
0
if (output_boxes_index is None or output_classes_index == 1)
else 1
) # 0 is default guess
output_boxes_index = 1 if (output_boxes_index == 0) else 0
scores_details = self.tensor_output_details[output_classes_index]
boxes_details = self.tensor_output_details[output_boxes_index]
# post processing works on the raw quantized values, which a float
# model doesn't have
if np.issubdtype(scores_details["dtype"], np.floating) or np.issubdtype(
boxes_details["dtype"], np.floating
):
raise ValueError(
f"{model_config.path} has float outputs. YOLO models for TFLite must be int8 quantized."
)
self.scores_tensor_index = scores_details["index"]
self.scores_scale, self.scores_zero_point = scores_details["quantization"]
# calculate the quantized version of the min_score
self.min_score_quantized = int(
(self.min_logit_value / self.scores_scale) + self.scores_zero_point
)
self.logit_shift_to_positive_values = (
max(0, math.ceil((128 + self.scores_zero_point) * self.scores_scale))
+ 1
) # round up
self.boxes_tensor_index = boxes_details["index"]
self.boxes_scale, self.boxes_zero_point = boxes_details["quantization"]
elif self.model_type == ModelTypeEnum.ssd:
logger.debug("Using SSD preprocessing/postprocessing")
# SSD models end in TFLite_Detection_PostProcess, which outputs
# boxes, class ids, scores and count
if len(self.tensor_output_details) < 4:
raise ValueError(
f"Invalid count of output tensors in SSD model. Found {len(self.tensor_output_details)}, expecting 4. YOLO models need a model_type of yolo-generic."
)
def _generate_anchors_and_strides(self):
# for decoding the bounding box DFL information into xy coordinates
all_anchors = []
all_strides = []
strides = (8, 16, 32) # YOLO's small, medium, large detection heads
for stride in strides:
feat_h, feat_w = self.model_height // stride, self.model_width // stride
grid_y, grid_x = np.meshgrid(
np.arange(feat_h, dtype=np.float32),
np.arange(feat_w, dtype=np.float32),
indexing="ij",
)
grid_coords = np.stack((grid_x.flatten(), grid_y.flatten()), axis=1)
anchor_points = grid_coords + 0.5
all_anchors.append(anchor_points)
all_strides.append(np.full((feat_h * feat_w, 1), stride, dtype=np.float32))
self.anchors = np.concatenate(all_anchors, axis=0)
self.anchor_strides = np.concatenate(all_strides, axis=0)
def pre_process(self, tensor_input):
if self.model_requires_int8:
tensor_input = np.bitwise_xor(tensor_input, 128).view(
np.int8
) # shift by -128
return tensor_input
def detect_raw(self, tensor_input):
"""Run the model on a frame.
Args:
tensor_input: The frame, already resized to the model's input
Returns:
Up to 20 detections as [class_id, score, ymin, xmin, ymax, xmax],
padded with zeros
"""
tensor_input = self.pre_process(tensor_input)
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
self.interpreter.invoke()
if self.model_type == ModelTypeEnum.yologeneric:
return self._post_process_yolo()
return self._post_process_ssd()
def _post_process_yolo(self):
# Multi-tensor YOLO model with (non-standard B(H*W)C output format).
# (the comments indicate the shape of tensors,
# using "2100" as the anchor count (for image size of 320x320),
# "NC" as number of classes,
# "N" as the count that survive after min-score filtering)
# TENSOR A) class scores (1, 2100, NC) with logit values
# TENSOR B) box coordinates (1, 2100, 64) encoded as dfl scores
# Recommend that the model clamp the logit values in tensor (A)
# to the range [-4,+4] to preserve precision from [2%,98%]
# and because NMS requires the min_score parameter to be >= 0
# don't dequantize scores data yet, wait until the low-confidence
# candidates are filtered out from the overall result set.
# This reduces the work and makes post-processing faster.
# this method works with raw quantized numbers when possible,
# which relies on the value of the scale factor to be >0.
# This speeds up max and argmax operations.
# Get max confidence for each detection and create the mask
detections = np.zeros(
(self.max_detections, 6), np.float32
) # initialize zero results
scores_output_quantized = self.interpreter.get_tensor(self.scores_tensor_index)[
0
] # (2100, NC)
max_scores_quantized = np.max(scores_output_quantized, axis=1) # (2100,)
mask = max_scores_quantized >= self.min_score_quantized # (2100,)
if not np.any(mask):
return detections # empty results
max_scores_filtered_shiftedpositive = (
(max_scores_quantized[mask] - self.scores_zero_point) * self.scores_scale
) + self.logit_shift_to_positive_values # (N,1) shifted logit values
scores_output_quantized_filtered = scores_output_quantized[mask]
# dequantize boxes. NMS needs them to be in float format
# remove candidates with probabilities < threshold
boxes_output_quantized_filtered = (
self.interpreter.get_tensor(self.boxes_tensor_index)[0]
)[mask] # (N, 64)
boxes_output_filtered = (
boxes_output_quantized_filtered.astype(np.float32) - self.boxes_zero_point
) * self.boxes_scale
# 2. Decode DFL to distances (ltrb)
dfl_distributions = boxes_output_filtered.reshape(
-1, 4, self.reg_max
) # (N, 4, 16)
# Softmax over the 16 bins
dfl_max = np.max(dfl_distributions, axis=2, keepdims=True)
dfl_exp = np.exp(dfl_distributions - dfl_max)
dfl_probs = dfl_exp / np.sum(dfl_exp, axis=2, keepdims=True) # (N, 4, 16)
# Weighted sum: (N, 4, 16) * (16,) -> (N, 4)
distances = np.einsum("pcr,r->pc", dfl_probs, self.project)
# Calculate box corners in pixel coordinates
anchors_filtered = self.anchors[mask]
anchor_strides_filtered = self.anchor_strides[mask]
x1y1 = (
anchors_filtered - distances[:, [0, 1]]
) * anchor_strides_filtered # (N, 2)
x2y2 = (
anchors_filtered + distances[:, [2, 3]]
) * anchor_strides_filtered # (N, 2)
boxes_filtered_decoded = np.concatenate((x1y1, x2y2), axis=-1) # (N, 4)
# 9. Apply NMS. Use logit scores here to defer sigmoid()
# until after filtering out redundant boxes
# Shift the logit scores to be non-negative (required by cv2)
indices = cv2.dnn.NMSBoxes(
bboxes=xyxy_to_xywh_for_nms(boxes_filtered_decoded),
scores=max_scores_filtered_shiftedpositive,
score_threshold=(
self.min_logit_value + self.logit_shift_to_positive_values
),
nms_threshold=0.4, # should this be a model config setting?
)
num_detections = len(indices)
if num_detections == 0:
return detections # empty results
nms_indices = np.array(indices, dtype=np.int32).ravel() # or .flatten()
if num_detections > self.max_detections:
nms_indices = nms_indices[: self.max_detections]
num_detections = self.max_detections
kept_logits_quantized = scores_output_quantized_filtered[nms_indices]
class_ids_post_nms = np.argmax(kept_logits_quantized, axis=1)
# Extract the final boxes and scores using fancy indexing
final_boxes = boxes_filtered_decoded[nms_indices]
final_scores_logits = (
max_scores_filtered_shiftedpositive[nms_indices]
- self.logit_shift_to_positive_values
) # Unshifted logits
# Detections array format: [class_id, score, ymin, xmin, ymax, xmax]
detections[:num_detections, 0] = class_ids_post_nms
detections[:num_detections, 1] = 1.0 / (
1.0 + np.exp(-final_scores_logits)
) # sigmoid
detections[:num_detections, 2] = final_boxes[:, 1] / self.model_height
detections[:num_detections, 3] = final_boxes[:, 0] / self.model_width
detections[:num_detections, 4] = final_boxes[:, 3] / self.model_height
detections[:num_detections, 5] = final_boxes[:, 2] / self.model_width
return detections
def _post_process_ssd(self):
boxes = self.interpreter.tensor(self.tensor_output_details[0]["index"])()[0]
class_ids = self.interpreter.tensor(self.tensor_output_details[1]["index"])()[0]
scores = self.interpreter.tensor(self.tensor_output_details[2]["index"])()[0]
count = int(
self.interpreter.tensor(self.tensor_output_details[3]["index"])()[0]
)
detections = np.zeros((self.max_detections, 6), np.float32)
for i in range(count):
if scores[i] < self.min_score:
break
if i == self.max_detections:
logger.debug("Too many detections (%s)", count)
break
detections[i] = [
class_ids[i],
float(scores[i]),
boxes[i][0],
boxes[i][1],
boxes[i][2],
boxes[i][3],
]
return detections
return detections
def tflite_load_delegate_interpreter(
+3 -6
View File
@@ -7,7 +7,7 @@ from frigate.detectors.detection_api import DetectionApi
from frigate.detectors.detector_config import BaseDetectorConfig
from frigate.log import suppress_stderr_during
from ..detector_utils import TFLiteDetectionModel
from ..detector_utils import tflite_detect_raw, tflite_init
try:
from tflite_runtime.interpreter import Interpreter
@@ -40,7 +40,6 @@ class CpuDetectorConfig(BaseDetectorConfig):
class CpuTfl(DetectionApi):
type_key = DETECTOR_KEY
supported_models = TFLiteDetectionModel.supported_models
def __init__(self, detector_config: CpuDetectorConfig):
# Suppress TFLite delegate creation messages that bypass Python logging
@@ -50,9 +49,7 @@ class CpuTfl(DetectionApi):
num_threads=detector_config.num_threads or 3,
)
self.tflite_model = TFLiteDetectionModel(
interpreter, detector_config.model, DETECTOR_KEY
)
tflite_init(self, interpreter)
def detect_raw(self, tensor_input):
return self.tflite_model.detect_raw(tensor_input)
return tflite_detect_raw(self, tensor_input)
+303 -9
View File
@@ -1,13 +1,15 @@
import logging
import math
import os
from typing import ClassVar, Literal
import cv2
import numpy as np
from pydantic import ConfigDict, Field
from frigate.detectors.detection_api import DetectionApi
from frigate.detectors.detector_config import BaseDetectorConfig
from ..detector_utils import TFLiteDetectionModel
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
from frigate.util.model import xyxy_to_xywh_for_nms
try:
from tflite_runtime.interpreter import Interpreter, load_delegate
@@ -39,7 +41,10 @@ class EdgeTpuDetectorConfig(BaseDetectorConfig):
class EdgeTpuTfl(DetectionApi):
type_key = DETECTOR_KEY
supported_models = TFLiteDetectionModel.supported_models
supported_models = [
ModelTypeEnum.ssd,
ModelTypeEnum.yologeneric,
]
def __init__(self, detector_config: EdgeTpuDetectorConfig):
device_config = {}
@@ -55,7 +60,7 @@ class EdgeTpuTfl(DetectionApi):
logger.info(f"Attempting to load TPU as {device_type}")
edge_tpu_delegate = load_delegate("libedgetpu.so.1.0", device_config)
logger.info("TPU found")
interpreter = Interpreter(
self.interpreter = Interpreter(
model_path=detector_config.model.path,
experimental_delegates=[edge_tpu_delegate],
)
@@ -73,9 +78,298 @@ class EdgeTpuTfl(DetectionApi):
raise
self.tflite_model = TFLiteDetectionModel(
interpreter, detector_config.model, DETECTOR_KEY
)
self.interpreter.allocate_tensors()
self.tensor_input_details = self.interpreter.get_input_details()
self.tensor_output_details = self.interpreter.get_output_details()
self.model_width = detector_config.model.width
self.model_height = detector_config.model.height
self.min_score = 0.4
self.max_detections = 20
self.model_type = detector_config.model.model_type
self.model_requires_int8 = self.tensor_input_details[0]["dtype"] == np.int8
if self.model_type == ModelTypeEnum.yologeneric:
logger.debug("Using YOLO preprocessing/postprocessing")
if len(self.tensor_output_details) not in [2, 3]:
logger.error(
f"Invalid count of output tensors in YOLO model. Found {len(self.tensor_output_details)}, expecting 2 or 3."
)
raise
self.reg_max = 16 # = 64 dfl_channels // 4 # YOLO standard
self.min_logit_value = np.log(
self.min_score / (1 - self.min_score)
) # for filtering
self._generate_anchors_and_strides() # decode bounding box DFL
self.project = np.arange(
self.reg_max, dtype=np.float32
) # for decoding bounding box DFL information
# Determine YOLO tensor indices and quantization scales for
# boxes and class_scores the tensor ordering and names are
# not reliable, so use tensor shape to detect which tensor
# holds boxes or class scores.
# The tensors have shapes (B, N, C)
# where N is the number of candidates (=2100 for 320x320)
# this may guess wrong if the number of classes is exactly 64
output_boxes_index = None
output_classes_index = None
for i, x in enumerate(self.tensor_output_details):
# the nominal index seems to start at 1 instead of 0
if len(x["shape"]) == 3 and x["shape"][2] == 64:
output_boxes_index = i
elif len(x["shape"]) == 3 and x["shape"][2] > 1:
# require the number of classes to be more than 1
# to differentiate from (not used) max score tensor
output_classes_index = i
if output_boxes_index is None or output_classes_index is None:
logger.warning("Unrecognized model output, unexpected tensor shapes.")
output_classes_index = (
0
if (output_boxes_index is None or output_classes_index == 1)
else 1
) # 0 is default guess
output_boxes_index = 1 if (output_boxes_index == 0) else 0
scores_details = self.tensor_output_details[output_classes_index]
self.scores_tensor_index = scores_details["index"]
self.scores_scale, self.scores_zero_point = scores_details["quantization"]
# calculate the quantized version of the min_score
self.min_score_quantized = int(
(self.min_logit_value / self.scores_scale) + self.scores_zero_point
)
self.logit_shift_to_positive_values = (
max(0, math.ceil((128 + self.scores_zero_point) * self.scores_scale))
+ 1
) # round up
boxes_details = self.tensor_output_details[output_boxes_index]
self.boxes_tensor_index = boxes_details["index"]
self.boxes_scale, self.boxes_zero_point = boxes_details["quantization"]
elif self.model_type == ModelTypeEnum.ssd:
logger.debug("Using SSD preprocessing/postprocessing")
# SSD model indices (4 outputs: boxes, class_ids, scores, count)
for x in self.tensor_output_details:
if len(x["shape"]) == 3:
self.output_boxes_index = x["index"]
elif len(x["shape"]) == 1:
self.output_count_index = x["index"]
self.output_class_ids_index = None
self.output_class_scores_index = None
else:
raise Exception(
f"{self.model_type} is currently not supported for edgetpu. See the docs for more info on supported models."
)
def _generate_anchors_and_strides(self):
# for decoding the bounding box DFL information into xy coordinates
all_anchors = []
all_strides = []
strides = (8, 16, 32) # YOLO's small, medium, large detection heads
for stride in strides:
feat_h, feat_w = self.model_height // stride, self.model_width // stride
grid_y, grid_x = np.meshgrid(
np.arange(feat_h, dtype=np.float32),
np.arange(feat_w, dtype=np.float32),
indexing="ij",
)
grid_coords = np.stack((grid_x.flatten(), grid_y.flatten()), axis=1)
anchor_points = grid_coords + 0.5
all_anchors.append(anchor_points)
all_strides.append(np.full((feat_h * feat_w, 1), stride, dtype=np.float32))
self.anchors = np.concatenate(all_anchors, axis=0)
self.anchor_strides = np.concatenate(all_strides, axis=0)
def determine_indexes_for_non_yolo_models(self):
"""Legacy method for SSD models."""
if (
self.output_class_ids_index is None
or self.output_class_scores_index is None
):
for i in range(4):
index = self.tensor_output_details[i]["index"]
if (
index != self.output_boxes_index
and index != self.output_count_index
):
if (
np.mod(np.float32(self.interpreter.tensor(index)()[0][0]), 1)
== 0.0
):
self.output_class_ids_index = index
else:
self.output_scores_index = index
def pre_process(self, tensor_input):
if self.model_requires_int8:
tensor_input = np.bitwise_xor(tensor_input, 128).view(
np.int8
) # shift by -128
return tensor_input
def detect_raw(self, tensor_input):
return self.tflite_model.detect_raw(tensor_input)
tensor_input = self.pre_process(tensor_input)
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
self.interpreter.invoke()
if self.model_type == ModelTypeEnum.yologeneric:
# Multi-tensor YOLO model with (non-standard B(H*W)C output format).
# (the comments indicate the shape of tensors,
# using "2100" as the anchor count (for image size of 320x320),
# "NC" as number of classes,
# "N" as the count that survive after min-score filtering)
# TENSOR A) class scores (1, 2100, NC) with logit values
# TENSOR B) box coordinates (1, 2100, 64) encoded as dfl scores
# Recommend that the model clamp the logit values in tensor (A)
# to the range [-4,+4] to preserve precision from [2%,98%]
# and because NMS requires the min_score parameter to be >= 0
# don't dequantize scores data yet, wait until the low-confidence
# candidates are filtered out from the overall result set.
# This reduces the work and makes post-processing faster.
# this method works with raw quantized numbers when possible,
# which relies on the value of the scale factor to be >0.
# This speeds up max and argmax operations.
# Get max confidence for each detection and create the mask
detections = np.zeros(
(self.max_detections, 6), np.float32
) # initialize zero results
scores_output_quantized = self.interpreter.get_tensor(
self.scores_tensor_index
)[0] # (2100, NC)
max_scores_quantized = np.max(scores_output_quantized, axis=1) # (2100,)
mask = max_scores_quantized >= self.min_score_quantized # (2100,)
if not np.any(mask):
return detections # empty results
max_scores_filtered_shiftedpositive = (
(max_scores_quantized[mask] - self.scores_zero_point)
* self.scores_scale
) + self.logit_shift_to_positive_values # (N,1) shifted logit values
scores_output_quantized_filtered = scores_output_quantized[mask]
# dequantize boxes. NMS needs them to be in float format
# remove candidates with probabilities < threshold
boxes_output_quantized_filtered = (
self.interpreter.get_tensor(self.boxes_tensor_index)[0]
)[mask] # (N, 64)
boxes_output_filtered = (
boxes_output_quantized_filtered.astype(np.float32)
- self.boxes_zero_point
) * self.boxes_scale
# 2. Decode DFL to distances (ltrb)
dfl_distributions = boxes_output_filtered.reshape(
-1, 4, self.reg_max
) # (N, 4, 16)
# Softmax over the 16 bins
dfl_max = np.max(dfl_distributions, axis=2, keepdims=True)
dfl_exp = np.exp(dfl_distributions - dfl_max)
dfl_probs = dfl_exp / np.sum(dfl_exp, axis=2, keepdims=True) # (N, 4, 16)
# Weighted sum: (N, 4, 16) * (16,) -> (N, 4)
distances = np.einsum("pcr,r->pc", dfl_probs, self.project)
# Calculate box corners in pixel coordinates
anchors_filtered = self.anchors[mask]
anchor_strides_filtered = self.anchor_strides[mask]
x1y1 = (
anchors_filtered - distances[:, [0, 1]]
) * anchor_strides_filtered # (N, 2)
x2y2 = (
anchors_filtered + distances[:, [2, 3]]
) * anchor_strides_filtered # (N, 2)
boxes_filtered_decoded = np.concatenate((x1y1, x2y2), axis=-1) # (N, 4)
# 9. Apply NMS. Use logit scores here to defer sigmoid()
# until after filtering out redundant boxes
# Shift the logit scores to be non-negative (required by cv2)
indices = cv2.dnn.NMSBoxes(
bboxes=xyxy_to_xywh_for_nms(boxes_filtered_decoded),
scores=max_scores_filtered_shiftedpositive,
score_threshold=(
self.min_logit_value + self.logit_shift_to_positive_values
),
nms_threshold=0.4, # should this be a model config setting?
)
num_detections = len(indices)
if num_detections == 0:
return detections # empty results
nms_indices = np.array(indices, dtype=np.int32).ravel() # or .flatten()
if num_detections > self.max_detections:
nms_indices = nms_indices[: self.max_detections]
num_detections = self.max_detections
kept_logits_quantized = scores_output_quantized_filtered[nms_indices]
class_ids_post_nms = np.argmax(kept_logits_quantized, axis=1)
# Extract the final boxes and scores using fancy indexing
final_boxes = boxes_filtered_decoded[nms_indices]
final_scores_logits = (
max_scores_filtered_shiftedpositive[nms_indices]
- self.logit_shift_to_positive_values
) # Unshifted logits
# Detections array format: [class_id, score, ymin, xmin, ymax, xmax]
detections[:num_detections, 0] = class_ids_post_nms
detections[:num_detections, 1] = 1.0 / (
1.0 + np.exp(-final_scores_logits)
) # sigmoid
detections[:num_detections, 2] = final_boxes[:, 1] / self.model_height
detections[:num_detections, 3] = final_boxes[:, 0] / self.model_width
detections[:num_detections, 4] = final_boxes[:, 3] / self.model_height
detections[:num_detections, 5] = final_boxes[:, 2] / self.model_width
return detections
elif self.model_type == ModelTypeEnum.ssd:
self.determine_indexes_for_non_yolo_models()
boxes = self.interpreter.tensor(self.tensor_output_details[0]["index"])()[0]
class_ids = self.interpreter.tensor(
self.tensor_output_details[1]["index"]
)()[0]
scores = self.interpreter.tensor(self.tensor_output_details[2]["index"])()[
0
]
count = int(
self.interpreter.tensor(self.tensor_output_details[3]["index"])()[0]
)
detections = np.zeros((self.max_detections, 6), np.float32)
for i in range(count):
if scores[i] < self.min_score:
break
if i == self.max_detections:
logger.debug(f"Too many detections ({count})!")
break
detections[i] = [
class_ids[i],
float(scores[i]),
boxes[i][0],
boxes[i][1],
boxes[i][2],
boxes[i][3],
]
return detections
else:
raise Exception(
f"{self.model_type} is currently not supported for edgetpu. See the docs for more info on supported models."
)
+4 -6
View File
@@ -7,7 +7,8 @@ from frigate.detectors.detection_api import DetectionApi
from frigate.detectors.detector_config import BaseDetectorConfig
from ..detector_utils import (
TFLiteDetectionModel,
tflite_detect_raw,
tflite_init,
tflite_load_delegate_interpreter,
)
@@ -29,7 +30,6 @@ class TeflonDetectorConfig(BaseDetectorConfig):
class TeflonTfl(DetectionApi):
type_key = DETECTOR_KEY
supported_models = TFLiteDetectionModel.supported_models
def __init__(self, detector_config: TeflonDetectorConfig):
# Location in Debian's mesa-teflon-delegate
@@ -39,9 +39,7 @@ class TeflonTfl(DetectionApi):
interpreter = tflite_load_delegate_interpreter(
delegate_library, detector_config, device_config
)
self.tflite_model = TFLiteDetectionModel(
interpreter, detector_config.model, DETECTOR_KEY
)
tflite_init(self, interpreter)
def detect_raw(self, tensor_input):
return self.tflite_model.detect_raw(tensor_input)
return tflite_detect_raw(self, tensor_input)
-3
View File
@@ -93,7 +93,6 @@ class EventCleanup(threading.Thread):
Event.start_time < expire_after,
Event.label == event.label,
Event.retain_indefinitely == False,
Event.has_snapshot == True,
)
.namedtuples()
.iterator()
@@ -115,7 +114,6 @@ class EventCleanup(threading.Thread):
Event.start_time < expire_after,
Event.label == event.label,
Event.retain_indefinitely == False,
Event.has_snapshot == True,
)
events_to_update: list[str] = []
@@ -171,7 +169,6 @@ class EventCleanup(threading.Thread):
Event.start_time < expire_after,
Event.label == event.label,
Event.retain_indefinitely == False,
Event.has_snapshot == True,
)
.namedtuples()
.iterator()
+49 -7
View File
@@ -7,6 +7,7 @@ import os
import re
import time
from collections.abc import AsyncGenerator, Callable
from dataclasses import dataclass
from typing import Any
import numpy as np
@@ -33,6 +34,7 @@ __all__ = [
"GenAIClientManager",
"GenAIConfig",
"GenAIProviderEnum",
"GenAIResponse",
"PROVIDERS",
"load_providers",
"register_genai_provider",
@@ -41,6 +43,18 @@ __all__ = [
PROVIDERS: dict[GenAIProviderEnum, type["GenAIClient"]] = {}
@dataclass
class GenAIResponse:
"""A provider's answer to a single request.
The reasoning trace is kept apart from the content so callers can save it
for debugging without it ending up in the parsed answer.
"""
content: str
reasoning: str | None = None
def register_genai_provider(key: GenAIProviderEnum) -> Callable:
"""Register a GenAI provider."""
@@ -109,11 +123,14 @@ class GenAIClient:
activity_context_prompt: str,
response_style: str = "default",
frame_captions: list[str] | None = None,
enable_thinking: bool = False,
) -> ReviewMetadata | None:
"""Generate a description for the review item activity.
`frame_captions` holds one caption per thumbnail for the annotated
frame mode; each is sent directly before its frame.
frame mode; each is sent directly before its frame. `enable_thinking`
lets the model reason before answering, when the provider exposes a
per-request toggle.
"""
if frame_captions and len(frame_captions) != len(thumbnails):
logger.warning(
@@ -137,6 +154,13 @@ class GenAIClient:
f"Sending {len(thumbnails)} images to create review description on {review_data['camera']}"
)
if enable_thinking and not self.supports_toggleable_thinking:
logger.debug(
"Thinking requested for review description but model %s does not "
"expose a per-request thinking toggle, using its default behavior",
self.genai_config.model,
)
if debug_save:
with open(
os.path.join(
@@ -164,12 +188,14 @@ class GenAIClient:
response_format = build_review_description_response_format(concerns)
response = self._send(
result = self._send(
context_prompt,
thumbnails,
response_format,
enable_thinking=enable_thinking,
image_captions=frame_captions,
)
response = result.content if result else None
if debug_save and response:
with open(
@@ -180,6 +206,17 @@ class GenAIClient:
) as f:
f.write(response)
# Saved even when the content is blank, since a model that spends its
# whole output budget thinking returns a trace but no answer.
if debug_save and result and result.reasoning:
with open(
os.path.join(
CLIPS_DIR, "genai-requests", review_data["id"], "reasoning.txt"
),
"w",
) as f:
f.write(result.reasoning)
if response:
clean_json = re.sub(
r"\n?```$", "", re.sub(r"^```[a-zA-Z0-9]*\n?", "", response)
@@ -268,7 +305,8 @@ class GenAIClient:
) as f:
f.write(timeline_summary_prompt)
response = self._send(timeline_summary_prompt, [])
result = self._send(timeline_summary_prompt, [])
response = result.content if result else None
if debug_save and response:
with open(
@@ -295,7 +333,8 @@ class GenAIClient:
return None
logger.debug(f"Sending images to genai provider with prompt: {prompt}")
return self._send(prompt, thumbnails)
result = self._send(prompt, thumbnails)
return result.content if result else None
def _init_provider(self) -> Any:
"""Initialize the client."""
@@ -308,13 +347,16 @@ class GenAIClient:
response_format: dict | None = None,
enable_thinking: bool = False,
image_captions: list[str] | None = None,
) -> str | None:
) -> GenAIResponse | None:
"""Submit a request to the provider.
``enable_thinking`` is honored only by providers that report
``supports_toggleable_thinking``. Description-style callers leave it
at the default (off) since synthesis tasks don't benefit from
reasoning traces.
at the default (off) unless the user opts in, since most models write
better descriptions without spending tokens on a reasoning trace.
Returns None when the request fails. Providers that surface a
reasoning trace separately return it in ``reasoning``.
``image_captions`` carries one caption per image, to be placed
immediately before its image so the model can tell the frames apart.
+9 -4
View File
@@ -12,7 +12,7 @@ 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
from frigate.genai import GenAIClient, GenAIResponse, register_genai_provider
from frigate.genai.utils import interleave_images
logger = logging.getLogger(__name__)
@@ -124,8 +124,13 @@ class GeminiClient(GenAIClient):
response_format: dict | None = None,
enable_thinking: bool = False,
image_captions: list[str] | None = None,
) -> str | None:
"""Submit a request to Gemini."""
) -> GenAIResponse | None:
"""Submit a request to Gemini.
Thinking is configured at the model level for Gemini (or through
``thinking_config`` in ``runtime_options``), so ``enable_thinking`` is
ignored here.
"""
contents: list[Any] = [
part
if isinstance(part, str)
@@ -165,7 +170,7 @@ class GeminiClient(GenAIClient):
except (ValueError, AttributeError):
# No description was generated
return None
return description
return GenAIResponse(description)
@property
def supports_transcription(self) -> bool:
+4 -3
View File
@@ -11,7 +11,7 @@ import numpy as np
import requests
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
from frigate.genai import GenAIClient, GenAIResponse, register_genai_provider
from frigate.genai.utils import (
interleave_images,
parse_tool_calls_from_message,
@@ -304,7 +304,7 @@ class LlamaCppClient(GenAIClient):
response_format: dict | None = None,
enable_thinking: bool = False,
image_captions: list[str] | None = None,
) -> str | None:
) -> GenAIResponse | None:
"""Submit a request to llama.cpp server."""
if self.provider is None:
logger.warning(
@@ -362,7 +362,8 @@ class LlamaCppClient(GenAIClient):
):
choice = result["choices"][0]
if "message" in choice and "content" in choice["message"]:
return str(choice["message"]["content"].strip())
message = self._message_from_choice(choice)
return GenAIResponse(message["content"] or "", message["reasoning"])
return None
except Exception as e:
logger.warning("llama.cpp returned an error: %s", str(e))
+5 -4
View File
@@ -14,7 +14,7 @@ from ollama import Client as ApiClient
from ollama import ResponseError
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
from frigate.genai import GenAIClient, GenAIResponse, register_genai_provider
from frigate.genai.utils import (
interleave_images,
parse_tool_calls_from_message,
@@ -243,7 +243,7 @@ class OllamaClient(GenAIClient):
response_format: dict | None = None,
enable_thinking: bool = False,
image_captions: list[str] | None = None,
) -> str | None:
) -> GenAIResponse | None:
"""Submit a request to Ollama through the chat API, the same path the
tool-calling chat uses, with image placeholders keeping any captions
next to their frames."""
@@ -299,7 +299,8 @@ class OllamaClient(GenAIClient):
response.get("eval_count"),
response.get("prompt_eval_count"),
)
response_text = self._message_from_response(response)["content"] or ""
message_result = self._message_from_response(response)
response_text = message_result["content"] or ""
if not response_text:
logger.warning(
@@ -310,7 +311,7 @@ class OllamaClient(GenAIClient):
response.get("eval_count"),
)
return response_text
return GenAIResponse(response_text, message_result["reasoning"])
def list_models(self) -> list[str]:
"""Return available model names from the Ollama server."""
+16 -4
View File
@@ -10,7 +10,7 @@ from httpx import TimeoutException
from openai import OpenAI
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
from frigate.genai import GenAIClient, GenAIResponse, register_genai_provider
from frigate.genai.utils import interleave_images
logger = logging.getLogger(__name__)
@@ -71,8 +71,14 @@ class OpenAIClient(GenAIClient):
response_format: dict | None = None,
enable_thinking: bool = False,
image_captions: list[str] | None = None,
) -> str | None:
"""Submit a request to OpenAI."""
) -> GenAIResponse | None:
"""Submit a request to OpenAI.
The chat completions API has no per-request thinking toggle, so
``enable_thinking`` is ignored here. Reasoning is controlled through
``runtime_options`` (e.g. ``reasoning_effort``), and any trace the
server returns separately is passed back as ``reasoning``.
"""
messages_content: list[dict] = []
for part in interleave_images(prompt, images, image_captions):
if isinstance(part, str):
@@ -120,6 +126,10 @@ class OpenAIClient(GenAIClient):
):
message = result.choices[0].message
content = message.content
raw_reasoning = getattr(message, "reasoning_content", None) or getattr(
message, "reasoning", None
)
reasoning = raw_reasoning.strip() if raw_reasoning else None
if not content:
# When reasoning is enabled for some OpenAI backends the actual response
@@ -134,7 +144,9 @@ class OpenAIClient(GenAIClient):
)
content = reasoning_content
return str(content.strip()) if content else None
return (
GenAIResponse(str(content.strip()), reasoning) if content else None
)
return None
except (TimeoutException, Exception) as e:
logger.warning("OpenAI returned an error: %s", str(e))
+15 -15
View File
@@ -19,10 +19,10 @@ from frigate.models import Event
# Base guidance per response field. `observations` is a reasoning scaffold,
# not user-facing, so style presets never override it.
REVIEW_DESCRIPTION_FIELD_GUIDELINES: dict[str, str] = {
"observations": "Include the very start of the activity — for example, a vehicle entering the frame or pulling into the driveway — even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence.",
"scene": 'Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name — do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.',
"title": "Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles.",
"shortSummary": "Briefly summarize the primary activity across the observations.",
"observations": "Include the very start of the activity, for example a vehicle entering the frame or pulling into the driveway, even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence. Use every name from Objects in Scene.",
"scene": 'Describe how the sequence begins, then the progression of events, all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.',
"title": "Every name in Objects in Scene belongs in the title. Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For an unnamed object, refer to it naturally with an article.",
"shortSummary": "Briefly summarize the primary activity across the observations. Use every name from Objects in Scene.",
"potential_threat_level": "Must be consistent with your scene description and the activity patterns above.",
}
@@ -31,19 +31,19 @@ REVIEW_DESCRIPTION_FIELD_GUIDELINES: dict[str, str] = {
# competing style instructions.
REVIEW_RESPONSE_STYLES: dict[str, dict[str, str]] = {
"natural": {
"scene": 'Recount what happened the way a person would describe it to a neighbor, in plain everyday language: how the sequence begins, then each significant movement and action in the order it happens. Use flowing sentences that connect related actions, written the way people actually talk rather than like a surveillance report. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name rather than a generic term. Refer to unnamed objects naturally with articles. Stay factual, and keep the description consistent with the threat level you assign.',
"title": 'Write the title as a short, sentence-case headline in present tense: the subject, then what they do, phrased the way you would text it to the homeowner. Describe only the action you see; do not assign the person a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens. For named subjects, always use their name.',
"shortSummary": "Sum up the primary activity in one short, natural sentence, as if mentioning it to someone in passing.",
"scene": 'Recount what happened the way a person would describe it to a neighbor, in plain everyday language: how the sequence begins, then each significant movement and action in the order it happens. Use flowing sentences that connect related actions, written the way people actually talk rather than like a surveillance report. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name. Refer to unnamed objects naturally with articles. Stay factual, and keep the description consistent with the threat level you assign.',
"title": 'Every name in Objects in Scene belongs in the title. Write the title as a short, sentence-case headline in present tense: the subject, then what they do, phrased the way you would text it to the homeowner. Describe only the action you see; do not assign the person a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens.',
"shortSummary": "Sum up the primary activity in one short, natural sentence, as if mentioning it to someone in passing. Use every name from Objects in Scene.",
},
"concise": {
"scene": 'Cover each significant movement and action in order using as few short, direct sentences as possible, omitting environmental and cosmetic detail unless it affects the assessment. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign.',
"title": 'Write the title as a terse label of two to four words naming the specific activity observed and where it happened. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. For named subjects, always use their name.',
"shortSummary": "Summarize the primary activity in one short sentence.",
"title": 'Every name in Objects in Scene belongs in the title. Write the title as a terse label of two to four words naming the specific activity observed and where it happened, apart from those names. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene.',
"shortSummary": "Summarize the primary activity in one short sentence. Use every name from Objects in Scene.",
},
"detailed": {
"scene": 'Describe how the sequence begins, then the progression of events — all significant movements and actions in order, including the specifics that best identify the subjects: colors, clothing, carried items, positions, and paths of movement, plus environmental details like lighting changes when they stand out. Favor the most identifying details over exhaustive coverage, and keep every added detail observational rather than speculative. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name rather than a generic term. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign.',
"title": 'Write the title as a specific description of who did what and where, in under roughly twelve words, including the most distinguishing visible detail of the subject, such as clothing or vehicle color. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens. For named subjects, always use their name.',
"shortSummary": "Briefly summarize the primary activity across the observations, including the most identifying visible detail, such as vehicle color or clothing.",
"scene": 'Describe how the sequence begins, then the progression of events, all significant movements and actions in order, including the specifics that best identify the subjects: colors, clothing, carried items, positions, and paths of movement, plus environmental details like lighting changes when they stand out. Favor the most identifying details over exhaustive coverage, and keep every added detail observational rather than speculative. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name. For unnamed objects, refer to them naturally with articles. Your description should align with and support the threat level you assign.',
"title": 'Every name in Objects in Scene belongs in the title. Write the title as a specific description of who did what and where, in under roughly twelve words, including the most distinguishing visible detail of the subject, such as clothing or vehicle color. Do not assign a role or purpose that is not visibly indicated by a uniform, a marked vehicle, or a "(delivery/service)" tag in Objects in Scene. Name the main thing done, not just movement through the scene, unless movement is all that happens.',
"shortSummary": "Briefly summarize the primary activity across the observations, including the most identifying visible detail, such as vehicle color or clothing. Use every name from Objects in Scene.",
},
}
@@ -63,7 +63,7 @@ def get_review_field_guidelines(response_style: str = "default") -> dict[str, st
# mode. Neither the notes nor this guidance say whether repeated detections are
# the same subject, since the tracking data cannot tell.
FRAME_ANNOTATION_GUIDANCE = """- Each image below is immediately preceded by a text label giving its frame number and how many seconds into the sequence it was captured. Use these labels to track the order of events and the time between them.
- Some images below are preceded by notes from the camera's object tracker recording what changed at that point: an object being first detected, starting to move, reversing direction, stopping, or no longer being detected. These notes come from tracking data rather than from the images, and they are reliable. Use them to establish how many distinct activities occur and in what order, and describe every one of them."""
- Some images below are preceded by a note of what changed at that moment: a subject being first detected, starting to move, reversing direction, stopping, or no longer being detected. When the note names the subject, use that name. These notes come from tracking data rather than from the images, and they are reliable. Use them to establish how many distinct activities occur and in what order, and describe every one of them."""
def build_review_description_prompt(
@@ -164,9 +164,9 @@ Respond with a JSON object matching the provided schema. Field-specific guidance
## Objects in Scene
Each line represents a detection state, not necessarily unique individuals. The `←` symbol separates a recognized subject's name from their object type — use only the name (before the `←`) in your response, not the type after it. The same subject may appear across multiple lines if detected multiple times.
The `←` symbol separates a recognized, known subject's name from their object type. Use each of these names. The same subject may appear on more than one line. A line with no `←` has no name; refer to it naturally with an article.
**Note: Unidentified objects (without names) are NOT indicators of suspicious activity—they simply mean the system hasn't identified that object.**
An object with no name is not suspicious just because it is unnamed.
{get_objects_list()}
{get_language_prompt()}
+10 -14
View File
@@ -100,14 +100,6 @@ class PendingReviewSegment:
if zone not in self.zones:
self.zones.append(zone)
def add_manual_detection(self, info: dict[str, Any]) -> None:
"""Add a manual event's label and sub label to the segment."""
if info.get("sub_label"):
self.detections[info["event_id"]] = f"{info['label']}-verified"
self.sub_labels[info["event_id"]] = info["sub_label"]
else:
self.detections[info["event_id"]] = info["label"]
def update_frame(
self,
camera_config: CameraConfig,
@@ -491,6 +483,8 @@ class ReviewSegmentMaintainer(threading.Thread):
alerts so manual events keep their historical severity.
"""
review_config = self.config.cameras[camera].review
# label contains 'label: sub_label', only the label is categorized
label = label.split(": ")[0]
if review_config.alerts.enabled and label in review_config.alerts.labels:
return SeverityEnum.alert
@@ -900,7 +894,9 @@ class ReviewSegmentMaintainer(threading.Thread):
current_segment.last_detection_time = frame_time
elif topic == DetectionTypeEnum.api or topic == DetectionTypeEnum.lpr:
if manual_info["state"] == ManualEventState.complete:
current_segment.add_manual_detection(manual_info)
current_segment.detections[manual_info["event_id"]] = (
manual_info["label"]
)
if topic == DetectionTypeEnum.api:
severity = self.get_manual_event_severity(
camera, manual_info["label"]
@@ -925,7 +921,9 @@ class ReviewSegmentMaintainer(threading.Thread):
self.indefinite_events[camera][manual_info["event_id"]] = (
manual_info["label"]
)
current_segment.add_manual_detection(manual_info)
current_segment.detections[manual_info["event_id"]] = (
manual_info["label"]
)
if topic == DetectionTypeEnum.api:
if (
self.get_manual_event_severity(
@@ -1016,12 +1014,11 @@ class ReviewSegmentMaintainer(threading.Thread):
camera,
frame_time,
severity,
{},
{manual_info["event_id"]: manual_info["label"]},
{},
[],
set(),
)
api_segment.add_manual_detection(manual_info)
self._activate_segment(api_segment)
if manual_info["state"] == ManualEventState.start:
@@ -1044,12 +1041,11 @@ class ReviewSegmentMaintainer(threading.Thread):
camera,
frame_time,
SeverityEnum.detection,
{},
{manual_info["event_id"]: manual_info["label"]},
{},
[],
set(),
)
lpr_segment.add_manual_detection(manual_info)
self._activate_segment(lpr_segment)
if manual_info["state"] == ManualEventState.start:
-11
View File
@@ -1,11 +0,0 @@
from frigate.config.config import stream_info_retriever
def _unreachable_stream_info(ffmpeg, path: str) -> dict:
return {"width": 0, "height": 0, "fourcc": None}
# Validating a camera without detect dimensions probes its stream. Test streams
# are unreachable, so return the failed probe result instead of waiting ~12s per
# stream for ffprobe to time out
stream_info_retriever.get_stream_info = _unreachable_stream_info
-278
View File
@@ -1,278 +0,0 @@
"""Run the unit test suite in parallel groups.
Test modules are split into groups balanced by test count, similar to Home
Assistant's split_tests.py, and each group runs in its own unittest process.
Usage (from the repo root):
python3 -u -m frigate.test [-j JOBS] [--groups GROUPS] [--timeout SECONDS]
"""
import argparse
import math
import os
import re
import signal
import subprocess
import sys
import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
TEST_METHOD = re.compile(r"^\s+(?:async )?def test", re.MULTILINE)
RAN_TESTS = re.compile(r"^Ran (\d+) tests? in", re.MULTILINE)
# Each group pays for its own interpreter and imports, so smaller groups only
# help when there are more cores than groups
TESTS_PER_GROUP = 250
# Each group runs in its own process group, which ctrl+c in the terminal does
# not reach, so the runner passes the interrupt on to these
interrupted = threading.Event()
running_groups: set[int] = set()
@dataclass
class TestGroup:
index: int
modules: list[str] = field(default_factory=list)
weight: int = 0
@dataclass
class GroupResult:
group: TestGroup
returncode: int
output: str
duration: float
@property
def tests_run(self) -> int:
"""Number of tests unittest reported running, 0 if it never finished."""
match = RAN_TESTS.search(self.output)
return int(match.group(1)) if match else 0
def discover_modules() -> dict[str, int]:
"""Find test modules the same way unittest discovery does.
Returns:
Module name mapped to the number of test methods it defines
"""
modules: dict[str, int] = {}
for path in sorted((REPO_ROOT / "frigate").rglob("test*.py")):
relative = path.relative_to(REPO_ROOT)
# discovery only descends into packages
if not all(
(REPO_ROOT / parent / "__init__.py").exists()
for parent in relative.parents
if parent != Path(".")
):
continue
name = ".".join(relative.with_suffix("").parts)
modules[name] = len(TEST_METHOD.findall(path.read_text()))
return modules
def split_groups(modules: dict[str, int], count: int) -> list[TestGroup]:
"""Split modules into groups with a similar number of tests.
Largest modules are placed first, each into the currently lightest group.
"""
groups = [TestGroup(index) for index in range(1, count + 1)]
for name, weight in sorted(modules.items(), key=lambda m: m[1], reverse=True):
group = min(groups, key=lambda g: g.weight)
group.modules.append(name)
group.weight += weight
return [group for group in groups if group.modules]
def signal_group(pgid: int, sig: signal.Signals) -> bool:
"""Send a signal to every process in a process group.
Returns:
False if no process is left in the group
"""
try:
os.killpg(pgid, sig)
except ProcessLookupError:
return False
return True
def stop_group(pgid: int) -> None:
"""Stop the processes a group's tests left running.
They get a moment to exit before being killed, so the multiprocessing
resource tracker, which ignores SIGTERM, can remove leaked shared memory
once the processes holding it have exited.
"""
if signal_group(pgid, signal.SIGTERM):
time.sleep(2)
signal_group(pgid, signal.SIGKILL)
def run_group(group: TestGroup, timeout: float) -> GroupResult:
"""Run a group's modules in a unittest process of its own.
Args:
group: The modules to run
timeout: Seconds before the group is stopped
Returns:
The exit code and combined output of the unittest process
"""
start = time.monotonic()
# a file instead of a pipe so a process left behind by a test can't keep
# the group from finishing by holding the pipe open
with tempfile.TemporaryFile("w+") as output:
proc = subprocess.Popen(
[sys.executable, "-u", "-X", "faulthandler", "-m", "unittest"]
+ group.modules,
cwd=REPO_ROOT,
# a group in the background that read the terminal would be
# stopped, and a stopped process doesn't act on SIGABRT
stdin=subprocess.DEVNULL,
stdout=output,
stderr=subprocess.STDOUT,
# a process group of its own so the processes its tests start can
# be stopped with it
process_group=0,
)
running_groups.add(proc.pid)
# the runner may have been interrupted while this group was starting
if interrupted.is_set():
signal_group(proc.pid, signal.SIGINT)
timed_out = False
try:
proc.wait(timeout)
except subprocess.TimeoutExpired:
# faulthandler prints the stack of every thread on SIGABRT, which
# shows where the group is stuck
timed_out = True
proc.send_signal(signal.SIGABRT)
proc.wait()
# aborting skips the cleanup of the running test, which would
# have stopped the processes it started
stop_group(proc.pid)
running_groups.discard(proc.pid)
output.seek(0)
text = output.read()
if timed_out:
text += f"\nGroup timed out after {timeout:.0f}s\n"
return GroupResult(group, proc.returncode, text, time.monotonic() - start)
def main() -> int:
"""Split the tests into groups, run them in parallel, and report the results.
Returns:
The exit code, 1 if any group failed
"""
cpus = os.cpu_count() or 1
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"-j",
"--jobs",
type=int,
default=cpus,
help=f"number of groups to run at once (default: {cpus})",
)
parser.add_argument(
"--groups",
type=int,
help=(
"number of groups to split the tests into (default: one per job, "
f"or more to keep groups under about {TESTS_PER_GROUP} tests)"
),
)
parser.add_argument(
"--timeout",
type=float,
default=600,
help="seconds before a group is stopped and reported as failed",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="print the output of passing groups as well as failing ones",
)
args = parser.parse_args()
modules = discover_modules()
group_count = args.groups or max(
args.jobs, math.ceil(sum(modules.values()) / TESTS_PER_GROUP)
)
groups = split_groups(modules, group_count)
print(f"Running {len(groups)} test groups with {args.jobs} jobs", flush=True)
start = time.monotonic()
failed: list[GroupResult] = []
total_tests = 0
with ThreadPoolExecutor(max_workers=args.jobs) as executor:
futures = [executor.submit(run_group, group, args.timeout) for group in groups]
try:
for future in as_completed(futures):
result = future.result()
total_tests += result.tests_run
status = "OK" if result.returncode == 0 else "FAILED"
print(
f"Group {result.group.index}/{len(groups)}: {status}, "
f"{result.tests_run} tests in {result.duration:.1f}s",
flush=True,
)
if result.returncode != 0:
failed.append(result)
if result.returncode != 0 or args.verbose:
print(result.output, flush=True)
except KeyboardInterrupt:
interrupted.set()
executor.shutdown(wait=False, cancel_futures=True)
for pgid in list(running_groups):
signal_group(pgid, signal.SIGINT)
raise
print(f"\nRan {total_tests} tests in {time.monotonic() - start:.1f}s")
if failed:
print(f"{len(failed)} of {len(groups)} groups FAILED, rerun one with:")
for result in sorted(failed, key=lambda r: r.group.index):
print(
f" Group {result.group.index}: python3 -u -m unittest "
f"{' '.join(result.group.modules)}"
)
return 1
print("OK")
return 0
if __name__ == "__main__":
sys.exit(main())
+2 -10
View File
@@ -1,12 +1,4 @@
"""Constants for testing."""
import os
import tempfile
# Unique per process so test groups can run in parallel without sharing a database
TEST_DB = os.path.join(tempfile.gettempdir(), f"frigate_test_{os.getpid()}.db")
TEST_DB_CLEANUPS = [TEST_DB, f"{TEST_DB}-shm", f"{TEST_DB}-wal"]
# The database is thrown away after each test, so skip syncing every commit to
# disk, which is slow when several test processes are writing at once
TEST_DB_PRAGMAS = {"synchronous": "off"}
TEST_DB = "test.db"
TEST_DB_CLEANUPS = ["test.db", "test.db-shm", "test.db-wal"]
+3 -3
View File
@@ -18,7 +18,7 @@ from frigate.debug_replay import DebugReplayManager
from frigate.jobs.export import JobStatePublisher
from frigate.models import Event, Recordings, ReviewSegment
from frigate.review.types import SeverityEnum
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
class AuthTestClient(TestClient):
@@ -38,12 +38,12 @@ class AuthTestClient(TestClient):
class BaseTestHttp(unittest.TestCase):
def setUp(self, models):
# setup clean database for each test run
migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
migrate_db = SqliteDatabase("test.db")
del logging.getLogger("peewee_migrate").handlers[:]
router = Router(migrate_db)
router.run()
migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
self.db = SqliteQueueDatabase(TEST_DB)
self.db.bind(models)
# The export job manager broadcasts via JobStatePublisher on
+12 -14
View File
@@ -1,12 +1,11 @@
import os
import shutil
import tempfile
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import cv2
import numpy as np
from frigate.output.preview import PREVIEW_FRAME_TYPE
from frigate.output.preview import PREVIEW_CACHE_DIR, PREVIEW_FRAME_TYPE
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@@ -16,15 +15,14 @@ class TestHttpLatestFrame(BaseTestHttp):
self.app = super().create_app()
self.app.detected_frames_processor = MagicMock()
# a directory of its own so test groups running in parallel can't
# delete each other's preview frames
self.preview_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.preview_dir, ignore_errors=True)
cache_dir_patch = patch(
"frigate.output.preview.PREVIEW_CACHE_DIR", self.preview_dir
)
cache_dir_patch.start()
self.addCleanup(cache_dir_patch.stop)
if os.path.exists(PREVIEW_CACHE_DIR):
shutil.rmtree(PREVIEW_CACHE_DIR)
os.makedirs(PREVIEW_CACHE_DIR)
def tearDown(self):
if os.path.exists(PREVIEW_CACHE_DIR):
shutil.rmtree(PREVIEW_CACHE_DIR)
super().tearDown()
def test_latest_frame_fallback_to_preview(self):
camera = "front_door"
@@ -47,7 +45,7 @@ class TestHttpLatestFrame(BaseTestHttp):
2,
)
preview_path = os.path.join(
self.preview_dir, f"preview_{camera}-1234567890.0.{PREVIEW_FRAME_TYPE}"
PREVIEW_CACHE_DIR, f"preview_{camera}-1234567890.0.{PREVIEW_FRAME_TYPE}"
)
cv2.imwrite(preview_path, dummy_frame)
@@ -82,7 +80,7 @@ class TestHttpLatestFrame(BaseTestHttp):
# 2. Create a dummy preview file
preview_path = os.path.join(
self.preview_dir, f"preview_{camera}-999.0.{PREVIEW_FRAME_TYPE}"
PREVIEW_CACHE_DIR, f"preview_{camera}-999.0.{PREVIEW_FRAME_TYPE}"
)
cv2.imwrite(preview_path, dummy_frame)
@@ -1,87 +0,0 @@
"""Tests for snapshot expiration in event cleanup."""
import datetime
import unittest
from unittest.mock import MagicMock, patch
from peewee import SqliteDatabase
from frigate.config import FrigateConfig
from frigate.events.cleanup import EventCleanup
from frigate.models import Event, Timeline
class TestEventCleanupSnapshots(unittest.TestCase):
def setUp(self):
self.db = SqliteDatabase(":memory:")
models = [Event, Timeline]
self.db.bind(models)
self.db.create_tables(models)
config = FrigateConfig(
**{
"mqtt": {"host": "mqtt"},
"snapshots": {"enabled": True, "retain": {"default": 14}},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
}
},
}
)
self.cleanup = EventCleanup(config, MagicMock(), MagicMock())
def tearDown(self):
self.db.close()
def _insert_event(self, id: str, camera: str, has_snapshot: bool) -> None:
end_time = (datetime.datetime.now() - datetime.timedelta(days=20)).timestamp()
Event.create(
id=id,
label="person",
camera=camera,
start_time=end_time - 10,
end_time=end_time,
top_score=0.9,
score=0.9,
false_positive=False,
zones=[],
thumbnail="",
has_clip=True,
has_snapshot=has_snapshot,
region=[],
box=[],
area=0,
retain_indefinitely=False,
plus_id="",
model_hash="",
detector_type="cpu",
model_type="ssd",
data={},
)
@patch("frigate.events.cleanup.delete_event_snapshot", return_value=True)
def test_expired_snapshots_are_only_deleted_once(self, delete_snapshot):
# events stay in the db while their clip is retained, so events whose
# snapshot already expired must not be selected again
for camera in ("front_door", "removed_camera"):
self._insert_event(f"{camera}-pending", camera, has_snapshot=True)
self._insert_event(f"{camera}-expired", camera, has_snapshot=False)
self.cleanup.expire_snapshots()
deleted = sorted(c.args[0].id for c in delete_snapshot.call_args_list)
assert deleted == ["front_door-pending", "removed_camera-pending"]
assert not Event.select().where(Event.has_snapshot == True).exists()
delete_snapshot.reset_mock()
self.cleanup.expire_snapshots()
delete_snapshot.assert_not_called()
+3 -7
View File
@@ -617,13 +617,9 @@ class TestExportTimelineAlignment(unittest.TestCase):
):
windows = exporter._staged_chapter_windows()
# the real cache dir only exists once the container has started
with tempfile.TemporaryDirectory() as tmpdir:
chapter_path = os.path.join(tmpdir, "chapters.txt")
exporter._chapter_metadata_path = lambda: chapter_path # type: ignore[method-assign]
path = exporter._build_recording_segment_chapter_metadata_file(windows)
self.assertEqual(path, chapter_path)
content = Path(chapter_path).read_text()
path = exporter._build_recording_segment_chapter_metadata_file(windows)
self.addCleanup(os.remove, path)
content = Path(path).read_text()
self.assertIn("START=0\nEND=21500", content)
self.assertIn("START=21500\nEND=43000", content)
+5
View File
@@ -83,6 +83,11 @@ class TestReviewResponseStyle(unittest.TestCase):
prompt,
)
def test_named_subject_is_the_title_subject(self):
prompt = self._build_prompt()
self.assertIn("Every name in Objects in Scene belongs in the title.", prompt)
self.assertIn("recognized, known subject", prompt)
class TestClassificationStateChanges(unittest.TestCase):
def _build_prompt(self, **extra) -> str:
+169 -2
View File
@@ -15,6 +15,8 @@ crashing message conversion.
import asyncio
import base64
import json
import os
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@@ -22,7 +24,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import requests
from frigate.config import GenAIConfig, GenAIProviderEnum
from frigate.genai import PROVIDERS, load_providers
from frigate.genai import PROVIDERS, GenAIClient, GenAIResponse, load_providers
load_providers()
@@ -194,6 +196,19 @@ class TestOpenAIProvider(unittest.TestCase):
final = _final_message(_collect(client, MULTIMODAL_MESSAGES))
self.assertEqual(final["content"], "ok")
def test_send_returns_reasoning_content(self):
client = self._client()
message = SimpleNamespace(
content=" ok ", reasoning_content=" thought ", reasoning=None
)
client.provider.chat.completions.create = MagicMock(
return_value=SimpleNamespace(choices=[SimpleNamespace(message=message)])
)
result = client._send("prompt", [b"a"], enable_thinking=True)
self.assertEqual(result, GenAIResponse("ok", "thought"))
# ---------------------------------------------------------------------------
# Gemini
@@ -399,7 +414,7 @@ class TestOllamaProvider(unittest.TestCase):
image_captions=["Frame 1 of 2", "Frame 2 of 2"],
)
self.assertEqual(result, '{"ok": true}')
self.assertEqual(result, GenAIResponse('{"ok": true}'))
client.provider.generate.assert_not_called()
params = client.provider.chat.call_args.kwargs
self.assertEqual(
@@ -427,6 +442,30 @@ class TestOllamaProvider(unittest.TestCase):
self.assertEqual(message["content"], "prompt\n[img]")
self.assertEqual(message["images"], [b"a"])
def test_send_with_thinking_returns_reasoning(self):
client = self._client()
client.provider = MagicMock()
client.provider.chat.return_value = {
"message": {"content": "ok", "thinking": " looked at each frame "},
"done": True,
}
client._supports_thinking_cache = True
result = client._send("prompt", [b"a"], enable_thinking=True)
self.assertTrue(client.provider.chat.call_args.kwargs["think"])
self.assertEqual(result, GenAIResponse("ok", "looked at each frame"))
def test_send_disables_thinking_by_default(self):
client = self._client()
client.provider = MagicMock()
client.provider.chat.return_value = {"message": {"content": "ok"}, "done": True}
client._supports_thinking_cache = True
client._send("prompt", [b"a"])
self.assertFalse(client.provider.chat.call_args.kwargs["think"])
def test_capabilities_drive_embeddings_and_thinking(self):
client = self._client()
client.provider = MagicMock()
@@ -696,6 +735,42 @@ class TestLlamaCppProvider(unittest.TestCase):
final = _final_message(self._run_with_lines(client, lines, MULTIMODAL_MESSAGES))
self.assertEqual(final["content"], "ok")
@staticmethod
def _chat_response(message):
response = MagicMock()
response.json.return_value = {"choices": [{"message": message}]}
return response
def test_send_with_thinking_returns_reasoning(self):
client = self._client()
client._supports_reasoning = True
response = self._chat_response(
{"content": " ok ", "reasoning_content": " looked at each frame "}
)
with patch.object(client, "_post", return_value=response) as post:
result = client._send("prompt", [b"a"], enable_thinking=True)
self.assertEqual(
post.call_args.kwargs["json"]["chat_template_kwargs"],
{"enable_thinking": True},
)
self.assertEqual(result, GenAIResponse("ok", "looked at each frame"))
def test_send_keeps_reasoning_when_content_is_null(self):
# A model that exhausts its output budget while thinking returns a
# trace with no content; the trace is what explains the blank answer.
client = self._client()
client._supports_reasoning = True
response = self._chat_response(
{"content": None, "reasoning_content": "still thinking"}
)
with patch.object(client, "_post", return_value=response):
result = client._send("prompt", [b"a"], enable_thinking=True)
self.assertEqual(result, GenAIResponse("", "still thinking"))
def _validated_client(self, server_context_size, provider_options=None):
"""Build a client as if the server reported the given context size."""
cfg = GenAIConfig(
@@ -1093,6 +1168,98 @@ class TestImageTokenEstimate(unittest.TestCase):
self.assertEqual(client._image_token_cache, {})
class _StubThinkingClient(GenAIClient):
def __init__(self, response: GenAIResponse | None) -> None:
super().__init__(GenAIConfig(provider="ollama", model="m"))
self.response = response
self.send_kwargs: dict = {}
@property
def supports_toggleable_thinking(self) -> bool:
return True
def _send(self, prompt, images, response_format=None, **kwargs):
self.send_kwargs = kwargs
return self.response
class TestReviewDescriptionThinking(unittest.TestCase):
REVIEW_ID = "1700000000.0-abc"
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.clips_dir = tmp.name
self.request_dir = os.path.join(
self.clips_dir, "genai-requests", self.REVIEW_ID
)
os.makedirs(self.request_dir)
patcher = patch("frigate.genai.CLIPS_DIR", self.clips_dir)
patcher.start()
self.addCleanup(patcher.stop)
def _generate(self, client, enable_thinking):
review_data = {
"id": self.REVIEW_ID,
"camera": "front",
"start": "Monday, 09:30 AM",
"duration": 10,
"zones": [],
"unified_objects": ["person"],
}
return client.generate_review_description(
review_data,
[b"a"],
[],
None,
True,
"activity context",
enable_thinking=enable_thinking,
)
def _saved(self, name):
path = os.path.join(self.request_dir, name)
if not os.path.exists(path):
return None
with open(path) as f:
return f.read()
def test_thinking_flag_reaches_provider_and_trace_is_saved(self):
answer = json.dumps(
{
"observations": [],
"scene": "A person walks up to the door.",
"title": "person at the door",
"shortSummary": "A person walks up to the door.",
"confidence": 0.9,
"potential_threat_level": 0,
}
)
client = _StubThinkingClient(GenAIResponse(answer, "frame 1 shows a person"))
metadata = self._generate(client, enable_thinking=True)
self.assertTrue(client.send_kwargs["enable_thinking"])
self.assertEqual(metadata.title, "Person at the door")
self.assertEqual(self._saved("response.txt"), answer)
self.assertEqual(self._saved("reasoning.txt"), "frame 1 shows a person")
def test_trace_is_saved_when_answer_is_blank(self):
client = _StubThinkingClient(GenAIResponse("", "ran out of tokens"))
self.assertIsNone(self._generate(client, enable_thinking=True))
self.assertIsNone(self._saved("response.txt"))
self.assertEqual(self._saved("reasoning.txt"), "ran out of tokens")
def test_thinking_off_by_default(self):
client = _StubThinkingClient(None)
self._generate(client, enable_thinking=False)
self.assertFalse(client.send_kwargs["enable_thinking"])
self.assertIsNone(self._saved("reasoning.txt"))
class TestBaseClientTranscribe(unittest.TestCase):
"""Providers that don't implement the role must be inert, not broken."""
+3 -3
View File
@@ -21,7 +21,7 @@ from frigate.api.media_auth import (
)
from frigate.config import FrigateConfig
from frigate.models import Event, Export, Recordings, ReviewSegment
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
_CONFIG = {
"mqtt": {"host": "mqtt"},
@@ -222,11 +222,11 @@ class TestExportResolution(unittest.TestCase):
"""Export resolution requires a DB lookup."""
def setUp(self):
migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
migrate_db = SqliteDatabase("test.db")
del logging.getLogger("peewee_migrate").handlers[:]
Router(migrate_db).run()
migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
self.db = SqliteQueueDatabase(TEST_DB)
self.db.bind([Event, ReviewSegment, Recordings, Export])
self.config = FrigateConfig(**_CONFIG)
+3 -3
View File
@@ -12,7 +12,7 @@ from playhouse.sqliteq import SqliteQueueDatabase
from frigate.models import Notice, NoticeStats
from frigate.notices.registry import NoticeRegistry
from frigate.notices.types import NOTICE_KINDS, NoticeKind, NoticeSeverity, notice_id
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
# kinds that switch on the lifecycle knobs, so the tests do not depend on the
# values the real catalog picks
@@ -49,12 +49,12 @@ class TestNoticeKinds(unittest.TestCase):
class RegistryTestCase(unittest.TestCase):
def setUp(self):
migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
migrate_db = SqliteDatabase("test.db")
del logging.getLogger("peewee_migrate").handlers[:]
router = Router(migrate_db)
router.run()
migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
self.db = SqliteQueueDatabase(TEST_DB)
self.db.bind([Notice, NoticeStats])
self.registry = NoticeRegistry()
self.listener = MagicMock()
+20 -20
View File
@@ -1,23 +1,23 @@
import os
import shutil
import tempfile
import unittest
from unittest.mock import patch
from frigate.output.preview import PREVIEW_FRAME_TYPE, get_most_recent_preview_frame
from frigate.output.preview import (
PREVIEW_CACHE_DIR,
PREVIEW_FRAME_TYPE,
get_most_recent_preview_frame,
)
class TestPreviewLoader(unittest.TestCase):
def setUp(self):
# a directory of its own so test groups running in parallel can't
# delete each other's preview frames
self.preview_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.preview_dir, ignore_errors=True)
cache_dir_patch = patch(
"frigate.output.preview.PREVIEW_CACHE_DIR", self.preview_dir
)
cache_dir_patch.start()
self.addCleanup(cache_dir_patch.stop)
if os.path.exists(PREVIEW_CACHE_DIR):
shutil.rmtree(PREVIEW_CACHE_DIR)
os.makedirs(PREVIEW_CACHE_DIR)
def tearDown(self):
if os.path.exists(PREVIEW_CACHE_DIR):
shutil.rmtree(PREVIEW_CACHE_DIR)
def test_get_most_recent_preview_frame_missing(self):
self.assertIsNone(get_most_recent_preview_frame("test_camera"))
@@ -28,14 +28,14 @@ class TestPreviewLoader(unittest.TestCase):
for ts in ["1000.0", "2000.0", "1500.0"]:
with open(
os.path.join(
self.preview_dir, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}"
PREVIEW_CACHE_DIR, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}"
),
"w",
) as f:
f.write(f"test_{ts}")
expected_path = os.path.join(
self.preview_dir, f"preview_{camera}-2000.0.{PREVIEW_FRAME_TYPE}"
PREVIEW_CACHE_DIR, f"preview_{camera}-2000.0.{PREVIEW_FRAME_TYPE}"
)
self.assertEqual(get_most_recent_preview_frame(camera), expected_path)
@@ -45,7 +45,7 @@ class TestPreviewLoader(unittest.TestCase):
for ts in ["1000.0", "2000.0"]:
with open(
os.path.join(
self.preview_dir, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}"
PREVIEW_CACHE_DIR, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}"
),
"w",
) as f:
@@ -53,7 +53,7 @@ class TestPreviewLoader(unittest.TestCase):
# Test finding frame before or at 1500
expected_path = os.path.join(
self.preview_dir, f"preview_{camera}-1000.0.{PREVIEW_FRAME_TYPE}"
PREVIEW_CACHE_DIR, f"preview_{camera}-1000.0.{PREVIEW_FRAME_TYPE}"
)
self.assertEqual(
get_most_recent_preview_frame(camera, before=1500.0), expected_path
@@ -67,7 +67,7 @@ class TestPreviewLoader(unittest.TestCase):
other_camera = "other_camera"
with open(
os.path.join(
self.preview_dir, f"preview_{other_camera}-3000.0.{PREVIEW_FRAME_TYPE}"
PREVIEW_CACHE_DIR, f"preview_{other_camera}-3000.0.{PREVIEW_FRAME_TYPE}"
),
"w",
) as f:
@@ -78,12 +78,12 @@ class TestPreviewLoader(unittest.TestCase):
def test_get_most_recent_preview_frame_hyphenated_camera(self):
for name in ("preview_front-2000.0", "preview_front-door-3000.0"):
with open(
os.path.join(self.preview_dir, f"{name}.{PREVIEW_FRAME_TYPE}"), "w"
os.path.join(PREVIEW_CACHE_DIR, f"{name}.{PREVIEW_FRAME_TYPE}"), "w"
) as f:
f.write("test")
expected_path = os.path.join(
self.preview_dir, f"preview_front-2000.0.{PREVIEW_FRAME_TYPE}"
PREVIEW_CACHE_DIR, f"preview_front-2000.0.{PREVIEW_FRAME_TYPE}"
)
self.assertEqual(get_most_recent_preview_frame("front"), expected_path)
self.assertEqual(
@@ -91,5 +91,5 @@ class TestPreviewLoader(unittest.TestCase):
)
def test_get_most_recent_preview_frame_no_directory(self):
shutil.rmtree(self.preview_dir)
shutil.rmtree(PREVIEW_CACHE_DIR)
self.assertIsNone(get_most_recent_preview_frame("test_camera"))
+27 -9
View File
@@ -79,8 +79,24 @@ class TestEventNames(unittest.TestCase):
self.assertEqual(event_name({"label": "animal"}), "an animal")
def test_sub_labeled_objects_use_their_name(self):
event = {"label": "waste_bin", "sub_label": "Compost"}
self.assertEqual(event_name(event), 'waste bin "Compost"')
self.assertEqual(
event_name({"label": "waste_bin", "sub_label": "Compost"}),
"Compost",
)
self.assertEqual(
event_name({"label": "person", "sub_label": "nick"}),
"Nick",
)
self.assertEqual(
event_name({"label": "person", "sub_label": "nick_smith"}),
"Nick Smith",
)
def test_unknown_face_stays_unnamed(self):
self.assertEqual(
event_name({"label": "person", "sub_label": "unknown"}),
"a person",
)
def test_repeated_objects_are_never_numbered(self):
# Whether these are the same subject is unknown, so the notes must not
@@ -184,8 +200,7 @@ class TestArrival(unittest.TestCase):
phrases,
[
"a person first detected at the right of the frame, moving up and left",
'waste bin "Compost" first detected at the right of the frame, '
"moving up and left",
"Compost first detected at the right of the frame, moving up and left",
],
)
@@ -201,12 +216,12 @@ class TestArrival(unittest.TestCase):
timeline = build_timeline([bin_], 100.0)
self.assertEqual(
timeline[0],
(0.3, 'waste bin "Compost" first detected at the right of the frame'),
(0.3, "Compost first detected at the right of the frame"),
)
self.assertAlmostEqual(timeline[1][0], 21.4)
self.assertEqual(
timeline[1][1],
'waste bin "Compost" starts moving up and left from the right of the frame',
"Compost starts moving up and left from the right of the frame",
)
@@ -233,8 +248,8 @@ class TestStateChanges(unittest.TestCase):
self.assertEqual(
timeline[1:],
[
(20.0, 'waste bin "Compost" has stopped moving'),
(30.0, 'waste bin "Compost" starts moving again'),
(20.0, "Compost has stopped moving"),
(30.0, "Compost starts moving again"),
],
)
@@ -355,7 +370,10 @@ class TestClassificationChangeCaptions(unittest.TestCase):
captions[2],
"Frame 3 of 3 (+20.0s):\n[state] front gate changed from closed to open",
)
self.assertTrue(captions[0].splitlines()[1].startswith("[tracker] "))
self.assertTrue(
captions[0].splitlines()[1].startswith("a person first detected")
)
self.assertNotIn("[tracker]", captions[0])
def test_change_is_noted_without_tracked_objects(self):
with patch(
+7 -55
View File
@@ -19,7 +19,6 @@ import numpy as np
from frigate.comms.detections_updater import DetectionTypeEnum
from frigate.config import FrigateConfig
from frigate.review.maintainer import ReviewSegmentMaintainer
from frigate.track.object_processing import ManualEventState
CAMERA = "front_door"
@@ -123,19 +122,13 @@ class ReviewFlowTestCase(unittest.TestCase):
def feed(self, *frames: tuple[float, list[dict[str, Any]]]) -> None:
"""Run the maintainer loop over the given (frame_time, objects) frames."""
self.feed_messages(
*[
(
DetectionTypeEnum.video.value,
(CAMERA, f"{CAMERA}_{frame_time}", frame_time, objects, [], []),
)
for frame_time, objects in frames
]
)
def feed_messages(self, *messages: tuple[str, tuple[Any, ...]]) -> None:
"""Run the maintainer loop over the given (topic, data) messages."""
queue = list(messages)
queue = [
(
DetectionTypeEnum.video.value,
(CAMERA, f"{CAMERA}_{frame_time}", frame_time, objects, [], []),
)
for frame_time, objects in frames
]
self.maintainer.stop_event = threading.Event()
def next_update(timeout: float) -> Any:
@@ -860,46 +853,5 @@ class TestSplitDetectionLabels(ReviewFlowTestCase):
self.assertCountEqual(detection["data"]["zones"], ["yard", "driveway"])
class TestManualEvents(ReviewFlowTestCase):
def manual_event(
self, event_id: str, frame_time: float, sub_label: str | None = None
) -> tuple[str, tuple[Any, ...]]:
"""Build a completed manual event as published by the object processor."""
return (
DetectionTypeEnum.api.value,
(
CAMERA,
frame_time,
{
"state": ManualEventState.complete,
"label": "person",
"sub_label": sub_label,
"event_id": event_id,
"end_time": frame_time + 5,
},
),
)
def test_sub_label_is_verified_on_new_segment(self) -> None:
self.feed_messages(self.manual_event("m1", 1, "Bob"))
data = self.maintainer.active_review_segments[CAMERA].get_data(False)["data"]
self.assertEqual(data["objects"], ["person-verified"])
self.assertEqual(data["verified_objects"], ["person-verified"])
self.assertEqual(data["sub_labels"], ["Bob"])
def test_sub_label_is_verified_on_active_segment(self) -> None:
self.feed(
(1, [self.tracked("dog1", "dog", 1)]),
)
self.feed_messages(
self.manual_event("m1", 2, "Bob"), self.manual_event("m2", 2)
)
data = self.maintainer.active_review_segments[CAMERA].get_data(False)["data"]
self.assertCountEqual(data["objects"], ["dog", "person-verified", "person"])
self.assertEqual(data["sub_labels"], ["Bob"])
if __name__ == "__main__":
unittest.main()
@@ -100,6 +100,24 @@ class TestManualEventSeverity(unittest.TestCase):
SeverityEnum.alert,
)
def test_sub_label_is_stripped_before_categorizing(self) -> None:
maintainer = self._make_maintainer(
"""
review:
alerts:
labels:
- person
detections:
labels:
- person
"""
)
self.assertEqual(
maintainer.get_manual_event_severity("front_door", "person: Bob"),
SeverityEnum.alert,
)
def test_alert_label_is_detection_when_alerts_disabled(self) -> None:
maintainer = self._make_maintainer(
"""
+3 -9
View File
@@ -13,28 +13,22 @@ from frigate.config import FrigateConfig
from frigate.const import STREAM_TYPE_MAIN, STREAM_TYPE_SUB
from frigate.models import Event, Recordings
from frigate.storage import MAX_CALCULATED_BANDWIDTH, StorageMaintainer
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
class TestHttp(unittest.TestCase):
def setUp(self):
# setup clean database for each test run
migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
migrate_db = SqliteDatabase("test.db")
del logging.getLogger("peewee_migrate").handlers[:]
router = Router(migrate_db)
router.run()
migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
self.db = SqliteQueueDatabase(TEST_DB)
models = [Event, Recordings]
self.db.bind(models)
self.test_dir = tempfile.mkdtemp()
# The maintainers are never stopped, so a real subscriber leaks a zmq
# context whose garbage collection can hang a later test in term()
subscriber_patch = patch("frigate.storage.CameraConfigUpdateSubscriber")
subscriber_patch.start()
self.addCleanup(subscriber_patch.stop)
self.minimal_config = {
"mqtt": {"host": "mqtt"},
"cameras": {
-229
View File
@@ -1,229 +0,0 @@
"""Tests for the TFLite detection model shared by the TFLite detectors."""
import unittest
import numpy as np
from frigate.detectors.detector_config import ModelConfig, ModelTypeEnum
from frigate.detectors.detector_utils import TFLiteDetectionModel
class FakeInterpreter:
"""Stands in for a TFLite interpreter with fixed output tensors."""
def __init__(self, outputs, input_dtype=np.uint8, allocate_error=None):
self.outputs = {}
self.output_details = []
self.allocate_error = allocate_error
self.input_details = [
{"index": 0, "dtype": input_dtype, "shape": np.array([1, 320, 320, 3])}
]
self.input = None
for i, (tensor, quantization) in enumerate(outputs, start=1):
self.outputs[i] = tensor
self.output_details.append(
{
"index": i,
"dtype": tensor.dtype,
"shape": np.array(tensor.shape),
"quantization": quantization,
}
)
def allocate_tensors(self):
if self.allocate_error:
raise self.allocate_error
def get_input_details(self):
return self.input_details
def get_output_details(self):
return self.output_details
def set_tensor(self, index, value):
self.input = value
def invoke(self):
pass
def tensor(self, index):
return lambda: self.outputs[index]
def get_tensor(self, index):
return self.outputs[index]
def model_with_type(model_type) -> ModelConfig:
return ModelConfig(
path="/config/model.tflite",
model_type=model_type,
labelmap_path=None,
labelmap={0: "person"},
width=320,
height=320,
)
def ssd_outputs(scores, count=None):
num = len(scores)
boxes = np.tile(np.array([0.1, 0.2, 0.3, 0.4], np.float32), (1, num, 1))
class_ids = np.arange(num, dtype=np.float32).reshape(1, num)
scores = np.array(scores, np.float32).reshape(1, num)
count = np.array([num if count is None else count], np.float32)
return [
(boxes, (0.0, 0)),
(class_ids, (0.0, 0)),
(scores, (0.0, 0)),
(count, (0.0, 0)),
]
# 320x320 YOLO heads at strides 8, 16 and 32 have 40*40 + 20*20 + 10*10 anchors
YOLO_ANCHORS = 2100
YOLO_CLASSES = 3
SCORES_SCALE = 1 / 32
BOXES_SCALE = 0.1
def yolo_outputs(candidates, dtype=np.int8):
"""Build quantized YOLO outputs.
Args:
candidates: (anchor index, class id, logit, ltrb distances in strides)
dtype: The output tensors' type
Returns:
The class score and DFL box tensors with their quantization
"""
scores = np.full((1, YOLO_ANCHORS, YOLO_CLASSES), -128, dtype)
boxes = np.full((1, YOLO_ANCHORS, 4, 16), -100, dtype)
for anchor, class_id, logit, distances in candidates:
scores[0, anchor, class_id] = round(logit / SCORES_SCALE)
# a single hot bin per side decodes to that bin's distance
for side, distance in enumerate(distances):
boxes[0, anchor, side, distance] = 100
boxes = boxes.reshape(1, YOLO_ANCHORS, 64)
if np.issubdtype(dtype, np.floating):
return [(scores, (0.0, 0)), (boxes, (0.0, 0))]
return [(scores, (SCORES_SCALE, 0)), (boxes, (BOXES_SCALE, 0))]
class TestTFLiteDetectionModel(unittest.TestCase):
def test_ssd_returns_detections_above_min_score(self):
interpreter = FakeInterpreter(ssd_outputs([0.9, 0.5, 0.3, 0.8]))
model = TFLiteDetectionModel(
interpreter, model_with_type(ModelTypeEnum.ssd), "cpu"
)
detections = model.detect_raw(np.zeros((1, 320, 320, 3), np.uint8))
# stops at the first score under the minimum, as SSD scores are sorted
np.testing.assert_allclose(
detections[:2],
[[0, 0.9, 0.1, 0.2, 0.3, 0.4], [1, 0.5, 0.1, 0.2, 0.3, 0.4]],
rtol=1e-6,
)
self.assertFalse(detections[2:].any())
def test_ssd_caps_detections_at_twenty(self):
interpreter = FakeInterpreter(ssd_outputs([0.9] * 25))
model = TFLiteDetectionModel(
interpreter, model_with_type(ModelTypeEnum.ssd), "cpu"
)
detections = model.detect_raw(np.zeros((1, 320, 320, 3), np.uint8))
self.assertEqual(detections.shape, (20, 6))
self.assertTrue((detections[:, 1] == np.float32(0.9)).all())
def test_ssd_model_without_four_outputs_is_rejected(self):
interpreter = FakeInterpreter(yolo_outputs([]))
with self.assertRaisesRegex(ValueError, "yolo-generic"):
TFLiteDetectionModel(interpreter, model_with_type(ModelTypeEnum.ssd), "cpu")
def test_int8_input_is_shifted_from_uint8(self):
interpreter = FakeInterpreter(ssd_outputs([0.9], count=0), np.int8)
model = TFLiteDetectionModel(
interpreter, model_with_type(ModelTypeEnum.ssd), "cpu"
)
frame = np.zeros((1, 320, 320, 3), np.uint8)
frame[0, 0, 0] = [0, 128, 255]
model.detect_raw(frame)
self.assertEqual(interpreter.input.dtype, np.int8)
self.assertEqual(interpreter.input[0, 0, 0].tolist(), [-128, 0, 127])
def test_yolo_decodes_quantized_outputs(self):
# anchor 420 is row 10, column 20 of the stride 8 head, centered on
# (20.5, 10.5) strides. Its neighbor overlaps it and should be removed
# by NMS as it has a lower score.
interpreter = FakeInterpreter(
yolo_outputs([(420, 2, 2.0, (2, 3, 4, 5)), (421, 1, 1.0, (2, 3, 4, 5))])
)
model = TFLiteDetectionModel(
interpreter, model_with_type(ModelTypeEnum.yologeneric), "cpu"
)
detections = model.detect_raw(np.zeros((1, 320, 320, 3), np.uint8))
# box corners are (20.5 - 2, 10.5 - 3) and (20.5 + 4, 10.5 + 5) strides
np.testing.assert_allclose(
detections[0],
[2, 1 / (1 + np.exp(-2.0)), 60 / 320, 148 / 320, 124 / 320, 196 / 320],
rtol=1e-4,
)
self.assertFalse(detections[1:].any())
def test_yolo_without_candidates_returns_no_detections(self):
interpreter = FakeInterpreter(yolo_outputs([]))
model = TFLiteDetectionModel(
interpreter, model_with_type(ModelTypeEnum.yologeneric), "cpu"
)
detections = model.detect_raw(np.zeros((1, 320, 320, 3), np.uint8))
self.assertFalse(detections.any())
def test_yolo_model_with_float_outputs_is_rejected(self):
interpreter = FakeInterpreter(yolo_outputs([], np.float32))
with self.assertRaisesRegex(ValueError, "int8 quantized"):
TFLiteDetectionModel(
interpreter, model_with_type(ModelTypeEnum.yologeneric), "cpu"
)
def test_unsupported_model_type_is_rejected(self):
interpreter = FakeInterpreter(ssd_outputs([0.9]))
with self.assertRaisesRegex(ValueError, "yolox is currently not supported"):
TFLiteDetectionModel(
interpreter, model_with_type(ModelTypeEnum.yolox), "cpu"
)
def test_edgetpu_compiled_model_is_explained(self):
interpreter = FakeInterpreter(
ssd_outputs([0.9]),
allocate_error=RuntimeError(
"Encountered unresolved custom op: edgetpu-custom-op."
),
)
with (
self.assertLogs("frigate.detectors.detector_utils", "ERROR") as logs,
self.assertRaises(RuntimeError),
):
TFLiteDetectionModel(interpreter, model_with_type(ModelTypeEnum.ssd), "cpu")
self.assertIn("compiled for the EdgeTPU", logs.output[0])
if __name__ == "__main__":
unittest.main(verbosity=2)
+2 -4
View File
@@ -635,8 +635,7 @@ class TrackedObjectProcessor(threading.Thread):
if end_time
else ManualEventState.start
),
"label": label,
"sub_label": sub_label,
"label": f"{label}: {sub_label}" if sub_label else label,
"event_id": event_id,
"end_time": end_time,
},
@@ -697,8 +696,7 @@ class TrackedObjectProcessor(threading.Thread):
frame_time,
{
"state": ManualEventState.start,
"label": label,
"sub_label": sub_label,
"label": f"{label}: {sub_label}" if sub_label else label,
"event_id": event_id,
"end_time": None,
},
+23 -7
View File
@@ -15,8 +15,10 @@ Setup:
The script presents a menu to edit provider settings (provider, base URL,
optional API key, model) or run an example. Examples are selected with the
up/down arrow keys and launched with Enter. A writing style preset can be
applied on top of the saved prompt to compare tone between runs, and after a
response the model can be asked follow-up questions about the same frames.
applied on top of the saved prompt to compare tone between runs, thinking can
be turned on for models with a per-request toggle (the reasoning trace is
printed before the response), and after a response the model can be asked
follow-up questions about the same frames.
Provider settings are stored in
testing-scripts/genai-review-examples/.settings.json (gitignored).
@@ -311,11 +313,12 @@ def followup_loop(client: Any, base_prompt: str, frames: list[bytes]) -> None:
f"Question: {question}"
)
start = time.monotonic()
answer = client._send(prompt, frames)
result = client._send(prompt, frames)
elapsed = time.monotonic() - start
if answer is None:
if result is None:
print("No response from provider (see logs above).")
continue
answer = result.content
print(f"\n{answer}\n({elapsed:.1f}s)")
transcript = f"{prompt}\n\nYour answer:\n{answer}"
@@ -343,6 +346,11 @@ def run_example(settings: TesterSettings) -> None:
return
style = styles[style_index]
thinking_index = select_option("Thinking:", ["off", "on"])
if thinking_index is None:
return
enable_thinking = thinking_index == 1
prompt_file = example / "prompt.txt"
if not prompt_file.is_file():
print(f"{example.name} has no prompt.txt, cannot run")
@@ -363,18 +371,26 @@ def run_example(settings: TesterSettings) -> None:
concerns = ["_"] if "other_concerns" in prompt else []
response_format = build_review_description_response_format(concerns)
if enable_thinking and not client.supports_toggleable_thinking:
print(" Warning: model has no per-request thinking toggle, sending anyway")
print(
f"\nSending {len(frames)} frames to {settings.provider} "
f"(model={settings.model or '(none)'}, style={style})..."
f"(model={settings.model or '(none)'}, style={style}, "
f"thinking={'on' if enable_thinking else 'off'})..."
)
start = time.monotonic()
response = client._send(prompt, frames, response_format)
result = client._send(prompt, frames, response_format, enable_thinking)
elapsed = time.monotonic() - start
if response is None:
if result is None:
print("No response from provider (see logs above).")
return
if result.reasoning:
print(f"\nReasoning:\n\n{result.reasoning}")
response = result.content
print(f"\nResponse ({elapsed:.1f}s):\n")
pretty_print_response(response)
followup_loop(client, f"{prompt}\n\nYour analysis:\n{response}", frames)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,90 @@
/**
* Review GenAI thinking toggle tests -- MEDIUM tier.
*
* Thinking can only be switched per request by providers that expose a
* toggle, so the option is shown only when one of those providers holds the
* descriptions role.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import { configFactory } from "../../fixtures/mock-data/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
const SETTINGS_URL = "/settings?page=globalReview";
async function installRoutes(
page: Page,
genai: Record<string, { provider: string; model: string; roles: string[] }>,
) {
const config = configFactory({ genai } as never);
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) =>
route.request().method() === "GET"
? route.fulfill({ json: config })
: route.fulfill({ json: { success: true } }),
);
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ json: {} }),
);
}
async function openGenAIConfig(page: Page) {
await page.getByText("GenAI config", { exact: true }).click();
await expect(page.locator("#pageRoot")).toContainText("Response style");
}
test.describe("review genai thinking @medium", () => {
test("is shown when the descriptions provider has a thinking toggle", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, {
local: { provider: "llamacpp", model: "m", roles: ["descriptions"] },
});
await frigateApp.goto(SETTINGS_URL);
await openGenAIConfig(frigateApp.page);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"Enable thinking",
);
});
test("is hidden when the descriptions provider has no thinking toggle", async ({
frigateApp,
}) => {
// the chat provider supports the toggle, but descriptions go elsewhere
await installRoutes(frigateApp.page, {
local: { provider: "ollama", model: "m", roles: ["chat"] },
cloud: { provider: "gemini", model: "m", roles: ["descriptions"] },
});
await frigateApp.goto(SETTINGS_URL);
await openGenAIConfig(frigateApp.page);
await expect(frigateApp.page.locator("#pageRoot")).not.toContainText(
"Enable thinking",
);
});
test("is hidden when no provider is configured", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, {});
await frigateApp.goto(SETTINGS_URL);
await openGenAIConfig(frigateApp.page);
await expect(frigateApp.page.locator("#pageRoot")).not.toContainText(
"Enable thinking",
);
});
});
+4 -4
View File
@@ -689,6 +689,10 @@
"label": "Response style",
"description": "Writing style preset for generated review descriptions. Presets adjust the tone and level of detail of the user-facing title, summary, and scene description; 'default' leaves the built-in prompt unchanged."
},
"enable_thinking": {
"label": "Enable thinking",
"description": "Let the model reason before writing the review description. Only applies to models with a per-request thinking toggle (Ollama and llama.cpp providers); other models keep their default behavior. Thinking increases response time and token usage. When thumbnail saving is enabled, the reasoning is saved alongside the response."
},
"activity_context_prompt": {
"label": "Activity context prompt",
"description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries."
@@ -858,10 +862,6 @@
"label": "Cooldown period",
"description": "Cooldown (seconds) between notifications to avoid spamming recipients."
},
"ttl": {
"label": "Notification TTL",
"description": "Seconds the push service keeps retrying a notification sent while an alert is active if your device can't be reached. 0 delivers immediately or drops it. The notification sent when an alert ends is always kept for up to an hour."
},
"enabled_in_config": {
"label": "Original notifications state",
"description": "Indicates whether notifications were enabled in the original static configuration."
+4 -4
View File
@@ -154,10 +154,6 @@
"label": "Cooldown period",
"description": "Cooldown (seconds) between notifications to avoid spamming recipients."
},
"ttl": {
"label": "Notification TTL",
"description": "Seconds the push service keeps retrying a notification sent while an alert is active if your device can't be reached. 0 delivers immediately or drops it. The notification sent when an alert ends is always kept for up to an hour."
},
"enabled_in_config": {
"label": "Original notifications state",
"description": "Indicates whether notifications were enabled in the original static configuration."
@@ -1064,6 +1060,10 @@
"label": "Response style",
"description": "Writing style preset for generated review descriptions. Presets adjust the tone and level of detail of the user-facing title, summary, and scene description; 'default' leaves the built-in prompt unchanged."
},
"enable_thinking": {
"label": "Enable thinking",
"description": "Let the model reason before writing the review description. Only applies to models with a per-request thinking toggle (Ollama and llama.cpp providers); other models keep their default behavior. Thinking increases response time and token usage. When thumbnail saving is enabled, the reasoning is saved alongside the response."
},
"activity_context_prompt": {
"label": "Activity context prompt",
"description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries."
+1 -2
View File
@@ -2051,8 +2051,7 @@
"profileBaseDisabled": "Recording is disabled in this camera's base config, so enabling it in a profile has no effect. Enable recording in the base config and use a profile to disable it instead."
},
"notifications": {
"profileBaseDisabled": "No cameras have notifications enabled in their base config, so enabling them in a profile has no effect. Enable notifications in the base config of at least one camera.",
"ttlDelay": "Notifications can arrive up to this many seconds late and still link to the live view after the review item has ended."
"profileBaseDisabled": "No cameras have notifications enabled in their base config, so enabling them in a profile has no effect. Enable notifications in the base config of at least one camera."
},
"birdseye": {
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye.",
@@ -7,7 +7,7 @@ const notifications: SectionConfigOverrides = {
fieldOrder: ["enabled", "email"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: ["ttl"],
advancedFields: [],
fieldMessages: [
{
key: "profile-base-notifications-disabled",
@@ -24,15 +24,6 @@ const notifications: SectionConfigOverrides = {
camera.notifications.enabled_in_config,
),
},
{
key: "notifications-ttl-delay",
field: "ttl",
messageKey: "configMessages.notifications.ttlDelay",
severity: "warning",
position: "after",
condition: (ctx) =>
typeof ctx.formData?.ttl === "number" && ctx.formData.ttl > 0,
},
],
},
global: {
@@ -45,7 +36,7 @@ const notifications: SectionConfigOverrides = {
},
},
camera: {
hiddenFields: ["enabled_in_config", "email", "ttl"],
hiddenFields: ["enabled_in_config", "email"],
},
};
@@ -1,5 +1,19 @@
import type { HiddenFieldContext } from "@/types/configForm";
import type { SectionConfigOverrides } from "./types";
// Only these providers expose a per-request thinking toggle, so the option
// has no effect when any other provider holds the descriptions role
const THINKING_TOGGLE_PROVIDERS = new Set(["ollama", "llamacpp"]);
const hideThinkingWithoutToggle = (ctx: HiddenFieldContext): string[] => {
const provider = Object.values(ctx.fullConfig.genai ?? {}).find((agent) =>
agent.roles?.includes("descriptions"),
)?.provider;
return provider && THINKING_TOGGLE_PROVIDERS.has(provider)
? []
: ["genai.enable_thinking"];
};
const review: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/review",
@@ -98,6 +112,7 @@ const review: SectionConfigOverrides = {
"genai.frame_mode": "/configuration/genai/genai_review#frame-mode",
"genai.additional_concerns":
"/configuration/genai/genai_review#additional-concerns",
"genai.enable_thinking": "/configuration/genai/genai_review#thinking",
},
restartRequired: [],
fieldOrder: ["alerts", "detections", "genai", "genai.enabled"],
@@ -107,6 +122,7 @@ const review: SectionConfigOverrides = {
"alerts.enabled_in_config",
"detections.enabled_in_config",
"genai.enabled_in_config",
hideThinkingWithoutToggle,
],
advancedFields: [],
uiSchema: {
@@ -340,7 +340,7 @@ export default function NotificationsSettingsExtras({
set(nextNotifications, "enabled", desiredEnabled);
const sanitizedNotifications = sanitizeSectionData(
nextNotifications as ConfigSectionData,
["enabled_in_config", "email", "ttl"],
["enabled_in_config", "email"],
);
onPendingDataChange("notifications", camera.name, sanitizedNotifications);
pendingCameraOverridesRef.current.add(camera.name);
@@ -490,7 +490,7 @@ export default function NotificationsSettingsExtras({
return (
<div className="flex size-full flex-col md:flex-row">
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto md:order-none">
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
<div className={cn("w-full max-w-5xl space-y-6")}>
{isAdmin && (
<SettingsGroupCard