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>
This commit is contained in:
Daniel-dev22 2026-06-20 09:25:04 -04:00
parent 5003ab895c
commit d80d020c86
3 changed files with 64 additions and 17 deletions

View File

@ -1,6 +1,35 @@
import random
import unittest
import numpy as np
from frigate.track.tracked_object import TrackedObjectAttribute
from frigate.util.object import average_boxes, interpolated_percentile
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)
def test_interpolated_percentile_matches_numpy(self) -> None:
rng = random.Random(1)
for _ in range(5000):
values = [rng.randint(0, 5000) for _ in range(rng.randint(1, 10))]
for q in (15, 85, 50):
self.assertEqual(
interpolated_percentile(values, q),
float(np.percentile(values, q)),
)
def test_interpolated_percentile_single_value(self) -> None:
self.assertEqual(interpolated_percentile([42], 15), 42.0)
class TestAttribute(unittest.TestCase):

View File

@ -27,7 +27,11 @@ from frigate.util.image import (
get_histogram,
intersection_over_union,
)
from frigate.util.object import average_boxes, median_of_boxes
from frigate.util.object import (
average_boxes,
interpolated_percentile,
median_of_boxes,
)
logger = logging.getLogger(__name__)
@ -428,10 +432,10 @@ class NorfairTracker(ObjectTracker):
position["xmaxs"].append(xmax)
position["ymaxs"].append(ymax)
# by using percentiles here, we hopefully remove outliers
position["xmin"] = np.percentile(position["xmins"], 15)
position["ymin"] = np.percentile(position["ymins"], 15)
position["xmax"] = np.percentile(position["xmaxs"], 85)
position["ymax"] = np.percentile(position["ymaxs"], 85)
position["xmin"] = interpolated_percentile(position["xmins"], 15)
position["ymin"] = interpolated_percentile(position["ymins"], 15)
position["xmax"] = interpolated_percentile(position["xmaxs"], 85)
position["ymax"] = interpolated_percentile(position["ymaxs"], 85)
return True

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]:
@ -359,6 +354,25 @@ def median_of_boxes(boxes: list[list[int, int, int, int]]) -> list[int, int, int
return sorted_boxes[int(len(sorted_boxes) / 2.0)]
def interpolated_percentile(values: list[int], q: float) -> float:
"""Linear-interpolated percentile, matching numpy.percentile for the small
lists used in position smoothing without the per-call numpy overhead."""
ordered = sorted(values)
n = len(ordered)
if n == 1:
return float(ordered[0])
rank = (q / 100.0) * (n - 1)
lo = int(rank)
frac = rank - lo
if frac == 0.0:
return float(ordered[lo])
diff = ordered[lo + 1] - ordered[lo]
# numpy's lerp switches sides at frac >= 0.5 to limit rounding error
if frac < 0.5:
return ordered[lo] + diff * frac
return ordered[lo + 1] - diff * (1.0 - frac)
def intersects_any(box_a, boxes):
for box in boxes:
if box_overlaps(box_a, box):