Skip to content

Polygon

Polygon class to handle complexity with polygon calculation

Polygon

Bases: DiscreteGeometryEntity

Polygon class which defines a polygon object which means any closed-shape

Source code in otary/geometry/discrete/shape/polygon.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
class Polygon(DiscreteGeometryEntity):
    """Polygon class which defines a polygon object which means any closed-shape"""

    # pylint: disable=too-many-public-methods

    def __init__(self, points: NDArray | list, is_cast_int: bool = False) -> None:
        if len(points) <= 2:
            raise ValueError(
                "Cannot create a Polygon since it must have 3 or more points"
            )
        super().__init__(points=points, is_cast_int=is_cast_int)

    # ---------------------------------- OTHER CONSTRUCTORS ----------------------------

    @classmethod
    def from_lines(cls, lines: NDArray) -> Polygon:
        """The lines should describe a perfect closed shape polygon

        Args:
            lines (NDArray): array of lines of shape (n, 2, 2)

        Returns:
            (Polygon): a Polygon object
        """
        nlines = len(lines)
        shifted_lines = np.roll(
            np.array(lines).reshape(nlines * 2, 2), shift=1, axis=0
        ).reshape(nlines, 2, 2)
        distances = np.linalg.norm(np.diff(shifted_lines, axis=1), axis=2)
        if np.any(distances):  # a distance is different from 0
            bad_idxs = np.nonzero(distances > 0)
            raise ValueError(
                f"Could not construct the polygon from the given lines."
                f"Please check at those indices: {bad_idxs}"
            )
        points = lines[:, 0]
        return Polygon(points=points)

    @classmethod
    def from_linear_entities_returns_vertices_ix(
        cls, linear_entities: Sequence[LinearEntity]
    ) -> tuple[Polygon, list[int]]:
        """Convert a list of linear entities to polygon.

        Beware: this method assumes entities are sorted and connected.
        Conneted means that the last point of each entity is the first point
        of the next entity.
        This implies that the polygon is necessarily closed.

        Args:
            linear_entities (Sequence[LinearEntity]): List of linear entities.

        Returns:
            (Polygon, list[int]): polygon and indices of first vertex of each entity
        """
        points = []
        vertices_ix: list[int] = []
        current_ix = 0
        for i, linear_entity in enumerate(linear_entities):
            if not isinstance(linear_entity, LinearEntity):
                raise TypeError(
                    f"Expected a list of LinearEntity, but got {type(linear_entity)}"
                )

            cond_first_pt_is_equal_prev_entity_last_pt = np.array_equal(
                linear_entity.points[0], linear_entities[i - 1].points[-1]
            )
            if not cond_first_pt_is_equal_prev_entity_last_pt:
                raise ValueError(
                    f"The first point of entity {i} ({linear_entity.points[0]}) "
                    f"is not equal to the last point of entity {i-1} "
                    f"({linear_entities[i-1].points[-1]})"
                )
            pts_except_last = linear_entity.points[:-1, :]
            points.append(pts_except_last)
            vertices_ix.append(current_ix)
            current_ix += len(pts_except_last)

        points = np.concatenate(points, axis=0)
        polygon = Polygon(points=points)
        return polygon, vertices_ix

    @classmethod
    def from_linear_entities(
        cls,
        linear_entities: Sequence[LinearEntity],
    ) -> Polygon:
        """Convert a list of linear entities to polygon.

        Beware: the method assumes entities are sorted and connected.

        Args:
            linear_entities (Sequence[LinearEntity]): List of linear entities.

        Returns:
            Polygon: polygon representation of the linear entity
        """
        return cls.from_linear_entities_returns_vertices_ix(linear_entities)[0]

    @classmethod
    def from_unordered_lines_approx(
        cls,
        lines: NDArray,
        max_dist_thresh: float = 50,
        max_iterations: int = 50,
        start_line_index: int = 0,
        img: Optional[NDArray] = None,
        is_debug_enabled: bool = False,
    ) -> Polygon:
        """Create a Polygon object from an unordered list of lines that approximate a
        closed-shape. They approximate in the sense that they do not necessarily
        share common points. This method computes the intersection points between lines.

        Args:
            img (_type_): array of shape (lx, ly)
            lines (NDArray): array of lines of shape (n, 2, 2)
            max_dist_thresh (float, optional): For any given point,
                the maximum distance to consider two points as close. Defaults to 50.
            max_iterations (float, optional): Maximum number of iterations before
                finding a polygon.
                It defines also the maximum number of lines in the polygon to be found.
            start_line_index (int, optional): The starting line to find searching for
                the polygon. Defaults to 0.

        Returns:
            (Polygon): a Polygon object
        """
        # pylint: disable=too-many-locals
        # pylint: disable=too-many-positional-arguments, too-many-arguments
        lines = np.asarray(lines)
        Segment.assert_list_of_lines(lines=lines)

        def debug_visualize(seg: NDArray):  # pragma: no cover
            if is_debug_enabled and img is not None:
                im = img.copy()
                im = cv2.cvtColor(im, cv2.COLOR_BGR2RGB)
                im = cv2.line(
                    img=im, pt1=seg[0], pt2=seg[1], color=(0, 250, 126), thickness=5
                )
                plt.imshow(im)
                plt.xticks([])
                plt.yticks([])
                plt.show()

        _lines = copy.deepcopy(lines)
        list_build_cnt = []
        is_polygon_found = False
        idx_seg_closest = start_line_index
        i = 0
        while not is_polygon_found and i < max_iterations:
            curseg = Segment(_lines[idx_seg_closest])
            curpoint = curseg.asarray[1]
            list_build_cnt.append(curseg.asarray)
            _lines = np.delete(_lines, idx_seg_closest, axis=0)

            if len(_lines) == 0:
                logging.debug("No more lines to be processed.")

            # find the closest point to the current one and associated line
            lines2points = _lines.reshape(len(_lines) * 2, 2)
            dist_from_curpoint = np.linalg.norm(lines2points - curpoint, axis=1)
            idx_closest_points = np.nonzero(dist_from_curpoint < max_dist_thresh)[0]

            debug_visualize(seg=curseg.asarray)

            if len(idx_closest_points) > 1:
                # more than one point close to the current point - take the closest
                idx_closest_points = np.array([np.argmin(dist_from_curpoint)])
            if len(idx_closest_points) == 0:
                # no point detected - can mean that the polygon is done or not
                first_seg = Segment(list_build_cnt[0])
                if np.linalg.norm(first_seg.asarray[0] - curpoint) < max_dist_thresh:
                    # TODO sometimes multiples intersection example 7
                    intersect_point = curseg.intersection_line(first_seg)
                    list_build_cnt[-1][1] = intersect_point
                    list_build_cnt[0][0] = intersect_point
                    is_polygon_found = True
                    break
                raise RuntimeError("No point detected close to the current point")

            # only one closest point - get indices of unique closest point on segment
            idx_point_closest = int(idx_closest_points[0])
            idx_seg_closest = int(np.floor(idx_point_closest / 2))

            # arrange the line so that the closest point is in the first place
            idx_point_in_line = 0 if (idx_point_closest / 2).is_integer() else 1
            seg_closest = _lines[idx_seg_closest]
            if idx_point_in_line == 1:  # flip points positions
                seg_closest = np.flip(seg_closest, axis=0)
            _lines[idx_seg_closest] = seg_closest

            # find intersection point between the two lines
            intersect_point = curseg.intersection_line(Segment(seg_closest))

            # update arrays with the intersection point
            _lines[idx_seg_closest][0] = intersect_point
            list_build_cnt[i][1] = intersect_point

            i += 1

        cnt = Polygon.from_lines(np.array(list_build_cnt, dtype=np.int32))
        return cnt

    # --------------------------------- PROPERTIES ------------------------------------

    @property
    def shapely_surface(self) -> SPolygon:
        """Returns the Shapely.Polygon as an surface representation of the Polygon.
        See https://shapely.readthedocs.io/en/stable/reference/shapely.Polygon.html

        Returns:
            Polygon: shapely.Polygon object
        """
        return SPolygon(self.asarray, holes=None)

    @property
    def shapely_edges(self) -> LinearRing:
        """Returns the Shapely.LinearRing as a curve representation of the Polygon.
        See https://shapely.readthedocs.io/en/stable/reference/shapely.LinearRing.html

        Returns:
            LinearRing: shapely.LinearRing object
        """
        return LinearRing(coordinates=self.asarray)

    @property
    def centroid(self) -> NDArray:
        """Compute the centroid point which can be seen as the center of gravity
        or center of mass of the shape.

        Beware: if the shape is degenerate, the centroid will be undefined.
        In that case, the mean of the points is returned.

        Returns:
            NDArray: centroid point
        """
        M = cv2.moments(self.asarray.astype(np.float32).reshape((-1, 1, 2)))

        # Avoid division by zero
        if M["m00"] != 0:
            cx = M["m10"] / M["m00"]
            cy = M["m01"] / M["m00"]
            centroid = np.asarray([cx, cy])
        else:
            centroid = self.center_mean

        return centroid

    @property
    def area(self) -> float:
        """Compute the area of the geometry entity

        Returns:
            float: area value
        """
        return cv2.contourArea(self.points.astype(np.int32))

    @property
    def perimeter(self) -> float:
        """Compute the perimeter of the geometry entity

        Returns:
            float: perimeter value
        """
        return cv2.arcLength(self.points.astype(np.float32), True)

    @property
    def is_self_intersected(self) -> bool:
        """Whether any of the segments intersect another segment in the same set

        Returns:
            bool: True if at least two lines intersect, False otherwise
        """
        return not self.shapely_edges.is_simple

    @property
    def is_convex(self) -> bool:
        """Whether the Polygon describes a convex shape of not.

        Returns:
            bool: True if convex else False
        """
        return cv2.isContourConvex(contour=self.asarray)

    @property
    def edges(self) -> NDArray:
        """Get the lines that compose the geometry entity.

        Args:
            points (NDArray): array of points of shape (n, 2)

        Returns:
            NDArray: array of lines of shape (n, 2, 2)
        """
        return np.stack([self.points, np.roll(self.points, shift=-1, axis=0)], axis=1)

    # ------------------------------- CLASSIC METHODS ----------------------------------

    def is_regular(self, margin_dist_error_pct: float = 0.01) -> bool:
        """Identifies whether the polygon is regular, this means is rectangular or is
        a square.

        Args:
            margin_area_error (float, optional): area error. Defaults to 25.

        Returns:
            bool: True if the polygon describes a rectangle or square.
        """
        # check we have four points
        if len(self.asarray) != 4:
            return False

        # compute diagonal 1 = taking reference index as 1st point in list - index 0
        refpoint = self.asarray[0]
        idx_max_dist = self.find_vertice_ix_farthest_from(point=refpoint)
        farther_point = self.asarray[idx_max_dist]
        diag1 = Segment(points=[refpoint, farther_point])

        # compute diagonal 2
        diag2_idxs = [1, 2, 3]  # every index except 0
        diag2_idxs.remove(idx_max_dist)  # delete index of point in first diag
        diag2 = Segment(points=self.asarray[diag2_idxs])

        # rectangular criteria = the diagonals have same lengths
        normed_length = np.sqrt(diag1.length * diag2.length)
        if np.abs(diag1.length - diag2.length) > normed_length * margin_dist_error_pct:
            return False

        # there should exist only one intersection point
        intersection_points = diag1.intersection(other=diag2)
        if len(intersection_points) != 1:
            return False

        # diagonals bisect on the center of both diagonal
        cross_point = intersection_points[0]
        dist_mid_cross_diag1 = np.linalg.norm(cross_point - diag1.centroid)
        dist_mid_cross_diag2 = np.linalg.norm(cross_point - diag2.centroid)
        if (
            np.abs(dist_mid_cross_diag1) > normed_length * margin_dist_error_pct
            or np.abs(dist_mid_cross_diag2) > normed_length * margin_dist_error_pct
        ):
            return False

        return True

    def is_clockwise(self, is_y_axis_down: bool = False) -> bool:
        """Determine if a polygon points go clockwise using the Shoelace formula.

        True if polygon vertices order is clockwise in the "y-axis points up"
        referential.

        Args:
            is_y_axis_down (bool, optional): If is_y_axis_down is True, then the image
                referential is used where y axis points down.

        Returns:
            bool: True if clockwise, False if counter-clockwise
        """
        x = self.asarray[:, 0]
        y = self.asarray[:, 1]

        x_next = np.roll(x, -1)
        y_next = np.roll(y, -1)

        s = np.sum((x_next - x) * (y_next + y))

        is_clockwise = bool(s > 0)  # Clockwise if positive (OpenCV's convention)

        if is_y_axis_down:  # in referential where y axis points down
            return not is_clockwise

        return is_clockwise

    def as_linear_spline(self, index: int = 0) -> LinearSpline:
        """Get the polygon as a LinearSpline object.
        This simply means a LinearSpline object with the same points as the Polygon
        but with an extra point: the one at the index.

        Returns:
            LinearSpline: linear spline from polygon
        """
        if index < 0:
            index += len(self)

        index = index % len(self)

        return LinearSpline(
            points=np.concat(
                [self.asarray[index : len(self)], self.asarray[0 : index + 1]], axis=0
            )
        )

    def contains(self, other: GeometryEntity, dilate_scale: float = 1) -> bool:
        """Whether the geometry contains the other or not

        Args:
            other (GeometryEntity): a GeometryEntity object
            dilate_scale (float): if greater than 1, the object will be scaled up
                before checking if it contains the other Geometry Entity. Can not be
                a value less than 1.

        Returns:
            bool: True if the entity contains the other
        """
        if dilate_scale != 1:
            surface = self.copy().expand(scale=dilate_scale).shapely_surface
        else:
            surface = self.shapely_surface
        return surface.contains(other.shapely_surface)

    def score_vertices_in_points(self, points: NDArray, max_distance: float) -> NDArray:
        """Returns a score of 0 or 1 for each point in the polygon if it is close
        enough to any point in the input points.

        Args:
            points (NDArray): list of 2D points
            margin_dist_error (float): mininum distance to consider two points as
                close enough to be considered as the same points

        Returns:
            NDArray: a list of score for each point in the contour
        """

        indices = get_shared_point_indices(
            points_to_check=self.asarray,
            checkpoints=points,
            margin_dist_error=max_distance,
            method="close",
            cond="any",
        )
        score = np.bincount(indices, minlength=len(self))
        return score

    def find_vertices_between(self, start_index: int, end_index: int) -> NDArray:
        """Get the vertices between two indices.

        Returns always the vertices between start_index and end_index using the
        natural order of the vertices in the contour.

        By convention, if start_index == end_index, then it returns the whole contour
        plus the vertice at start_index.

        Args:
            start_index (int): index of the first vertex
            end_index (int): index of the last vertex

        Returns:
            NDArray: array of vertices
        """
        if start_index < 0:
            start_index += len(self)
        if end_index < 0:
            end_index += len(self)

        start_index = start_index % len(self)
        end_index = end_index % len(self)

        if start_index > end_index:
            vertices = np.concat(
                [
                    self.asarray[start_index : len(self)],
                    self.asarray[0 : end_index + 1],
                ],
                axis=0,
            )
        elif start_index == end_index:
            vertices = self.as_linear_spline(index=start_index).asarray
        else:
            vertices = self.asarray[start_index : end_index + 1]

        return vertices

    def find_interpolated_point_and_prev_ix(
        self, start_index: int, end_index: int, pct_dist: float
    ) -> tuple[NDArray, int]:
        """Return a point along the contour path from start_idx to end_idx (inclusive),
        at a relative distance pct_dist ∈ [0, 1] along that path.

        By convention, if start_index == end_index, then use the whole contour
        start at this index position.

        Parameters:
            start_idx (int): Index of the start point in the contour
            end_idx (int): Index of the end point in the contour
            pct_dist (float): Value in [0, 1], 0 returns start, 1 returns end.
                Any value in [0, 1] returns a point between start and end that is
                pct_dist along the path.

        Returns:
            NDArray: Interpolated point [x, y]
        """
        if not 0 <= pct_dist <= 1:
            raise ValueError("pct_dist must be in [0, 1]")

        if start_index < 0:
            start_index += len(self)
        if end_index < 0:
            end_index += len(self)

        start_index = start_index % len(self)
        end_index = end_index % len(self)

        path = LinearSpline(
            points=self.find_vertices_between(
                start_index=start_index, end_index=end_index
            )
        )

        point, index = path.find_interpolated_point_and_prev_ix(pct_dist=pct_dist)
        index = (index + start_index) % len(self)

        return point, index

    def find_interpolated_point(
        self, start_index: int, end_index: int, pct_dist: float
    ) -> NDArray:
        """Return a point along the contour path from start_idx to end_idx (inclusive),
        at a relative distance pct_dist ∈ [0, 1] along that path.

        By convention, if start_index == end_index, then use the whole contour
        start at this index position.

        Parameters:
            start_idx (int): Index of the start point in the contour
            end_idx (int): Index of the end point in the contour
            pct_dist (float): Value in [0, 1], 0 returns start, 1 returns end.
                Any value in [0, 1] returns a point between start and end that is
                pct_dist along the path.

        Returns:
            NDArray: Interpolated point [x, y]
        """
        return self.find_interpolated_point_and_prev_ix(
            start_index=start_index, end_index=end_index, pct_dist=pct_dist
        )[0]

    def normal_point(
        self,
        start_index: int,
        end_index: int,
        dist_along_edge_pct: float,
        dist_from_edge: float,
        is_outward: bool = True,
    ) -> NDArray:
        """Compute the outward normal point.
        This is a point that points toward the outside of the polygon

        Args:
            start_index (int): start index for the edge selection
            end_index (int): end index for the edge selection
            dist_along_edge_pct (float): distance along the edge to place the point
            dist_from_edge (float): distance outward from the edge
            is_outward (bool, optional): True if the normal points to the outside of
                the polygon. False if the normal points to the inside of the polygon.
                Defaults to True.

        Returns:
            NDArray: 2D point as array
        """
        # pylint: disable=too-many-locals
        # pylint: disable=too-many-arguments, too-many-positional-arguments,
        if not 0.0 <= dist_along_edge_pct <= 1.0:
            raise ValueError("dist_along_edge_pct must be in [0, 1]")

        pt_interpolated, prev_ix = self.find_interpolated_point_and_prev_ix(
            start_index=start_index, end_index=end_index, pct_dist=dist_along_edge_pct
        )
        next_ix = (prev_ix + 1) % len(self)

        is_interpolated_pt_existing_edge = np.array_equal(
            pt_interpolated, self.asarray[prev_ix]
        ) or np.array_equal(pt_interpolated, self.asarray[next_ix])
        if is_interpolated_pt_existing_edge:
            raise ValueError(
                "Interpolated point for normal computation is an existing vertice "
                "along polygon. Please choose another dist_along_edge_pct parameter."
            )

        edge = Vector(points=[self.asarray[prev_ix], self.asarray[next_ix]])

        normal = edge.normal().normalized

        pt_plus = pt_interpolated + dist_from_edge * normal
        pt_minus = pt_interpolated - dist_from_edge * normal

        dist_plus = np.linalg.norm(pt_plus - self.centroid)
        dist_minus = np.linalg.norm(pt_minus - self.centroid)

        # choose the point which distance to the center is greater
        if dist_plus > dist_minus:
            if is_outward:
                return pt_plus
            return pt_minus

        if is_outward:
            return pt_minus
        return pt_plus

    def inter_area(self, other: Polygon) -> float:
        """Inter area with another Polygon

        Args:
            other (Polygon): other Polygon

        Returns:
            float: inter area value
        """
        inter_pts = cv2.intersectConvexConvex(self.asarray, other.asarray)
        if inter_pts[0] > 0:
            inter_area = cv2.contourArea(inter_pts[1])
        else:
            inter_area = 0.0
        return inter_area

    def union_area(self, other: Polygon) -> float:
        """Union area with another Polygon

        Args:
            other (Polygon): other Polygon

        Returns:
            float: union area value
        """
        return self.area + other.area - self.inter_area(other)

    def iou(self, other: Polygon) -> float:
        """Intersection over union with another Polygon

        Args:
            other (Polygon): other Polygon

        Returns:
            float: intersection over union value
        """
        inter_area = self.inter_area(other)

        # optimized not to compute twice the inter area
        union_area = self.area + other.area - inter_area

        if union_area == 0:
            return 0.0
        return inter_area / union_area

    # ---------------------------- MODIFICATION METHODS -------------------------------

    def add_vertice(self, point: NDArray, index: int) -> Self:
        """Add a point at a given index in the Polygon object

        Args:
            point (NDArray): point to be added
            index (int): index where the point will be added

        Returns:
            Polygon: Polygon object with an added point
        """
        size = len(self)
        if index >= size:
            raise ValueError(
                f"The index value {index} is too big. "
                f"The maximum possible index value is {size-1}."
            )
        if index < 0:
            if abs(index) > size + 1:
                raise ValueError(
                    f"The index value {index} is too small. "
                    f"The minimum possible index value is {-(size+1)}"
                )
            index = size + index + 1

        self.points = np.concatenate(
            [self.points[:index], [point], self.points[index:]]
        )
        return self

    def rearrange_first_vertice_at_index(self, index: int) -> Self:
        """Rearrange the list of points that defines the Polygon so that the first
        point in the list of points is the one at index given by the argument of this
        function.

        Args:
            index (int): index value

        Returns:
            Polygon: Polygon which is the exact same one but with a rearranged list
                of points.
        """
        size = len(self)
        if index >= size:
            raise ValueError(
                f"The index value {index} is too big. "
                f"The maximum possible index value is {size-1}."
            )
        if index < 0:
            if abs(index) > size:
                raise ValueError(
                    f"The index value {index} is too small. "
                    f"The minimum possible index value is {-size}"
                )
            index = size + index

        self.points = np.concatenate([self.points[index:], self.points[:index]])
        return self

    def rearrange_first_vertice_closest_to_point(
        self, point: NDArray = np.zeros(shape=(2,))
    ) -> Polygon:
        """Rearrange the list of vertices that defines the Polygon so that the first
        point in the list of vertices is the one that is the closest by distance to
        the reference point.

        Args:
            reference_point (NDArray): point that is taken as a reference in the
                space to find the one in the Polygon list of points that is the
                closest to this reference point. Default to origin point [0, 0].

        Returns:
            Polygon: Polygon which is the exact same one but with a rearranged list
                of points.
        """
        idx_min_dist = self.find_vertice_ix_closest_from(point=point)
        return self.rearrange_first_vertice_at_index(index=idx_min_dist)

    def reorder_clockwise(self, is_y_axis_down: bool = False) -> Polygon:
        """Reorder the vertices of the polygon in clockwise order where the first point
        stays the same.

        Args:
            is_y_axis_down (bool, optional): True if cv2 is used. Defaults to False.

        Returns:
            Polygon: reordered polygon
        """
        if self.is_clockwise(is_y_axis_down=is_y_axis_down):
            return self
        self.asarray = np.roll(self.asarray[::-1], shift=1, axis=0)
        return self

    def __rescale(self, scale: float) -> Polygon:
        """Create a new polygon that is scaled up or down.

        The rescale method compute the vector that is directed from the polygon center
        to each point. Then it rescales each vector and use the head point of each
        vector to compose the new scaled polygon.

        Args:
            scale (float): float value to scale the polygon

        Returns:
            Polygon: scaled polygon
        """
        if scale == 1.0:  # no rescaling
            return self

        center = self.centroid
        self.asarray = self.asarray.astype(float)
        for i, point in enumerate(self.asarray):
            self.asarray[i] = Vector([center, point]).rescale_head(scale).head
        return self

    def expand(self, scale: float) -> Polygon:
        """Stretch, dilate or expand a polygon

        Args:
            scale (float): scale expanding factor. Must be greater than 1.

        Returns:
            Polygon: new bigger polygon
        """
        if scale < 1:
            raise ValueError(
                "The scale value can not be less than 1 when expanding a polygon. "
                f"Found {scale}"
            )
        return self.__rescale(scale=scale)

    def shrink(self, scale: float) -> Polygon:
        """Contract or shrink a polygon

        Args:
            scale (float): scale shrinking factor. Must be greater than 1.

        Returns:
            Polygon: new bigger polygon
        """
        if scale < 1:
            raise ValueError(
                "The scale value can not be less than 1 when shrinking a polygon. "
                f"Found {scale}"
            )
        return self.__rescale(scale=1 / scale)

    def to_image_crop_referential(
        self,
        other: Polygon,
        crop: Rectangle,
        image_crop_shape: Optional[tuple[int, int]] = None,
    ) -> Polygon:
        """This function can be useful for a very specific need:
        In a single image you have two same polygons and their coordinates are defined
        in this image referential.

        You want to obtain the original polygon and all its vertices information
        in the image crop referential to match the other polygon within it.

        This method manipulates three referentials:
        1. image referential (main referential)
        2. crop referential
        3. image crop referential. It is different from the crop referential
            because the width and height of the crop referential may not be the same.

        Args:
            other (Polygon): other Polygon in the image referential
            crop_rect (Rectangle): crop rectangle in the image referential
            image_crop_shape (tuple[int, int], optionla): [width, height] of the crop
                image. If None, the shape is assumed to be directly the crop shape.


        Returns:
            Polygon: original polygon in the image crop referential
        """
        if not crop.contains(other=other):
            raise ValueError(
                f"The crop rectangle {crop} does not contain the other polygon {other}"
            )
        crop_width = int(crop.get_width_from_topleft(0))
        crop_height = int(crop.get_height_from_topleft(0))

        if image_crop_shape is None:
            image_crop_shape = (crop_width, crop_height)

        # self polygon in the original image shifted and normalized
        aabb_main = self.enclosing_axis_aligned_bbox()
        contour_main_shifted_normalized = self.copy().shift(
            vector=-np.asarray([self.xmin, self.ymin])
        ) / np.array(
            [aabb_main.get_width_from_topleft(0), aabb_main.get_height_from_topleft(0)]
        )

        # AABB of the polygon in the crop referential
        aabb_crop = other.enclosing_axis_aligned_bbox()
        aabb_crop_normalized = (
            aabb_crop - np.asarray([crop.xmin, crop.ymin])
        ) / np.array([crop_width, crop_height])

        # obtain the self polygon in the image crop referential
        aabb_crop2 = aabb_crop_normalized * np.array(image_crop_shape)
        new_polygon = contour_main_shifted_normalized * np.array(
            [
                aabb_crop2.get_width_from_topleft(0),
                aabb_crop2.get_height_from_topleft(0),
            ]
        ) + np.asarray([aabb_crop2.xmin, aabb_crop2.ymin])

        return new_polygon

    # ------------------------------- Fundamental Methods ------------------------------

    def is_equal(self, polygon: Polygon, dist_margin_error: float = 5) -> bool:
        """Check whether two polygons objects are equal by considering a margin of
        error based on a distance between points.

        Args:
            polygon (Polygon): Polygon object
            dist_margin_error (float, optional): distance margin of error.
                Defaults to 5.

        Returns:
            bool: True if the polygon are equal, False otherwise
        """
        if self.n_points != polygon.n_points:
            # if polygons do not have the same number of points they can not be similar
            return False

        # check if each points composing the polygons are close to each other
        new_cnt = polygon.copy().rearrange_first_vertice_closest_to_point(
            self.points[0]
        )
        points_diff = new_cnt.points - self.points
        distances = np.linalg.norm(points_diff, axis=1)
        max_distance = np.max(distances)
        return max_distance <= dist_margin_error

