Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions mindee/geometry/polygon.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down
10 changes: 3 additions & 7 deletions mindee/parsing/standard/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from typing import Any, List, Optional, Type

from mindee.geometry.point import Point
from mindee.geometry.polygon import Polygon
from mindee.geometry.quadrilateral import Quadrilateral, get_bounding_box
from mindee.parsing.common.string_dict import StringDict
Expand All @@ -16,13 +15,10 @@ class FieldPositionMixin:

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"]
)
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:
Expand Down
4 changes: 2 additions & 2 deletions mindee/parsing/standard/position.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down
19 changes: 7 additions & 12 deletions mindee/parsing/v2/field/field_location.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,26 @@
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:
"""
String representation.

:return: String representation of the field location.
"""
return str(self.polygon) if self.polygon else ""
return f"{self.polygon} on page {self.page}"
30 changes: 30 additions & 0 deletions tests/test_geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
12 changes: 6 additions & 6 deletions tests/v2/test_inference_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down