Compare commits

..
2 Commits
35 changed files with 882 additions and 804 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {
"version": "2.7.1",
"resolved": "ghcr.io/devcontainers/features/common-utils@sha256:e76a6a8a35d1ad824daad1540725e0ec9f8ed2dc00d2850e990a9830013c7d08",
"integrity": "sha256:e76a6a8a35d1ad824daad1540725e0ec9f8ed2dc00d2850e990a9830013c7d08"
}
}
}
@@ -504,6 +504,9 @@ review:
# Presets adjust the tone and level of detail of the user-facing title, # Presets adjust the tone and level of detail of the user-facing title,
# summary, and scene description; "default" leaves the built-in prompt unchanged. # summary, and scene description; "default" leaves the built-in prompt unchanged.
response_style: default 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) # Optional: Save thumbnails sent to the GenAI provider for review/debugging purposes (default: shown below)
debug_save_thumbnails: False debug_save_thumbnails: False
+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: 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 | | Model | Review [frame mode](/configuration/genai/genai_review#frame-mode) | 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-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` | `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. | | `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` | `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. | | `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. |
| `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. |
#### Embedding models #### 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: 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 | | 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. | | `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. | | `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: 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. - **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. 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. 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. - 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. - 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. - 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. - 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> </TabItem>
</ConfigTabs> </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 ## 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). 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 -2
View File
@@ -934,8 +934,7 @@ def profile(request: Request):
) )
def logout(request: Request): def logout(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth auth_config: AuthConfig = request.app.frigate_config.auth
base_path = request.headers.get("x-ingress-path", "").rstrip("/") response = RedirectResponse("/login", status_code=303)
response = RedirectResponse(f"{base_path}/login", status_code=303)
response.delete_cookie(auth_config.cookie_name) response.delete_cookie(auth_config.cookie_name)
return response return response
+5
View File
@@ -171,6 +171,11 @@ class GenAIReviewConfig(FrigateBaseModel):
title="Response style", 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.", 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( activity_context_prompt: str = Field(
default="""### Normal Activity Indicators (Level 0) default="""### Normal Activity Indicators (Level 0)
- Known/verified people in any zone at any time - Known/verified people in any zone at any time
@@ -12,6 +12,8 @@ import math
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any from typing import Any
from titlecase import titlecase
from frigate.models import Event, Timeline from frigate.models import Event, Timeline
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -78,7 +80,13 @@ def describe_heading(dx: float, dy: float) -> str:
def event_name(event: dict[str, Any]) -> 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 Objects are never numbered or given track identifiers. Frigate opens a new
tracked object whenever a subject is re-detected, so the tracking data 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. ambiguous rather than implying either answer.
""" """
label = str(event["label"]).replace("_", " ").replace("-verified", "") 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: if sub_label and sub_label.lower() != "unknown":
return f'{label} "{sub_label}"' return titlecase(sub_label.replace("_", " "))
article = "an" if label[:1].lower() in "aeiou" else "a" article = "an" if label[:1].lower() in "aeiou" else "a"
return f"{article} {label}" 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 # audio and manual review items can have state changes but no tracked objects
if events: if events:
# No source prefix. A tag placed before the name is read as the
# subject's identity.
timeline.extend( timeline.extend(
(timestamp, f"[tracker] {note}") build_timeline(events, span_end, get_state_changes(detection_ids))
for timestamp, note in build_timeline(
events, span_end, get_state_changes(detection_ids)
)
) )
timeline.extend( timeline.extend(
@@ -813,6 +813,7 @@ def run_analysis(
genai_config.activity_context_prompt, genai_config.activity_context_prompt,
genai_config.response_style, genai_config.response_style,
frame_captions, frame_captions,
genai_config.enable_thinking,
) )
review_inference_speed.update(datetime.datetime.now().timestamp() - start) review_inference_speed.update(datetime.datetime.now().timestamp() - start)
+25 -315
View File
@@ -1,13 +1,8 @@
import logging import logging
import math
import os import os
import cv2
import numpy as np import numpy as np
from frigate.detectors.detector_config import ModelConfig, ModelTypeEnum
from frigate.util.model import xyxy_to_xywh_for_nms
try: try:
from tflite_runtime.interpreter import Interpreter, load_delegate from tflite_runtime.interpreter import Interpreter, load_delegate
except ModuleNotFoundError: except ModuleNotFoundError:
@@ -17,324 +12,39 @@ except ModuleNotFoundError:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TFLiteDetectionModel: def tflite_init(self, interpreter):
"""Object detection pre and post processing for a TFLite interpreter. self.interpreter = interpreter
Every TFLite detector shares this, differing only in how it builds the self.interpreter.allocate_tensors()
interpreter (on the CPU, or with the EdgeTPU or Teflon delegate).
"""
supported_models = [ self.tensor_input_details = self.interpreter.get_input_details()
ModelTypeEnum.ssd, self.tensor_output_details = self.interpreter.get_output_details()
ModelTypeEnum.yologeneric,
]
def __init__(
self, interpreter: Interpreter, model_config: ModelConfig, detector_key: str
):
"""Prepare the interpreter and work out the model's output layout.
Args: def tflite_detect_raw(self, tensor_input):
interpreter: The TFLite interpreter, with any delegate loaded self.interpreter.set_tensor(self.tensor_input_details[0]["index"], tensor_input)
model_config: The model being run self.interpreter.invoke()
detector_key: The detector's type, for error messages
"""
self.model_type = model_config.model_type
if self.model_type not in self.supported_models: boxes = self.interpreter.tensor(self.tensor_output_details[0]["index"])()[0]
raise ValueError( class_ids = self.interpreter.tensor(self.tensor_output_details[1]["index"])()[0]
f"{self.model_type.value} is currently not supported for {detector_key}. See the docs for more info on supported models." 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: for i in range(count):
self.interpreter.allocate_tensors() if scores[i] < 0.4 or i == 20:
except RuntimeError as e: break
# an EdgeTPU compiled model wraps its graph in a custom op that only detections[i] = [
# the EdgeTPU delegate can run class_ids[i],
if "edgetpu-custom-op" in str(e): float(scores[i]),
logger.error( boxes[i][0],
"%s is compiled for the EdgeTPU and can only be run by an edgetpu detector", boxes[i][1],
model_config.path, boxes[i][2],
) boxes[i][3],
raise ]
self.tensor_input_details = self.interpreter.get_input_details() return detections
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
def tflite_load_delegate_interpreter( 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.detectors.detector_config import BaseDetectorConfig
from frigate.log import suppress_stderr_during from frigate.log import suppress_stderr_during
from ..detector_utils import TFLiteDetectionModel from ..detector_utils import tflite_detect_raw, tflite_init
try: try:
from tflite_runtime.interpreter import Interpreter from tflite_runtime.interpreter import Interpreter
@@ -40,7 +40,6 @@ class CpuDetectorConfig(BaseDetectorConfig):
class CpuTfl(DetectionApi): class CpuTfl(DetectionApi):
type_key = DETECTOR_KEY type_key = DETECTOR_KEY
supported_models = TFLiteDetectionModel.supported_models
def __init__(self, detector_config: CpuDetectorConfig): def __init__(self, detector_config: CpuDetectorConfig):
# Suppress TFLite delegate creation messages that bypass Python logging # Suppress TFLite delegate creation messages that bypass Python logging
@@ -50,9 +49,7 @@ class CpuTfl(DetectionApi):
num_threads=detector_config.num_threads or 3, num_threads=detector_config.num_threads or 3,
) )
self.tflite_model = TFLiteDetectionModel( tflite_init(self, interpreter)
interpreter, detector_config.model, DETECTOR_KEY
)
def detect_raw(self, tensor_input): 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 logging
import math
import os import os
from typing import ClassVar, Literal from typing import ClassVar, Literal
import cv2
import numpy as np
from pydantic import ConfigDict, Field from pydantic import ConfigDict, Field
from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detection_api import DetectionApi
from frigate.detectors.detector_config import BaseDetectorConfig from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
from frigate.util.model import xyxy_to_xywh_for_nms
from ..detector_utils import TFLiteDetectionModel
try: try:
from tflite_runtime.interpreter import Interpreter, load_delegate from tflite_runtime.interpreter import Interpreter, load_delegate
@@ -39,7 +41,10 @@ class EdgeTpuDetectorConfig(BaseDetectorConfig):
class EdgeTpuTfl(DetectionApi): class EdgeTpuTfl(DetectionApi):
type_key = DETECTOR_KEY type_key = DETECTOR_KEY
supported_models = TFLiteDetectionModel.supported_models supported_models = [
ModelTypeEnum.ssd,
ModelTypeEnum.yologeneric,
]
def __init__(self, detector_config: EdgeTpuDetectorConfig): def __init__(self, detector_config: EdgeTpuDetectorConfig):
device_config = {} device_config = {}
@@ -55,7 +60,7 @@ class EdgeTpuTfl(DetectionApi):
logger.info(f"Attempting to load TPU as {device_type}") logger.info(f"Attempting to load TPU as {device_type}")
edge_tpu_delegate = load_delegate("libedgetpu.so.1.0", device_config) edge_tpu_delegate = load_delegate("libedgetpu.so.1.0", device_config)
logger.info("TPU found") logger.info("TPU found")
interpreter = Interpreter( self.interpreter = Interpreter(
model_path=detector_config.model.path, model_path=detector_config.model.path,
experimental_delegates=[edge_tpu_delegate], experimental_delegates=[edge_tpu_delegate],
) )
@@ -73,9 +78,298 @@ class EdgeTpuTfl(DetectionApi):
raise raise
self.tflite_model = TFLiteDetectionModel( self.interpreter.allocate_tensors()
interpreter, detector_config.model, DETECTOR_KEY
) 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): 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 frigate.detectors.detector_config import BaseDetectorConfig
from ..detector_utils import ( from ..detector_utils import (
TFLiteDetectionModel, tflite_detect_raw,
tflite_init,
tflite_load_delegate_interpreter, tflite_load_delegate_interpreter,
) )
@@ -29,7 +30,6 @@ class TeflonDetectorConfig(BaseDetectorConfig):
class TeflonTfl(DetectionApi): class TeflonTfl(DetectionApi):
type_key = DETECTOR_KEY type_key = DETECTOR_KEY
supported_models = TFLiteDetectionModel.supported_models
def __init__(self, detector_config: TeflonDetectorConfig): def __init__(self, detector_config: TeflonDetectorConfig):
# Location in Debian's mesa-teflon-delegate # Location in Debian's mesa-teflon-delegate
@@ -39,9 +39,7 @@ class TeflonTfl(DetectionApi):
interpreter = tflite_load_delegate_interpreter( interpreter = tflite_load_delegate_interpreter(
delegate_library, detector_config, device_config delegate_library, detector_config, device_config
) )
self.tflite_model = TFLiteDetectionModel( tflite_init(self, interpreter)
interpreter, detector_config.model, DETECTOR_KEY
)
def detect_raw(self, tensor_input): 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.start_time < expire_after,
Event.label == event.label, Event.label == event.label,
Event.retain_indefinitely == False, Event.retain_indefinitely == False,
Event.has_snapshot == True,
) )
.namedtuples() .namedtuples()
.iterator() .iterator()
@@ -115,7 +114,6 @@ class EventCleanup(threading.Thread):
Event.start_time < expire_after, Event.start_time < expire_after,
Event.label == event.label, Event.label == event.label,
Event.retain_indefinitely == False, Event.retain_indefinitely == False,
Event.has_snapshot == True,
) )
events_to_update: list[str] = [] events_to_update: list[str] = []
@@ -171,7 +169,6 @@ class EventCleanup(threading.Thread):
Event.start_time < expire_after, Event.start_time < expire_after,
Event.label == event.label, Event.label == event.label,
Event.retain_indefinitely == False, Event.retain_indefinitely == False,
Event.has_snapshot == True,
) )
.namedtuples() .namedtuples()
.iterator() .iterator()
+49 -7
View File
@@ -7,6 +7,7 @@ import os
import re import re
import time import time
from collections.abc import AsyncGenerator, Callable from collections.abc import AsyncGenerator, Callable
from dataclasses import dataclass
from typing import Any from typing import Any
import numpy as np import numpy as np
@@ -33,6 +34,7 @@ __all__ = [
"GenAIClientManager", "GenAIClientManager",
"GenAIConfig", "GenAIConfig",
"GenAIProviderEnum", "GenAIProviderEnum",
"GenAIResponse",
"PROVIDERS", "PROVIDERS",
"load_providers", "load_providers",
"register_genai_provider", "register_genai_provider",
@@ -41,6 +43,18 @@ __all__ = [
PROVIDERS: dict[GenAIProviderEnum, type["GenAIClient"]] = {} 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: def register_genai_provider(key: GenAIProviderEnum) -> Callable:
"""Register a GenAI provider.""" """Register a GenAI provider."""
@@ -109,11 +123,14 @@ class GenAIClient:
activity_context_prompt: str, activity_context_prompt: str,
response_style: str = "default", response_style: str = "default",
frame_captions: list[str] | None = None, frame_captions: list[str] | None = None,
enable_thinking: bool = False,
) -> ReviewMetadata | None: ) -> ReviewMetadata | None:
"""Generate a description for the review item activity. """Generate a description for the review item activity.
`frame_captions` holds one caption per thumbnail for the annotated `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): if frame_captions and len(frame_captions) != len(thumbnails):
logger.warning( logger.warning(
@@ -137,6 +154,13 @@ class GenAIClient:
f"Sending {len(thumbnails)} images to create review description on {review_data['camera']}" 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: if debug_save:
with open( with open(
os.path.join( os.path.join(
@@ -164,12 +188,14 @@ class GenAIClient:
response_format = build_review_description_response_format(concerns) response_format = build_review_description_response_format(concerns)
response = self._send( result = self._send(
context_prompt, context_prompt,
thumbnails, thumbnails,
response_format, response_format,
enable_thinking=enable_thinking,
image_captions=frame_captions, image_captions=frame_captions,
) )
response = result.content if result else None
if debug_save and response: if debug_save and response:
with open( with open(
@@ -180,6 +206,17 @@ class GenAIClient:
) as f: ) as f:
f.write(response) 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: if response:
clean_json = re.sub( clean_json = re.sub(
r"\n?```$", "", re.sub(r"^```[a-zA-Z0-9]*\n?", "", response) r"\n?```$", "", re.sub(r"^```[a-zA-Z0-9]*\n?", "", response)
@@ -268,7 +305,8 @@ class GenAIClient:
) as f: ) as f:
f.write(timeline_summary_prompt) 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: if debug_save and response:
with open( with open(
@@ -295,7 +333,8 @@ class GenAIClient:
return None return None
logger.debug(f"Sending images to genai provider with prompt: {prompt}") 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: def _init_provider(self) -> Any:
"""Initialize the client.""" """Initialize the client."""
@@ -308,13 +347,16 @@ class GenAIClient:
response_format: dict | None = None, response_format: dict | None = None,
enable_thinking: bool = False, enable_thinking: bool = False,
image_captions: list[str] | None = None, image_captions: list[str] | None = None,
) -> str | None: ) -> GenAIResponse | None:
"""Submit a request to the provider. """Submit a request to the provider.
``enable_thinking`` is honored only by providers that report ``enable_thinking`` is honored only by providers that report
``supports_toggleable_thinking``. Description-style callers leave it ``supports_toggleable_thinking``. Description-style callers leave it
at the default (off) since synthesis tasks don't benefit from at the default (off) unless the user opts in, since most models write
reasoning traces. 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 ``image_captions`` carries one caption per image, to be placed
immediately before its image so the model can tell the frames apart. 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 google.genai.types import FunctionCallingConfigMode
from frigate.config import GenAIProviderEnum 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 from frigate.genai.utils import interleave_images
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -124,8 +124,13 @@ class GeminiClient(GenAIClient):
response_format: dict | None = None, response_format: dict | None = None,
enable_thinking: bool = False, enable_thinking: bool = False,
image_captions: list[str] | None = None, image_captions: list[str] | None = None,
) -> str | None: ) -> GenAIResponse | None:
"""Submit a request to Gemini.""" """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] = [ contents: list[Any] = [
part part
if isinstance(part, str) if isinstance(part, str)
@@ -165,7 +170,7 @@ class GeminiClient(GenAIClient):
except (ValueError, AttributeError): except (ValueError, AttributeError):
# No description was generated # No description was generated
return None return None
return description return GenAIResponse(description)
@property @property
def supports_transcription(self) -> bool: def supports_transcription(self) -> bool:
+4 -3
View File
@@ -11,7 +11,7 @@ import numpy as np
import requests import requests
from frigate.config import GenAIProviderEnum 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 ( from frigate.genai.utils import (
interleave_images, interleave_images,
parse_tool_calls_from_message, parse_tool_calls_from_message,
@@ -304,7 +304,7 @@ class LlamaCppClient(GenAIClient):
response_format: dict | None = None, response_format: dict | None = None,
enable_thinking: bool = False, enable_thinking: bool = False,
image_captions: list[str] | None = None, image_captions: list[str] | None = None,
) -> str | None: ) -> GenAIResponse | None:
"""Submit a request to llama.cpp server.""" """Submit a request to llama.cpp server."""
if self.provider is None: if self.provider is None:
logger.warning( logger.warning(
@@ -362,7 +362,8 @@ class LlamaCppClient(GenAIClient):
): ):
choice = result["choices"][0] choice = result["choices"][0]
if "message" in choice and "content" in choice["message"]: 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 return None
except Exception as e: except Exception as e:
logger.warning("llama.cpp returned an error: %s", str(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 ollama import ResponseError
from frigate.config import GenAIProviderEnum 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 ( from frigate.genai.utils import (
interleave_images, interleave_images,
parse_tool_calls_from_message, parse_tool_calls_from_message,
@@ -243,7 +243,7 @@ class OllamaClient(GenAIClient):
response_format: dict | None = None, response_format: dict | None = None,
enable_thinking: bool = False, enable_thinking: bool = False,
image_captions: list[str] | None = None, image_captions: list[str] | None = None,
) -> str | None: ) -> GenAIResponse | None:
"""Submit a request to Ollama through the chat API, the same path the """Submit a request to Ollama through the chat API, the same path the
tool-calling chat uses, with image placeholders keeping any captions tool-calling chat uses, with image placeholders keeping any captions
next to their frames.""" next to their frames."""
@@ -299,7 +299,8 @@ class OllamaClient(GenAIClient):
response.get("eval_count"), response.get("eval_count"),
response.get("prompt_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: if not response_text:
logger.warning( logger.warning(
@@ -310,7 +311,7 @@ class OllamaClient(GenAIClient):
response.get("eval_count"), response.get("eval_count"),
) )
return response_text return GenAIResponse(response_text, message_result["reasoning"])
def list_models(self) -> list[str]: def list_models(self) -> list[str]:
"""Return available model names from the Ollama server.""" """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 openai import OpenAI
from frigate.config import GenAIProviderEnum 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 from frigate.genai.utils import interleave_images
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -71,8 +71,14 @@ class OpenAIClient(GenAIClient):
response_format: dict | None = None, response_format: dict | None = None,
enable_thinking: bool = False, enable_thinking: bool = False,
image_captions: list[str] | None = None, image_captions: list[str] | None = None,
) -> str | None: ) -> GenAIResponse | None:
"""Submit a request to OpenAI.""" """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] = [] messages_content: list[dict] = []
for part in interleave_images(prompt, images, image_captions): for part in interleave_images(prompt, images, image_captions):
if isinstance(part, str): if isinstance(part, str):
@@ -120,6 +126,10 @@ class OpenAIClient(GenAIClient):
): ):
message = result.choices[0].message message = result.choices[0].message
content = message.content 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: if not content:
# When reasoning is enabled for some OpenAI backends the actual response # When reasoning is enabled for some OpenAI backends the actual response
@@ -134,7 +144,9 @@ class OpenAIClient(GenAIClient):
) )
content = reasoning_content content = reasoning_content
return str(content.strip()) if content else None return (
GenAIResponse(str(content.strip()), reasoning) if content else None
)
return None return None
except (TimeoutException, Exception) as e: except (TimeoutException, Exception) as e:
logger.warning("OpenAI returned an error: %s", str(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, # Base guidance per response field. `observations` is a reasoning scaffold,
# not user-facing, so style presets never override it. # not user-facing, so style presets never override it.
REVIEW_DESCRIPTION_FIELD_GUIDELINES: dict[str, str] = { 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.", "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 — 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.', "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": "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.", "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.", "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.", "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. # competing style instructions.
REVIEW_RESPONSE_STYLES: dict[str, dict[str, str]] = { REVIEW_RESPONSE_STYLES: dict[str, dict[str, str]] = {
"natural": { "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.', "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": '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.', "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.", "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": { "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.', "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.', "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.", "shortSummary": "Summarize the primary activity in one short sentence. Use every name from Objects in Scene.",
}, },
"detailed": { "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.', "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": '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.', "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.", "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 # mode. Neither the notes nor this guidance say whether repeated detections are
# the same subject, since the tracking data cannot tell. # 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. 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( def build_review_description_prompt(
@@ -164,9 +164,9 @@ Respond with a JSON object matching the provided schema. Field-specific guidance
## Objects in Scene ## 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_objects_list()}
{get_language_prompt()} {get_language_prompt()}
+10 -14
View File
@@ -100,14 +100,6 @@ class PendingReviewSegment:
if zone not in self.zones: if zone not in self.zones:
self.zones.append(zone) 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( def update_frame(
self, self,
camera_config: CameraConfig, camera_config: CameraConfig,
@@ -491,6 +483,8 @@ class ReviewSegmentMaintainer(threading.Thread):
alerts so manual events keep their historical severity. alerts so manual events keep their historical severity.
""" """
review_config = self.config.cameras[camera].review 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: if review_config.alerts.enabled and label in review_config.alerts.labels:
return SeverityEnum.alert return SeverityEnum.alert
@@ -900,7 +894,9 @@ class ReviewSegmentMaintainer(threading.Thread):
current_segment.last_detection_time = frame_time current_segment.last_detection_time = frame_time
elif topic == DetectionTypeEnum.api or topic == DetectionTypeEnum.lpr: elif topic == DetectionTypeEnum.api or topic == DetectionTypeEnum.lpr:
if manual_info["state"] == ManualEventState.complete: 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: if topic == DetectionTypeEnum.api:
severity = self.get_manual_event_severity( severity = self.get_manual_event_severity(
camera, manual_info["label"] camera, manual_info["label"]
@@ -925,7 +921,9 @@ class ReviewSegmentMaintainer(threading.Thread):
self.indefinite_events[camera][manual_info["event_id"]] = ( self.indefinite_events[camera][manual_info["event_id"]] = (
manual_info["label"] 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 topic == DetectionTypeEnum.api:
if ( if (
self.get_manual_event_severity( self.get_manual_event_severity(
@@ -1016,12 +1014,11 @@ class ReviewSegmentMaintainer(threading.Thread):
camera, camera,
frame_time, frame_time,
severity, severity,
{}, {manual_info["event_id"]: manual_info["label"]},
{}, {},
[], [],
set(), set(),
) )
api_segment.add_manual_detection(manual_info)
self._activate_segment(api_segment) self._activate_segment(api_segment)
if manual_info["state"] == ManualEventState.start: if manual_info["state"] == ManualEventState.start:
@@ -1044,12 +1041,11 @@ class ReviewSegmentMaintainer(threading.Thread):
camera, camera,
frame_time, frame_time,
SeverityEnum.detection, SeverityEnum.detection,
{}, {manual_info["event_id"]: manual_info["label"]},
{}, {},
[], [],
set(), set(),
) )
lpr_segment.add_manual_detection(manual_info)
self._activate_segment(lpr_segment) self._activate_segment(lpr_segment)
if manual_info["state"] == ManualEventState.start: if manual_info["state"] == ManualEventState.start:
@@ -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()
+5
View File
@@ -83,6 +83,11 @@ class TestReviewResponseStyle(unittest.TestCase):
prompt, 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): class TestClassificationStateChanges(unittest.TestCase):
def _build_prompt(self, **extra) -> str: def _build_prompt(self, **extra) -> str:
+169 -2
View File
@@ -15,6 +15,8 @@ crashing message conversion.
import asyncio import asyncio
import base64 import base64
import json import json
import os
import tempfile
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -22,7 +24,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import requests import requests
from frigate.config import GenAIConfig, GenAIProviderEnum from frigate.config import GenAIConfig, GenAIProviderEnum
from frigate.genai import PROVIDERS, load_providers from frigate.genai import PROVIDERS, GenAIClient, GenAIResponse, load_providers
load_providers() load_providers()
@@ -194,6 +196,19 @@ class TestOpenAIProvider(unittest.TestCase):
final = _final_message(_collect(client, MULTIMODAL_MESSAGES)) final = _final_message(_collect(client, MULTIMODAL_MESSAGES))
self.assertEqual(final["content"], "ok") 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 # Gemini
@@ -399,7 +414,7 @@ class TestOllamaProvider(unittest.TestCase):
image_captions=["Frame 1 of 2", "Frame 2 of 2"], 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() client.provider.generate.assert_not_called()
params = client.provider.chat.call_args.kwargs params = client.provider.chat.call_args.kwargs
self.assertEqual( self.assertEqual(
@@ -427,6 +442,30 @@ class TestOllamaProvider(unittest.TestCase):
self.assertEqual(message["content"], "prompt\n[img]") self.assertEqual(message["content"], "prompt\n[img]")
self.assertEqual(message["images"], [b"a"]) 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): def test_capabilities_drive_embeddings_and_thinking(self):
client = self._client() client = self._client()
client.provider = MagicMock() client.provider = MagicMock()
@@ -696,6 +735,42 @@ class TestLlamaCppProvider(unittest.TestCase):
final = _final_message(self._run_with_lines(client, lines, MULTIMODAL_MESSAGES)) final = _final_message(self._run_with_lines(client, lines, MULTIMODAL_MESSAGES))
self.assertEqual(final["content"], "ok") 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): def _validated_client(self, server_context_size, provider_options=None):
"""Build a client as if the server reported the given context size.""" """Build a client as if the server reported the given context size."""
cfg = GenAIConfig( cfg = GenAIConfig(
@@ -1093,6 +1168,98 @@ class TestImageTokenEstimate(unittest.TestCase):
self.assertEqual(client._image_token_cache, {}) 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): class TestBaseClientTranscribe(unittest.TestCase):
"""Providers that don't implement the role must be inert, not broken.""" """Providers that don't implement the role must be inert, not broken."""
+27 -9
View File
@@ -79,8 +79,24 @@ class TestEventNames(unittest.TestCase):
self.assertEqual(event_name({"label": "animal"}), "an animal") self.assertEqual(event_name({"label": "animal"}), "an animal")
def test_sub_labeled_objects_use_their_name(self): def test_sub_labeled_objects_use_their_name(self):
event = {"label": "waste_bin", "sub_label": "Compost"} self.assertEqual(
self.assertEqual(event_name(event), 'waste bin "Compost"') 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): def test_repeated_objects_are_never_numbered(self):
# Whether these are the same subject is unknown, so the notes must not # Whether these are the same subject is unknown, so the notes must not
@@ -184,8 +200,7 @@ class TestArrival(unittest.TestCase):
phrases, phrases,
[ [
"a person first detected at the right of the frame, moving up and left", "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, ' "Compost first detected at the right of the frame, moving up and left",
"moving up and left",
], ],
) )
@@ -201,12 +216,12 @@ class TestArrival(unittest.TestCase):
timeline = build_timeline([bin_], 100.0) timeline = build_timeline([bin_], 100.0)
self.assertEqual( self.assertEqual(
timeline[0], 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.assertAlmostEqual(timeline[1][0], 21.4)
self.assertEqual( self.assertEqual(
timeline[1][1], 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( self.assertEqual(
timeline[1:], timeline[1:],
[ [
(20.0, 'waste bin "Compost" has stopped moving'), (20.0, "Compost has stopped moving"),
(30.0, 'waste bin "Compost" starts moving again'), (30.0, "Compost starts moving again"),
], ],
) )
@@ -355,7 +370,10 @@ class TestClassificationChangeCaptions(unittest.TestCase):
captions[2], captions[2],
"Frame 3 of 3 (+20.0s):\n[state] front gate changed from closed to open", "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): def test_change_is_noted_without_tracked_objects(self):
with patch( with patch(
+7 -55
View File
@@ -19,7 +19,6 @@ import numpy as np
from frigate.comms.detections_updater import DetectionTypeEnum from frigate.comms.detections_updater import DetectionTypeEnum
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.review.maintainer import ReviewSegmentMaintainer from frigate.review.maintainer import ReviewSegmentMaintainer
from frigate.track.object_processing import ManualEventState
CAMERA = "front_door" CAMERA = "front_door"
@@ -123,19 +122,13 @@ class ReviewFlowTestCase(unittest.TestCase):
def feed(self, *frames: tuple[float, list[dict[str, Any]]]) -> None: def feed(self, *frames: tuple[float, list[dict[str, Any]]]) -> None:
"""Run the maintainer loop over the given (frame_time, objects) frames.""" """Run the maintainer loop over the given (frame_time, objects) frames."""
self.feed_messages( queue = [
*[ (
( DetectionTypeEnum.video.value,
DetectionTypeEnum.video.value, (CAMERA, f"{CAMERA}_{frame_time}", frame_time, objects, [], []),
(CAMERA, f"{CAMERA}_{frame_time}", frame_time, objects, [], []), )
) for frame_time, objects in frames
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)
self.maintainer.stop_event = threading.Event() self.maintainer.stop_event = threading.Event()
def next_update(timeout: float) -> Any: def next_update(timeout: float) -> Any:
@@ -860,46 +853,5 @@ class TestSplitDetectionLabels(ReviewFlowTestCase):
self.assertCountEqual(detection["data"]["zones"], ["yard", "driveway"]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -100,6 +100,24 @@ class TestManualEventSeverity(unittest.TestCase):
SeverityEnum.alert, 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: def test_alert_label_is_detection_when_alerts_disabled(self) -> None:
maintainer = self._make_maintainer( maintainer = self._make_maintainer(
""" """
-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 if end_time
else ManualEventState.start else ManualEventState.start
), ),
"label": label, "label": f"{label}: {sub_label}" if sub_label else label,
"sub_label": sub_label,
"event_id": event_id, "event_id": event_id,
"end_time": end_time, "end_time": end_time,
}, },
@@ -697,8 +696,7 @@ class TrackedObjectProcessor(threading.Thread):
frame_time, frame_time,
{ {
"state": ManualEventState.start, "state": ManualEventState.start,
"label": label, "label": f"{label}: {sub_label}" if sub_label else label,
"sub_label": sub_label,
"event_id": event_id, "event_id": event_id,
"end_time": None, "end_time": None,
}, },
+23 -7
View File
@@ -15,8 +15,10 @@ Setup:
The script presents a menu to edit provider settings (provider, base URL, 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 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 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 applied on top of the saved prompt to compare tone between runs, thinking can
response the model can be asked follow-up questions about the same frames. 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 Provider settings are stored in
testing-scripts/genai-review-examples/.settings.json (gitignored). 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}" f"Question: {question}"
) )
start = time.monotonic() start = time.monotonic()
answer = client._send(prompt, frames) result = client._send(prompt, frames)
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
if answer is None: if result is None:
print("No response from provider (see logs above).") print("No response from provider (see logs above).")
continue continue
answer = result.content
print(f"\n{answer}\n({elapsed:.1f}s)") print(f"\n{answer}\n({elapsed:.1f}s)")
transcript = f"{prompt}\n\nYour answer:\n{answer}" transcript = f"{prompt}\n\nYour answer:\n{answer}"
@@ -343,6 +346,11 @@ def run_example(settings: TesterSettings) -> None:
return return
style = styles[style_index] 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" prompt_file = example / "prompt.txt"
if not prompt_file.is_file(): if not prompt_file.is_file():
print(f"{example.name} has no prompt.txt, cannot run") 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 [] concerns = ["_"] if "other_concerns" in prompt else []
response_format = build_review_description_response_format(concerns) 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( print(
f"\nSending {len(frames)} frames to {settings.provider} " 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() start = time.monotonic()
response = client._send(prompt, frames, response_format) result = client._send(prompt, frames, response_format, enable_thinking)
elapsed = time.monotonic() - start elapsed = time.monotonic() - start
if response is None: if result is None:
print("No response from provider (see logs above).") print("No response from provider (see logs above).")
return return
if result.reasoning:
print(f"\nReasoning:\n\n{result.reasoning}")
response = result.content
print(f"\nResponse ({elapsed:.1f}s):\n") print(f"\nResponse ({elapsed:.1f}s):\n")
pretty_print_response(response) pretty_print_response(response)
followup_loop(client, f"{prompt}\n\nYour analysis:\n{response}", frames) 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",
);
});
});
@@ -689,6 +689,10 @@
"label": "Response style", "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." "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": { "activity_context_prompt": {
"label": "Activity context prompt", "label": "Activity context prompt",
"description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries." "description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries."
+4
View File
@@ -1060,6 +1060,10 @@
"label": "Response style", "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." "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": { "activity_context_prompt": {
"label": "Activity context prompt", "label": "Activity context prompt",
"description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries." "description": "Custom prompt describing what is and is not suspicious activity to provide context for GenAI summaries."
@@ -1,5 +1,19 @@
import type { HiddenFieldContext } from "@/types/configForm";
import type { SectionConfigOverrides } from "./types"; 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 = { const review: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/review", sectionDocs: "/configuration/review",
@@ -98,6 +112,7 @@ const review: SectionConfigOverrides = {
"genai.frame_mode": "/configuration/genai/genai_review#frame-mode", "genai.frame_mode": "/configuration/genai/genai_review#frame-mode",
"genai.additional_concerns": "genai.additional_concerns":
"/configuration/genai/genai_review#additional-concerns", "/configuration/genai/genai_review#additional-concerns",
"genai.enable_thinking": "/configuration/genai/genai_review#thinking",
}, },
restartRequired: [], restartRequired: [],
fieldOrder: ["alerts", "detections", "genai", "genai.enabled"], fieldOrder: ["alerts", "detections", "genai", "genai.enabled"],
@@ -107,6 +122,7 @@ const review: SectionConfigOverrides = {
"alerts.enabled_in_config", "alerts.enabled_in_config",
"detections.enabled_in_config", "detections.enabled_in_config",
"genai.enabled_in_config", "genai.enabled_in_config",
hideThinkingWithoutToggle,
], ],
advancedFields: [], advancedFields: [],
uiSchema: { uiSchema: {