From c9f7cc81653ab945d4f33840beb0d17e59e90c86 Mon Sep 17 00:00:00 2001 From: El Mehdi TAFIK Date: Fri, 16 Oct 2026 13:53:31 +0100 Subject: [PATCH] Compare mask coordinates numerically (#24666) * Compare mask coordinates numerically * Fix zone coordinate comparisons and normalize mask examples * Fix validation errors for malformed zone coordinates --- docs/docs/configuration/advanced/reference.md | 2 +- docs/docs/configuration/masks.md | 4 +- frigate/config/camera/zone.py | 4 +- frigate/test/test_mask_coordinates.py | 58 +++++++++++++++++++ frigate/test/test_zone_coordinates.py | 51 ++++++++++++++++ frigate/util/config.py | 6 +- frigate/util/image.py | 2 +- 7 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 frigate/test/test_mask_coordinates.py create mode 100644 frigate/test/test_zone_coordinates.py diff --git a/docs/docs/configuration/advanced/reference.md b/docs/docs/configuration/advanced/reference.md index 0370e90611..91e3b3b12e 100644 --- a/docs/docs/configuration/advanced/reference.md +++ b/docs/docs/configuration/advanced/reference.md @@ -556,7 +556,7 @@ motion: # Optional: Whether this mask is active (default: true) enabled: true # Required: Coordinates polygon for the mask - coordinates: "0.000,0.469,1.000,0.469,1.000,1.000,0.000,1.000" + coordinates: "0.000,0.469,1.0,0.469,1.0,1.0,0.000,1.0" # Optional: improve contrast (default: shown below) # Enables dynamic contrast improvement. This should help improve night detections at the cost of making motion detection more sensitive # for daytime. diff --git a/docs/docs/configuration/masks.md b/docs/docs/configuration/masks.md index 50c9fffc31..db9ef36367 100644 --- a/docs/docs/configuration/masks.md +++ b/docs/docs/configuration/masks.md @@ -82,13 +82,13 @@ objects: person_filter1: friendly_name: "Roof area" enabled: true - coordinates: "0.000,0.000,1.000,0.000,1.000,0.400,0.000,0.400" + coordinates: "0.000,0.000,1.0,0.000,1.0,0.400,0.000,0.400" car: mask: car_filter1: friendly_name: "Sidewalk area" enabled: true - coordinates: "0.000,0.700,1.000,0.700,1.000,1.000,0.000,1.000" + coordinates: "0.000,0.700,1.0,0.700,1.0,1.0,0.000,1.0" ``` diff --git a/frigate/config/camera/zone.py b/frigate/config/camera/zone.py index 24c62e0ff6..6a6a160224 100644 --- a/frigate/config/camera/zone.py +++ b/frigate/config/camera/zone.py @@ -128,8 +128,8 @@ class ZoneConfig(BaseModel): # we know if any points are > 1 then it is using the # old native resolution coordinates if isinstance(coordinates, list): - explicit = any(p.split(",")[0] > "1.0" for p in coordinates) try: + explicit = any(float(p.split(",")[0]) > 1.0 for p in coordinates) self._contour = np.array( [ ( @@ -157,8 +157,8 @@ class ZoneConfig(BaseModel): ) elif isinstance(coordinates, str): points = coordinates.split(",") - explicit = any(p > "1.0" for p in points) try: + explicit = any(float(p) > 1.0 for p in points) self._contour = np.array( [ ( diff --git a/frigate/test/test_mask_coordinates.py b/frigate/test/test_mask_coordinates.py new file mode 100644 index 0000000000..afa517e98f --- /dev/null +++ b/frigate/test/test_mask_coordinates.py @@ -0,0 +1,58 @@ +"""Tests for mask coordinate normalization and rasterization.""" + +import unittest +from itertools import product + +import numpy as np + +from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig + + +class TestMaskCoordinates(unittest.TestCase): + def test_relative_coordinate_precision(self): + cases = ( + ("0.000,0.000,{edge},0.000,{edge},0.400,0.000,0.400", slice(0, 41)), + ("0.000,0.700,{edge},0.700,{edge},{edge},0.000,{edge}", slice(70, 100)), + ) + for config_type, (template, rows), edge, as_list in product( + (RuntimeMotionConfig, RuntimeFilterConfig), + cases, + ("1", "1.0", "1.00", "1.000", "1.000000"), + (False, True), + ): + coordinates = template.format(edge=edge) + if as_list: + coordinates = [coordinates] + with self.subTest( + config_type=config_type.__name__, coordinates=coordinates + ): + config = config_type( + frame_shape=(100, 200), + mask={"test_mask": {"coordinates": coordinates}}, + ) + expected = np.full((100, 200), 255, dtype=np.uint8) + expected[rows] = 0 + + np.testing.assert_array_equal(config.rasterized_mask, expected) + self.assertEqual(config.mask["test_mask"].coordinates, coordinates) + + def test_pixel_coordinates(self): + pixel_mask = "0,0,200,0,200,40,0,40" + relative_mask = "0.000,0.700,1.000,0.700,1.000,1.000,0.000,1.000" + for config_type, coordinates in product( + (RuntimeMotionConfig, RuntimeFilterConfig), + (pixel_mask, [pixel_mask], [pixel_mask, relative_mask]), + ): + with self.subTest( + config_type=config_type.__name__, coordinates=coordinates + ): + config = config_type( + frame_shape=(100, 200), + mask={"test_mask": {"coordinates": coordinates}}, + ) + expected = np.full((100, 200), 255, dtype=np.uint8) + expected[:41] = 0 + if isinstance(coordinates, list) and len(coordinates) == 2: + expected[70:] = 0 + + np.testing.assert_array_equal(config.rasterized_mask, expected) diff --git a/frigate/test/test_zone_coordinates.py b/frigate/test/test_zone_coordinates.py new file mode 100644 index 0000000000..5fa99c918e --- /dev/null +++ b/frigate/test/test_zone_coordinates.py @@ -0,0 +1,51 @@ +"""Tests for relative and legacy zone coordinates.""" + +import unittest +from itertools import product + +import numpy as np + +from frigate.config.camera.zone import ZoneConfig + + +class TestZoneCoordinates(unittest.TestCase): + def test_relative_coordinate_precision(self): + expected = np.array([[0, 0], [200, 0], [200, 40], [0, 40]]) + + for edge, as_list in product( + ("1", "1.0", "1.00", "1.000", "1.000000"), (False, True) + ): + points = ["0,0", f"{edge},0", f"{edge},0.4", "0,0.4"] + coordinates = points if as_list else ",".join(points) + + with self.subTest(coordinates=coordinates): + zone = ZoneConfig(coordinates=coordinates) + zone.generate_contour((100, 200)) + + np.testing.assert_array_equal(zone.contour, expected) + self.assertEqual(zone.coordinates, coordinates) + + def test_invalid_coordinates(self): + for points in ( + ["invalid,0", "1,0", "1,0.4", "0,0.4"], + ["0,invalid", "1,0", "1,0.4", "0,0.4"], + ): + for coordinates in (",".join(points), points): + with self.subTest(coordinates=coordinates): + zone = ZoneConfig(coordinates=coordinates) + with self.assertRaisesRegex( + ValueError, "Invalid coordinates found in configuration file" + ): + zone.generate_contour((100, 200)) + + def test_pixel_coordinates(self): + points = ["0,0", "200,0", "200,40", "0,40"] + expected = np.array([[0, 0], [200, 0], [200, 40], [0, 40]]) + + for coordinates in (",".join(points), points): + with self.subTest(coordinates=coordinates): + zone = ZoneConfig(coordinates=coordinates) + zone.generate_contour((100, 200)) + + np.testing.assert_array_equal(zone.contour, expected) + self.assertEqual(zone.coordinates, "0.0,0.0,1.0,0.0,1.0,0.4,0.0,0.4") diff --git a/frigate/util/config.py b/frigate/util/config.py index 9fe72f8cd5..2cfe8612d2 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -1000,12 +1000,12 @@ def get_relative_coordinates( # old native resolution coordinates where = f" for camera {camera_name}" if camera_name else "" if mask: - if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")): + if isinstance(mask, list) and any(float(x) > 1.0 for x in mask[0].split(",")): relative_masks = [] for m in mask: points = m.split(",") - if any(x > "1.0" for x in points): + if any(float(x) > 1.0 for x in points): rel_points = [] for i in range(0, len(points), 2): x = int(points[i]) @@ -1026,7 +1026,7 @@ def get_relative_coordinates( relative_masks.append(m) mask = relative_masks - elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")): + elif isinstance(mask, str) and any(float(x) > 1.0 for x in mask.split(",")): points = mask.split(",") rel_points = [] diff --git a/frigate/util/image.py b/frigate/util/image.py index f62ffed065..5b4a1d2a38 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -1225,7 +1225,7 @@ def add_mask(mask: str, mask_img: np.ndarray): # masks and zones are saved as relative coordinates # we know if any points are > 1 then it is using the # old native resolution coordinates - if any(x > "1.0" for x in points): + if any(float(x) > 1.0 for x in points): raise Exception("add mask expects relative coordinates only") contour = np.array(