From d8ea638728b8d41a2fd8f315a474ea08d6d09ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ianar=C3=A9=20S=C3=A9vi?= Date: Tue, 2 Sep 2025 13:38:48 +0200 Subject: [PATCH 1/2] :recycle: better polygon init in standard field --- mindee/parsing/standard/base.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mindee/parsing/standard/base.py b/mindee/parsing/standard/base.py index 1313f781..9fa3c5b0 100644 --- a/mindee/parsing/standard/base.py +++ b/mindee/parsing/standard/base.py @@ -1,7 +1,6 @@ from typing import Any, List, Optional, Type -from mindee.geometry.point import Point -from mindee.geometry.polygon import Polygon +from mindee.geometry.polygon import Polygon, polygon_from_prediction from mindee.geometry.quadrilateral import Quadrilateral, get_bounding_box from mindee.parsing.common.string_dict import StringDict @@ -18,9 +17,7 @@ def _set_position(self, raw_prediction: StringDict): self.bounding_box = None self.polygon = Polygon() try: - self.polygon = Polygon( - Point(point[0], point[1]) for point in raw_prediction["polygon"] - ) + self.polygon = polygon_from_prediction(raw_prediction["polygon"]) except (KeyError, TypeError): pass if self.polygon: From d87531ba330dd52cee95f313db2ff2af61fa1dcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ianar=C3=A9=20S=C3=A9vi?= Date: Tue, 2 Sep 2025 14:15:45 +0200 Subject: [PATCH 2/2] :recycle: rework polygons, use them more like a real class --- mindee/geometry/polygon.py | 47 +++++++++++++++++++++-- mindee/parsing/standard/base.py | 9 ++--- mindee/parsing/standard/position.py | 4 +- mindee/parsing/v2/field/field_location.py | 19 ++++----- tests/test_geometry.py | 30 +++++++++++++++ tests/v2/test_inference_response.py | 12 +++--- 6 files changed, 92 insertions(+), 29 deletions(-) diff --git a/mindee/geometry/polygon.py b/mindee/geometry/polygon.py index 2c19939a..d5c61ec3 100644 --- a/mindee/geometry/polygon.py +++ b/mindee/geometry/polygon.py @@ -1,25 +1,60 @@ -from typing import Sequence +from typing import List, Optional, Sequence from mindee.geometry.minmax import get_min_max_x, get_min_max_y from mindee.geometry.point import Point from mindee.geometry.polygon_utils import get_centroid, is_point_in_x, is_point_in_y -class Polygon(list): +class Polygon(List[Point]): """ Contains any number of vertex coordinates (Points). Inherits from base class ``list`` so is compatible with type ``Points``. """ + def __init__(self, vertices: Optional[list] = None): + # we should NOT allow the creation of invalid polygons, but it would be a breaking change + if not vertices: + vertices = [] + else: + vertices = [Point(point[0], point[1]) for point in vertices] + super().__init__(vertices) + + def _raise_if_invalid(self) -> None: + if len(self) < 3: + raise ValueError("A polygon must have at least 3 vertices") + @property def centroid(self) -> Point: """The central point (centroid) of the polygon.""" + self._raise_if_invalid() return get_centroid(self) + def is_point_in_x(self, point: Point) -> bool: + """ + Determine if the Point is in the Polygon's X-axis. + + :param point: Point to compare + """ + self._raise_if_invalid() + min_x, max_x = get_min_max_x(self) + return is_point_in_x(point, min_x, max_x) + + def is_point_in_y(self, point: Point) -> bool: + """ + Determine if the Point is in the Polygon's Y-axis. + + :param point: Point to compare + """ + self._raise_if_invalid() + min_y, max_y = get_min_max_y(self) + return is_point_in_y(point, min_y, max_y) + def is_point_in_polygon_x(point: Point, polygon: Polygon) -> bool: """ + Deprecated, use ``is_point_in_x`` from ``Polygon`` class instead. + Determine if the Point is in the Polygon's X-axis. :param point: Point to compare @@ -31,6 +66,8 @@ def is_point_in_polygon_x(point: Point, polygon: Polygon) -> bool: def is_point_in_polygon_y(point: Point, polygon: Polygon) -> bool: """ + Deprecated, use ``is_point_in_y`` from ``Polygon`` class instead. + Determine if the Point is in the Polygon's Y-axis. :param point: Point to compare @@ -40,13 +77,15 @@ def is_point_in_polygon_y(point: Point, polygon: Polygon) -> bool: return is_point_in_y(point, min_y, max_y) -def polygon_from_prediction(prediction: Sequence[list]) -> Polygon: +def polygon_from_prediction(prediction: Sequence[List[float]]) -> Polygon: """ + Deprecated, init ``Polygon`` class directly instead. + Transform a prediction into a Polygon. :param prediction: API prediction. """ - return Polygon(Point(point[0], point[1]) for point in prediction) + return Polygon([Point(point[0], point[1]) for point in prediction]) def merge_polygons(vertices: Sequence[Polygon]) -> Polygon: diff --git a/mindee/parsing/standard/base.py b/mindee/parsing/standard/base.py index 9fa3c5b0..1960d72a 100644 --- a/mindee/parsing/standard/base.py +++ b/mindee/parsing/standard/base.py @@ -1,6 +1,6 @@ from typing import Any, List, Optional, Type -from mindee.geometry.polygon import Polygon, polygon_from_prediction +from mindee.geometry.polygon import Polygon from mindee.geometry.quadrilateral import Quadrilateral, get_bounding_box from mindee.parsing.common.string_dict import StringDict @@ -15,11 +15,10 @@ class FieldPositionMixin: def _set_position(self, raw_prediction: StringDict): self.bounding_box = None - self.polygon = Polygon() try: - self.polygon = polygon_from_prediction(raw_prediction["polygon"]) - except (KeyError, TypeError): - pass + self.polygon = Polygon(raw_prediction.get("polygon", [])) + except TypeError: + self.polygon = Polygon([]) if self.polygon: self.bounding_box = get_bounding_box(self.polygon) else: diff --git a/mindee/parsing/standard/position.py b/mindee/parsing/standard/position.py index 1e19063a..a7bfd530 100644 --- a/mindee/parsing/standard/position.py +++ b/mindee/parsing/standard/position.py @@ -1,7 +1,7 @@ from typing import Optional from mindee.error.geometry_error import GeometryError -from mindee.geometry.polygon import Polygon, polygon_from_prediction +from mindee.geometry.polygon import Polygon from mindee.geometry.quadrilateral import Quadrilateral, quadrilateral_from_prediction from mindee.parsing.common.string_dict import StringDict from mindee.parsing.standard.base import BaseField @@ -57,7 +57,7 @@ def get_polygon(key: str) -> Optional[Polygon]: if not polygon: return None try: - return polygon_from_prediction(polygon) + return Polygon(polygon) except GeometryError: return None diff --git a/mindee/parsing/v2/field/field_location.py b/mindee/parsing/v2/field/field_location.py index 9c23e547..74b20a96 100644 --- a/mindee/parsing/v2/field/field_location.py +++ b/mindee/parsing/v2/field/field_location.py @@ -1,26 +1,21 @@ -from typing import Optional - -from mindee.geometry import Polygon +from mindee.geometry.polygon import Polygon from mindee.parsing.common.string_dict import StringDict class FieldLocation: """Location of a field.""" + polygon: Polygon + page: int + def __init__(self, server_response: StringDict) -> None: """ Initialize FieldLocation from server response. :param server_response: Raw server response. """ - self.polygon: Optional[Polygon] = None - self.page: Optional[int] = None - - if "polygon" in server_response and server_response["polygon"] is not None: - self.polygon = Polygon(server_response["polygon"]) - - if "page" in server_response and isinstance(server_response["page"], int): - self.page = server_response["page"] + self.polygon = Polygon(server_response["polygon"]) + self.page = int(server_response["page"]) def __str__(self) -> str: """ @@ -28,4 +23,4 @@ def __str__(self) -> str: :return: String representation of the field location. """ - return str(self.polygon) if self.polygon else "" + return f"{self.polygon} on page {self.page}" diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 4c98d19b..38396e19 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -53,37 +53,67 @@ def test_bounding_box_single_polygon(rectangle_a, rectangle_b, quadrangle_a): def test_is_point_in_polygon_y(rectangle_a, rectangle_b, quadrangle_a): + rectangle_a_polygon = geometry.Polygon(rectangle_a) + rectangle_b_polygon = geometry.Polygon(rectangle_b) + quadrangle_a_polygon = geometry.Polygon(quadrangle_a) + # Should be in polygon A & B, since polygons overlap point_a = geometry.Point(0.125, 0.535) # Should only be in polygon C point_b = geometry.Point(0.300, 0.420) assert geometry.is_point_in_polygon_y(point_a, rectangle_a) + assert rectangle_a_polygon.is_point_in_y(point_a) assert geometry.is_point_in_polygon_y(point_a, rectangle_b) + assert rectangle_b_polygon.is_point_in_y(point_a) assert geometry.is_point_in_polygon_y(point_a, quadrangle_a) is False + assert quadrangle_a_polygon.is_point_in_y(point_a) is False assert geometry.is_point_in_polygon_y(point_b, rectangle_a) is False + assert rectangle_a_polygon.is_point_in_y(point_b) is False assert geometry.is_point_in_polygon_y(point_b, rectangle_b) is False + assert rectangle_b_polygon.is_point_in_y(point_b) is False assert geometry.is_point_in_polygon_y(point_b, quadrangle_a) + assert quadrangle_a_polygon.is_point_in_y(point_b) def test_is_point_in_polygon_x(rectangle_a, rectangle_b, quadrangle_a): + rectangle_a_polygon = geometry.Polygon(rectangle_a) + rectangle_b_polygon = geometry.Polygon(rectangle_b) + quadrangle_a_polygon = geometry.Polygon(quadrangle_a) + # Should be in polygon A & B, since polygons overlap point_a = geometry.Point(0.125, 0.535) # Should only be in polygon C point_b = geometry.Point(0.300, 0.420) assert geometry.is_point_in_polygon_x(point_a, rectangle_a) + assert rectangle_a_polygon.is_point_in_x(point_a) assert geometry.is_point_in_polygon_x(point_a, rectangle_b) + assert rectangle_b_polygon.is_point_in_x(point_a) assert geometry.is_point_in_polygon_x(point_a, quadrangle_a) is False + assert quadrangle_a_polygon.is_point_in_x(point_a) is False assert geometry.is_point_in_polygon_x(point_b, rectangle_a) is False + assert rectangle_a_polygon.is_point_in_x(point_b) is False assert geometry.is_point_in_polygon_x(point_b, rectangle_b) is False + assert rectangle_b_polygon.is_point_in_x(point_b) is False assert geometry.is_point_in_polygon_x(point_b, quadrangle_a) def test_get_centroid(rectangle_a): assert geometry.get_centroid(rectangle_a) == (0.149, 0.538) + assert geometry.Polygon(rectangle_a).centroid == geometry.Point(0.149, 0.538) + + +def test_empty_polygon(): + empty = geometry.Polygon() + with pytest.raises(ValueError): + empty.is_point_in_y([0.5, 0.5]) + with pytest.raises(ValueError): + empty.is_point_in_x([0.5, 0.5]) + with pytest.raises(ValueError): + empty.centroid def test_bounding_box_several_polygons(rectangle_b, quadrangle_a): diff --git a/tests/v2/test_inference_response.py b/tests/v2/test_inference_response.py index 26a6464f..0ac98433 100644 --- a/tests/v2/test_inference_response.py +++ b/tests/v2/test_inference_response.py @@ -248,16 +248,16 @@ def test_field_locations_and_confidence() -> None: date_field: SimpleField = inference_result.inference.result.fields["date"] assert date_field.locations, "date field should expose locations" - loc0 = date_field.locations[0] - assert loc0 is not None - assert loc0.page == 0 + location = date_field.locations[0] + assert location is not None + assert location.page == 0 - polygon = loc0.polygon + polygon = location.polygon assert polygon is not None assert len(polygon[0]) == 2 - assert polygon[0][0] == 0.948979073166918 - assert polygon[0][1] == 0.23097924535067715 + assert polygon[0].x == 0.948979073166918 + assert polygon[0].y == 0.23097924535067715 assert polygon[1][0] == 0.85422 assert polygon[1][1] == 0.230072