diff --git a/frigate/motion/improved_motion.py b/frigate/motion/improved_motion.py index 6694dafff5..ed807e3a3e 100644 --- a/frigate/motion/improved_motion.py +++ b/frigate/motion/improved_motion.py @@ -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 diff --git a/frigate/test/test_motion_detector.py b/frigate/test/test_motion_detector.py index cdf4210a51..7b52a091d9 100644 --- a/frigate/test/test_motion_detector.py +++ b/frigate/test/test_motion_detector.py @@ -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):