area property

Compute the area of the geometry entity

Returns:

Name Type Description
float float

area value

centroid property

Compute the centroid point which can be seen as the center of gravity or center of mass of the shape.

Beware: if the shape is degenerate, the centroid will be undefined. In that case, the mean of the points is returned.

Returns:

Name Type Description
NDArray NDArray

centroid point

edges property

Get the lines that compose the geometry entity.

Parameters:

Name Type Description Default
points NDArray

array of points of shape (n, 2)

required

Returns:

Name Type Description
NDArray NDArray

array of lines of shape (n, 2, 2)

is_convex property

Whether the Polygon describes a convex shape of not.

Returns:

Name Type Description
bool bool

True if convex else False

is_self_intersected property

Whether any of the segments intersect another segment in the same set

Returns:

Name Type Description
bool bool

True if at least two lines intersect, False otherwise

perimeter property

Compute the perimeter of the geometry entity

Returns:

Name Type Description
float float

perimeter value

shapely_edges property

Returns the Shapely.LinearRing as a curve representation of the Polygon. See https://shapely.readthedocs.io/en/stable/reference/shapely.LinearRing.html

Returns:

Name Type Description
LinearRing LinearRing

shapely.LinearRing object

shapely_surface property

Returns the Shapely.Polygon as an surface representation of the Polygon. See https://shapely.readthedocs.io/en/stable/reference/shapely.Polygon.html

