Compare commits

...
8 Commits
Author SHA1 Message Date
Nicolas Mowen 2472fc338e Handle back seeking going to previous clip 2026-06-23 15:47:37 -06:00
ec3fb00494 perf(track): use sum()/len() instead of np.mean in average_boxes (#23521)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* perf(track): avoid numpy reductions on tiny box lists in position smoothing

update_position runs per tracked object per frame. While a position has
fewer than 10 samples it calls np.percentile four times, and average_boxes
(per stationary object per frame) calls np.mean four times - all on lists of
at most 10 ints, where numpy's per-call dispatch/validation overhead
dominates the actual work.

Replace them with pure-Python equivalents:
- average_boxes: sum()/len() instead of np.mean (bit-identical output)
- interpolated_percentile(): linear-interpolated percentile matching
  numpy.percentile (including its lerp branch at frac>=0.5) for the small
  lists used here, in place of np.percentile

Measured in the release image (numpy 1.26.4) on a 10-element list:
np.percentile 18735 ns -> 191 ns/call (98x); np.mean-based average_boxes
7480 ns -> 591 ns (12.7x); ~74 us saved per object-frame in update_position.
A live py-spy --gil profile of a camera process_frames worker showed
np.percentile (update_position) and np.mean (average_boxes) among the top
Frigate-owned on-CPU frames.

Output is unchanged: added tests assert both helpers are bit-identical to
numpy over randomized small inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Drop interpolated_percentile, keep only average_boxes

Per review: reimplementing np.percentile hurts readability and risks
divergence from numpy (e.g. numpy 2.x). Revert update_position to
np.percentile and remove the helper; keep only the average_boxes change
(sum()/len() instead of np.mean), which stays bit-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:47:04 -06:00
081d6f95ef perf(track): avoid per-frame allocations and list lookups in tracker (#23523)
Two small per-object-per-frame improvements in the tracker hot path
(match_and_update), both bit-identical:

- get_stationary_threshold returned a freshly constructed StationaryThresholds
  (a dataclass plus a list) on every call for any label not in the three
  known lists - i.e. for common labels like person/dog. The default thresholds
  are constant and never mutated, so return a shared module-level singleton,
  as the other three cases already do.
- untracked_object_boxes membership used `box not in [list of boxes]` (O(n));
  build a set of box tuples for O(1) membership. Boxes are hashable as tuples
  and output is unchanged.

get_stationary_threshold appeared in a live py-spy --gil profile of a camera
process_frames worker. Adds tests for the threshold lookups.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:24:05 -06:00
a2b46f5d84 perf(util): cut redundant work in per-frame detection consolidation (#23522)
video/detect.py runs these for every frame:

- get_cluster_candidates: used_boxes was a list with `in` membership tests
  inside the nested loop (O(n) per check). It is only ever membership-tested,
  so switching it to a set (O(1)) leaves output unchanged.
- get_consolidated_object_detections: area(current_box) was recomputed on
  every inner-loop iteration though it is loop-invariant; hoist it to one
  call per outer detection.

Both are bit-identical (verified against the previous implementations over
randomized inputs). Measured in the release image, get_cluster_candidates on
a frame of 30 detection boxes: 59.2 us -> 42.1 us (1.4x); the gain scales
with the number of boxes per frame.

Adds a partition-invariant test (every box index lands in exactly one
cluster).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:23:18 -06:00
f065cc8642 fix unbounded recordings_info growth for cameras with no cache segments (#23528)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
A record-enabled camera whose record stream produces no cache segments
never appears in grouped_recordings, so the per-camera prune in
RecordingMaintainer.move_files() never runs for it. Its
object_recordings_info and audio_recordings_info buffers then grow
without bound until the recording process is OOM-killed (discussion
#23451).

Run a prune every move_files() cycle for cameras absent from
grouped_recordings, dropping entries older than the longest a segment
could still wait in cache before being matched
(MAX_SEGMENTS_IN_CACHE * MAX_SEGMENT_DURATION * 2). Cameras present in
grouped_recordings are left untouched and keep their existing prune.

Add a regression test asserting that an absent camera's stale entries
are dropped (recent ones kept) while a present camera's entries are
left intact.

Co-authored-by: John Pescatore <johnpescatore@claude.internal.johnpescatore.com>
2026-06-22 14:33:56 -06:00
Josh HawkinsandGitHub 9ce80e7266 Improve storage docs (#23542)
* improve storage docs

* clarify

* tweak language

* move section
2026-06-22 15:27:24 -05:00
mayerwinandGitHub bb5056a68a docs: correct face_recognition min_area default to 750 (#23535) 2026-06-22 06:38:52 -06:00
d982b3a782 perf(util): use monotonic clock and bounded deque in EventsPerSecond (#23520)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* perf(util): use monotonic clock and bounded deque in EventsPerSecond

EventsPerSecond is updated on every captured frame, every detection and
every processed frame across all cameras and detectors. The previous
implementation derived timestamps from datetime.now().timestamp() (wall
clock), so an NTP or manual clock adjustment could skew the rolling-window
expiry; it also stored timestamps in a list and expired them with
del self._timestamps[0] (O(n) per removal) plus a periodic slice-copy to
cap growth.

Switch to time.monotonic() for the interval math (correct by construction
and immune to wall-clock jumps) and a collections.deque(maxlen=...) so
expiry is O(1) (popleft) and retention is bounded automatically. This
mirrors the deque-based expiry already used in video/ffmpeg.py and
watchdog.py. Observable output is unchanged.

Adds frigate/test/test_builtin.py covering rate calculation, window
expiry and the memory bound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: drop test_timestamps_are_memory_bounded

It only asserted that deque(maxlen=) caps length, which is stdlib behavior
rather than something this change needs to verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:38:41 -06:00
17 changed files with 293 additions and 39 deletions
+2 -2
View File
@@ -86,7 +86,7 @@ Navigate to <NavPath path="Settings > Enrichments > Face recognition" />.
- **Detection threshold**: Face detection confidence score required before recognition runs. This field only applies to the standalone face detection model; `min_score` should be used to filter for models that have face detection built in.
- Default: `0.7`
- **Minimum face area**: Minimum size (in pixels) a face must be before recognition runs. Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant faces.
- Default: `500` pixels
- Default: `750` pixels
</TabItem>
<TabItem value="yaml">
@@ -95,7 +95,7 @@ Navigate to <NavPath path="Settings > Enrichments > Face recognition" />.
face_recognition:
enabled: true
detection_threshold: 0.7
min_area: 500
min_area: 750
```
</TabItem>
+2
View File
@@ -59,6 +59,8 @@ Metrics are available at `/api/metrics` by default. No additional Frigate config
- `frigate_storage_used_bytes{storage=""}` - Storage used bytes
- `frigate_storage_mount_type{mount_type="", storage=""}` - Storage mount type info
These gauges report the operating system's figures for the whole filesystem (the same numbers as `df`), not Frigate's own recording footprint. For how this differs from the recordings usage shown in the UI, see [Understanding storage usage](/configuration/record#understanding-storage-usage).
### Service Metrics
- `frigate_service_uptime_seconds` - Uptime in seconds
+60 -4
View File
@@ -199,10 +199,6 @@ Because recording segments are written in 10 second chunks, pre-capture timing d
Pre and post capture footage is included in the **recording timeline**, visible in the History view. Note that pre/post capture settings only affect which recording segments are **retained on disk** — they do not change the start and end points shown in the UI. The History view will still center on the review item's actual time range, but you can scrub backward and forward through the retained pre/post capture footage on the timeline. The Explore view shows object-specific clips that are trimmed to when the tracked object was actually visible, so pre/post capture time will not be reflected there.
## Will Frigate delete old recordings if my storage runs out?
If there is less than an hour left of storage, the oldest hour of recordings will be deleted and a message will be printed in the Frigate logs. This emergency cleanup deletes the oldest recordings first regardless of retention settings to reclaim space as quickly as possible.
## Configuring Recording Retention
Frigate supports both continuous and tracked object based recordings with separate retention modes and retention periods.
@@ -355,3 +351,63 @@ Setting `verbose: true` writes a detailed report of every orphaned file and data
This operation uses considerable CPU resources and includes a safety threshold that aborts if more than 50% of files would be deleted. Only run when necessary. If you set `force: true` the safety threshold will be bypassed; do not use `force` unless you are certain the deletions are intended.
:::
## Understanding storage usage
The storage usage Frigate reports will not exactly match what the operating system reports with `df` or `du`. This is expected, not a bug. The sections below explain how Frigate derives its storage figures and why they differ from the disk's own accounting.
### How Frigate measures recording usage
The **Recordings** value on the Storage Metrics page (<NavPath path="System > Storage" />) — and the per-camera **Camera Storage** breakdown — is the sum of the recording segment sizes Frigate has written, taken from Frigate's database. It is **not** computed by a scan of the disk. Frigate tracks usage this way by design: repeatedly walking the entire drive to total its size would keep hard drives spun up and add unnecessary I/O.
The disk **total** shown beside it, and the free-space figure Frigate uses to decide when to delete recordings, instead come from the operating system's report for the whole filesystem mounted at `/media/frigate`. As a result, the **Unused** value on the page is _total disk capacity minus Frigate's recordings_ — not the drive's real free space, which will be lower whenever anything else is stored on the disk.
### What counts toward usage — and why it won't match `df`
Only **recording segments** (`/media/frigate/recordings`) are included in the recordings storage total. Plenty of other things consume real disk space but are **not** part of that number:
- **Snapshots and thumbnails** (`/media/frigate/clips`) — see [Snapshots](/configuration/snapshots). These are retained independently of recordings.
- **Preview videos** and **review thumbnails** (also under `/media/frigate/clips`).
- **Exports** (`/media/frigate/exports`) — exports are never removed by retention.
- **The database, downloaded detection models, and face / license plate training images** (stored under `/config`).
- **Debug images from enrichments** (`/media/frigate/clips`) — when enabled, License Plate Recognition's `debug_save_plates` and GenAI's `debug_save_thumbnails` save plate crops and request images for troubleshooting.
These files are the usual explanation for an "other" or seemingly unaccounted bucket of space — it is real, it is Frigate's, and it simply isn't part of the _recordings_ total. They are also why comparing the **Recordings** figure to `df -h` always shows a gap: `df` additionally counts any non-Frigate data on the disk, filesystem overhead and reserved blocks (ext4 reserves ~5% for root by default, so a disk can read "full" before recordings approach the total), and recently deleted recordings whose space has not yet been reclaimed.
:::tip
The Storage page is not intended to be a system-wide disk monitor — it shows how much space _Frigate's recordings_ use. To see true disk usage, use `df -h` (free space) and `du -sh` (per-directory usage) on the host.
:::
### Free space and the `/media/frigate` mount
Frigate reports the capacity and free space of whatever filesystem is actually mounted at `/media/frigate` **inside the container**. If an external drive or network share isn't truly mounted there — a missing `/etc/fstab` entry, a share that was offline when the container started, or a host that doesn't pass the path through — the container falls back to the host's OS disk, and Frigate will correctly report that smaller disk instead of the drive you intended.
If the reported capacity doesn't match your drive, the mount is the place to look, not Frigate. Verify what is actually mounted from inside the container:
```bash
docker exec -it frigate df -h /media/frigate
docker exec -it frigate mount | grep media
```
See the [storage mount layout](/frigate/installation#storage) for how the volumes are expected to be configured.
### The `/tmp/cache` area is separate
Recording segments are first written to `/tmp/cache` — a small, in-memory (`tmpfs`) area — before being checked and moved to `/media/frigate/recordings`. Because it is separate and small, `/tmp/cache` can fill up and produce `No space left on device` errors even when the recordings disk has plenty of room — they are different storage areas. See [Recordings troubleshooting](/troubleshooting/recordings) for diagnosing cache and slow-storage issues.
### When the metrics don't match what's on disk
Because usage is tracked in the database, deleting recording files directly on disk — or files left behind after an upgrade — will not update the reported usage, and can even push it above 100%. Frigate is unaware of files it didn't record and won't count or remove them automatically. Use [Syncing Media Files With Disk](#syncing-media-files-with-disk) to reconcile the database with what is actually on disk.
## Will Frigate delete old recordings if my storage runs out?
Yes. Frigate continuously checks the **free space of the disk** holding `/media/frigate/recordings`. This is different from adding up the size of every recording: free space is a single number the operating system already tracks, so Frigate can ask for it instantly without reading through your files or spinning up the disk — which is exactly why it relies on this check rather than scanning the drive. When less than roughly one hour of recording space remains — estimated from the current recording bitrate, **not** a fixed percentage — Frigate deletes the oldest recordings to reclaim space and logs a message. This emergency cleanup removes the oldest recordings first **regardless of retention settings**.
Two consequences follow from this being based on whole-disk free space:
- Because the check uses the disk's real free space, **anything** filling the drive — including non-Frigate files — can trigger deletion of your oldest recordings.
- Cleanup can run while a meaningful percentage of the disk is still free (for example, with high bitrates or many cameras), because the threshold is "less than ~1 hour of recording headroom," not "X% full."
Frequent emergency cleanups usually mean your configured retention exceeds what the disk can hold. Reduce your retention days so the normal retention cleanup keeps up and the emergency path rarely triggers.
+2
View File
@@ -42,6 +42,8 @@ Frigate requires a CPU with AVX + AVX2 instructions. Most modern CPUs (post-2011
Storage is an important consideration when planning a new installation. To get a more precise estimate of your storage requirements, you can use an IP camera storage calculator. Websites like [IPConfigure Storage Calculator](https://calculator.ipconfigure.com/) can help you determine the necessary disk space based on your camera settings.
Once running, see [Understanding storage usage](/configuration/record#understanding-storage-usage) for how Frigate measures and reports disk usage — and why its numbers won't exactly match `df` or `du`.
#### SSDs (Solid State Drives)
SSDs are an excellent choice for Frigate, offering high speed and responsiveness. The older concern that SSDs would quickly "wear out" from constant video recording is largely no longer valid for modern consumer and enterprise-grade SSDs.
+19
View File
@@ -42,6 +42,8 @@ from frigate.util.services import get_video_properties
logger = logging.getLogger(__name__)
STALE_RECORDINGS_INFO_TTL = MAX_SEGMENTS_IN_CACHE * MAX_SEGMENT_DURATION * 2
class SegmentInfo:
def __init__(
@@ -301,6 +303,8 @@ class RecordingMaintainer(threading.Thread):
RecordingsDataTypeEnum.saved.value,
)
self._expire_stale_recordings_info(grouped_recordings)
recordings_to_insert: list[Optional[dict[str, Any]]] = await asyncio.gather(
*tasks
)
@@ -311,6 +315,21 @@ class RecordingMaintainer(threading.Thread):
[r for r in recordings_to_insert if r is not None],
)
def _expire_stale_recordings_info(
self, grouped_recordings: defaultdict[str, list[dict[str, Any]]]
) -> None:
expire_before = datetime.datetime.now().timestamp() - STALE_RECORDINGS_INFO_TTL
for recordings_info in (
self.object_recordings_info,
self.audio_recordings_info,
):
for camera in list(recordings_info.keys()):
if camera in grouped_recordings:
continue
info = recordings_info[camera]
while info and info[0][0] < expire_before:
info.pop(0)
def drop_segment(self, cache_path: str) -> None:
Path(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None)
+41
View File
@@ -0,0 +1,41 @@
"""Tests for frigate.util.builtin helpers."""
import unittest
from unittest.mock import patch
from frigate.util.builtin import EventsPerSecond
class TestEventsPerSecond(unittest.TestCase):
def test_eps_is_zero_before_any_events(self) -> None:
eps = EventsPerSecond()
with patch("frigate.util.builtin.time.monotonic", return_value=100.0):
self.assertEqual(eps.eps(), 0.0)
def test_eps_counts_events_in_window(self) -> None:
eps = EventsPerSecond(last_n_seconds=10)
clock = [1000.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
# one event per second for five seconds
for _ in range(5):
clock[0] += 1.0
eps.update()
# five events over the five seconds since start
self.assertAlmostEqual(eps.eps(), 1.0)
def test_old_timestamps_expire_from_window(self) -> None:
eps = EventsPerSecond(last_n_seconds=10)
clock = [0.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
for _ in range(10):
clock[0] += 1.0
eps.update()
# jump well past the window so every timestamp ages out
clock[0] += 100.0
self.assertEqual(eps.eps(), 0.0)
if __name__ == "__main__":
unittest.main()
+40
View File
@@ -115,6 +115,46 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(result)
maintainer.drop_segment.assert_called_once_with(cache_path)
async def test_expire_stale_recordings_info_drops_only_absent_cameras(self):
config = MagicMock(spec=FrigateConfig)
config.cameras = {}
stop_event = MagicMock()
maintainer = RecordingMaintainer(config, stop_event)
now = datetime.datetime.now().timestamp()
ancient = now - 86400
recent = now - 1
maintainer.object_recordings_info["present_cam"] = [(ancient, [], [], [])]
maintainer.audio_recordings_info["present_cam"] = [(ancient, 0, [])]
maintainer.object_recordings_info["absent_cam"] = [
(ancient, [], [], []),
(recent, [], [], []),
]
maintainer.audio_recordings_info["absent_cam"] = [
(ancient, 0, []),
(recent, 0, []),
]
grouped_recordings = {"present_cam": [{"start_time": ancient}]}
maintainer._expire_stale_recordings_info(grouped_recordings)
self.assertEqual(
maintainer.object_recordings_info["present_cam"], [(ancient, [], [], [])]
)
self.assertEqual(
maintainer.audio_recordings_info["present_cam"], [(ancient, 0, [])]
)
self.assertEqual(
maintainer.object_recordings_info["absent_cam"], [(recent, [], [], [])]
)
self.assertEqual(
maintainer.audio_recordings_info["absent_cam"], [(recent, 0, [])]
)
if __name__ == "__main__":
unittest.main()
+16
View File
@@ -1,6 +1,22 @@
import random
import unittest
import numpy as np
from frigate.track.tracked_object import TrackedObjectAttribute
from frigate.util.object import average_boxes
class TestBoxStatistics(unittest.TestCase):
def test_average_boxes_matches_numpy(self) -> None:
rng = random.Random(0)
for _ in range(5000):
boxes = [
[rng.randint(0, 4000) for _ in range(4)]
for _ in range(rng.randint(1, 10))
]
expected = [float(np.mean([b[i] for b in boxes])) for i in range(4)]
self.assertEqual(average_boxes(boxes), expected)
class TestAttribute(unittest.TestCase):
@@ -0,0 +1,39 @@
"""Tests for stationary object classification thresholds."""
import unittest
from frigate.track.stationary_classifier import (
DEFAULT_OBJECT_THRESHOLDS,
DYNAMIC_OBJECT_THRESHOLDS,
NON_STATIONARY_OBJECT_THRESHOLDS,
STATIONARY_OBJECT_THRESHOLDS,
StationaryThresholds,
get_stationary_threshold,
)
class TestStationaryThresholds(unittest.TestCase):
def test_known_labels_return_expected_singletons(self) -> None:
self.assertIs(get_stationary_threshold("package"), STATIONARY_OBJECT_THRESHOLDS)
self.assertIs(get_stationary_threshold("car"), DYNAMIC_OBJECT_THRESHOLDS)
self.assertIs(
get_stationary_threshold("license_plate"),
NON_STATIONARY_OBJECT_THRESHOLDS,
)
def test_unknown_label_returns_shared_default(self) -> None:
# an unknown label must reuse the shared default instance, not allocate
# a fresh one on every call (this runs per object per frame)
first = get_stationary_threshold("person")
second = get_stationary_threshold("dog")
self.assertIs(first, DEFAULT_OBJECT_THRESHOLDS)
self.assertIs(second, DEFAULT_OBJECT_THRESHOLDS)
def test_default_matches_a_fresh_instance(self) -> None:
# the shared default must be value-equivalent to the previous
# per-call StationaryThresholds()
self.assertEqual(DEFAULT_OBJECT_THRESHOLDS, StationaryThresholds())
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -82,6 +82,24 @@ class TestRegion(unittest.TestCase):
assert len(cluster_candidates) == 2
def test_cluster_candidates_partition_boxes(self):
# every box index must appear in exactly one cluster (no box used twice,
# none dropped) - the invariant the used-box tracking enforces
boxes = [
(100, 100, 200, 200),
(202, 150, 252, 200),
(210, 160, 260, 210),
(900, 900, 950, 950),
(905, 905, 955, 955),
]
cluster_candidates = get_cluster_candidates(
self.frame_shape, self.min_region_size, boxes
)
assigned = [idx for cluster in cluster_candidates for idx in cluster]
self.assertEqual(sorted(assigned), list(range(len(boxes))))
def test_transliterate_to_latin(self):
self.assertEqual(transliterate_to_latin("frégate"), "fregate")
self.assertEqual(transliterate_to_latin("utilité"), "utilite")
+4 -2
View File
@@ -641,9 +641,11 @@ class NorfairTracker(ObjectTracker):
self.deregister(self.track_id_map[e_id], e_id)
# update list of object boxes that don't have a tracked object yet
tracked_object_boxes = [obj["box"] for obj in self.tracked_objects.values()]
tracked_object_boxes = {
tuple(obj["box"]) for obj in self.tracked_objects.values()
}
self.untracked_object_boxes = [
o[2] for o in detections if o[2] not in tracked_object_boxes
o[2] for o in detections if tuple(o[2]) not in tracked_object_boxes
]
def print_objects_as_table(self, tracked_objects: Sequence) -> None:
+4 -1
View File
@@ -63,6 +63,9 @@ NON_STATIONARY_OBJECT_THRESHOLDS = StationaryThresholds(
max_stationary_history=4,
)
# Default thresholds for any other object label
DEFAULT_OBJECT_THRESHOLDS = StationaryThresholds()
def get_stationary_threshold(label: str) -> StationaryThresholds:
"""Get the stationary thresholds for a given object label."""
@@ -76,7 +79,7 @@ def get_stationary_threshold(label: str) -> StationaryThresholds:
if label in NON_STATIONARY_OBJECT_THRESHOLDS.objects:
return NON_STATIONARY_OBJECT_THRESHOLDS
return StationaryThresholds()
return DEFAULT_OBJECT_THRESHOLDS
class StationaryMotionClassifier:
+7 -9
View File
@@ -2,7 +2,6 @@
import ast
import copy
import datetime
import logging
import math
import multiprocessing.queues
@@ -10,7 +9,9 @@ import queue
import re
import shlex
import struct
import time
import urllib.parse
from collections import deque
from collections.abc import Mapping
from multiprocessing.managers import ValueProxy
from pathlib import Path
@@ -32,23 +33,20 @@ class EventsPerSecond:
self._start = None
self._max_events = max_events
self._last_n_seconds = last_n_seconds
self._timestamps = []
self._timestamps: deque[float] = deque(maxlen=max_events)
def start(self) -> None:
self._start = datetime.datetime.now().timestamp()
self._start = time.monotonic()
def update(self) -> None:
now = datetime.datetime.now().timestamp()
now = time.monotonic()
if self._start is None:
self._start = now
self._timestamps.append(now)
# truncate the list when it goes 100 over the max_size
if len(self._timestamps) > self._max_events + 100:
self._timestamps = self._timestamps[(1 - self._max_events) :]
self.expire_timestamps(now)
def eps(self) -> float:
now = datetime.datetime.now().timestamp()
now = time.monotonic()
if self._start is None:
self._start = now
# compute the (approximate) events in the last n seconds
@@ -63,7 +61,7 @@ class EventsPerSecond:
def expire_timestamps(self, now: float) -> None:
threshold = now - self._last_n_seconds
while self._timestamps and self._timestamps[0] < threshold:
del self._timestamps[0]
self._timestamps.popleft()
class InferenceSpeed:
+15 -19
View File
@@ -339,18 +339,13 @@ def reduce_boxes(boxes, iou_threshold=0.0):
def average_boxes(boxes: list[list[int, int, int, int]]) -> list[int, int, int, int]:
"""Return a box that is the average of a list of boxes."""
x_mins = []
y_mins = []
x_max = []
y_max = []
for box in boxes:
x_mins.append(box[0])
y_mins.append(box[1])
x_max.append(box[2])
y_max.append(box[3])
return [np.mean(x_mins), np.mean(y_mins), np.mean(x_max), np.mean(y_max)]
n = len(boxes)
return [
sum(box[0] for box in boxes) / n,
sum(box[1] for box in boxes) / n,
sum(box[2] for box in boxes) / n,
sum(box[3] for box in boxes) / n,
]
def median_of_boxes(boxes: list[list[int, int, int, int]]) -> list[int, int, int, int]:
@@ -401,13 +396,13 @@ def get_cluster_candidates(frame_shape, min_region, boxes):
# determined by the max_region size minus half the box + 20%
# TODO: see if we can do this with numpy
cluster_candidates = []
used_boxes = []
used_boxes = set()
# loop over each box
for current_index, b in enumerate(boxes):
if current_index in used_boxes:
continue
cluster = [current_index]
used_boxes.append(current_index)
used_boxes.add(current_index)
cluster_boundary = get_cluster_boundary(b, min_region)
# find all other boxes that fit inside the boundary
for compare_index, compare_box in enumerate(boxes):
@@ -436,7 +431,7 @@ def get_cluster_candidates(frame_shape, min_region, boxes):
if should_cluster:
cluster.append(compare_index)
used_boxes.append(compare_index)
used_boxes.add(compare_index)
cluster_candidates.append(cluster)
# return the unique clusters only
@@ -558,6 +553,7 @@ def reduce_detections(
current_detection = sorted_by_area[current_detection_idx]
current_label = current_detection[0]
current_box = current_detection[2]
current_area = area(current_box)
overlap = 0
for to_check_idx in range(
min(current_detection_idx + 1, len(sorted_by_area)),
@@ -568,14 +564,14 @@ def reduce_detections(
# if area of current detection / area of check < 5% they should not be compared
# this covers cases where a large car parked in a driveway doesn't block detections
# of cars in the street behind it
if area(current_box) / area(to_check) < 0.05:
if current_area / area(to_check) < 0.05:
continue
intersect_box = intersection(current_box, to_check)
# if % of smaller detection is inside of another detection, consolidate
if intersect_box is not None and area(intersect_box) / area(
current_box
) > LABEL_CONSOLIDATION_MAP.get(
if intersect_box is not None and area(
intersect_box
) / current_area > LABEL_CONSOLIDATION_MAP.get(
current_label, LABEL_CONSOLIDATION_DEFAULT
):
overlap = 1
+10 -2
View File
@@ -47,6 +47,7 @@ type HlsVideoPlayerProps = {
frigateControls?: boolean;
inpointOffset?: number;
onClipEnded?: (currentTime: number) => void;
onClipPrevious?: (diff: number) => void;
onPlayerLoaded?: () => void;
onTimeUpdate?: (time: number) => void;
onPlaying?: () => void;
@@ -74,6 +75,7 @@ export default function HlsVideoPlayer({
frigateControls = true,
inpointOffset = 0,
onClipEnded,
onClipPrevious,
onPlayerLoaded,
onTimeUpdate,
onPlaying,
@@ -339,11 +341,17 @@ export default function HlsVideoPlayer({
onSeek={(diff) => {
const currentTime = videoRef.current?.currentTime;
if (!videoRef.current || !currentTime) {
if (!videoRef.current || currentTime == undefined) {
return;
}
videoRef.current.currentTime = Math.max(0, currentTime + diff);
const newTime = currentTime + diff;
if (newTime < 0 && onClipPrevious) {
onClipPrevious(diff);
} else {
videoRef.current.currentTime = Math.max(0, newTime);
}
}}
onSetPlaybackRate={(rate) => {
setPlaybackRate(rate, true);
@@ -49,6 +49,7 @@ type DynamicVideoPlayerProps = {
onControllerReady: (controller: DynamicVideoController) => void;
onTimestampUpdate?: (timestamp: number) => void;
onClipEnded?: () => void;
onClipPrevious?: (diff: number) => void;
onSeekToTime?: (timestamp: number, play?: boolean) => void;
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
toggleFullscreen: () => void;
@@ -68,6 +69,7 @@ export default function DynamicVideoPlayer({
onControllerReady,
onTimestampUpdate,
onClipEnded,
onClipPrevious,
onSeekToTime,
setFullResolution,
toggleFullscreen,
@@ -343,6 +345,7 @@ export default function DynamicVideoPlayer({
onTimeUpdate={onTimeUpdate}
onPlayerLoaded={onPlayerLoaded}
onClipEnded={onValidateClipEnd}
onClipPrevious={onClipPrevious}
onSeekToTime={(timestamp, play) => {
if (onSeekToTime) {
onSeekToTime(timestamp, play);
+11
View File
@@ -336,6 +336,16 @@ export function RecordingView({
[currentTimeRange, updateSelectedSegment],
);
const onClipPrevious = useCallback(
(diff: number) => {
manuallySetCurrentTime(
currentTime + diff,
mainControllerRef.current?.isPlaying() ?? false,
);
},
[currentTime, manuallySetCurrentTime],
);
const onShareReviewLink = useCallback(
(timestamp: number) => {
const reviewUrl = createRecordingReviewUrl(location.pathname, {
@@ -902,6 +912,7 @@ export function RecordingView({
);
}}
onClipEnded={onClipEnded}
onClipPrevious={onClipPrevious}
onSeekToTime={manuallySetCurrentTime}
onControllerReady={(controller) => {
mainControllerRef.current = controller;