mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-29 18:04:42 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3725ad142f | ||
|
|
ee80129086 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
@@ -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()}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user