Returns:

Name Type Description
Polygon Polygon

shapely.Polygon object

__rescale(scale)

Create a new polygon that is scaled up or down.

The rescale method compute the vector that is directed from the polygon center to each point. Then it rescales each vector and use the head point of each vector to compose the new scaled polygon.

Parameters:

Name Type Description Default
scale float

float value to scale the polygon

required

Returns:

Name Type Description
Polygon Polygon

scaled polygon

Source code in otary/geometry/discrete/shape/polygon.py
def __rescale(self, scale: float) -> Polygon:
    """Create a new polygon that is scaled up or down.

    The rescale method compute the vector that is directed from the polygon center
    to each point. Then it rescales each vector and use the head point of each
    vector to compose the new scaled polygon.

    Args:
        scale (float): float value to scale the polygon

    Returns:
        Polygon: scaled polygon
    """
    if scale == 1.0:  # no rescaling
        return self

    center = self.centroid
    self.asarray = self.asarray.astype(float)
    for i, point in enumerate(self.asarray):
        self.asarray[i] = Vector([center, point]).rescale_head(scale).head
    return self

add_vertice(point, index)

Add a point at a given index in the Polygon object

Parameters:

Name Type Description Default
point NDArray

point to be added

required
index int

index where the point will be added

required

Returns:

