Compare commits

..
Author SHA1 Message Date
Josh Hawkins 53b0dff949 add docs note about privacy masks 2026-10-06 15:42:49 -05:00
Josh Hawkins 6eebd4234c add notice and system ui message when a camera isn't using go2rtc 2026-10-06 15:29:16 -05:00
Josh Hawkins 945de037a5 fix classification wizard finding no sample images on large databases
The wizard grouped tracked objects by camera and 6 hour block, oldest first, then kept the first 100. With more than 100 groups that was always the oldest objects, which often have no snapshot or thumbnail left on disk, so no examples were generated. Shuffle the selection before truncating, and keep extracting from the remaining tracked objects until 100 usable images are found.
2026-10-06 15:09:51 -05:00
12 changed files with 137 additions and 118 deletions
+2 -2
View File
@@ -2,9 +2,9 @@ aiofiles == 25.1.*
click == 8.5.*
# FastAPI
aiohttp == 3.12.*
starlette == 1.7.*
starlette == 0.47.*
starlette-context == 0.5.*
fastapi[standard-no-fastapi-cloud-cli] == 0.142.*
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
uvicorn == 0.52.*
slowapi == 0.1.*
joserfc == 1.6.*
+8
View File
@@ -53,6 +53,14 @@ go2rtc:
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
### Can I add a privacy mask to hide part of my camera's view?
Frigate does not have privacy masks. [Motion masks and object filter masks](../configuration/masks.md) only affect detection, they don't hide anything in live view, recordings, or snapshots.
Privacy masks are best configured in the camera's firmware settings so the area is blacked out before the video ever leaves the camera and no extra processing is needed. Check there first.
If your camera does not support privacy masks, there is no efficient alternative. Frigate copies the camera's video into recordings and live view without decoding it, so part of the image can't be hidden without transcoding (re-encoding) the stream. This can be done with a custom ffmpeg filter in go2rtc, but it is not recommended. Every masked camera needs a continuous re-encode, which significantly increases CPU usage, especially for high resolution streams.
### My mjpeg stream or snapshots look green and crazy
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
+3 -7
View File
@@ -17448,9 +17448,9 @@
"license": "ISC"
},
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -17458,10 +17458,6 @@
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr/node_modules/ipaddr.js": {
+2 -7
View File
@@ -8017,7 +8017,7 @@ components:
properties:
file:
type: string
contentMediaType: application/octet-stream
format: binary
title: File
type: object
required:
@@ -8027,7 +8027,7 @@ components:
properties:
file:
type: string
contentMediaType: application/octet-stream
format: binary
title: File
type: object
required:
@@ -9601,11 +9601,6 @@ components:
type:
type: string
title: Error Type
input:
title: Input
ctx:
type: object
title: Context
type: object
required:
- loc
+6 -29
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import logging
import queue
import selectors
import socket
import threading
import time
from collections.abc import Callable
@@ -58,11 +57,6 @@ class MqttClient(Communicator):
self._next_connect_time = 0.0
self._last_on_connect_dispatch = 0.0
# lets other threads interrupt the worker's socket wait
self._wake_recv, self._wake_send = socket.socketpair()
self._wake_recv.setblocking(False)
self._wake_send.setblocking(False)
def subscribe(self, receiver: Callable) -> None:
"""Wrapper for allowing dispatcher to subscribe."""
self._dispatcher = receiver
@@ -92,7 +86,6 @@ class MqttClient(Communicator):
return
self._publish_queue.put(QueuedPublish(full_topic, payload, retain))
self._wake_worker()
def stop(self) -> None:
if self._worker is None:
@@ -108,11 +101,9 @@ class MqttClient(Communicator):
publish_done,
)
)
self._wake_worker()
publish_done.wait(MQTT_SHUTDOWN_FLUSH_TIMEOUT)
self._stop_event.set()
self._wake_worker()
if self.client is not None:
try:
@@ -378,17 +369,11 @@ class MqttClient(Communicator):
exc_info=True,
)
def _wake_worker(self) -> None:
try:
self._wake_send.send(b"\0")
except BlockingIOError:
# the buffer is full, so a wake is already pending
pass
def _loop_client(self, timeout: float) -> int:
"""Drive Paho without select()'s limit on socket file descriptors."""
assert self.client is not None
client = self.client
if client is None:
return mqtt.MQTT_ERR_NO_CONN
sock = client.socket()
if sock is None:
return mqtt.MQTT_ERR_NO_CONN
@@ -400,19 +385,11 @@ class MqttClient(Communicator):
pending = hasattr(sock, "pending") and sock.pending() > 0
with selectors.DefaultSelector() as selector:
selector.register(sock, events)
selector.register(self._wake_recv, selectors.EVENT_READ)
ready = {
key.fileobj: mask
for key, mask in selector.select(0.0 if pending else timeout)
}
ready = selector.select(0.0 if pending else timeout)
if self._wake_recv in ready:
try:
self._wake_recv.recv(4096)
except BlockingIOError:
pass
ready_events = ready.get(sock, 0)
ready_events = 0
for _, mask in ready:
ready_events |= mask
if pending or ready_events & selectors.EVENT_READ:
result = client.loop_read()
if result != mqtt.MQTT_ERR_SUCCESS or client.socket() is None:
+3 -16
View File
@@ -126,18 +126,12 @@ class TestMqttClientLifecycle(unittest.TestCase):
os.makedirs(MODEL_CACHE_DIR)
self.config = build_config()
self.client = self._build_client()
self.client = MqttClient(self.config)
self.receiver = RuntimeSnapshotReceiver()
self.client.attach_dispatcher(build_dispatcher(self.config, []))
def _build_client(self) -> MqttClient:
client = MqttClient(self.config)
self.addCleanup(client._wake_recv.close)
self.addCleanup(client._wake_send.close)
return client
def test_subscribe_stores_receiver_without_starting_worker(self) -> None:
client = self._build_client()
client = MqttClient(self.config)
with patch.object(client, "_start_worker") as mock_start_worker:
client.subscribe(self.receiver._receive)
@@ -148,7 +142,7 @@ class TestMqttClientLifecycle(unittest.TestCase):
mock_start_worker.assert_not_called()
def test_attach_dispatcher_supplies_command_surface(self) -> None:
client = self._build_client()
client = MqttClient(self.config)
self.assertFalse(client._is_supported_command_topic("front/detect/set"))
@@ -301,13 +295,6 @@ class TestMqttClientLifecycle(unittest.TestCase):
self.assertEqual(self.client._subscription_mid, 42)
self.client.client.subscribe.assert_called_once_with("frigate/#", qos=0)
def test_publish_wakes_worker(self) -> None:
self.client.connected = True
self.client.publish("events", "payload")
self.assertEqual(self.client._wake_recv.recv(16), b"\0")
def test_handle_connect_event_reconnects_on_recoverable_subscribe_error(
self,
) -> None:
+3 -19
View File
@@ -2,7 +2,6 @@ import fcntl
import resource
import selectors
import socket
import time
import unittest
from unittest.mock import MagicMock, patch
@@ -24,11 +23,6 @@ class TestMqttNetworkLoop(unittest.TestCase):
self.sock, self.peer = socket.socketpair()
self.addCleanup(self.sock.close)
self.addCleanup(self.peer.close)
self.transport._wake_recv, self.transport._wake_send = socket.socketpair()
self.transport._wake_recv.setblocking(False)
self.transport._wake_send.setblocking(False)
self.addCleanup(self.transport._wake_recv.close)
self.addCleanup(self.transport._wake_send.close)
self.client.socket.return_value = self.sock
def test_high_fd_handles_connack_suback_publish_and_puback(self) -> None:
@@ -129,18 +123,8 @@ class TestMqttNetworkLoop(unittest.TestCase):
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_CONN_LOST)
self.client.loop_misc.assert_not_called()
def test_missing_socket_reports_no_connection(self) -> None:
def test_missing_client_or_socket_reports_no_connection(self) -> None:
self.client.socket.return_value = None
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_NO_CONN)
def test_wake_interrupts_wait_and_is_consumed(self) -> None:
self.transport._wake_worker()
start = time.monotonic()
self.assertEqual(self.transport._loop_client(5), mqtt.MQTT_ERR_SUCCESS)
self.assertLess(time.monotonic() - start, 1)
self.client.loop_read.assert_not_called()
start = time.monotonic()
self.transport._loop_client(0.2)
self.assertGreater(time.monotonic() - start, 0.15)
self.transport.client = None
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_NO_CONN)
+20 -4
View File
@@ -747,8 +747,14 @@ def collect_object_classification_examples(
selected_events = _select_balanced_events(events, target_count=100)
logger.debug(f"Selected {len(selected_events)} events")
# Step 3: Extract thumbnails from events
thumbnails = _extract_event_thumbnails(selected_events, temp_dir)
# Step 3: Extract thumbnails from events, falling back to the remaining
# events when the selected ones have no image on disk
selected_ids = {e.id for e in selected_events}
remaining_events = [e for e in events if e.id not in selected_ids]
random.shuffle(remaining_events)
thumbnails = _extract_event_thumbnails(
selected_events + remaining_events, temp_dir, target_count=100
)
logger.debug(f"Successfully extracted {len(thumbnails)} thumbnails")
# Step 4: Select 24 most visually distinct thumbnails
@@ -833,10 +839,16 @@ def _select_balanced_events(
else:
selected.extend(remaining)
# groups are ordered oldest first, so truncating unshuffled keeps only the
# oldest events, which are the least likely to still have images on disk
random.shuffle(selected)
return selected[:target_count]
def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]:
def _extract_event_thumbnails(
events: list[Event], output_dir: str, target_count: int = 100
) -> list[str]:
"""
Extract a training image for each event.
@@ -850,8 +862,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
using a step ladder sized from the box/region area ratio.
Args:
events: List of Event objects
events: List of Event objects, in order of preference
output_dir: Directory to save crops
target_count: Number of images to extract before stopping
Returns:
List of paths to successfully extracted images
@@ -859,6 +872,9 @@ def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]
image_paths = []
for idx, event in enumerate(events):
if len(image_paths) >= target_count:
break
try:
img = _load_event_classification_crop(event)
if img is None:
+25 -32
View File
@@ -136,36 +136,33 @@ ACCESS_NOTES = {
}
# Mirrors the router set wired up in frigate.api.fastapi_app.
ROUTERS = [
auth.router,
camera.router,
chat.router,
classification.router,
review.router,
main_app.router,
preview.router,
notification.router,
export.router,
hardware.router,
notices.router,
event.router,
media.router,
motion_search.router,
record.router,
debug_replay.router,
]
def build_app() -> FastAPI:
"""Build a bare app with every router mounted.
It omits the global admin dependency and all runtime state; the OpenAPI
route table and the per-route dependencies are all we need to export and
classify.
This mirrors the router set wired up in frigate.api.fastapi_app. It omits
the global admin dependency and all runtime state; the OpenAPI route table
and the per-route dependencies are all we need to export and classify.
"""
app = FastAPI()
for router in ROUTERS:
routers = [
auth.router,
camera.router,
chat.router,
classification.router,
review.router,
main_app.router,
preview.router,
notification.router,
export.router,
hardware.router,
notices.router,
event.router,
media.router,
motion_search.router,
record.router,
debug_replay.router,
]
for router in routers:
app.include_router(router)
return app
@@ -321,17 +318,13 @@ def _classify_base(
def build_access_map(
app: FastAPI,
exempt_paths: set[str],
exempt_prefixes: tuple[str, ...],
) -> dict[tuple[str, str], dict]:
"""Map (path, lowercase method) -> classification details."""
access_map: dict[tuple[str, str], dict] = {}
# app.routes holds opaque wrappers for included routers on newer FastAPI.
# The routers mount without a prefix, so their own routes carry final paths.
routes = [route for router in ROUTERS for route in router.routes]
for route in routes:
for route in app.routes:
if not isinstance(route, APIRoute):
continue
level, roles, flag = classify_route(route, exempt_paths, exempt_prefixes)
@@ -522,7 +515,7 @@ def render(spec: dict) -> str:
def build_spec() -> tuple[dict, dict, list, list, list]:
app = build_app()
exempt_paths, exempt_prefixes = read_exempt_rules()
access_map = build_access_map(exempt_paths, exempt_prefixes)
access_map = build_access_map(app, exempt_paths, exempt_prefixes)
spec = base_document(app.openapi())
normalized = strip_volatile_defaults(spec)
+46 -2
View File
@@ -14,6 +14,17 @@ const NOW = Math.floor(Date.now() / 1000);
// the fixture detector runs at 75.5 ms, above the live warning threshold
const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } };
// the fixture has no go2rtc streams, which gives every camera a live view hint
const RESTREAMED = {
go2rtc: {
streams: {
front_door: ["rtsp://x"],
backyard: ["rtsp://x"],
garage: ["rtsp://x"],
},
},
};
const ERROR_NOTICE = {
id: "model_download_failed:yolo/model.onnx",
kind: "model_download_failed",
@@ -51,6 +62,7 @@ test.describe("System — Health tab @medium", () => {
frigateApp,
}) => {
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
notices: [ERROR_NOTICE, EVENT_NOTICE],
});
@@ -84,6 +96,7 @@ test.describe("System — Health tab @medium", () => {
for (const action of ["acknowledge", "mute"] as const) {
test(`${action} posts and removes the row`, async ({ frigateApp }) => {
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
notices: [EVENT_NOTICE],
});
@@ -126,7 +139,10 @@ test.describe("System — Health tab @medium", () => {
}
test("empty state with no notices", async ({ frigateApp }) => {
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await expect(
@@ -329,6 +345,7 @@ test.describe("System — Health tab @medium", () => {
frigateApp,
}) => {
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
notices: [ERROR_NOTICE, EVENT_NOTICE],
});
@@ -765,6 +782,7 @@ test.describe("System — Health notices sources @medium", () => {
test("status bar problems stay out of the list", async ({ frigateApp }) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({
config: RESTREAMED,
stats: {
service: { retention_unmet: true },
cameras: { front_door: { camera_fps: 0 } },
@@ -829,6 +847,28 @@ test.describe("System — Health notices sources @medium", () => {
).toBeVisible({ timeout: 15_000 });
});
test("a camera without a go2rtc stream gets a live view hint", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { go2rtc: { streams: { front_door: ["rtsp://x"] } } },
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(
"health-problem-config:live:no-go2rtc-stream:camera.backyard",
);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row).toHaveAttribute("data-severity", "info");
await expect(row).toContainText("lower frame rate and no audio");
await expect(
frigateApp.page.getByTestId(
"health-problem-config:live:no-go2rtc-stream:camera.front_door",
),
).toHaveCount(0);
});
test("a global config problem is not repeated per camera", async ({
frigateApp,
}) => {
@@ -893,7 +933,10 @@ test.describe("System — Health notices sources @medium", () => {
test("empty state when stats, config, and registry are clean", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await expect(
@@ -1130,6 +1173,7 @@ test.describe("System — Health notices sources @medium", () => {
}) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({
config: RESTREAMED,
stats: QUIET_STATS,
notices: [EVENT_NOTICE],
});
@@ -1997,6 +1997,9 @@
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.",
"genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images."
},
"live": {
"noGo2rtcStream": "Live view for this camera is using a basic player with a lower frame rate and no audio. Set up a go2rtc stream for this camera to get smoother video and audio."
},
"audio": {
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
},
@@ -1,8 +1,24 @@
import { isRestreamedStream } from "@/utils/liveTranscode";
import type { SectionConfigOverrides } from "./types";
const live: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/live",
messages: [
{
key: "no-go2rtc-stream",
health: true,
messageKey: "configMessages.live.noGo2rtcStream",
severity: "info",
docLink: "/configuration/live",
condition: (ctx) => {
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
return !Object.values(ctx.fullCameraConfig.live.streams).some(
(name) => isRestreamedStream(ctx.fullConfig, name),
);
},
},
],
restartRequired: [],
fieldOrder: ["streams", "transcode", "height", "quality"],
fieldGroups: {},