mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
perf(motion): compute contrast percentiles from a histogram, not np.percentile
ImprovedMotionDetector.detect() runs on every frame of every camera. With improve_contrast enabled (the default), it called np.percentile twice per frame to find the 4th/96th percentile of the resized motion frame, each call partitioning the whole array. Compute both percentiles from a single 256-bin uint8 histogram instead. The result is bit-identical to int(np.percentile(...)) (the values are used as uint8 contrast bounds); only the array is now scanned once rather than partitioned twice. Measured in the release image on a height-100 motion frame (the default): np.percentile x2 ~225 us -> histogram ~11 us per frame (~20x). This runs per frame per camera, so it scales with camera count. Adds a test asserting the histogram percentiles equal numpy's across uniform, low-contrast, single-color and bimodal frames. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5003ab895c
commit
56f048794b
@ -13,6 +13,25 @@ from frigate.util.image import grab_cv2_contours
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def percentiles_from_uint8(
|
||||
frame: np.ndarray, quantiles: tuple[float, ...]
|
||||
) -> tuple[np.uint8, ...]:
|
||||
"""Linear-interpolated percentiles of a uint8 image, matching
|
||||
numpy.percentile, but computed from a 256-bin histogram so the frame is
|
||||
scanned once instead of being partitioned once per quantile."""
|
||||
csum = np.cumsum(cv2.calcHist([frame], [0], None, [256], [0, 256]).ravel())
|
||||
n = csum[-1]
|
||||
results = []
|
||||
for q in quantiles:
|
||||
rank = q / 100.0 * (n - 1)
|
||||
lo = int(np.floor(rank))
|
||||
hi = int(np.ceil(rank))
|
||||
v_lo = float(np.searchsorted(csum, lo, side="right"))
|
||||
v_hi = float(np.searchsorted(csum, hi, side="right"))
|
||||
results.append(np.uint8(v_lo + (rank - lo) * (v_hi - v_lo)))
|
||||
return tuple(results)
|
||||
|
||||
|
||||
class ImprovedMotionDetector(MotionDetector):
|
||||
def __init__(
|
||||
self,
|
||||
@ -87,8 +106,7 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
# Improve contrast
|
||||
if self.config.improve_contrast:
|
||||
# TODO tracking moving average of min/max to avoid sudden contrast changes
|
||||
min_value = np.percentile(resized_frame, 4).astype(np.uint8)
|
||||
max_value = np.percentile(resized_frame, 96).astype(np.uint8)
|
||||
min_value, max_value = percentiles_from_uint8(resized_frame, (4, 96))
|
||||
# skip contrast calcs if the image is a single color
|
||||
if min_value < max_value:
|
||||
# keep track of the last 50 contrast values
|
||||
|
||||
@ -3,7 +3,29 @@ import unittest
|
||||
import numpy as np
|
||||
|
||||
from frigate.config.camera.motion import MotionConfig
|
||||
from frigate.motion.improved_motion import ImprovedMotionDetector
|
||||
from frigate.motion.improved_motion import (
|
||||
ImprovedMotionDetector,
|
||||
percentiles_from_uint8,
|
||||
)
|
||||
|
||||
|
||||
class TestPercentilesFromUint8(unittest.TestCase):
|
||||
def test_matches_numpy_percentile(self) -> None:
|
||||
rng = np.random.default_rng(0)
|
||||
for trial in range(2000):
|
||||
shape = (int(rng.integers(60, 200)), int(rng.integers(60, 260)))
|
||||
kind = trial % 4
|
||||
if kind == 0:
|
||||
frame = rng.integers(0, 256, size=shape, dtype=np.uint8)
|
||||
elif kind == 1: # low contrast
|
||||
frame = rng.integers(100, 140, size=shape, dtype=np.uint8)
|
||||
elif kind == 2: # single color
|
||||
frame = np.full(shape, 7, dtype=np.uint8)
|
||||
else: # bimodal
|
||||
frame = (rng.random(shape) * 40 + rng.choice([0, 200])).astype(np.uint8)
|
||||
lo, hi = percentiles_from_uint8(frame, (4, 96))
|
||||
self.assertEqual(int(lo), int(np.percentile(frame, 4)))
|
||||
self.assertEqual(int(hi), int(np.percentile(frame, 96)))
|
||||
|
||||
|
||||
class TestImprovedMotionDetector(unittest.TestCase):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user