Name Type Description
Polygon Self

Polygon object with an added point

Source code in otary/geometry/discrete/shape/polygon.py
def add_vertice(self, point: NDArray, index: int) -> Self:
    """Add a point at a given index in the Polygon object

    Args:
        point (NDArray): point to be added
        index (int): index where the point will be added

    Returns:
        Polygon: Polygon object with an added point
    """
    size = len(self)
    if index >= size:
        raise ValueError(
            f"The index value {index} is too big. "
            f"The maximum possible index value is {size-1}."
        )
    if index < 0:
        if abs(index) > size + 1:
            raise ValueError(
                f"The index value {index} is too small. "
                f"The minimum possible index value is {-(size+1)}"
            )
        index = size + index + 1

    self.points = np.concatenate(
        [self.points[:index], [point], self.points[index:]]
    )
    return self

as_linear_spline(index=0)

Get the polygon as a LinearSpline object. This simply means a LinearSpline object with the same points as the Polygon but with an extra point: the one at the index.

Returns:

Name Type Description
LinearSpline LinearSpline

linear spline from polygon

Source code in otary/geometry/discrete/shape/polygon.py
def as_linear_spline(self, index: int = 0) -> LinearSpline:
    """Get the polygon as a LinearSpline object.
    This simply means a LinearSpline object with the same points as the Polygon
    but with an extra point: the one at the index.

    Returns:
        LinearSpline: linear spline from polygon
    """
    if index < 0:
        index += len(self)

    index = index % len(self)

    return LinearSpline(
        points=np.concat(
            [self.asarray[index : len(self)], self.asarray[0 : index + 1]], axis=0
        )
    )

contains(other, dilate_scale=1)

Whether the geometry contains the other or not

Parameters:

Name Type Description Default
other GeometryEntity

a GeometryEntity object

required
dilate_scale float

