Compare commits

...
14 Commits
Author SHA1 Message Date
gwmullinandGitHub e9e86d6e04 Merge c575fb223b into ec3fb00494 2026-06-23 23:07:14 +00: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
Greg c575fb223b Editor fail, re-ruff format. 2026-05-18 14:18:17 -07:00
Greg 9fa345f192 Remove 2x unnecessary index on reviewsegment, remove reference to prior code implementation in comment in event.py 2026-05-18 14:16:43 -07:00
Gdub 7b55c4b758 Rerun ruff formatting. 2026-05-18 13:39:12 -07:00
Gdub 570e2e3f76 Slightly simplify review logic and avoid duplicating the json response for empty review IDs. 2026-05-18 13:30:46 -07:00
Greg 39fba9b0a7 Use peewee instead of rw sql for the CTE query. 2026-05-11 16:46:43 -07:00
Greg 328a26b169 Collapse a few sequential queries into a single one. 2026-05-11 15:45:35 -07:00
Greg 311fb1bd19 Rewrite to use a CTE to leverage speedups by using sqllite internal optimization to do a single query instead of a starter query to get distinct labels and a subsequent loop of querys per distinct event labels.
Frigate is currently shipping sqlite 3.46.1, which is above the minimum version 3.25 needed for CTEs.
2026-05-08 16:18:37 -07:00
Greg 48b1426891 Add additional indicies on event and review tables. Every events or timeline endpoint filters on event start time and camera, this should speed things up by avoiding a range scan on the table. 2026-05-08 15:59:23 -07:00
14 changed files with 410 additions and 143 deletions
+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.
+103 -83
View File
@@ -389,82 +389,106 @@ def events_explore(
limit: int = 10,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
# get distinct labels for all events
distinct_labels = (
Event.select(Event.label)
.where(Event.camera << allowed_cameras)
.distinct()
.order_by(Event.label)
if not allowed_cameras:
return JSONResponse(content=[])
explore_columns = (
Event.id,
Event.camera,
Event.label,
Event.sub_label,
Event.zones,
Event.start_time,
Event.end_time,
Event.has_clip,
Event.has_snapshot,
Event.plus_id,
Event.retain_indefinitely,
Event.top_score,
Event.false_positive,
Event.box,
Event.data,
)
label_counts = {}
def event_generator():
for label_obj in distinct_labels.iterator():
label = label_obj.label
# get most recent events for this label
label_events = (
Event.select()
.where((Event.label == label) & (Event.camera << allowed_cameras))
.order_by(Event.start_time.desc())
.limit(limit)
.iterator()
)
# count total events for this label
label_counts[label] = (
Event.select()
.where((Event.label == label) & (Event.camera << allowed_cameras))
.count()
)
yield from label_events
def process_events():
for event in event_generator():
processed_event = {
"id": event.id,
"camera": event.camera,
"label": event.label,
"zones": event.zones,
"start_time": event.start_time,
"end_time": event.end_time,
"has_clip": event.has_clip,
"has_snapshot": event.has_snapshot,
"plus_id": event.plus_id,
"retain_indefinitely": event.retain_indefinitely,
"sub_label": event.sub_label,
"top_score": event.top_score,
"false_positive": event.false_positive,
"box": event.box,
"data": {
k: v
for k, v in event.data.items()
if k
in [
"type",
"score",
"top_score",
"description",
"sub_label_score",
"average_estimated_speed",
"velocity_angle",
"path_data",
"recognized_license_plate",
"recognized_license_plate_score",
]
},
"event_count": label_counts[event.label],
}
yield processed_event
# convert iterator to list and sort
processed_events = sorted(
process_events(),
key=lambda x: (x["event_count"], x["start_time"]),
reverse=True,
# Single query: per-label COUNT and top-N ranking by start_time computed
# via window functions in a CTE, then filtered to rn <= limit
event_count = (
fn.COUNT(Event.id).over(partition_by=[Event.label]).alias("event_count")
)
rn = (
fn.ROW_NUMBER()
.over(partition_by=[Event.label], order_by=[Event.start_time.desc()])
.alias("rn")
)
base_query = Event.select(
*explore_columns,
event_count,
rn,
).where(Event.camera << allowed_cameras)
ranked = base_query.cte("ranked")
query = (
Event.select(
ranked.c.id,
ranked.c.camera,
ranked.c.label,
ranked.c.sub_label,
ranked.c.zones,
ranked.c.start_time,
ranked.c.end_time,
ranked.c.has_clip,
ranked.c.has_snapshot,
ranked.c.plus_id,
ranked.c.retain_indefinitely,
ranked.c.top_score,
ranked.c.false_positive,
ranked.c.box,
ranked.c.data,
ranked.c.event_count,
)
.from_(ranked)
.with_cte(ranked)
.where(ranked.c.rn <= limit)
.order_by(ranked.c.event_count.desc(), ranked.c.start_time.desc())
.objects()
)
allowed_data_keys = {
"type",
"score",
"top_score",
"description",
"sub_label_score",
"average_estimated_speed",
"velocity_angle",
"path_data",
"recognized_license_plate",
"recognized_license_plate_score",
}
processed_events = [
{
"id": event.id,
"camera": event.camera,
"label": event.label,
"zones": event.zones,
"start_time": event.start_time,
"end_time": event.end_time,
"has_clip": event.has_clip,
"has_snapshot": event.has_snapshot,
"plus_id": event.plus_id,
"retain_indefinitely": event.retain_indefinitely,
"sub_label": event.sub_label,
"top_score": event.top_score,
"false_positive": event.false_positive,
"box": event.box,
"data": {
k: v for k, v in (event.data or {}).items() if k in allowed_data_keys
},
"event_count": event.event_count,
}
for event in query
]
return JSONResponse(content=processed_events)
@@ -487,22 +511,18 @@ async def event_ids(ids: str, request: Request):
status_code=400,
)
for event_id in ids:
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
# we should not fail the entire request if an event is not found
continue
try:
events = Event.select().where(Event.id << ids).dicts().iterator()
return JSONResponse(list(events))
events = list(Event.select().where(Event.id << ids).dicts().iterator())
except Exception:
return JSONResponse(
content=({"success": False, "message": "Events not found"}), status_code=400
)
for event in events:
await require_camera_access(event["camera"], request=request)
return JSONResponse(events)
@router.get(
"/events/search",
+61 -34
View File
@@ -10,7 +10,7 @@ import pandas as pd
from fastapi import APIRouter, Request
from fastapi.params import Depends
from fastapi.responses import JSONResponse
from peewee import Case, DoesNotExist, IntegrityError, fn, operator
from peewee import Case, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict
from frigate.api.auth import (
@@ -172,11 +172,19 @@ async def review_ids(request: Request, ids: str):
status_code=400,
)
try:
reviews = list(
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
)
except Exception:
return JSONResponse(
content=({"success": False, "message": "Review segments not found"}),
status_code=400,
)
found_ids = {r["id"] for r in reviews}
for review_id in ids:
try:
review = ReviewSegment.get(ReviewSegment.id == review_id)
await require_camera_access(review.camera, request=request)
except DoesNotExist:
if review_id not in found_ids:
return JSONResponse(
content=(
{"success": False, "message": f"Review {review_id} not found"}
@@ -184,16 +192,10 @@ async def review_ids(request: Request, ids: str):
status_code=404,
)
try:
reviews = (
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
)
return JSONResponse(list(reviews))
except Exception:
return JSONResponse(
content=({"success": False, "message": "Review segments not found"}),
status_code=400,
)
for review in reviews:
await require_camera_access(review["camera"], request=request)
return JSONResponse(reviews)
@router.get(
@@ -490,27 +492,52 @@ async def set_multiple_reviewed(
user_id = current_user["username"]
for review_id in body.ids:
try:
review = ReviewSegment.get(ReviewSegment.id == review_id)
await require_camera_access(review.camera, request=request)
review_status = UserReviewStatus.get(
UserReviewStatus.user_id == user_id,
UserReviewStatus.review_segment == review_id,
reviews = list(
ReviewSegment.select(ReviewSegment.id, ReviewSegment.camera).where(
ReviewSegment.id << body.ids
)
)
for review in reviews:
await require_camera_access(review.camera, request=request)
found_ids = [r.id for r in reviews]
if found_ids:
existing_statuses = list(
UserReviewStatus.select().where(
(UserReviewStatus.user_id == user_id)
& (UserReviewStatus.review_segment << found_ids)
)
# Update based on the reviewed parameter
if review_status.has_been_reviewed != body.reviewed:
review_status.has_been_reviewed = body.reviewed
review_status.save()
except DoesNotExist:
try:
UserReviewStatus.create(
user_id=user_id,
review_segment=ReviewSegment.get(id=review_id),
has_been_reviewed=body.reviewed,
)
status_by_review = {s.review_segment_id: s for s in existing_statuses}
to_update = []
to_create = []
for review_id in found_ids:
if review_id in status_by_review:
status = status_by_review[review_id]
if status.has_been_reviewed != body.reviewed:
status.has_been_reviewed = body.reviewed
to_update.append(status)
else:
to_create.append(
{
"user_id": user_id,
"review_segment_id": review_id,
"has_been_reviewed": body.reviewed,
}
)
except (DoesNotExist, IntegrityError):
pass
if to_update:
UserReviewStatus.bulk_update(
to_update, fields=[UserReviewStatus.has_been_reviewed], batch_size=100
)
if to_create:
UserReviewStatus.insert_many(to_create).execute()
return JSONResponse(
content=(
+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)
+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:
+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
+27
View File
@@ -0,0 +1,27 @@
"""Peewee migrations -- 036_add_perf_indexes.py.
Adds composite/single-column indexes to speed up the most common queries
issued by the web UI on initial page load:
- event(camera, start_time DESC): /events list filtered by camera + time range
- reviewsegment(camera, start_time DESC): /api/review filtered by camera + time range
- reviewsegment(end_time): supports the end_time > after half of /api/review's range
The existing event(label, start_time DESC) index from migration 027 already
covers /events/explore, so it is intentionally not duplicated here.
"""
import peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.sql(
'CREATE INDEX IF NOT EXISTS "event_camera_start_time" '
'ON "event" ("camera", "start_time" DESC)'
)
def rollback(migrator, database, fake=False, **kwargs):
migrator.sql('DROP INDEX IF EXISTS "event_camera_start_time"')