Compare commits

..
Author SHA1 Message Date
Josh Hawkins aa8086393b end tracked events before replacing replay camera state on a scene switch
- test the replay model rebind against two configured models
2026-11-12 17:39:19 -06:00
Josh Hawkins 4e679ba17a allow switching the detect scene of a debug replay camera without a restart
- show the scene field in the replay detect settings
- drop the replay camera's cached model on a detect change so the refresh recycle picks up the new scene's model
- recreate the tracked object state on refresh when the camera's model changed
- reject a scene no model can serve with a 400 instead of crashing the tracker and maintainer threads
- size each camera's detection input shm for the largest model and recreate leftover smaller segments, so a camera re-added under a larger model no longer overflows it
- ignore event ends for objects already dropped by a state reset
2026-11-12 17:32:22 -06:00
Josh HawkinsandGitHub 06d1365056 Report ownership scan progress (#24940)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* report ownership scan progress

The ownership scan printed nothing until it finished, which took 30+ minutes on large recordings volumes and looked stuck. It now logs a running count of checked and mismatched entries every 30 seconds.

* check the scan clock on every entry
2026-11-11 08:52:58 -07:00
Josh HawkinsandGitHub 8a88cefc9a Autotracking tweaks (#24935)
* fix autotracking movement time prediction and recorded move times

Movement time prediction multiplied the fitted intercept by the saved zero-step calibration time instead of 1, so predictions didn't match the regression. In absolute zoom mode, a pan/tilt followed by a zoom recorded the zoom's timestamps against the pan/tilt distance, because _zoom_absolute resets the PTZ start and stop times. The pan/tilt times are now captured before the zoom runs.

* predict velocity and zoom size for objects approaching the autotracked camera

The velocity check required both box corners to move in the same direction, but the corners of an approaching object diverge, so its velocity was rejected and zeroed and the camera aimed where the object had been. That check is removed, and the existing magnitude, delta, and variance checks still reject noise. The zoom size prediction fit area against epoch time with no intercept, which returned roughly the mean area instead of a forecast. It now scales the target box by the growth the corner velocities predict over the movement time.

* keep predicted target box positive and add regression tests

A shrinking object's predicted size could reach zero, so the relative zoom ratio divided by zero and sent a NaN zoom to the camera. The predicted size is now floored at 1 px. Tests also cover the movement time intercept and the pan/tilt timestamps that absolute zoom overwrites.
2026-11-11 08:38:21 -07:00
Josh HawkinsandGitHub fba967c74b fix annotation offset seeking in tracking details (#24936)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
The VOD manifest used to find the keyframe-snapped clip start was built from the live annotation offset while the playing clip used the offset from when the event opened. Each offset step fetched a manifest for a different clip, so the seek mapping drifted: the video jumped forward about one GOP every second of offset and flickered while each new manifest loaded. The manifest now uses the same stable source offset as the playlist
2026-11-10 07:57:21 -06:00
12 changed files with 153 additions and 45 deletions
+14 -1
View File
@@ -105,6 +105,8 @@ if [[ -n "$sentinel" ]]; then
mismatch_expr+=(-a ! -path "$sentinel")
fi
exec 3>&1
for path in "$@"; do
# An absent root is an incomplete sweep, not a finished one: /media/frigate
# is not in the image, so a boot before the volume is mounted would
@@ -121,7 +123,18 @@ for path in "$@"; do
# stale mount. Tolerate it rather than aborting under errexit, but never
# read a failed scan as "nothing to do": that would record the sweep as
# complete without having looked.
if ! count=$(find "$path" -name lost+found -prune -o "${mismatch_expr[@]}" -printf '.' 2>/dev/null | wc -c); then
# The walk prints nothing until it ends, so report every 30s through fd 3
# (stdout) while the count itself comes back through the substitution.
if ! count=$(find "$path" -name lost+found -prune -o "(" "${mismatch_expr[@]}" ")" -printf 'm\n' -o -printf '.\n' 2>/dev/null \
| awk -v path="$path" '
BEGIN { next_report = systime() + 30 }
$0 == "m" { mismatched++ }
systime() >= next_report {
printf "[INFO] fix-ownership: still scanning %s, %d entries checked, %d need changes\n", path, NR, mismatched > "/dev/fd/3"
fflush("/dev/fd/3")
next_report = systime() + 30
}
END { print mismatched + 0 }'); then
swept_clean=0
echo "[WARN] fix-ownership: could not scan ${path}; will retry on next boot"
continue
+2 -1
View File
@@ -71,13 +71,14 @@ Both the script and the boot sweep report progress, so you can tell a slow sweep
```
[INFO] fix-ownership: scanning /media/frigate for ownership mismatches; this may take a while on large filesystems
[INFO] fix-ownership: still scanning /media/frigate, 2481000 entries checked, 2390112 need changes
[WARN] fix-ownership: adjusting ownership of 4823941 entries under /media/frigate
[INFO] fix-ownership: /media/frigate 5% (241197/4823941 entries)
[INFO] fix-ownership: /media/frigate 10% (482394/4823941 entries)
[INFO] fix-ownership: finished /media/frigate in 12m 4s
```
The scan has no percentage because the total isn't known until it finishes. Watch the boot sweep with `docker logs -f frigate`.
The scan has no percentage because the total isn't known until it finishes, so it reports a running count every 30 seconds instead. Watch the boot sweep with `docker logs -f frigate`.
Once the volumes are aligned, start Frigate normally. A file at `/config/.permissions_version` records what was done, so later boots skip the sweep unless you change `PUID`/`PGID`.
+17 -1
View File
@@ -45,7 +45,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
from frigate.const import REDACTED_CREDENTIAL_SENTINEL, REPLAY_CAMERA_PREFIX
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
from frigate.genai import PROVIDERS, load_providers
from frigate.jobs.media_sync import (
@@ -979,6 +979,17 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
status_code=400,
)
# an unresolvable scene would crash the threads that look up the model
detect_update = sections.get("detect")
if isinstance(detect_update, dict) and "scene" in detect_update:
try:
config._resolve_camera_model(cam_name, detect_update["scene"])
except (TypeError, ValueError):
return JSONResponse(
content={"success": False, "message": "Invalid detect scene"},
status_code=400,
)
for section_name, update in sections.items():
err = apply_section_update(camera_config, section_name, update)
if err is not None:
@@ -987,6 +998,11 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
status_code=400,
)
# the model is resolved again from the scene when the replay
# camera is recycled by the refresh below
if "detect" in sections and cam_name.startswith(REPLAY_CAMERA_PREFIX):
config.drop_camera_model(cam_name)
# Publish ZMQ updates so processing threads pick up changes
if body.update_topic and body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/")
+2 -9
View File
@@ -76,7 +76,7 @@ from frigate.models import (
from frigate.notices import install_registry
from frigate.notices.registry import NoticeRegistry
from frigate.object_detection.base import ObjectDetectProcess
from frigate.object_detection.util import detection_frame_size
from frigate.object_detection.util import create_detection_input_shm
from frigate.output.output import OutputProcess
from frigate.ptz.autotrack import PtzAutoTracker
from frigate.ptz.onvif import OnvifController
@@ -399,14 +399,7 @@ class FrigateApp:
model = self.config.model_for_camera(name)
model_cameras[model.scene].append(name)
try:
shm_in = UntrackedSharedMemory(
name=name,
create=True,
size=detection_frame_size(model),
)
except FileExistsError:
shm_in = UntrackedSharedMemory(name=name)
shm_in = create_detection_input_shm(name, self.config.models)
try:
shm_out = UntrackedSharedMemory(
+3 -6
View File
@@ -16,7 +16,7 @@ from frigate.config.camera.updater import (
)
from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.models import Regions
from frigate.object_detection.util import detection_frame_size
from frigate.object_detection.util import create_detection_input_shm
from frigate.util.builtin import empty_and_close_queue
from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory
from frigate.util.object import get_camera_regions_grid
@@ -127,13 +127,10 @@ class CameraMaintainer(threading.Thread):
max(model.width, model.height),
)
create_detection_input_shm(name, self.config.models)
try:
UntrackedSharedMemory(name=f"out-{name}", create=True, size=20 * 6 * 4)
UntrackedSharedMemory(
name=name,
create=True,
size=detection_frame_size(model),
)
except FileExistsError:
pass
+2 -1
View File
@@ -296,7 +296,8 @@ class CameraState:
return frame_copy
def finished(self, obj_id: str) -> None:
del self.tracked_objects[obj_id]
# state rebuilt after a resolution or scene change no longer has the object
self.tracked_objects.pop(obj_id, None)
def on(self, event_type: str, callback: Callable[..., Any]) -> None:
self.callbacks[event_type].append(callback)
+23 -14
View File
@@ -113,6 +113,23 @@ class AsyncLocalObjectDetector(BaseLocalDetector):
return self.detect_api.receive_output()
def map_input_frame(
frame_manager: SharedMemoryFrameManager, camera: str, model: ModelConfig
) -> np.ndarray | None:
"""Map a camera's detection input segment as this model's input tensor.
The segment is sized for the largest configured model, so it can be larger
than this model's input.
"""
buf = frame_manager.write(camera)
shape = (1, model.height, model.width, 3)
if buf is None or len(buf) < model.height * model.width * 3:
return None
return np.ndarray(shape, dtype=np.uint8, buffer=buf)
class DetectorRunner(FrigateProcess):
def __init__(
self,
@@ -154,14 +171,10 @@ class DetectorRunner(FrigateProcess):
connection_id = self.detection_queue.get(timeout=1)
except queue.Empty:
continue
input_frame = frame_manager.get(
input_frame = map_input_frame(
frame_manager,
connection_id,
(
1,
self.detector_config.model.height, # type: ignore[union-attr]
self.detector_config.model.width, # type: ignore[union-attr]
3,
),
self.detector_config.model, # type: ignore[arg-type]
)
if input_frame is None:
@@ -227,14 +240,10 @@ class AsyncDetectorRunner(FrigateProcess):
continue
assert self._frame_manager is not None
input_frame = self._frame_manager.get(
input_frame = map_input_frame(
self._frame_manager,
connection_id,
(
1,
self.detector_config.model.height, # type: ignore[union-attr]
self.detector_config.model.width, # type: ignore[union-attr]
3,
),
self.detector_config.model, # type: ignore[arg-type]
)
if input_frame is None:
+26 -5
View File
@@ -6,18 +6,39 @@ import threading
from numpy import ndarray
from frigate.detectors.detector_config import InputTensorEnum, ModelConfig
from frigate.util.image import UntrackedSharedMemory
def detection_frame_size(model: ModelConfig) -> int:
"""Get the shared memory size a camera needs to hand frames to a model.
def create_detection_input_shm(
camera: str, models: list[ModelConfig]
) -> UntrackedSharedMemory:
"""Create the shared memory a camera hands detection frames through.
It is sized for the largest model so a camera re-added under the same name
with another scene can reuse it. Detectors keep mappings of it, so it is
never unlinked while they run.
Args:
model: The model the camera runs on
camera: Name of the camera
models: Every configured model
Returns:
Size in bytes of one model input frame
The camera's detection input segment
"""
return model.height * model.width * 3
size = max(model.height * model.width * 3 for model in models)
try:
return UntrackedSharedMemory(name=camera, create=True, size=size)
except FileExistsError:
shm = UntrackedSharedMemory(name=camera)
if shm.size >= size:
return shm
# left by an earlier run with smaller models, so no detector maps it yet
shm.close()
shm.unlink()
return UntrackedSharedMemory(name=camera, create=True, size=size)
class RequestStore:
+40
View File
@@ -98,6 +98,46 @@ class TestHttpApp(BaseTestHttp):
assert response.status_code == 200
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
@patch("frigate.detectors.detector_config.load_labels")
def test_config_set_in_memory_rebinds_replay_model_on_scene_change(
self, mock_labels
):
mock_labels.return_value = {}
replay = "_replay_front_door"
self.minimal_config["models"] = [
{"devices": ["cpu"]},
{"scene": "thermal", "devices": ["openvino:CPU"], "width": 300},
]
self.minimal_config["cameras"][replay] = self.minimal_config["cameras"].pop(
"front_door"
)
app = super().create_app()
app.config_publisher = Mock()
assert app.frigate_config.model_for_camera(replay).scene == "default"
with AuthTestClient(app) as client:
response = client.put(
"/config/set",
json={
"requires_restart": 0,
"skip_save": True,
"update_topic": f"config/cameras/{replay}/detect",
"config_data": {
"cameras": {replay: {"detect": {"scene": "thermal"}}}
},
},
)
assert response.status_code == 200
assert app.frigate_config.cameras[replay].detect.scene == "thermal"
assert app.frigate_config.model_for_camera(replay).scene == "thermal"
topics = [
call.args[0].update_type.name
for call in app.config_publisher.publish_update.call_args_list
]
assert topics[0] == "detect"
assert topics[-1] == "refresh"
####################################################################################################################
################################### Credential redaction sentinel ################################################
####################################################################################################################
+16
View File
@@ -91,6 +91,7 @@ class TrackedObjectProcessor(threading.Thread):
CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.motion,
CameraConfigUpdateEnum.objects,
CameraConfigUpdateEnum.refresh,
CameraConfigUpdateEnum.remove,
CameraConfigUpdateEnum.timestamp_style,
CameraConfigUpdateEnum.zones,
@@ -784,6 +785,21 @@ class TrackedObjectProcessor(threading.Thread):
self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity)
# a replay camera recycled onto another scene's model needs new state
for camera in updated_topics.get("refresh", []):
camera_state = self.camera_states.get(camera)
if (
camera_state is None
or camera_state.model is self.config.model_for_camera(camera)
):
continue
self.force_end_all_events(camera, camera_state)
camera_state.shutdown()
self.frame_manager.close_camera(camera)
self.create_camera_state(camera)
# manage camera disabled state
for camera, config in self.config.cameras.items():
if not config.enabled_in_config:
@@ -240,14 +240,14 @@ const detect: SectionConfigOverrides = {
},
replay: {
restartRequired: [],
fieldOrder: ["width", "height", "fps"],
fieldOrder: ["scene", "width", "height", "fps"],
fieldGroups: {
resolution: ["width", "height", "fps"],
},
hiddenFields: [
"enabled",
"enabled_in_config",
"scene",
hideSceneWithoutChoice,
"min_initialized",
"max_disappeared",
"annotation_offset",
@@ -150,17 +150,18 @@ export function TrackingDetails({
// Fetch the VOD manifest JSON to get the actual clipFrom after keyframe
// snapping. The backend may snap clipFrom backwards to a keyframe, making
// the video start earlier than the requested time.
// the video start earlier than the requested time. Uses sourceOffsetRef so
// the manifest describes the same clip as videoSource.
const vodManifestUrl = useMemo(() => {
if (!event.camera) return null;
const startTime =
event.start_time + annotationOffset / 1000 - REVIEW_PADDING;
const sourceOffset = sourceOffsetRef.current;
const startTime = event.start_time + sourceOffset / 1000 - REVIEW_PADDING;
const endTime =
(event.end_time ?? Date.now() / 1000) +
annotationOffset / 1000 +
sourceOffset / 1000 +
REVIEW_PADDING;
return `vod/clip/${event.camera}/start/${startTime}/end/${endTime}`;
}, [event, annotationOffset]);
}, [event]);
const { data: vodManifest } = useSWR<VodManifest>(vodManifestUrl, null, {
revalidateOnFocus: false,