if greater than 1, the object will be scaled up before checking if it contains the other Geometry Entity. Can not be a value less than 1.

1

Returns:

Name Type Description
bool bool

True if the entity contains the other

Source code in otary/geometry/discrete/shape/polygon.py
def contains(self, other: GeometryEntity, dilate_scale: float = 1) -> bool:
    """Whether the geometry contains the other or not

    Args:
        other (GeometryEntity): a GeometryEntity object
        dilate_scale (float): if greater than 1, the object will be scaled up
            before checking if it contains the other Geometry Entity. Can not be
            a value less than 1.

    Returns:
        bool: True if the entity contains the other
    """
    if dilate_scale != 1:
        surface = self.copy().expand(scale=dilate_scale).shapely_surface
    else:
        surface = self.shapely_surface
    return surface.contains(other.shapely_surface)

expand(scale)

Stretch, dilate or expand a polygon

Parameters:

Name Type Description Default
scale float

scale expanding factor. Must be greater than 1.

required

Returns:

Name Type Description
Polygon Polygon

new bigger polygon

Source code in otary/geometry/discrete/shape/polygon.py
def expand(self, scale: float) -> Polygon:
    """Stretch, dilate or expand a polygon

    Args:
        scale (float): scale expanding factor. Must be greater than 1.

    Returns:
        Polygon: new bigger polygon
    """
    if scale < 1:
        raise ValueError(
            "The scale value can not be less than 1 when expanding a polygon. "
            f"Found {scale}"
        )
    return self.__rescale(scale=scale)

find_interpolated_point(start_index, end_index, pct_dist)

Return a point along the contour path from start_idx to end_idx (inclusive), at a relative distance pct_dist ∈ [0, 1] along that path.

By convention, if start_index == end_index, then use the whole contour start at this index position.

Parameters:

Name Type Description Default
start_idx int

Index of the start point in the contour

required
end_idx int

Index of the end point in the contour

required
pct_dist float

Value in [0, 1], 0 returns start, 1 returns end. Any value in [0, 1] returns a point between start and end that is pct_dist along the path.

required

Returns:

Name Type Description
NDArray NDArray

Interpolated point [x, y]

Source code in otary/geometry/discrete/shape/polygon.py
def find_interpolated_point(
    self, start_index: int, end_index: int, pct_dist: float
) -> NDArray:
    """Return a point along the contour path from start_idx to end_idx (inclusive),
    at a relative distance pct_dist ∈ [0, 1] along that path.

    By convention, if start_index == end_index, then use the whole contour
    start at this index position.

    Parameters:
        start_idx (int): Index of the start point in the contour
        end_idx (int): Index of the end point in the contour
        pct_dist (float): Value in [0, 1], 0 returns start, 1 returns end.
            Any value in [0, 1] returns a point between start and end that is
            pct_dist along the path.

    Returns:
        NDArray: Interpolated point [x, y]
    """
    return self.find_interpolated_point_and_prev_ix(
        start_index=start_index, end_index=end_index, pct_dist=pct_dist
    )[0]

find_interpolated_point_and_prev_ix(start_index, end_index, pct_dist)

Return a point along the contour path from start_idx to end_idx (inclusive), at a relative distance pct_dist ∈ [0, 1] along that path.

By convention, if start_index == end_index, then use the whole contour start at this index position.

Parameters:

Name Type Description Default
start_idx int

Index of the start point in the contour

required
end_idx int

Index of the end point in the contour

required
pct_dist float

Value in [0, 1], 0 returns start, 1 returns end. Any value in [0, 1] returns a point between start and end that is pct_dist along the path.

required

Returns:

Name Type Description
NDArray tuple[NDArray, int]

Interpolated point [x, y]

Source code in otary/geometry/discrete/shape/polygon.py
def find_interpolated_point_and_prev_ix(
    self, start_index: int, end_index: int, pct_dist: float
) -> tuple[NDArray, int]:
    """Return a point along the contour path from start_idx to end_idx (inclusive),
    at a relative distance pct_dist ∈ [0, 1] along that path.

    By convention, if start_index == end_index, then use the whole contour
    start at this index position.

    Parameters:
        start_idx (int): Index of the start point in the contour
        end_idx (int): Index of the end point in the contour
        pct_dist (float): Value in [0, 1], 0 returns start, 1 returns end.
            Any value in [0, 1] returns a point between start and end that is
            pct_dist along the path.

    Returns:
        NDArray: Interpolated point [x, y]
    """
    if not 0 <= pct_dist <= 1:
        raise ValueError("pct_dist must be in [0, 1]")

    if start_index < 0:
        start_index += len(self)
    if end_index < 0:
        end_index += len(self)

    start_index = start_index % len(self)
    end_index = end_index % len(self)

    path = LinearSpline(
        points=self.find_vertices_between(
            start_index=start_index, end_index=end_index
        )
    )

    point, index = path.find_interpolated_point_and_prev_ix(pct_dist=pct_dist)
    index = (index + start_index) % len(self)

    return point, index

find_vertices_between(start_index, end_index)

Get the vertices between two indices.

Returns always the vertices between start_index and end_index using the natural order of the vertices in the contour.

By convention, if start_index == end_index, then it returns the whole contour plus the vertice at start_index.

Parameters:

Name Type Description Default
start_index int

index of the first vertex

required
end_index int

index of the last vertex

required

Returns:

Name Type Description
NDArray NDArray

array of vertices

Source code in otary/geometry/discrete/shape/polygon.py
def find_vertices_between(self, start_index: int, end_index: int) -> NDArray:
    """Get the vertices between two indices.

    Returns always the vertices between start_index and end_index using the
    natural order of the vertices in the contour.

    By convention, if start_index == end_index, then it returns the whole contour
    plus the vertice at start_index.

    Args:
        start_index (int): index of the first vertex
        end_index (int): index of the last vertex

    Returns:
        NDArray: array of vertices
    """
    if start_index < 0:
        start_index += len(self)
    if end_index < 0:
        end_index += len(self)

    start_index = start_index % len(self)
    end_index = end_index % len(self)

    if start_index > end_index:
        vertices = np.concat(
            [
                self.asarray[start_index : len(self)],
                self.asarray[0 : end_index + 1],
            ],
            axis=0,
        )
    elif start_index == end_index:
        vertices = self.as_linear_spline(index=start_index).asarray
    else:
        vertices = self.asarray[start_index : end_index + 1]

    return vertices

from_linear_entities(linear_entities) classmethod

Convert a list of linear entities to polygon.

Beware: the method assumes entities are sorted and connected.

Parameters:

Name Type Description Default
linear_entities Sequence[LinearEntity]

List of linear entities.

required

Returns:

Name Type Description
Polygon Polygon

polygon representation of the linear entity

Source code in otary/geometry/discrete/shape/polygon.py
@classmethod
def from_linear_entities(
    cls,
    linear_entities: Sequence[LinearEntity],
) -> Polygon:
    """Convert a list of linear entities to polygon.

    Beware: the method assumes entities are sorted and connected.

    Args:
        linear_entities (Sequence[LinearEntity]): List of linear entities.

    Returns:
        Polygon: polygon representation of the linear entity
    """
    return cls.from_linear_entities_returns_vertices_ix(linear_entities)[0]

from_linear_entities_returns_vertices_ix(linear_entities) classmethod

Convert a list of linear entities to polygon.

Beware: this method assumes entities are sorted and connected. Conneted means that the last point of each entity is the first point of the next entity. This implies that the polygon is necessarily closed.

Parameters:

Name Type Description Default
linear_entities Sequence[LinearEntity]

List of linear entities.

required

Returns:

Type Description
(Polygon, list[int])

polygon and indices of first vertex of each entity

