mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-04 21:00:32 +03:00
Compare commits
4
Commits
3a07751a37
...
4c11de87b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c11de87b0 | ||
|
|
fab48ce4a2 | ||
|
|
50dc7bb6ba | ||
|
|
7ed7ed56cf |
@@ -1,10 +1,14 @@
|
||||
"""Convert the default SSDLite MobileNet v2 model to OpenVINO IR.
|
||||
|
||||
Replaces the legacy openvino-dev Model Optimizer conversion. The TensorFlow
|
||||
frontend converts the Object Detection API frozen graph natively; the four TF
|
||||
outputs are then repacked into the single [1, 1, 100, 7] DetectionOutput-style
|
||||
tensor that Frigate's OpenVINO detector expects, and the input is flipped to
|
||||
BGR to match the legacy reverse_input_channels behavior.
|
||||
frontend translates the Object Detection API pre and post processors literally,
|
||||
producing per-class NonMaxSuppression, NonZero ops and map loops with data
|
||||
dependent shapes that the GPU plugin handles very badly. Both are cut out the
|
||||
way ssd_v2_support.json used to do it: the preprocessor is an identity at the
|
||||
native 300x300 input, and the postprocessor becomes a single fused
|
||||
DetectionOutput. The result is the [1, 1, 100, 7] tensor that Frigate's
|
||||
OpenVINO detector expects, with the input flipped to BGR to match the legacy
|
||||
reverse_input_channels behavior.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
@@ -12,31 +16,91 @@ import openvino as ov
|
||||
from openvino import opset8 as ops
|
||||
from openvino.preprocess import PrePostProcessor
|
||||
|
||||
MODEL_DIR = "/models/ssdlite_mobilenet_v2_coco_2018_05_09"
|
||||
OUTPUT_PATH = "/models/ssdlite_mobilenet_v2.xml"
|
||||
INPUT_SHAPE = [1, 300, 300, 3]
|
||||
|
||||
# faster_rcnn_box_coder divides the deltas by pipeline.config's y/x/height/width
|
||||
# scales of 10/10/5/5, which DetectionOutput expresses as per-prior variances.
|
||||
BOX_VARIANCES = np.float32([0.1, 0.1, 0.2, 0.2])
|
||||
|
||||
model = ov.convert_model(
|
||||
"/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb",
|
||||
input=[("image_tensor:0", [1, 300, 300, 3])],
|
||||
f"{MODEL_DIR}/frozen_inference_graph.pb",
|
||||
input=[("image_tensor:0", INPUT_SHAPE)],
|
||||
)
|
||||
|
||||
# rows of (image_id, class_id, score, xmin, ymin, xmax, ymax)
|
||||
boxes = model.output("detection_boxes:0").get_node().input_value(0)
|
||||
classes = model.output("detection_classes:0").get_node().input_value(0)
|
||||
scores = model.output("detection_scores:0").get_node().input_value(0)
|
||||
nodes = {op.get_friendly_name(): op for op in model.get_ordered_ops()}
|
||||
parameter = model.get_parameters()[0]
|
||||
|
||||
# (ymin,xmin,ymax,xmax) -> (xmin,ymin,xmax,ymax)
|
||||
boxes = ops.gather(boxes, [1, 0, 3, 2], 2)
|
||||
classes = ops.unsqueeze(classes, 2)
|
||||
scores = ops.unsqueeze(scores, 2)
|
||||
image_id = ops.multiply(scores, np.float32(0.0))
|
||||
preprocessor = nodes["Preprocessor/map/TensorArrayStack/TensorArrayGatherV3"]
|
||||
box_deltas = nodes["Postprocessor/Reshape_1"].output(0)
|
||||
class_scores = nodes["Postprocessor/convert_scores"].output(0)
|
||||
anchors_output = nodes["Postprocessor/Reshape"].output(0)
|
||||
|
||||
detections = ops.concat([image_id, classes, scores, boxes], 2)
|
||||
detections = ops.unsqueeze(detections, 1)
|
||||
# The anchors only depend on the static input shape, so fold them into a
|
||||
# constant and drop the generator subgraph with the rest of the postprocessor.
|
||||
probe = ov.Core().compile_model(
|
||||
ov.Model([anchors_output, preprocessor.output(0)], [parameter], "probe"), "CPU"
|
||||
)
|
||||
probe_input = np.random.default_rng(0).integers(0, 255, INPUT_SHAPE, dtype=np.uint8)
|
||||
anchors, resized = (out.copy() for out in probe([probe_input]).values())
|
||||
|
||||
assert np.allclose(resized, probe_input, atol=1e-3), (
|
||||
"preprocessor is not an identity at 300x300, it cannot be bypassed"
|
||||
)
|
||||
|
||||
image = ops.convert(parameter, "f32")
|
||||
|
||||
for consumer in list(preprocessor.output(0).get_target_inputs()):
|
||||
consumer.replace_source_output(image.output(0))
|
||||
|
||||
# (ymin, xmin, ymax, xmax) -> (xmin, ymin, xmax, ymax)
|
||||
priors = anchors[:, [1, 0, 3, 2]].astype(np.float32).reshape(-1)
|
||||
variances = np.tile(BOX_VARIANCES, len(anchors))
|
||||
proposals = ops.constant(np.stack([priors, variances])[np.newaxis])
|
||||
|
||||
# (ty, tx, th, tw) -> (dx, dy, dw, dh) for the CENTER_SIZE decode
|
||||
box_logits = ops.reshape(ops.gather(box_deltas, [1, 0, 3, 2], 1), [1, -1], False)
|
||||
class_preds = ops.reshape(class_scores, [1, -1], False)
|
||||
|
||||
detections = ops.detection_output(
|
||||
box_logits,
|
||||
class_preds,
|
||||
proposals,
|
||||
{
|
||||
"background_label_id": 0,
|
||||
"top_k": 100,
|
||||
"keep_top_k": [100],
|
||||
"nms_threshold": 0.6,
|
||||
"confidence_threshold": 0.3,
|
||||
"code_type": "caffe.PriorBoxParameter.CENTER_SIZE",
|
||||
"share_location": True,
|
||||
"variance_encoded_in_target": False,
|
||||
"normalized": True,
|
||||
"clip_before_nms": False,
|
||||
"clip_after_nms": True,
|
||||
"decrease_label_id": False,
|
||||
},
|
||||
)
|
||||
detections.output(0).get_tensor().set_names({"detection_out"})
|
||||
|
||||
model = ov.Model([detections], model.get_parameters(), "ssdlite_mobilenet_v2")
|
||||
model = ov.Model([detections], [parameter], "ssdlite_mobilenet_v2")
|
||||
|
||||
ppp = PrePostProcessor(model)
|
||||
ppp.input().tensor().set_layout(ov.Layout("NHWC"))
|
||||
ppp.input().preprocess().reverse_channels()
|
||||
model = ppp.build()
|
||||
|
||||
ov.save_model(model, "/models/ssdlite_mobilenet_v2.xml", compress_to_fp16=True)
|
||||
# Fail the build rather than silently ship the dynamically shaped graph again.
|
||||
op_types = [op.get_type_name() for op in model.get_ordered_ops()]
|
||||
assert op_types.count("DetectionOutput") == 1, "postprocessor was not fused"
|
||||
|
||||
for dynamic_op in ("NonMaxSuppression", "NonZero", "Loop", "TensorIterator"):
|
||||
assert dynamic_op not in op_types, f"{dynamic_op} left in the graph"
|
||||
|
||||
output_shape = model.outputs[0].get_partial_shape()
|
||||
assert output_shape.is_static and list(output_shape) == [1, 1, 100, 7], (
|
||||
f"unexpected detector output shape {output_shape}"
|
||||
)
|
||||
|
||||
ov.save_model(model, OUTPUT_PATH, compress_to_fp16=True)
|
||||
|
||||
@@ -981,7 +981,9 @@ cameras:
|
||||
# Optional: Adjust sort order of cameras in the UI. Larger numbers come later (default: shown below)
|
||||
# By default the cameras are sorted alphabetically.
|
||||
order: 0
|
||||
# Optional: Whether or not to show the camera in the Frigate UI (default: shown below)
|
||||
# Optional: Whether or not to show the camera on the default All Cameras live dashboard.
|
||||
# The camera is still available everywhere else, including camera groups and settings
|
||||
# (default: shown below)
|
||||
dashboard: True
|
||||
# Optional: Whether this camera is visible in review (the review page and its camera
|
||||
# filter, motion review, and the history view) (default: shown below)
|
||||
|
||||
@@ -334,7 +334,7 @@ When your browser runs into problems playing back your camera streams, it will l
|
||||
|
||||
- **stalled**
|
||||
- What it means: Playback has stalled because the player has fallen too far behind live (extended buffering or no data arriving).
|
||||
- What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in the UI pane of Frigate's settings.
|
||||
- What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in <NavPath path="Settings > UI" /> .
|
||||
|
||||
- Possible console messages from the player code:
|
||||
- `Buffer time (10 seconds) exceeded, browser may not be playing media correctly.`
|
||||
|
||||
@@ -39,7 +39,7 @@ The per-clip variation is typically quite low and is mostly an artifact of keyfr
|
||||
|
||||
Debug Replay lets you re-run Frigate's detection pipeline against a section of recorded video without manually configuring a dummy camera. It automatically extracts the recording, creates a temporary camera with the same detection settings as the original, and loops the clip through the pipeline so you can observe detections in real time.
|
||||
|
||||
The replay camera behaves like a live camera feed rather than History's video player: it loops the clip continuously as Frigate analyzes it and has no playback controls, so you cannot pause, scrub, or step through it frame by frame.
|
||||
The replay camera behaves like a live camera feed rather than History's video player: it loops the clip continuously as Frigate analyzes it and has no playback controls, so you cannot pause, scrub, or step through it frame by frame. The Debug Replay camera does not save recordings or snapshots or surface anything in Explore, but it otherwise behaves like a regular camera, including running enrichments such as Face Recognition, LPR, and custom classification.
|
||||
|
||||
Debug Replay isn't intended to be a one-stop pane for all Frigate diagnostics or a comprehensive debugging environment for every Frigate feature. It merely makes it easier to spin up a "dummy camera" and perform some common adjustments in real time. You'll still need to use the normal tools (logs, an MQTT client, etc) to debug your feature.
|
||||
|
||||
|
||||
+15
-1
@@ -31,7 +31,10 @@ from frigate.api.auth import (
|
||||
get_allowed_cameras_for_filter,
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.config_util import swap_runtime_config
|
||||
from frigate.api.config_util import (
|
||||
publish_camera_section_updates,
|
||||
swap_runtime_config,
|
||||
)
|
||||
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
|
||||
from frigate.api.defs.request.app_body import (
|
||||
AppConfigSetBody,
|
||||
@@ -963,6 +966,17 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
body.update_topic, settings
|
||||
)
|
||||
|
||||
# a config/cameras/* topic publishes camera copies, a
|
||||
# global topic the global object. FrigateConfig.parse
|
||||
# folds some global sections down into every camera,
|
||||
# and workers read both objects, so any such section
|
||||
# needs its camera copies sent alongside the global
|
||||
# publish above.
|
||||
if body.update_topic == "config/birdseye":
|
||||
publish_camera_section_updates(
|
||||
request.app, config, CameraConfigUpdateEnum.birdseye
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
|
||||
|
||||
def publish_camera_section_updates(
|
||||
app: FastAPI, config: FrigateConfig, update_type: CameraConfigUpdateEnum
|
||||
) -> None:
|
||||
"""Broadcast every camera's re-resolved value for a global section.
|
||||
|
||||
Global sections are folded into each camera at parse time and the camera
|
||||
copies are what workers read, so send them rather than leave a worker to
|
||||
guess which cameras were inheriting.
|
||||
"""
|
||||
for camera_name, camera_config in config.cameras.items():
|
||||
settings = getattr(camera_config, update_type.name, None)
|
||||
|
||||
if settings is None:
|
||||
continue
|
||||
|
||||
app.config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(update_type, camera_name), settings
|
||||
)
|
||||
|
||||
|
||||
def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
|
||||
@@ -16,6 +40,10 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
|
||||
camera the user turned off would silently come back on.
|
||||
"""
|
||||
app.frigate_config = config
|
||||
|
||||
if app.config_holder is not None:
|
||||
app.config_holder.set(config)
|
||||
|
||||
app.genai_manager.update_config(config)
|
||||
|
||||
if app.profile_manager is not None:
|
||||
|
||||
@@ -35,6 +35,7 @@ from frigate.comms.event_metadata_updater import (
|
||||
)
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.config.holder import ConfigHolder
|
||||
from frigate.config.profile_manager import ProfileManager
|
||||
from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog
|
||||
from frigate.embeddings import EmbeddingsContext
|
||||
@@ -74,6 +75,7 @@ def create_fastapi_app(
|
||||
dispatcher: Dispatcher | None = None,
|
||||
profile_manager: ProfileManager | None = None,
|
||||
enforce_default_admin: bool = True,
|
||||
config_holder: ConfigHolder | None = None,
|
||||
):
|
||||
logger.info("Starting FastAPI app")
|
||||
app = FastAPI(
|
||||
@@ -162,6 +164,7 @@ def create_fastapi_app(
|
||||
app.replay_manager = replay_manager
|
||||
app.dispatcher = dispatcher
|
||||
app.profile_manager = profile_manager
|
||||
app.config_holder = config_holder
|
||||
|
||||
if frigate_config.auth.enabled:
|
||||
secret = get_jwt_secret()
|
||||
|
||||
+14
-1
@@ -30,6 +30,7 @@ from frigate.comms.ws import WebSocketClient
|
||||
from frigate.comms.zmq_proxy import ZmqProxy
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.config.config import FrigateConfig
|
||||
from frigate.config.holder import ConfigHolder
|
||||
from frigate.config.profile_manager import ProfileManager
|
||||
from frigate.const import (
|
||||
CACHE_DIR,
|
||||
@@ -122,7 +123,18 @@ class FrigateApp:
|
||||
self.processes: dict[str, int] = {}
|
||||
self.embeddings: EmbeddingsContext | None = None
|
||||
self.profile_manager: ProfileManager | None = None
|
||||
self.config = config
|
||||
self.config_holder = ConfigHolder(config)
|
||||
|
||||
@property
|
||||
def config(self) -> FrigateConfig:
|
||||
"""The current config, not the one Frigate booted with.
|
||||
|
||||
Read through the holder so the deferred watchdog factories below build
|
||||
a replacement process from the config as it is now. There is no setter
|
||||
on purpose: a plain attribute would let a caller pin this back to a
|
||||
single object and reintroduce the staleness.
|
||||
"""
|
||||
return self.config_holder.config
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
dirs = [
|
||||
@@ -645,6 +657,7 @@ class FrigateApp:
|
||||
self.replay_manager,
|
||||
self.dispatcher,
|
||||
self.profile_manager,
|
||||
config_holder=self.config_holder,
|
||||
),
|
||||
host="127.0.0.1",
|
||||
port=5001,
|
||||
|
||||
@@ -13,8 +13,8 @@ class CameraUiConfig(FrigateBaseModel):
|
||||
)
|
||||
dashboard: bool = Field(
|
||||
default=True,
|
||||
title="Show in UI",
|
||||
description="Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again.",
|
||||
title="Show on Live dashboard",
|
||||
description="Toggle whether this camera is visible on the default All Cameras live dashboard. The camera remains available everywhere else in the UI, including camera groups and settings.",
|
||||
)
|
||||
review: bool = Field(
|
||||
default=True,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Shared handle on the config object that is current for this instance."""
|
||||
|
||||
from .config import FrigateConfig
|
||||
|
||||
__all__ = ["ConfigHolder"]
|
||||
|
||||
|
||||
class ConfigHolder:
|
||||
"""Indirection for the most recently parsed config.
|
||||
|
||||
/api/config/set re-parses yaml into a brand new FrigateConfig instead of
|
||||
mutating the old one, so any reference captured during startup goes stale
|
||||
the first time a user saves. Anything that has to build something after
|
||||
startup, most importantly the watchdog factories that rebuild a crashed
|
||||
process, must read through a holder rather than close over a config
|
||||
object, or the rebuilt process comes back with the config as it was at
|
||||
boot and silently discards every change made since.
|
||||
|
||||
There is deliberately no setter on the read side: the swap runs in exactly
|
||||
one place (frigate.api.config_util.swap_runtime_config) and everyone else
|
||||
only reads.
|
||||
"""
|
||||
|
||||
def __init__(self, config: FrigateConfig) -> None:
|
||||
self._config = config
|
||||
|
||||
@property
|
||||
def config(self) -> FrigateConfig:
|
||||
"""The config as of the most recent successful save."""
|
||||
return self._config
|
||||
|
||||
def set(self, config: FrigateConfig) -> None:
|
||||
"""Install a freshly parsed config as the current one."""
|
||||
self._config = config
|
||||
@@ -23,6 +23,7 @@ from frigate.genai.prompts import (
|
||||
build_review_summary_prompt,
|
||||
)
|
||||
from frigate.models import Event
|
||||
from frigate.util.builtin import has_non_finite_number
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -164,6 +165,15 @@ class GenAIClient:
|
||||
except json.JSONDecodeError as je:
|
||||
logger.error("Failed to parse review description JSON: %s", je)
|
||||
return None
|
||||
|
||||
# model_construct skips validation, so non-finite numbers that
|
||||
# the validated path would have rejected have to be caught here
|
||||
if has_non_finite_number(raw):
|
||||
logger.error(
|
||||
"Discarding review description containing non-finite numbers."
|
||||
)
|
||||
return None
|
||||
|
||||
# observations and confidence are required on the model; fill an empty default
|
||||
# if the response omitted it so attribute access stays safe.
|
||||
raw.setdefault("observations", [])
|
||||
|
||||
@@ -178,13 +178,10 @@ class OutputProcess(FrigateProcess):
|
||||
)
|
||||
|
||||
if update_topic is not None and birdseye_config is not None:
|
||||
previous_global_mode = self.config.birdseye.mode
|
||||
# only the global-only fields are applied here; the per-camera
|
||||
# enabled and mode arrive on config/cameras/<name>/birdseye,
|
||||
# already resolved against yaml by the config parse
|
||||
self.config.birdseye = birdseye_config
|
||||
|
||||
for camera_config in self.config.cameras.values():
|
||||
if camera_config.birdseye.mode == previous_global_mode:
|
||||
camera_config.birdseye.mode = birdseye_config.mode
|
||||
|
||||
logger.debug("Applied dynamic birdseye config update")
|
||||
|
||||
# check if there is an updated config
|
||||
|
||||
@@ -13,6 +13,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdatePublisher,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.config.holder import ConfigHolder
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
@@ -373,6 +374,128 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_global_birdseye_save_fans_out_resolved_camera_configs(
|
||||
self, mock_find_config
|
||||
):
|
||||
"""A global birdseye save must also publish the per-camera values.
|
||||
|
||||
Global birdseye only seeds enabled and mode; the camera copies are what
|
||||
the output process actually reads. Sending just the global object makes
|
||||
a worker guess which cameras were inheriting, and the only available
|
||||
guess (mode still equals the previous global) wrongly claims a camera
|
||||
whose explicit yaml mode happens to match.
|
||||
"""
|
||||
self.minimal_config["birdseye"] = {"enabled": True, "mode": "motion"}
|
||||
# explicit override that matches the global value being replaced
|
||||
self.minimal_config["cameras"]["front_door"]["birdseye"] = {"mode": "motion"}
|
||||
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, mock_publisher = self._create_app_with_publisher()
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"birdseye": {"mode": "continuous"}},
|
||||
"update_topic": "config/birdseye",
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# the global object still goes out on its own topic
|
||||
mock_publisher.publisher.publish.assert_called_once()
|
||||
topic, settings = mock_publisher.publisher.publish.call_args[0]
|
||||
self.assertEqual(topic, "config/birdseye")
|
||||
self.assertEqual(settings.mode.value, "continuous")
|
||||
|
||||
published = {
|
||||
call[0][0].camera: call[0][1]
|
||||
for call in mock_publisher.publish_update.call_args_list
|
||||
}
|
||||
self.assertEqual(set(published), {"front_door", "back_yard"})
|
||||
|
||||
for call in mock_publisher.publish_update.call_args_list:
|
||||
self.assertEqual(
|
||||
call[0][0].update_type, CameraConfigUpdateEnum.birdseye
|
||||
)
|
||||
|
||||
# the override survives, the inheriting camera follows global
|
||||
self.assertEqual(published["front_door"].mode.value, "motion")
|
||||
self.assertEqual(published["back_yard"].mode.value, "continuous")
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_save_updates_the_config_holder(self, mock_find_config):
|
||||
"""A save must move the holder onto the freshly parsed config.
|
||||
|
||||
FrigateApp reads the holder when the watchdog rebuilds a crashed
|
||||
process; if the save leaves it on the boot config, that process comes
|
||||
back having lost every change made since Frigate started.
|
||||
"""
|
||||
from fastapi import Request
|
||||
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||
mock_publisher.publisher = MagicMock()
|
||||
boot_config = FrigateConfig(**self.minimal_config)
|
||||
holder = ConfigHolder(boot_config)
|
||||
|
||||
try:
|
||||
app = create_fastapi_app(
|
||||
boot_config,
|
||||
self.db,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
mock_publisher,
|
||||
None,
|
||||
enforce_default_admin=False,
|
||||
config_holder=holder,
|
||||
)
|
||||
|
||||
async def mock_get_current_user(request: Request):
|
||||
return {"username": "admin", "role": "admin"}
|
||||
|
||||
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||
return list(self.minimal_config.get("cameras", {}).keys())
|
||||
|
||||
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||
mock_get_allowed_cameras_for_filter
|
||||
)
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {"birdseye": {"inactivity_threshold": 5}},
|
||||
"update_topic": "config/birdseye",
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
self.assertIsNot(holder.config, boot_config)
|
||||
self.assertIs(holder.config, app.frigate_config)
|
||||
self.assertEqual(holder.config.birdseye.inactivity_threshold, 5)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from frigate.api.config_util import swap_runtime_config
|
||||
from frigate.config.holder import ConfigHolder
|
||||
|
||||
|
||||
class TestSwapRuntimeConfig(unittest.TestCase):
|
||||
@@ -12,6 +13,7 @@ class TestSwapRuntimeConfig(unittest.TestCase):
|
||||
def _make_app(self) -> MagicMock:
|
||||
app = MagicMock()
|
||||
app.dispatcher.comms = [MagicMock(), MagicMock()]
|
||||
app.config_holder = ConfigHolder(MagicMock(name="boot_config"))
|
||||
return app
|
||||
|
||||
def test_rebinds_all_references(self) -> None:
|
||||
@@ -37,11 +39,40 @@ class TestSwapRuntimeConfig(unittest.TestCase):
|
||||
# the swap rebuilds cameras from yaml, so overrides must be re-layered
|
||||
app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
|
||||
|
||||
def test_updates_the_config_holder(self) -> None:
|
||||
app = self._make_app()
|
||||
holder = app.config_holder
|
||||
config = MagicMock(name="new_config")
|
||||
|
||||
swap_runtime_config(app, config)
|
||||
|
||||
self.assertIs(holder.config, config)
|
||||
|
||||
def test_deferred_factory_builds_from_the_swapped_config(self) -> None:
|
||||
"""A watchdog-style factory must not rebuild from the boot config.
|
||||
|
||||
The factories in FrigateApp are lambdas evaluated when a process is
|
||||
restarted, long after a user may have saved. Reading through the
|
||||
holder is what keeps a rebuilt process from reverting every change
|
||||
made since Frigate started.
|
||||
"""
|
||||
app = self._make_app()
|
||||
holder = app.config_holder
|
||||
boot_config = holder.config
|
||||
factory = lambda: holder.config # noqa: E731
|
||||
self.assertIs(factory(), boot_config)
|
||||
|
||||
config = MagicMock(name="new_config")
|
||||
swap_runtime_config(app, config)
|
||||
|
||||
self.assertIs(factory(), config)
|
||||
|
||||
def test_tolerates_missing_optional_collaborators(self) -> None:
|
||||
app = MagicMock()
|
||||
app.profile_manager = None
|
||||
app.stats_emitter = None
|
||||
app.dispatcher = None
|
||||
app.config_holder = None
|
||||
config = MagicMock(name="new_config")
|
||||
|
||||
# must not raise when the optional collaborators are absent
|
||||
|
||||
@@ -472,6 +472,18 @@ def sanitize_float(value):
|
||||
return value
|
||||
|
||||
|
||||
def has_non_finite_number(value: Any) -> bool:
|
||||
"""Return True if any number in a parsed JSON value is NaN or infinite."""
|
||||
if isinstance(value, float):
|
||||
return not math.isfinite(value)
|
||||
if isinstance(value, dict):
|
||||
return any(has_non_finite_number(v) for v in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(has_non_finite_number(v) for v in value)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
||||
return 1 - cosine_distance(a, b)
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -53,21 +53,31 @@ ARCFACE_INPUT_SIZE = 112
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bgr_to_rgb(frame: np.ndarray) -> np.ndarray:
|
||||
"""Mirror BaseEmbedding._bgr_to_rgb."""
|
||||
if isinstance(frame, np.ndarray) and frame.ndim == 3:
|
||||
return np.ascontiguousarray(frame[:, :, ::-1])
|
||||
|
||||
return frame
|
||||
|
||||
|
||||
def _process_image_frigate(image: np.ndarray) -> Image.Image:
|
||||
"""Mirror BaseEmbedding._process_image for an ndarray input.
|
||||
|
||||
NOTE: Frigate passes the output of `cv2.imread` (BGR) directly in. PIL's
|
||||
`Image.fromarray` does NOT reorder channels, so the embedder effectively
|
||||
receives a BGR-ordered tensor. We replicate that faithfully here. (Tested
|
||||
— swapping to RGB produces near-identical embeddings; this model is
|
||||
robust to channel order.)
|
||||
`Image.fromarray` does not reorder channels, so whatever order it is
|
||||
handed is what reaches the model. Callers swap to RGB first, exactly as
|
||||
ArcfaceEmbedding._preprocess_inputs does.
|
||||
"""
|
||||
return Image.fromarray(image)
|
||||
|
||||
|
||||
def arcface_preprocess(image_bgr: np.ndarray) -> np.ndarray:
|
||||
"""Mirror ArcfaceEmbedding._preprocess_inputs."""
|
||||
pil = _process_image_frigate(image_bgr)
|
||||
"""Mirror ArcfaceEmbedding._preprocess_inputs.
|
||||
|
||||
Face crops arrive BGR from cv2 and #23712 added the swap to RGB before
|
||||
embedding, so this script has to do it too.
|
||||
"""
|
||||
pil = _process_image_frigate(_bgr_to_rgb(image_bgr))
|
||||
|
||||
width, height = pil.size
|
||||
if width != ARCFACE_INPUT_SIZE or height != ARCFACE_INPUT_SIZE:
|
||||
@@ -138,9 +148,7 @@ class LandmarkAligner:
|
||||
M[0, 2] += tX - eyesCenter[0]
|
||||
M[1, 2] += tY - eyesCenter[1]
|
||||
|
||||
aligned = cv2.warpAffine(
|
||||
image, M, (out_w, out_h), flags=cv2.INTER_CUBIC
|
||||
)
|
||||
aligned = cv2.warpAffine(image, M, (out_w, out_h), flags=cv2.INTER_CUBIC)
|
||||
info = dict(
|
||||
angle=float(angle),
|
||||
eye_dist_px=dist,
|
||||
@@ -433,9 +441,7 @@ def vector_outlier_test(
|
||||
if neg
|
||||
else np.array([])
|
||||
)
|
||||
baseline_conf_neg = np.array(
|
||||
[similarity_to_confidence(c) for c in baseline_neg]
|
||||
)
|
||||
baseline_conf_neg = np.array([similarity_to_confidence(c) for c in baseline_neg])
|
||||
|
||||
print(
|
||||
f"\nBaseline (trim_mean only, {len(pos)} images):"
|
||||
@@ -465,9 +471,7 @@ def vector_outlier_test(
|
||||
mean, keep = iterative_mean(all_embs, T)
|
||||
pos_sims = np.array([cosine(p.embedding, mean) for p in pos])
|
||||
neg_sims = (
|
||||
np.array([cosine(n.embedding, mean) for n in neg])
|
||||
if neg
|
||||
else np.array([])
|
||||
np.array([cosine(n.embedding, mean) for n in neg]) if neg else np.array([])
|
||||
)
|
||||
neg_conf = np.array([similarity_to_confidence(c) for c in neg_sims])
|
||||
margin = pos_sims.min() - (neg_sims.max() if len(neg_sims) else 0)
|
||||
@@ -483,9 +487,7 @@ def vector_outlier_test(
|
||||
# Show which images get dropped at the shipped threshold + neighbors
|
||||
for T_show in (0.25, 0.30, 0.33):
|
||||
_, keep = iterative_mean(all_embs, T_show)
|
||||
print(
|
||||
f"\nAt T={T_show}, the {int((~keep).sum())} dropped positives are:"
|
||||
)
|
||||
print(f"\nAt T={T_show}, the {int((~keep).sum())} dropped positives are:")
|
||||
final_mean = stats.trim_mean(all_embs[keep], base_trim, axis=0)
|
||||
m_n = final_mean / (np.linalg.norm(final_mean) + 1e-9)
|
||||
for i, (p, k) in enumerate(zip(pos, keep)):
|
||||
@@ -501,9 +503,7 @@ def vector_outlier_test(
|
||||
)
|
||||
|
||||
|
||||
def degenerate_embedding_test(
|
||||
pos: list[FaceSample], neg: list[FaceSample]
|
||||
) -> None:
|
||||
def degenerate_embedding_test(pos: list[FaceSample], neg: list[FaceSample]) -> None:
|
||||
"""Detect whether negatives and low-quality positives share a degenerate
|
||||
'tiny/noisy face' region of the embedding space.
|
||||
|
||||
@@ -533,8 +533,7 @@ def degenerate_embedding_test(
|
||||
f"(how tightly negatives cluster together)"
|
||||
)
|
||||
print(
|
||||
f" pos<->pos mean cos : {np.nanmean(pp):.3f} "
|
||||
f"(how tightly positives cluster)"
|
||||
f" pos<->pos mean cos : {np.nanmean(pp):.3f} (how tightly positives cluster)"
|
||||
)
|
||||
print(
|
||||
f" pos<->neg mean cos : {pn.mean():.3f} "
|
||||
@@ -558,11 +557,7 @@ def degenerate_embedding_test(
|
||||
neg_scores = np.array([cosine(n.embedding, clean_mean) for n in neg])
|
||||
neg_confs = np.array([similarity_to_confidence(c) for c in neg_scores])
|
||||
pos_scores = np.array(
|
||||
[
|
||||
cosine(pos[i].embedding, clean_mean)
|
||||
for i in range(len(pos))
|
||||
if keep[i]
|
||||
]
|
||||
[cosine(pos[i].embedding, clean_mean) for i in range(len(pos)) if keep[i]]
|
||||
)
|
||||
print(
|
||||
f"\n mean_intra >= {thresh}: keeping {int(keep.sum())}/{len(pos)} positives"
|
||||
@@ -585,9 +580,7 @@ def degenerate_embedding_test(
|
||||
)
|
||||
|
||||
|
||||
def contamination_analysis(
|
||||
pos: list[FaceSample], neg: list[FaceSample]
|
||||
) -> None:
|
||||
def contamination_analysis(pos: list[FaceSample], neg: list[FaceSample]) -> None:
|
||||
"""Check whether the positive collection contains a second identity.
|
||||
|
||||
Two signals:
|
||||
@@ -617,10 +610,7 @@ def contamination_analysis(
|
||||
"\nPositives closer to a negative than to their own class avg"
|
||||
"\n(these are candidates for mislabeled images):"
|
||||
)
|
||||
print(
|
||||
f"\n{'max_neg':>7} {'mean_neg':>8} {'mean_intra':>10} "
|
||||
f"{'delta':>6} name"
|
||||
)
|
||||
print(f"\n{'max_neg':>7} {'mean_neg':>8} {'mean_intra':>10} {'delta':>6} name")
|
||||
rows = list(zip(pos_names, max_to_neg, mean_to_neg, mean_intra))
|
||||
rows.sort(key=lambda r: -(r[1] - r[3]))
|
||||
for nm, mxn, mnn, mi in rows[:15]:
|
||||
@@ -704,7 +694,9 @@ def main() -> int:
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
ap.add_argument("--positive", required=True, help="Training folder for one identity")
|
||||
ap.add_argument(
|
||||
"--positive", required=True, help="Training folder for one identity"
|
||||
)
|
||||
ap.add_argument(
|
||||
"--negative",
|
||||
default=None,
|
||||
|
||||
@@ -859,8 +859,8 @@
|
||||
"description": "Numeric order used to sort the camera in the UI (default dashboard and lists); larger numbers appear later."
|
||||
},
|
||||
"dashboard": {
|
||||
"label": "Show in UI",
|
||||
"description": "Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again."
|
||||
"label": "Show on Live dashboard",
|
||||
"description": "Toggle whether this camera is visible on the default All Cameras live dashboard. The camera remains available everywhere else in the UI, including camera groups and settings."
|
||||
},
|
||||
"review": {
|
||||
"label": "Show in review",
|
||||
|
||||
@@ -1543,8 +1543,8 @@
|
||||
"description": "Numeric order used to sort the camera in the UI (default dashboard and lists); larger numbers appear later."
|
||||
},
|
||||
"dashboard": {
|
||||
"label": "Show in UI",
|
||||
"description": "Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again."
|
||||
"label": "Show on Live dashboard",
|
||||
"description": "Toggle whether this camera is visible on the default All Cameras live dashboard. The camera remains available everywhere else in the UI, including camera groups and settings."
|
||||
},
|
||||
"review": {
|
||||
"label": "Show in review",
|
||||
|
||||
@@ -499,7 +499,7 @@
|
||||
"webuiUrlHelp": "URL to visit the camera's web UI directly from the Debug view. Leave blank to disable the link.",
|
||||
"webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com).",
|
||||
"dashboardLabel": "Show on Live dashboard",
|
||||
"dashboardHelp": "Show this camera on the Live dashboard.",
|
||||
"dashboardHelp": "Show this camera on the default All Cameras live dashboard. It remains available everywhere else, including camera groups.",
|
||||
"reviewLabel": "Show in Review",
|
||||
"reviewHelp": "Show this camera in Review, including the camera filter, motion review, and the history view."
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function ZoneEditPane({
|
||||
}
|
||||
|
||||
return Object.values(config.cameras)
|
||||
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
|
||||
.filter((conf) => conf.enabled_in_config)
|
||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||
}, [config]);
|
||||
|
||||
|
||||
@@ -706,12 +706,7 @@ export default function Settings() {
|
||||
}
|
||||
|
||||
return Object.values(config.cameras)
|
||||
.filter(
|
||||
(conf) =>
|
||||
conf.ui.dashboard &&
|
||||
conf.enabled_in_config &&
|
||||
!isReplayCamera(conf.name),
|
||||
)
|
||||
.filter((conf) => conf.enabled_in_config && !isReplayCamera(conf.name))
|
||||
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||
}, [config]);
|
||||
|
||||
|
||||
@@ -540,6 +540,36 @@ export default function GeneralMetrics({
|
||||
return Object.keys(series).length > 0 ? Object.values(series) : undefined;
|
||||
}, [statsHistory]);
|
||||
|
||||
// Number of cards the hardware grid renders. Which ones appear depends on
|
||||
// the vendor, so the column count follows the count rather than assuming a
|
||||
// fixed set is present.
|
||||
const hardwareCardCount = useMemo(() => {
|
||||
if (!statsHistory[0]?.gpu_usages) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const hasNpu = statsHistory[0].npu_usages != undefined;
|
||||
|
||||
return (
|
||||
1 + // gpu usage always renders alongside gpu_usages
|
||||
(gpuMemSeries ? 1 : 0) +
|
||||
(gpuEncSeries?.length ? 1 : 0) +
|
||||
(gpuComputeSeries?.length ? 1 : 0) +
|
||||
(gpuDecSeries?.length ? 1 : 0) +
|
||||
(gpuTempSeries?.length ? 1 : 0) +
|
||||
(hasNpu ? 1 : 0) +
|
||||
(hasNpu && npuTempSeries?.length ? 1 : 0)
|
||||
);
|
||||
}, [
|
||||
statsHistory,
|
||||
gpuMemSeries,
|
||||
gpuEncSeries,
|
||||
gpuComputeSeries,
|
||||
gpuDecSeries,
|
||||
gpuTempSeries,
|
||||
npuTempSeries,
|
||||
]);
|
||||
|
||||
// other processes stats
|
||||
|
||||
const hardwareType = useMemo(() => {
|
||||
@@ -763,12 +793,9 @@ export default function GeneralMetrics({
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2",
|
||||
gpuTempSeries?.length && "md:grid-cols-3",
|
||||
(gpuEncSeries?.length || gpuComputeSeries?.length) &&
|
||||
"xl:grid-cols-4",
|
||||
(gpuEncSeries?.length || gpuComputeSeries?.length) &&
|
||||
gpuTempSeries?.length &&
|
||||
"3xl:grid-cols-5",
|
||||
hardwareCardCount >= 3 && "lg:grid-cols-3",
|
||||
hardwareCardCount >= 4 && "xl:grid-cols-4",
|
||||
hardwareCardCount >= 5 && "3xl:grid-cols-5",
|
||||
)}
|
||||
>
|
||||
{statsHistory[0]?.gpu_usages && (
|
||||
|
||||
Reference in New Issue
Block a user