Source code in otary/geometry/discrete/shape/polygon.py
@classmethod
def from_linear_entities_returns_vertices_ix(
    cls, linear_entities: Sequence[LinearEntity]
) -> tuple[Polygon, list[int]]:
    """Convert a list of linear entities to polygon.

    Beware: this method assumes entities are sorted and connected.
    Conneted means that the last point of each entity is the first point
    of the next entity.
    This implies that the polygon is necessarily closed.

    Args:
        linear_entities (Sequence[LinearEntity]): List of linear entities.

    Returns:
        (Polygon, list[int]): polygon and indices of first vertex of each entity
    """
    points = []
    vertices_ix: list[int] = []
    current_ix = 0
    for i, linear_entity in enumerate(linear_entities):
        if not isinstance(linear_entity, LinearEntity):
            raise TypeError(
                f"Expected a list of LinearEntity, but got {type(linear_entity)}"
            )

        cond_first_pt_is_equal_prev_entity_last_pt = np.array_equal(
            linear_entity.points[0], linear_entities[i - 1].points[-1]
        )
        if not cond_first_pt_is_equal_prev_entity_last_pt:
            raise ValueError(
                f"The first point of entity {i} ({linear_entity.points[0]}) "
                f"is not equal to the last point of entity {i-1} "
                f"({linear_entities[i-1].points[-1]})"
            )
        pts_except_last = linear_entity.points[:-1, :]
        points.append(pts_except_last)
        vertices_ix.append(current_ix)
        current_ix += len(pts_except_last)

    points = np.concatenate(points, axis=0)
    polygon = Polygon(points=points)
    return polygon, vertices_ix

from_lines(lines) classmethod

The lines should describe a perfect closed shape polygon

Parameters:

Name Type Description Default
lines NDArray

array of lines of shape (n, 2, 2)

required

Returns:

Type Description
Polygon

a Polygon object

Source code in otary/geometry/discrete/shape/polygon.py
@classmethod
def from_lines(cls, lines: NDArray) -> Polygon:
    """The lines should describe a perfect closed shape polygon

    Args:
        lines (NDArray): array of lines of shape (n, 2, 2)

    Returns:
        (Polygon): a Polygon object
    """
    nlines = len(lines)
    shifted_lines = np.roll(
        np.array(lines).reshape(nlines * 2, 2), shift=1, axis=0
    ).reshape(nlines, 2, 2)
    distances = np.linalg.norm(np.diff(shifted_lines, axis=1), axis=2)
    if np.any(distances):  # a distance is different from 0
        bad_idxs = np.nonzero(distances > 0)
        raise ValueError(
            f"Could not construct the polygon from the given lines."
            f"Please check at those indices: {bad_idxs}"
        )
    points = lines[:, 0]
    return Polygon(points=points)

from_unordered_lines_approx(lines, max_dist_thresh=50, max_iterations=50, start_line_index=0, img=None, is_debug_enabled=False) classmethod

Create a Polygon object from an unordered list of lines that approximate a closed-shape. They approximate in the sense that they do not necessarily share common points. This method computes the intersection points between lines.

Parameters:

Name Type Description Default
img _type_

array of shape (lx, ly)

None
lines NDArray

array of lines of shape (n, 2, 2)

required
max_dist_thresh float

For any given point, the maximum distance to consider two points as close. Defaults to 50.

50
max_iterations float

Maximum number of iterations before finding a polygon. It defines also the maximum number of lines in the polygon to be found.

50
start_line_index int

The starting line to find searching for the polygon. Defaults to 0.

0

Returns:

Type Description
Polygon

a Polygon object

Source code in otary/geometry/discrete/shape/polygon.py
@classmethod
def from_unordered_lines_approx(
    cls,
    lines: NDArray,
    max_dist_thresh: float = 50,
    max_iterations: int = 50,
    start_line_index: int = 0,
    img: Optional[NDArray] = None,
    is_debug_enabled: bool = False,
) -> Polygon:
    """Create a Polygon object from an unordered list of lines that approximate a
    closed-shape. They approximate in the sense that they do not necessarily
    share common points. This method computes the intersection points between lines.

    Args:
        img (_type_): array of shape (lx, ly)
        lines (NDArray): array of lines of shape (n, 2, 2)
        max_dist_thresh (float, optional): For any given point,
            the maximum distance to consider two points as close. Defaults to 50.
        max_iterations (float, optional): Maximum number of iterations before
            finding a polygon.
            It defines also the maximum number of lines in the polygon to be found.
        start_line_index (int, optional): The starting line to find searching for
            the polygon. Defaults to 0.

    Returns:
        (Polygon): a Polygon object
    """
    # pylint: disable=too-many-locals
    # pylint: disable=too-many-positional-arguments, too-many-arguments
    lines = np.asarray(lines)
    Segment.assert_list_of_lines(lines=lines)

    def debug_visualize(seg: NDArray):  # pragma: no cover
        if is_debug_enabled and img is not None:
            im = img.copy()
            im = cv2.cvtColor(im, cv2.COLOR_BGR2RGB)
            im = cv2.line(
                img=im, pt1=seg[0], pt2=seg[1], color=(0, 250, 126), thickness=5
            )
            plt.imshow(im)
            plt.xticks([])
            plt.yticks([])
            plt.show()

    _lines = copy.deepcopy(lines)
    list_build_cnt = []
    is_polygon_found = False
    idx_seg_closest = start_line_index
    i = 0
    while not is_polygon_found and i < max_iterations:
        curseg = Segment(_lines[idx_seg_closest])
        curpoint = curseg.asarray[1]
        list_build_cnt.append(curseg.asarray)
        _lines = np.delete(_lines, idx_seg_closest, axis=0)

        if len(_lines) == 0:
            logging.debug("No more lines to be processed.")

        # find the closest point to the current one and associated line
        lines2points = _lines.reshape(len(_lines) * 2, 2)
        dist_from_curpoint = np.linalg.norm(lines2points - curpoint, axis=1)
        idx_closest_points = np.nonzero(dist_from_curpoint < max_dist_thresh)[0]

        debug_visualize(seg=curseg.asarray)

        if len(idx_closest_points) > 1:
            # more than one point close to the current point - take the closest
            idx_closest_points = np.array([np.argmin(dist_from_curpoint)])
        if len(idx_closest_points) == 0:
            # no point detected - can mean that the polygon is done or not
            first_seg = Segment(list_build_cnt[0])
            if np.linalg.norm(first_seg.asarray[0] - curpoint) < max_dist_thresh:
                # TODO sometimes multiples intersection example 7
                intersect_point = curseg.intersection_line(first_seg)
                list_build_cnt[-1][1] = intersect_point
                list_build_cnt[0][0] = intersect_point
                is_polygon_found = True
                break
            raise RuntimeError("No point detected close to the current point")

        # only one closest point - get indices of unique closest point on segment
        idx_point_closest = int(idx_closest_points[0])
        idx_seg_closest = int(np.floor(idx_point_closest / 2))

        # arrange the line so that the closest point is in the first place
        idx_point_in_line = 0 if (idx_point_closest / 2).is_integer() else 1
        seg_closest = _lines[idx_seg_closest]
        if idx_point_in_line == 1:  # flip points positions
            seg_closest = np.flip(seg_closest, axis=0)
        _lines[idx_seg_closest] = seg_closest

        # find intersection point between the two lines
        intersect_point = curseg.intersection_line(Segment(seg_closest))

        # update arrays with the intersection point
        _lines[idx_seg_closest][0] = intersect_point
        list_build_cnt[i][1] = intersect_point

        i += 1

    cnt = Polygon.from_lines(np.array(list_build_cnt, dtype=np.int32))
    return cnt

inter_area(other)

Inter area with another Polygon

Parameters:

Name Type Description Default
other Polygon

other Polygon

required

Returns:

Name Type Description
float float

inter area value

Source code in otary/geometry/discrete/shape/polygon.py
def inter_area(self, other: Polygon) -> float:
    """Inter area with another Polygon

    Args:
        other (Polygon): other Polygon

    Returns:
        float: inter area value
    """
    inter_pts = cv2.intersectConvexConvex(self.asarray, other.asarray)
    if inter_pts[0] > 0:
        inter_area = cv2.contourArea(inter_pts[1])
    else:
        inter_area = 0.0
    return inter_area

iou(other)

Intersection over union with another Polygon

Parameters:

Name Type Description Default
other Polygon

other Polygon

required

Returns:

Name Type Description
float float

intersection over union value

Source code in otary/geometry/discrete/shape/polygon.py
def iou(self, other: Polygon) -> float:
    """Intersection over union with another Polygon

    Args:
        other (Polygon): other Polygon

    Returns:
        float: intersection over union value
    """
    inter_area = self.inter_area(other)

    # optimized not to compute twice the inter area
    union_area = self.area + other.area - inter_area

    if union_area == 0:
        return 0.0
    return inter_area / union_area

is_clockwise(is_y_axis_down=False)

Determine if a polygon points go clockwise using the Shoelace formula.

True if polygon vertices order is clockwise in the "y-axis points up" referential.

Parameters:

Name Type Description Default
is_y_axis_down bool

If is_y_axis_down is True, then the image referential is used where y axis points down.

False

Returns:

Name Type Description
bool bool

True if clockwise, False if counter-clockwise

Source code in otary/geometry/discrete/shape/polygon.py
def is_clockwise(self, is_y_axis_down: bool = False) -> bool:
    """Determine if a polygon points go clockwise using the Shoelace formula.

    True if polygon vertices order is clockwise in the "y-axis points up"
    referential.

    Args:
        is_y_axis_down (bool, optional): If is_y_axis_down is True, then the image
            referential is used where y axis points down.

    Returns:
        bool: True if clockwise, False if counter-clockwise
    """
    x = self.asarray[:, 0]
    y = self.asarray[:, 1]

    x_next = np.roll(x, -1)
    y_next = np.roll(y, -1)

    s = np.sum((x_next - x) * (y_next + y))

    is_clockwise = bool(s > 0)  # Clockwise if positive (OpenCV's convention)

    if is_y_axis_down:  # in referential where y axis points down
        return not is_clockwise

    return is_clockwise

is_equal(polygon, dist_margin_error=5)

Check whether two polygons objects are equal by considering a margin of error based on a distance between points.

Parameters:

Name Type Description Default
polygon Polygon

Polygon object

required
dist_margin_error float

distance margin of error. Defaults to 5.

5

Returns:

Name Type Description
bool bool

True if the polygon are equal, False otherwise

Source code in otary/geometry/discrete/shape/polygon.py
def is_equal(self, polygon: Polygon, dist_margin_error: float = 5) -> bool:
    """Check whether two polygons objects are equal by considering a margin of
    error based on a distance between points.

    Args:
        polygon (Polygon): Polygon object
        dist_margin_error (float, optional): distance margin of error.
            Defaults to 5.

    Returns:
        bool: True if the polygon are equal, False otherwise
    """
    if self.n_points != polygon.n_points:
        # if polygons do not have the same number of points they can not be similar
        return False

    # check if each points composing the polygons are close to each other
    new_cnt = polygon.copy().rearrange_first_vertice_closest_to_point(
        self.points[0]
    )
    points_diff = new_cnt.points - self.points
    distances = np.linalg.norm(points_diff, axis=1)
    max_distance = np.max(distances)
    return max_distance <= dist_margin_error

is_regular(margin_dist_error_pct=0.01)

Identifies whether the polygon is regular, this means is rectangular or is a square.

Parameters:

Name Type Description Default
margin_area_error float

area error. Defaults to 25.

required

Returns:

Name Type Description
bool bool

True if the polygon describes a rectangle or square.

Source code in otary/geometry/discrete/shape/polygon.py
def is_regular(self, margin_dist_error_pct: float = 0.01) -> bool:
    """Identifies whether the polygon is regular, this means is rectangular or is
    a square.

    Args:
        margin_area_error (float, optional): area error. Defaults to 25.

    Returns:
        bool: True if the polygon describes a rectangle or square.
    """
    # check we have four points
    if len(self.asarray) != 4:
        return False

    # compute diagonal 1 = taking reference index as 1st point in list - index 0
    refpoint = self.asarray[0]
    idx_max_dist = self.find_vertice_ix_farthest_from(point=refpoint)
    farther_point = self.asarray[idx_max_dist]
    diag1 = Segment(points=[refpoint, farther_point])

    # compute diagonal 2
    diag2_idxs = [1, 2, 3]  # every index except 0
    diag2_idxs.remove(idx_max_dist)  # delete index of point in first diag
    diag2 = Segment(points=self.asarray[diag2_idxs])

    # rectangular criteria = the diagonals have same lengths
    normed_length = np.sqrt(diag1.length * diag2.length)
    if np.abs(diag1.length - diag2.length) > normed_length * margin_dist_error_pct:
        return False

    # there should exist only one intersection point
    intersection_points = diag1.intersection(other=diag2)
    if len(intersection_points) != 1:
        return False

    # diagonals bisect on the center of both diagonal
    cross_point = intersection_points[0]
    dist_mid_cross_diag1 = np.linalg.norm(cross_point - diag1.centroid)
    dist_mid_cross_diag2 = np.linalg.norm(cross_point - diag2.centroid)
    if (
        np.abs(dist_mid_cross_diag1) > normed_length * margin_dist_error_pct
        or np.abs(dist_mid_cross_diag2) > normed_length * margin_dist_error_pct
    ):
        return False

    return True

normal_point(start_index, end_index, dist_along_edge_pct, dist_from_edge, is_outward=True)

Compute the outward normal point. This is a point that points toward the outside of the polygon

Parameters:

Name Type Description Default
start_index int

start index for the edge selection

required
end_index int

end index for the edge selection

required
dist_along_edge_pct float

distance along the edge to place the point

required
dist_from_edge float

distance outward from the edge

required
is_outward bool

True if the normal points to the outside of the polygon. False if the normal points to the inside of the polygon. Defaults to True.

True

Returns:

Name Type Description
NDArray NDArray

2D point as array

Source code in otary/geometry/discrete/shape/polygon.py
def normal_point(
    self,
    start_index: int,
    end_index: int,
    dist_along_edge_pct: float,
    dist_from_edge: float,
    is_outward: bool = True,
) -> NDArray:
    """Compute the outward normal point.
    This is a point that points toward the outside of the polygon

    Args:
        start_index (int): start index for the edge selection
        end_index (int): end index for the edge selection
        dist_along_edge_pct (float): distance along the edge to place the point
        dist_from_edge (float): distance outward from the edge
        is_outward (bool, optional): True if the normal points to the outside of
            the polygon. False if the normal points to the inside of the polygon.
            Defaults to True.

    Returns:
        NDArray: 2D point as array
    """
    # pylint: disable=too-many-locals
    # pylint: disable=too-many-arguments, too-many-positional-arguments,
    if not 0.0 <= dist_along_edge_pct <= 1.0:
        raise ValueError("dist_along_edge_pct must be in [0, 1]")

    pt_interpolated, prev_ix = self.find_interpolated_point_and_prev_ix(
        start_index=start_index, end_index=end_index, pct_dist=dist_along_edge_pct
    )
    next_ix = (prev_ix + 1) % len(self)

    is_interpolated_pt_existing_edge = np.array_equal(
        pt_interpolated, self.asarray[prev_ix]
    ) or np.array_equal(pt_interpolated, self.asarray[next_ix])
    if is_interpolated_pt_existing_edge:
        raise ValueError(
            "Interpolated point for normal computation is an existing vertice "
            "along polygon. Please choose another dist_along_edge_pct parameter."
        )

    edge = Vector(points=[self.asarray[prev_ix], self.asarray[next_ix]])

    normal = edge.normal().normalized

    pt_plus = pt_interpolated + dist_from_edge * normal
    pt_minus = pt_interpolated - dist_from_edge * normal

    dist_plus = np.linalg.norm(pt_plus - self.centroid)
    dist_minus = np.linalg.norm(pt_minus - self.centroid)

    # choose the point which distance to the center is greater
    if dist_plus > dist_minus:
        if is_outward:
            return pt_plus
        return pt_minus

    if is_outward:
        return pt_minus
    return pt_plus

rearrange_first_vertice_at_index(index)

Rearrange the list of points that defines the Polygon so that the first point in the list of points is the one at index given by the argument of this function.

Parameters:

Name Type Description Default
index int

index value

required

Returns:

Name Type Description
Polygon Self

Polygon which is the exact same one but with a rearranged list of points.

Source code in otary/geometry/discrete/shape/polygon.py
def rearrange_first_vertice_at_index(self, index: int) -> Self:
    """Rearrange the list of points that defines the Polygon so that the first
    point in the list of points is the one at index given by the argument of this
    function.

    Args:
        index (int): index value

    Returns:
        Polygon: Polygon which is the exact same one but with a rearranged list
            of points.
    """
    size = len(self)
    if index >= size:
        raise ValueError(
            f"The index value {index} is too big. "
            f"The maximum possible index value is {size-1}."
        )
    if index < 0:
        if abs(index) > size:
            raise ValueError(
                f"The index value {index} is too small. "
                f"The minimum possible index value is {-size}"
            )
        index = size + index

    self.points = np.concatenate([self.points[index:], self.points[:index]])
    return self

rearrange_first_vertice_closest_to_point(point=np.zeros(shape=(2,)))

Rearrange the list of vertices that defines the Polygon so that the first point in the list of vertices is the one that is the closest by distance to the reference point.

Parameters:

Name Type Description Default
reference_point NDArray

point that is taken as a reference in the space to find the one in the Polygon list of points that is the closest to this reference point. Default to origin point [0, 0].

required

Returns:

Name Type Description
Polygon Polygon

Polygon which is the exact same one but with a rearranged list of points.

Source code in otary/geometry/discrete/shape/polygon.py
def rearrange_first_vertice_closest_to_point(
    self, point: NDArray = np.zeros(shape=(2,))
) -> Polygon:
    """Rearrange the list of vertices that defines the Polygon so that the first
    point in the list of vertices is the one that is the closest by distance to
    the reference point.

    Args:
        reference_point (NDArray): point that is taken as a reference in the
            space to find the one in the Polygon list of points that is the
            closest to this reference point. Default to origin point [0, 0].

    Returns:
        Polygon: Polygon which is the exact same one but with a rearranged list
            of points.
    """
    idx_min_dist = self.find_vertice_ix_closest_from(point=point)
    return self.rearrange_first_vertice_at_index(index=idx_min_dist)

reorder_clockwise(is_y_axis_down=False)

Reorder the vertices of the polygon in clockwise order where the first point stays the same.

Parameters:

Name Type Description Default
is_y_axis_down bool

True if cv2 is used. Defaults to False.

False

Returns:

Name Type Description
Polygon Polygon

reordered polygon

Source code in otary/geometry/discrete/shape/polygon.py
def reorder_clockwise(self, is_y_axis_down: bool = False) -> Polygon:
    """Reorder the vertices of the polygon in clockwise order where the first point
    stays the same.

    Args:
        is_y_axis_down (bool, optional): True if cv2 is used. Defaults to False.

    Returns:
        Polygon: reordered polygon
    """
    if self.is_clockwise(is_y_axis_down=is_y_axis_down):
        return self
    self.asarray = np.roll(self.asarray[::-1], shift=1, axis=0)
    return self

score_vertices_in_points(points, max_distance)

Returns a score of 0 or 1 for each point in the polygon if it is close enough to any point in the input points.

Parameters:

Name Type Description Default
points NDArray

list of 2D points

required
margin_dist_error float

mininum distance to consider two points as close enough to be considered as the same points

required

Returns:

Name Type Description
NDArray NDArray

a list of score for each point in the contour

Source code in otary/geometry/discrete/shape/polygon.py
def score_vertices_in_points(self, points: NDArray, max_distance: float) -> NDArray:
    """Returns a score of 0 or 1 for each point in the polygon if it is close
    enough to any point in the input points.

    Args:
        points (NDArray): list of 2D points
        margin_dist_error (float): mininum distance to consider two points as
            close enough to be considered as the same points

    Returns:
        NDArray: a list of score for each point in the contour
    """

    indices = get_shared_point_indices(
        points_to_check=self.asarray,
        checkpoints=points,
        margin_dist_error=max_distance,
        method="close",
        cond="any",
    )
    score = np.bincount(indices, minlength=len(self))
    return score

shrink(scale)

Contract or shrink a polygon

Parameters:

Name Type Description Default
scale float

scale shrinking factor. Must be greater than 1.

required

Returns:

Name Type Description
Polygon Polygon

new bigger polygon

Source code in otary/geometry/discrete/shape/polygon.py
def shrink(self, scale: float) -> Polygon:
    """Contract or shrink a polygon

    Args:
        scale (float): scale shrinking factor. Must be greater than 1.

    Returns:
        Polygon: new bigger polygon
    """
    if scale < 1:
        raise ValueError(
            "The scale value can not be less than 1 when shrinking a polygon. "
            f"Found {scale}"
        )
    return self.__rescale(scale=1 / scale)

to_image_crop_referential(other, crop, image_crop_shape=None)

This function can be useful for a very specific need: In a single image you have two same polygons and their coordinates are defined in this image referential.

You want to obtain the original polygon and all its vertices information in the image crop referential to match the other polygon within it.

This method manipulates three referentials: 1. image referential (main referential) 2. crop referential 3. image crop referential. It is different from the crop referential because the width and height of the crop referential may not be the same.

Parameters:

Name Type Description Default
other Polygon

other Polygon in the image referential

required
crop_rect Rectangle

crop rectangle in the image referential

required
image_crop_shape (tuple[int, int], optionla)

[width, height] of the crop image. If None, the shape is assumed to be directly the crop shape.

None

Returns:

Name Type Description
Polygon Polygon

original polygon in the image crop referential

Source code in otary/geometry/discrete/shape/polygon.py
def to_image_crop_referential(
    self,
    other: Polygon,
    crop: Rectangle,
    image_crop_shape: Optional[tuple[int, int]] = None,
) -> Polygon:
    """This function can be useful for a very specific need:
    In a single image you have two same polygons and their coordinates are defined
    in this image referential.

    You want to obtain the original polygon and all its vertices information
    in the image crop referential to match the other polygon within it.

    This method manipulates three referentials:
    1. image referential (main referential)
    2. crop referential
    3. image crop referential. It is different from the crop referential
        because the width and height of the crop referential may not be the same.

    Args:
        other (Polygon): other Polygon in the image referential
        crop_rect (Rectangle): crop rectangle in the image referential
        image_crop_shape (tuple[int, int], optionla): [width, height] of the crop
            image. If None, the shape is assumed to be directly the crop shape.


    Returns:
        Polygon: original polygon in the image crop referential
    """
    if not crop.contains(other=other):
        raise ValueError(
            f"The crop rectangle {crop} does not contain the other polygon {other}"
        )
    crop_width = int(crop.get_width_from_topleft(0))
    crop_height = int(crop.get_height_from_topleft(0))

    if image_crop_shape is None:
        image_crop_shape = (crop_width, crop_height)

    # self polygon in the original image shifted and normalized
    aabb_main = self.enclosing_axis_aligned_bbox()
    contour_main_shifted_normalized = self.copy().shift(
        vector=-np.asarray([self.xmin, self.ymin])
    ) / np.array(
        [aabb_main.get_width_from_topleft(0), aabb_main.get_height_from_topleft(0)]
    )

    # AABB of the polygon in the crop referential
    aabb_crop = other.enclosing_axis_aligned_bbox()
    aabb_crop_normalized = (
        aabb_crop - np.asarray([crop.xmin, crop.ymin])
    ) / np.array([crop_width, crop_height])

    # obtain the self polygon in the image crop referential
    aabb_crop2 = aabb_crop_normalized * np.array(image_crop_shape)
    new_polygon = contour_main_shifted_normalized * np.array(
        [
            aabb_crop2.get_width_from_topleft(0),
            aabb_crop2.get_height_from_topleft(0),
        ]
    ) + np.asarray([aabb_crop2.xmin, aabb_crop2.ymin])

    return new_polygon

union_area(other)

Union area with another Polygon

Parameters:

Name Type Description Default
other Polygon

other Polygon

required

Returns:

Name Type Description
float float

union area value

Source code in otary/geometry/discrete/shape/polygon.py
def union_area(self, other: Polygon) -> float:
    """Union area with another Polygon

    Args:
        other (Polygon): other Polygon

    Returns:
        float: union area value
    """
    return self.area + other.area - self.inter_area(other)