vkit.element.polygon

  1# Copyright 2022 vkit-x Administrator. All Rights Reserved.
  2#
  3# This project (vkit-x/vkit) is dual-licensed under commercial and SSPL licenses.
  4#
  5# The commercial license gives you the full rights to create and distribute software
  6# on your own terms without any SSPL license obligations. For more information,
  7# please see the "LICENSE_COMMERCIAL.txt" file.
  8#
  9# This project is also available under Server Side Public License (SSPL).
 10# The SSPL licensing is ideal for use cases such as open source projects with
 11# SSPL distribution, student/academic purposes, hobby projects, internal research
 12# projects without external distribution, or other projects where all SSPL
 13# obligations can be met. For more information, please see the "LICENSE_SSPL.txt" file.
 14from typing import cast, Optional, Tuple, Union, Sequence, Iterable, List
 15import logging
 16import math
 17
 18import attrs
 19import numpy as np
 20import cv2 as cv
 21from shapely.geometry import (
 22    Polygon as ShapelyPolygon,
 23    MultiPolygon as ShapelyMultiPolygon,
 24    CAP_STYLE,
 25    JOIN_STYLE,
 26)
 27from shapely.ops import unary_union
 28import pyclipper
 29
 30from vkit.utility import attrs_lazy_field
 31from .type import Shapable, ElementSetOperationMode
 32
 33logger = logging.getLogger(__name__)
 34
 35_T = Union[float, str]
 36
 37
 38@attrs.define
 39class PolygonInternals:
 40    bounding_box: 'Box'
 41    np_self_relative_points: np.ndarray
 42
 43    _area: Optional[float] = attrs_lazy_field()
 44    _self_relative_polygon: Optional['Polygon'] = attrs_lazy_field()
 45    _np_mask: Optional[np.ndarray] = attrs_lazy_field()
 46    _mask: Optional['Mask'] = attrs_lazy_field()
 47
 48    def lazy_post_init_area(self):
 49        if self._area is not None:
 50            return self._area
 51
 52        self._area = float(ShapelyPolygon(self.np_self_relative_points).area)
 53        return self._area
 54
 55    @property
 56    def area(self):
 57        return self.lazy_post_init_area()
 58
 59    def lazy_post_init_self_relative_polygon(self):
 60        if self._self_relative_polygon is not None:
 61            return self._self_relative_polygon
 62
 63        self._self_relative_polygon = Polygon.from_np_array(self.np_self_relative_points)
 64        return self._self_relative_polygon
 65
 66    @property
 67    def self_relative_polygon(self):
 68        return self.lazy_post_init_self_relative_polygon()
 69
 70    def lazy_post_init_np_mask(self):
 71        if self._np_mask is not None:
 72            return self._np_mask
 73
 74        np_mask = np.zeros(self.bounding_box.shape, dtype=np.uint8)
 75        cv.fillPoly(np_mask, [self.self_relative_polygon.to_np_array()], 1)
 76        self._np_mask = np_mask.astype(np.bool_)
 77        return self._np_mask
 78
 79    @property
 80    def np_mask(self):
 81        return self.lazy_post_init_np_mask()
 82
 83    def lazy_post_init_mask(self):
 84        if self._mask is not None:
 85            return self._mask
 86
 87        self._mask = Mask(mat=self.np_mask.astype(np.uint8))
 88        self._mask = self._mask.to_box_attached(self.bounding_box)
 89        return self._mask
 90
 91    @property
 92    def mask(self):
 93        return self.lazy_post_init_mask()
 94
 95
 96@attrs.define(frozen=True, eq=False)
 97class Polygon:
 98    points: 'PointTuple'
 99
100    _internals: Optional[PolygonInternals] = attrs_lazy_field()
101
102    def __attrs_post_init__(self):
103        assert self.points
104
105    def lazy_post_init_internals(self):
106        if self._internals is not None:
107            return self._internals
108
109        np_self_relative_points = self.to_smooth_np_array()
110
111        y_min = np_self_relative_points[:, 1].min()
112        y_max = np_self_relative_points[:, 1].max()
113
114        x_min = np_self_relative_points[:, 0].min()
115        x_max = np_self_relative_points[:, 0].max()
116
117        np_self_relative_points[:, 0] -= x_min
118        np_self_relative_points[:, 1] -= y_min
119
120        bounding_box = Box(
121            up=round(y_min),
122            down=round(y_max),
123            left=round(x_min),
124            right=round(x_max),
125        )
126
127        object.__setattr__(
128            self,
129            '_internals',
130            PolygonInternals(
131                bounding_box=bounding_box,
132                np_self_relative_points=np_self_relative_points,
133            ),
134        )
135        return cast(PolygonInternals, self._internals)
136
137    ###############
138    # Constructor #
139    ###############
140    @classmethod
141    def create(cls, points: Union['PointList', 'PointTuple', Iterable['Point']]):
142        return cls(points=PointTuple(points))
143
144    ############
145    # Property #
146    ############
147    @property
148    def num_points(self):
149        return len(self.points)
150
151    @property
152    def internals(self):
153        return self.lazy_post_init_internals()
154
155    @property
156    def area(self):
157        return self.internals.area
158
159    @property
160    def bounding_box(self):
161        return self.internals.bounding_box
162
163    @property
164    def self_relative_polygon(self):
165        return self.internals.self_relative_polygon
166
167    @property
168    def mask(self):
169        return self.internals.mask
170
171    ##############
172    # Conversion #
173    ##############
174    @classmethod
175    def from_xy_pairs(cls, xy_pairs: Iterable[Tuple[_T, _T]]):
176        return cls(points=PointTuple.from_xy_pairs(xy_pairs))
177
178    def to_xy_pairs(self):
179        return self.points.to_xy_pairs()
180
181    def to_smooth_xy_pairs(self):
182        return self.points.to_smooth_xy_pairs()
183
184    @classmethod
185    def from_flatten_xy_pairs(cls, flatten_xy_pairs: Sequence[_T]):
186        return cls(points=PointTuple.from_flatten_xy_pairs(flatten_xy_pairs))
187
188    def to_flatten_xy_pairs(self):
189        return self.points.to_flatten_xy_pairs()
190
191    def to_smooth_flatten_xy_pairs(self):
192        return self.points.to_smooth_flatten_xy_pairs()
193
194    @classmethod
195    def from_np_array(cls, np_points: np.ndarray):
196        return cls(points=PointTuple.from_np_array(np_points))
197
198    def to_np_array(self):
199        return self.points.to_np_array()
200
201    def to_smooth_np_array(self):
202        return self.points.to_smooth_np_array()
203
204    @classmethod
205    def from_shapely_polygon(cls, shapely_polygon: ShapelyPolygon):
206        xy_pairs = cls.remove_duplicated_xy_pairs(shapely_polygon.exterior.coords)  # type: ignore
207        return cls.from_xy_pairs(xy_pairs)
208
209    def to_shapely_polygon(self):
210        return ShapelyPolygon(self.to_xy_pairs())
211
212    def to_smooth_shapely_polygon(self):
213        return ShapelyPolygon(self.to_smooth_xy_pairs())
214
215    ############
216    # Operator #
217    ############
218    def get_center_point(self):
219        shapely_polygon = self.to_smooth_shapely_polygon()
220        centroid = shapely_polygon.centroid
221        x, y = centroid.coords[0]
222        return Point.create(y=y, x=x)
223
224    def get_rectangular_height(self):
225        # See Box.to_polygon.
226        assert self.num_points == 4
227        (
228            point_up_left,
229            point_up_right,
230            point_down_right,
231            point_down_left,
232        ) = self.points
233        left_side_height = math.hypot(
234            point_up_left.smooth_y - point_down_left.smooth_y,
235            point_up_left.smooth_x - point_down_left.smooth_x,
236        )
237        right_side_height = math.hypot(
238            point_up_right.smooth_y - point_down_right.smooth_y,
239            point_up_right.smooth_x - point_down_right.smooth_x,
240        )
241        return (left_side_height + right_side_height) / 2
242
243    def get_rectangular_width(self):
244        # See Box.to_polygon.
245        assert self.num_points == 4
246        (
247            point_up_left,
248            point_up_right,
249            point_down_right,
250            point_down_left,
251        ) = self.points
252        up_side_width = math.hypot(
253            point_up_left.smooth_y - point_up_right.smooth_y,
254            point_up_left.smooth_x - point_up_right.smooth_x,
255        )
256        down_side_width = math.hypot(
257            point_down_left.smooth_y - point_down_right.smooth_y,
258            point_down_left.smooth_x - point_down_right.smooth_x,
259        )
260        return (up_side_width + down_side_width) / 2
261
262    def to_clipped_points(self, shapable_or_shape: Union[Shapable, Tuple[int, int]]):
263        return self.points.to_clipped_points(shapable_or_shape)
264
265    def to_clipped_polygon(self, shapable_or_shape: Union[Shapable, Tuple[int, int]]):
266        return Polygon(points=self.to_clipped_points(shapable_or_shape))
267
268    def to_shifted_points(self, offset_y: int = 0, offset_x: int = 0):
269        return self.points.to_shifted_points(offset_y=offset_y, offset_x=offset_x)
270
271    def to_relative_points(self, origin_y: int, origin_x: int):
272        return self.points.to_relative_points(origin_y=origin_y, origin_x=origin_x)
273
274    def to_shifted_polygon(self, offset_y: int = 0, offset_x: int = 0):
275        return Polygon(points=self.to_shifted_points(offset_y=offset_y, offset_x=offset_x))
276
277    def to_relative_polygon(self, origin_y: int, origin_x: int):
278        return Polygon(points=self.to_relative_points(origin_y=origin_y, origin_x=origin_x))
279
280    def to_conducted_resized_polygon(
281        self,
282        shapable_or_shape: Union[Shapable, Tuple[int, int]],
283        resized_height: Optional[int] = None,
284        resized_width: Optional[int] = None,
285    ):
286        return Polygon(
287            points=self.points.to_conducted_resized_points(
288                shapable_or_shape=shapable_or_shape,
289                resized_height=resized_height,
290                resized_width=resized_width,
291            ),
292        )
293
294    def to_resized_polygon(
295        self,
296        resized_height: Optional[int] = None,
297        resized_width: Optional[int] = None,
298    ):
299        return self.to_conducted_resized_polygon(
300            shapable_or_shape=self.bounding_box.shape,
301            resized_height=resized_height,
302            resized_width=resized_width,
303        )
304
305    @classmethod
306    def project_polygon_to_unit_vector(cls, np_points: np.ndarray, radian: float):
307        np_vector = np.asarray([math.cos(radian), math.sin(radian)])
308
309        np_projected: np.ndarray = np.dot(np_points, np_vector.reshape(2, 1)).flatten()
310        scale_begin = float(np_projected.min())
311        scale_end = float(np_projected.max())
312
313        np_point_begin = np_vector * scale_begin
314        np_point_end = np_vector * scale_end
315
316        return np_point_begin, np_point_end
317
318    @classmethod
319    def calculate_lines_intersection_point(
320        cls,
321        np_point0: np.ndarray,
322        radian0: float,
323        np_point1: np.ndarray,
324        radian1: float,
325    ):
326        x0, y0 = np_point0
327        x1, y1 = np_point1
328
329        slope0 = np.tan(radian0)
330        slope1 = np.tan(radian1)
331
332        # Within pi / 2 + k * pi plus or minus 0.1 degree.
333        invalid_slope_abs = 572.9572133543033
334
335        if abs(slope0) > invalid_slope_abs and abs(slope1) > invalid_slope_abs:
336            raise RuntimeError('Lines are vertical.')
337
338        if abs(slope0) > invalid_slope_abs:
339            its_x = float(x0)
340            its_y = float(y1 + slope1 * (x0 - x1))
341
342        elif abs(slope1) > invalid_slope_abs:
343            its_x = float(x1)
344            its_y = float(y0 + slope0 * (x1 - x0))
345
346        else:
347            c0 = y0 - slope0 * x0
348            c1 = y1 - slope1 * x1
349
350            with np.errstate(divide='ignore', invalid='ignore'):
351                its_x = (c1 - c0) / (slope0 - slope1)
352            if its_x == np.inf:
353                raise RuntimeError('Lines not intersected.')
354
355            its_y = slope0 * its_x + c0
356
357        return Point.create(y=float(its_y), x=float(its_x))
358
359    def to_bounding_rectangular_polygon(
360        self,
361        shape: Tuple[int, int],
362        angle: Optional[float] = None,
363    ):
364        if angle is None:
365            shapely_polygon = self.to_smooth_shapely_polygon()
366
367            assert isinstance(shapely_polygon.minimum_rotated_rectangle, ShapelyPolygon)
368            # NOTE: For unknown reasons, the polygon border COULD exceed the bounding box
369            # a little bit. Hence we cannot assume the bounding box contains the polygon.
370            polygon = self.from_shapely_polygon(shapely_polygon.minimum_rotated_rectangle)
371            assert polygon.num_points == 4
372
373        else:
374            # Make sure in range [0, 180).
375            angle = angle % 180
376
377            main_radian = math.radians(angle)
378            orthogonal_radian = math.radians(angle + 90)
379
380            # Project points.
381            np_smooth_points = self.to_smooth_np_array()
382            (
383                np_main_point_begin,
384                np_main_point_end,
385            ) = self.project_polygon_to_unit_vector(
386                np_points=np_smooth_points,
387                radian=main_radian,
388            )
389            (
390                np_orthogonal_point_begin,
391                np_orthogonal_point_end,
392            ) = self.project_polygon_to_unit_vector(
393                np_points=np_smooth_points,
394                radian=orthogonal_radian,
395            )
396
397            # Build polygon.
398            polygon = Polygon.create(
399                points=[
400                    # main begin, orthogonal begin.
401                    self.calculate_lines_intersection_point(
402                        np_point0=np_main_point_begin,
403                        radian0=orthogonal_radian,
404                        np_point1=np_orthogonal_point_begin,
405                        radian1=main_radian,
406                    ),
407                    # main begin, orthogonal end.
408                    self.calculate_lines_intersection_point(
409                        np_point0=np_main_point_begin,
410                        radian0=orthogonal_radian,
411                        np_point1=np_orthogonal_point_end,
412                        radian1=main_radian,
413                    ),
414                    # main end, orthogonal end.
415                    self.calculate_lines_intersection_point(
416                        np_point0=np_main_point_end,
417                        radian0=orthogonal_radian,
418                        np_point1=np_orthogonal_point_end,
419                        radian1=main_radian,
420                    ),
421                    # main end, orthogonal begin.
422                    self.calculate_lines_intersection_point(
423                        np_point0=np_main_point_end,
424                        radian0=orthogonal_radian,
425                        np_point1=np_orthogonal_point_begin,
426                        radian1=main_radian,
427                    ),
428                ]
429            )
430
431        # NOTE: Could be out-of-bound.
432        polygon = polygon.to_clipped_polygon(shape)
433
434        return polygon
435
436    def to_bounding_box(self):
437        return self.bounding_box
438
439    def fill_np_array(
440        self,
441        mat: np.ndarray,
442        value: Union[np.ndarray, Tuple[float, ...], float],
443        alpha: Union['ScoreMap', np.ndarray, float] = 1.0,
444        keep_max_value: bool = False,
445        keep_min_value: bool = False,
446    ):
447        self.mask.fill_np_array(
448            mat=mat,
449            value=value,
450            alpha=alpha,
451            keep_max_value=keep_max_value,
452            keep_min_value=keep_min_value,
453        )
454
455    def extract_mask(self, mask: 'Mask'):
456        return self.mask.extract_mask(mask)
457
458    def fill_mask(
459        self,
460        mask: 'Mask',
461        value: Union['Mask', np.ndarray, int] = 1,
462        keep_max_value: bool = False,
463        keep_min_value: bool = False,
464    ):
465        self.mask.fill_mask(
466            mask=mask,
467            value=value,
468            keep_max_value=keep_max_value,
469            keep_min_value=keep_min_value,
470        )
471
472    def extract_score_map(self, score_map: 'ScoreMap'):
473        return self.mask.extract_score_map(score_map)
474
475    def fill_score_map(
476        self,
477        score_map: 'ScoreMap',
478        value: Union['ScoreMap', np.ndarray, float],
479        keep_max_value: bool = False,
480        keep_min_value: bool = False,
481    ):
482        self.mask.fill_score_map(
483            score_map=score_map,
484            value=value,
485            keep_max_value=keep_max_value,
486            keep_min_value=keep_min_value,
487        )
488
489    def extract_image(self, image: 'Image'):
490        return self.mask.extract_image(image)
491
492    def fill_image(
493        self,
494        image: 'Image',
495        value: Union['Image', np.ndarray, Tuple[int, ...], int],
496        alpha: Union['ScoreMap', np.ndarray, float] = 1.0,
497    ):
498        self.mask.fill_image(
499            image=image,
500            value=value,
501            alpha=alpha,
502        )
503
504    @classmethod
505    def remove_duplicated_xy_pairs(cls, xy_pairs: Sequence[Tuple[int, int]]):
506        xy_pairs = tuple(map(tuple, xy_pairs))
507        unique_xy_pairs = []
508
509        idx = 0
510        while idx < len(xy_pairs):
511            unique_xy_pairs.append(xy_pairs[idx])
512
513            next_idx = idx + 1
514            while next_idx < len(xy_pairs) and xy_pairs[idx] == xy_pairs[next_idx]:
515                next_idx += 1
516            idx = next_idx
517
518        # Check head & tail.
519        if len(unique_xy_pairs) > 1 and unique_xy_pairs[0] == unique_xy_pairs[-1]:
520            unique_xy_pairs.pop()
521
522        assert len(unique_xy_pairs) >= 3
523        return unique_xy_pairs
524
525    def to_vatti_clipped_polygon(self, ratio: float, shrink: bool):
526        assert 0.0 <= ratio <= 1.0
527        if ratio == 1.0:
528            return self, 0.0
529
530        xy_pairs = self.to_smooth_xy_pairs()
531
532        shapely_polygon = ShapelyPolygon(xy_pairs)
533        if shapely_polygon.area == 0:
534            logger.warning('shapely_polygon.area == 0, this breaks vatti_clip.')
535
536        distance: float = shapely_polygon.area * (1 - np.power(ratio, 2)) / shapely_polygon.length
537        if shrink:
538            distance *= -1
539
540        clipper = pyclipper.PyclipperOffset()  # type: ignore
541        clipper.AddPath(xy_pairs, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON)  # type: ignore
542
543        clipped_paths = clipper.Execute(distance)
544        assert clipped_paths
545        clipped_path: Sequence[Tuple[int, int]] = clipped_paths[0]
546
547        clipped_xy_pairs = self.remove_duplicated_xy_pairs(clipped_path)
548        clipped_polygon = self.from_xy_pairs(clipped_xy_pairs)
549
550        return clipped_polygon, distance
551
552    def to_shrank_polygon(
553        self,
554        ratio: float,
555        no_exception: bool = True,
556    ):
557        try:
558            shrank_polygon, _ = self.to_vatti_clipped_polygon(ratio, shrink=True)
559
560            shrank_bounding_box = shrank_polygon.bounding_box
561            vert_contains = (
562                self.bounding_box.up <= shrank_bounding_box.up
563                and shrank_bounding_box.down <= self.bounding_box.down
564            )
565            hori_contains = (
566                self.bounding_box.left <= shrank_bounding_box.left
567                and shrank_bounding_box.right <= self.bounding_box.right
568            )
569            if not (shrank_bounding_box.valid and vert_contains and hori_contains):
570                logger.warning('Invalid shrank_polygon bounding box. Fallback to NOP.')
571                return self
572
573            if 0 < shrank_polygon.area <= self.area:
574                return shrank_polygon
575            else:
576                logger.warning('Invalid shrank_polygon.area. Fallback to NOP.')
577                return self
578
579        except Exception:
580            if no_exception:
581                logger.exception('Failed to shrink. Fallback to NOP.')
582                return self
583            else:
584                raise
585
586    def to_dilated_polygon(
587        self,
588        ratio: float,
589        no_exception: bool = True,
590    ):
591        try:
592            dilated_polygon, _ = self.to_vatti_clipped_polygon(ratio, shrink=False)
593
594            dilated_bounding_box = dilated_polygon.bounding_box
595            vert_contains = (
596                dilated_bounding_box.up <= self.bounding_box.up
597                and self.bounding_box.down <= dilated_bounding_box.down
598            )
599            hori_contains = (
600                dilated_bounding_box.left <= self.bounding_box.left
601                and self.bounding_box.right <= dilated_bounding_box.right
602            )
603            if not (dilated_bounding_box.valid and vert_contains and hori_contains):
604                logger.warning('Invalid dilated_polygon bounding box. Fallback to NOP.')
605                return self
606
607            if dilated_polygon.area >= self.area:
608                return dilated_polygon
609            else:
610                logger.warning('Invalid dilated_polygon.area. Fallback to NOP.')
611                return self
612
613        except Exception:
614            if no_exception:
615                logger.exception('Failed to dilate. Fallback to NOP.')
616                return self
617            else:
618                raise
619
620
621# Experimental operations.
622# TODO: Might add to Polygon class.
623def get_line_lengths(shapely_polygon: ShapelyPolygon):
624    assert shapely_polygon.exterior is not None
625    points = tuple(shapely_polygon.exterior.coords)
626    for idx, p0 in enumerate(points):
627        p1 = points[(idx + 1) % len(points)]
628        length = math.sqrt((p0[0] - p1[0])**2 + (p0[1] - p1[1])**2)
629        yield length
630
631
632def estimate_shapely_polygon_height(shapely_polygon: ShapelyPolygon):
633    length = max(get_line_lengths(shapely_polygon))
634    return float(shapely_polygon.area) / length
635
636
637def calculate_patch_buffer_eps(shapely_polygon: ShapelyPolygon):
638    return estimate_shapely_polygon_height(shapely_polygon) / 10
639
640
641def patch_unionized_unionized_shapely_polygon(unionized_shapely_polygon: ShapelyPolygon):
642    eps = calculate_patch_buffer_eps(unionized_shapely_polygon)
643    unionized_shapely_polygon = unionized_shapely_polygon.buffer(
644        eps,
645        cap_style=CAP_STYLE.round,  # type: ignore
646        join_style=JOIN_STYLE.round,  # type: ignore
647    )
648    unionized_shapely_polygon = unionized_shapely_polygon.buffer(
649        -eps,
650        cap_style=CAP_STYLE.round,  # type: ignore
651        join_style=JOIN_STYLE.round,  # type: ignore
652    )
653    return unionized_shapely_polygon
654
655
656def unionize_polygons(polygons: Iterable[Polygon]):
657    shapely_polygons = [polygon.to_smooth_shapely_polygon() for polygon in polygons]
658
659    unionized_shapely_polygons = []
660
661    # Patch unary_union.
662    unary_union_output = unary_union(shapely_polygons)
663    if not isinstance(unary_union_output, ShapelyMultiPolygon):
664        assert isinstance(unary_union_output, ShapelyPolygon)
665        unary_union_output = [unary_union_output]
666
667    for unionized_shapely_polygon in unary_union_output:  # type: ignore
668        unionized_shapely_polygon = patch_unionized_unionized_shapely_polygon(
669            unionized_shapely_polygon
670        )
671        unionized_shapely_polygons.append(unionized_shapely_polygon)
672
673    unionized_polygons = [
674        Polygon.from_xy_pairs(unionized_shapely_polygon.exterior.coords)
675        for unionized_shapely_polygon in unionized_shapely_polygons
676    ]
677
678    scatter_indices: List[int] = []
679    for shapely_polygon in shapely_polygons:
680        best_unionized_polygon_idx = None
681        best_area = 0.0
682        conflict = False
683
684        for unionized_polygon_idx, unionized_shapely_polygon in enumerate(
685            unionized_shapely_polygons
686        ):
687            if not unionized_shapely_polygon.intersects(shapely_polygon):
688                continue
689            area = unionized_shapely_polygon.intersection(shapely_polygon).area
690            if area > best_area:
691                best_area = area
692                best_unionized_polygon_idx = unionized_polygon_idx
693                conflict = False
694            elif area == best_area:
695                conflict = True
696
697        assert not conflict
698        assert best_unionized_polygon_idx is not None
699        scatter_indices.append(best_unionized_polygon_idx)
700
701    return unionized_polygons, scatter_indices
702
703
704def generate_fill_by_polygons_mask(
705    shape: Tuple[int, int],
706    polygons: Iterable[Polygon],
707    mode: ElementSetOperationMode,
708):
709    if mode == ElementSetOperationMode.UNION:
710        return None
711    else:
712        return Mask.from_polygons(shape, polygons, mode)
713
714
715# Cyclic dependency, by design.
716from .point import Point, PointList, PointTuple  # noqa: E402
717from .box import Box  # noqa: E402
718from .mask import Mask  # noqa: E402
719from .score_map import ScoreMap  # noqa: E402
720from .image import Image  # noqa: E402
class PolygonInternals:
40class PolygonInternals:
41    bounding_box: 'Box'
42    np_self_relative_points: np.ndarray
43
44    _area: Optional[float] = attrs_lazy_field()
45    _self_relative_polygon: Optional['Polygon'] = attrs_lazy_field()
46    _np_mask: Optional[np.ndarray] = attrs_lazy_field()
47    _mask: Optional['Mask'] = attrs_lazy_field()
48
49    def lazy_post_init_area(self):
50        if self._area is not None:
51            return self._area
52
53        self._area = float(ShapelyPolygon(self.np_self_relative_points).area)
54        return self._area
55
56    @property
57    def area(self):
58        return self.lazy_post_init_area()
59
60    def lazy_post_init_self_relative_polygon(self):
61        if self._self_relative_polygon is not None:
62            return self._self_relative_polygon
63
64        self._self_relative_polygon = Polygon.from_np_array(self.np_self_relative_points)
65        return self._self_relative_polygon
66
67    @property
68    def self_relative_polygon(self):
69        return self.lazy_post_init_self_relative_polygon()
70
71    def lazy_post_init_np_mask(self):
72        if self._np_mask is not None:
73            return self._np_mask
74
75        np_mask = np.zeros(self.bounding_box.shape, dtype=np.uint8)
76        cv.fillPoly(np_mask, [self.self_relative_polygon.to_np_array()], 1)
77        self._np_mask = np_mask.astype(np.bool_)
78        return self._np_mask
79
80    @property
81    def np_mask(self):
82        return self.lazy_post_init_np_mask()
83
84    def lazy_post_init_mask(self):
85        if self._mask is not None:
86            return self._mask
87
88        self._mask = Mask(mat=self.np_mask.astype(np.uint8))
89        self._mask = self._mask.to_box_attached(self.bounding_box)
90        return self._mask
91
92    @property
93    def mask(self):
94        return self.lazy_post_init_mask()
PolygonInternals( bounding_box: vkit.element.box.Box, np_self_relative_points: numpy.ndarray)
2def __init__(self, bounding_box, np_self_relative_points):
3    self.bounding_box = bounding_box
4    self.np_self_relative_points = np_self_relative_points
5    self._area = attr_dict['_area'].default
6    self._self_relative_polygon = attr_dict['_self_relative_polygon'].default
7    self._np_mask = attr_dict['_np_mask'].default
8    self._mask = attr_dict['_mask'].default

Method generated by attrs for class PolygonInternals.

def lazy_post_init_area(self):
49    def lazy_post_init_area(self):
50        if self._area is not None:
51            return self._area
52
53        self._area = float(ShapelyPolygon(self.np_self_relative_points).area)
54        return self._area
def lazy_post_init_self_relative_polygon(self):
60    def lazy_post_init_self_relative_polygon(self):
61        if self._self_relative_polygon is not None:
62            return self._self_relative_polygon
63
64        self._self_relative_polygon = Polygon.from_np_array(self.np_self_relative_points)
65        return self._self_relative_polygon
def lazy_post_init_np_mask(self):
71    def lazy_post_init_np_mask(self):
72        if self._np_mask is not None:
73            return self._np_mask
74
75        np_mask = np.zeros(self.bounding_box.shape, dtype=np.uint8)
76        cv.fillPoly(np_mask, [self.self_relative_polygon.to_np_array()], 1)
77        self._np_mask = np_mask.astype(np.bool_)
78        return self._np_mask
def lazy_post_init_mask(self):
84    def lazy_post_init_mask(self):
85        if self._mask is not None:
86            return self._mask
87
88        self._mask = Mask(mat=self.np_mask.astype(np.uint8))
89        self._mask = self._mask.to_box_attached(self.bounding_box)
90        return self._mask
class Polygon:
 98class Polygon:
 99    points: 'PointTuple'
100
101    _internals: Optional[PolygonInternals] = attrs_lazy_field()
102
103    def __attrs_post_init__(self):
104        assert self.points
105
106    def lazy_post_init_internals(self):
107        if self._internals is not None:
108            return self._internals
109
110        np_self_relative_points = self.to_smooth_np_array()
111
112        y_min = np_self_relative_points[:, 1].min()
113        y_max = np_self_relative_points[:, 1].max()
114
115        x_min = np_self_relative_points[:, 0].min()
116        x_max = np_self_relative_points[:, 0].max()
117
118        np_self_relative_points[:, 0] -= x_min
119        np_self_relative_points[:, 1] -= y_min
120
121        bounding_box = Box(
122            up=round(y_min),
123            down=round(y_max),
124            left=round(x_min),
125            right=round(x_max),
126        )
127
128        object.__setattr__(
129            self,
130            '_internals',
131            PolygonInternals(
132                bounding_box=bounding_box,
133                np_self_relative_points=np_self_relative_points,
134            ),
135        )
136        return cast(PolygonInternals, self._internals)
137
138    ###############
139    # Constructor #
140    ###############
141    @classmethod
142    def create(cls, points: Union['PointList', 'PointTuple', Iterable['Point']]):
143        return cls(points=PointTuple(points))
144
145    ############
146    # Property #
147    ############
148    @property
149    def num_points(self):
150        return len(self.points)
151
152    @property
153    def internals(self):
154        return self.lazy_post_init_internals()
155
156    @property
157    def area(self):
158        return self.internals.area
159
160    @property
161    def bounding_box(self):
162        return self.internals.bounding_box
163
164    @property
165    def self_relative_polygon(self):
166        return self.internals.self_relative_polygon
167
168    @property
169    def mask(self):
170        return self.internals.mask
171
172    ##############
173    # Conversion #
174    ##############
175    @classmethod
176    def from_xy_pairs(cls, xy_pairs: Iterable[Tuple[_T, _T]]):
177        return cls(points=PointTuple.from_xy_pairs(xy_pairs))
178
179    def to_xy_pairs(self):
180        return self.points.to_xy_pairs()
181
182    def to_smooth_xy_pairs(self):
183        return self.points.to_smooth_xy_pairs()
184
185    @classmethod
186    def from_flatten_xy_pairs(cls, flatten_xy_pairs: Sequence[_T]):
187        return cls(points=PointTuple.from_flatten_xy_pairs(flatten_xy_pairs))
188
189    def to_flatten_xy_pairs(self):
190        return self.points.to_flatten_xy_pairs()
191
192    def to_smooth_flatten_xy_pairs(self):
193        return self.points.to_smooth_flatten_xy_pairs()
194
195    @classmethod
196    def from_np_array(cls, np_points: np.ndarray):
197        return cls(points=PointTuple.from_np_array(np_points))
198
199    def to_np_array(self):
200        return self.points.to_np_array()
201
202    def to_smooth_np_array(self):
203        return self.points.to_smooth_np_array()
204
205    @classmethod
206    def from_shapely_polygon(cls, shapely_polygon: ShapelyPolygon):
207        xy_pairs = cls.remove_duplicated_xy_pairs(shapely_polygon.exterior.coords)  # type: ignore
208        return cls.from_xy_pairs(xy_pairs)
209
210    def to_shapely_polygon(self):
211        return ShapelyPolygon(self.to_xy_pairs())
212
213    def to_smooth_shapely_polygon(self):
214        return ShapelyPolygon(self.to_smooth_xy_pairs())
215
216    ############
217    # Operator #
218    ############
219    def get_center_point(self):
220        shapely_polygon = self.to_smooth_shapely_polygon()
221        centroid = shapely_polygon.centroid
222        x, y = centroid.coords[0]
223        return Point.create(y=y, x=x)
224
225    def get_rectangular_height(self):
226        # See Box.to_polygon.
227        assert self.num_points == 4
228        (
229            point_up_left,
230            point_up_right,
231            point_down_right,
232            point_down_left,
233        ) = self.points
234        left_side_height = math.hypot(
235            point_up_left.smooth_y - point_down_left.smooth_y,
236            point_up_left.smooth_x - point_down_left.smooth_x,
237        )
238        right_side_height = math.hypot(
239            point_up_right.smooth_y - point_down_right.smooth_y,
240            point_up_right.smooth_x - point_down_right.smooth_x,
241        )
242        return (left_side_height + right_side_height) / 2
243
244    def get_rectangular_width(self):
245        # See Box.to_polygon.
246        assert self.num_points == 4
247        (
248            point_up_left,
249            point_up_right,
250            point_down_right,
251            point_down_left,
252        ) = self.points
253        up_side_width = math.hypot(
254            point_up_left.smooth_y - point_up_right.smooth_y,
255            point_up_left.smooth_x - point_up_right.smooth_x,
256        )
257        down_side_width = math.hypot(
258            point_down_left.smooth_y - point_down_right.smooth_y,
259            point_down_left.smooth_x - point_down_right.smooth_x,
260        )
261        return (up_side_width + down_side_width) / 2
262
263    def to_clipped_points(self, shapable_or_shape: Union[Shapable, Tuple[int, int]]):
264        return self.points.to_clipped_points(shapable_or_shape)
265
266    def to_clipped_polygon(self, shapable_or_shape: Union[Shapable, Tuple[int, int]]):
267        return Polygon(points=self.to_clipped_points(shapable_or_shape))
268
269    def to_shifted_points(self, offset_y: int = 0, offset_x: int = 0):
270        return self.points.to_shifted_points(offset_y=offset_y, offset_x=offset_x)
271
272    def to_relative_points(self, origin_y: int, origin_x: int):
273        return self.points.to_relative_points(origin_y=origin_y, origin_x=origin_x)
274
275    def to_shifted_polygon(self, offset_y: int = 0, offset_x: int = 0):
276        return Polygon(points=self.to_shifted_points(offset_y=offset_y, offset_x=offset_x))
277
278    def to_relative_polygon(self, origin_y: int, origin_x: int):
279        return Polygon(points=self.to_relative_points(origin_y=origin_y, origin_x=origin_x))
280
281    def to_conducted_resized_polygon(
282        self,
283        shapable_or_shape: Union[Shapable, Tuple[int, int]],
284        resized_height: Optional[int] = None,
285        resized_width: Optional[int] = None,
286    ):
287        return Polygon(
288            points=self.points.to_conducted_resized_points(
289                shapable_or_shape=shapable_or_shape,
290                resized_height=resized_height,
291                resized_width=resized_width,
292            ),
293        )
294
295    def to_resized_polygon(
296        self,
297        resized_height: Optional[int] = None,
298        resized_width: Optional[int] = None,
299    ):
300        return self.to_conducted_resized_polygon(
301            shapable_or_shape=self.bounding_box.shape,
302            resized_height=resized_height,
303            resized_width=resized_width,
304        )
305
306    @classmethod
307    def project_polygon_to_unit_vector(cls, np_points: np.ndarray, radian: float):
308        np_vector = np.asarray([math.cos(radian), math.sin(radian)])
309
310        np_projected: np.ndarray = np.dot(np_points, np_vector.reshape(2, 1)).flatten()
311        scale_begin = float(np_projected.min())
312        scale_end = float(np_projected.max())
313
314        np_point_begin = np_vector * scale_begin
315        np_point_end = np_vector * scale_end
316
317        return np_point_begin, np_point_end
318
319    @classmethod
320    def calculate_lines_intersection_point(
321        cls,
322        np_point0: np.ndarray,
323        radian0: float,
324        np_point1: np.ndarray,
325        radian1: float,
326    ):
327        x0, y0 = np_point0
328        x1, y1 = np_point1
329
330        slope0 = np.tan(radian0)
331        slope1 = np.tan(radian1)
332
333        # Within pi / 2 + k * pi plus or minus 0.1 degree.
334        invalid_slope_abs = 572.9572133543033
335
336        if abs(slope0) > invalid_slope_abs and abs(slope1) > invalid_slope_abs:
337            raise RuntimeError('Lines are vertical.')
338
339        if abs(slope0) > invalid_slope_abs:
340            its_x = float(x0)
341            its_y = float(y1 + slope1 * (x0 - x1))
342
343        elif abs(slope1) > invalid_slope_abs:
344            its_x = float(x1)
345            its_y = float(y0 + slope0 * (x1 - x0))
346
347        else:
348            c0 = y0 - slope0 * x0
349            c1 = y1 - slope1 * x1
350
351            with np.errstate(divide='ignore', invalid='ignore'):
352                its_x = (c1 - c0) / (slope0 - slope1)
353            if its_x == np.inf:
354                raise RuntimeError('Lines not intersected.')
355
356            its_y = slope0 * its_x + c0
357
358        return Point.create(y=float(its_y), x=float(its_x))
359
360    def to_bounding_rectangular_polygon(
361        self,
362        shape: Tuple[int, int],
363        angle: Optional[float] = None,
364    ):
365        if angle is None:
366            shapely_polygon = self.to_smooth_shapely_polygon()
367
368            assert isinstance(shapely_polygon.minimum_rotated_rectangle, ShapelyPolygon)
369            # NOTE: For unknown reasons, the polygon border COULD exceed the bounding box
370            # a little bit. Hence we cannot assume the bounding box contains the polygon.
371            polygon = self.from_shapely_polygon(shapely_polygon.minimum_rotated_rectangle)
372            assert polygon.num_points == 4
373
374        else:
375            # Make sure in range [0, 180).
376            angle = angle % 180
377
378            main_radian = math.radians(angle)
379            orthogonal_radian = math.radians(angle + 90)
380
381            # Project points.
382            np_smooth_points = self.to_smooth_np_array()
383            (
384                np_main_point_begin,
385                np_main_point_end,
386            ) = self.project_polygon_to_unit_vector(
387                np_points=np_smooth_points,
388                radian=main_radian,
389            )
390            (
391                np_orthogonal_point_begin,
392                np_orthogonal_point_end,
393            ) = self.project_polygon_to_unit_vector(
394                np_points=np_smooth_points,
395                radian=orthogonal_radian,
396            )
397
398            # Build polygon.
399            polygon = Polygon.create(
400                points=[
401                    # main begin, orthogonal begin.
402                    self.calculate_lines_intersection_point(
403                        np_point0=np_main_point_begin,
404                        radian0=orthogonal_radian,
405                        np_point1=np_orthogonal_point_begin,
406                        radian1=main_radian,
407                    ),
408                    # main begin, orthogonal end.
409                    self.calculate_lines_intersection_point(
410                        np_point0=np_main_point_begin,
411                        radian0=orthogonal_radian,
412                        np_point1=np_orthogonal_point_end,
413                        radian1=main_radian,
414                    ),
415                    # main end, orthogonal end.
416                    self.calculate_lines_intersection_point(
417                        np_point0=np_main_point_end,
418                        radian0=orthogonal_radian,
419                        np_point1=np_orthogonal_point_end,
420                        radian1=main_radian,
421                    ),
422                    # main end, orthogonal begin.
423                    self.calculate_lines_intersection_point(
424                        np_point0=np_main_point_end,
425                        radian0=orthogonal_radian,
426                        np_point1=np_orthogonal_point_begin,
427                        radian1=main_radian,
428                    ),
429                ]
430            )
431
432        # NOTE: Could be out-of-bound.
433        polygon = polygon.to_clipped_polygon(shape)
434
435        return polygon
436
437    def to_bounding_box(self):
438        return self.bounding_box
439
440    def fill_np_array(
441        self,
442        mat: np.ndarray,
443        value: Union[np.ndarray, Tuple[float, ...], float],
444        alpha: Union['ScoreMap', np.ndarray, float] = 1.0,
445        keep_max_value: bool = False,
446        keep_min_value: bool = False,
447    ):
448        self.mask.fill_np_array(
449            mat=mat,
450            value=value,
451            alpha=alpha,
452            keep_max_value=keep_max_value,
453            keep_min_value=keep_min_value,
454        )
455
456    def extract_mask(self, mask: 'Mask'):
457        return self.mask.extract_mask(mask)
458
459    def fill_mask(
460        self,
461        mask: 'Mask',
462        value: Union['Mask', np.ndarray, int] = 1,
463        keep_max_value: bool = False,
464        keep_min_value: bool = False,
465    ):
466        self.mask.fill_mask(
467            mask=mask,
468            value=value,
469            keep_max_value=keep_max_value,
470            keep_min_value=keep_min_value,
471        )
472
473    def extract_score_map(self, score_map: 'ScoreMap'):
474        return self.mask.extract_score_map(score_map)
475
476    def fill_score_map(
477        self,
478        score_map: 'ScoreMap',
479        value: Union['ScoreMap', np.ndarray, float],
480        keep_max_value: bool = False,
481        keep_min_value: bool = False,
482    ):
483        self.mask.fill_score_map(
484            score_map=score_map,
485            value=value,
486            keep_max_value=keep_max_value,
487            keep_min_value=keep_min_value,
488        )
489
490    def extract_image(self, image: 'Image'):
491        return self.mask.extract_image(image)
492
493    def fill_image(
494        self,
495        image: 'Image',
496        value: Union['Image', np.ndarray, Tuple[int, ...], int],
497        alpha: Union['ScoreMap', np.ndarray, float] = 1.0,
498    ):
499        self.mask.fill_image(
500            image=image,
501            value=value,
502            alpha=alpha,
503        )
504
505    @classmethod
506    def remove_duplicated_xy_pairs(cls, xy_pairs: Sequence[Tuple[int, int]]):
507        xy_pairs = tuple(map(tuple, xy_pairs))
508        unique_xy_pairs = []
509
510        idx = 0
511        while idx < len(xy_pairs):
512            unique_xy_pairs.append(xy_pairs[idx])
513
514            next_idx = idx + 1
515            while next_idx < len(xy_pairs) and xy_pairs[idx] == xy_pairs[next_idx]:
516                next_idx += 1
517            idx = next_idx
518
519        # Check head & tail.
520        if len(unique_xy_pairs) > 1 and unique_xy_pairs[0] == unique_xy_pairs[-1]:
521            unique_xy_pairs.pop()
522
523        assert len(unique_xy_pairs) >= 3
524        return unique_xy_pairs
525
526    def to_vatti_clipped_polygon(self, ratio: float, shrink: bool):
527        assert 0.0 <= ratio <= 1.0
528        if ratio == 1.0:
529            return self, 0.0
530
531        xy_pairs = self.to_smooth_xy_pairs()
532
533        shapely_polygon = ShapelyPolygon(xy_pairs)
534        if shapely_polygon.area == 0:
535            logger.warning('shapely_polygon.area == 0, this breaks vatti_clip.')
536
537        distance: float = shapely_polygon.area * (1 - np.power(ratio, 2)) / shapely_polygon.length
538        if shrink:
539            distance *= -1
540
541        clipper = pyclipper.PyclipperOffset()  # type: ignore
542        clipper.AddPath(xy_pairs, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON)  # type: ignore
543
544        clipped_paths = clipper.Execute(distance)
545        assert clipped_paths
546        clipped_path: Sequence[Tuple[int, int]] = clipped_paths[0]
547
548        clipped_xy_pairs = self.remove_duplicated_xy_pairs(clipped_path)
549        clipped_polygon = self.from_xy_pairs(clipped_xy_pairs)
550
551        return clipped_polygon, distance
552
553    def to_shrank_polygon(
554        self,
555        ratio: float,
556        no_exception: bool = True,
557    ):
558        try:
559            shrank_polygon, _ = self.to_vatti_clipped_polygon(ratio, shrink=True)
560
561            shrank_bounding_box = shrank_polygon.bounding_box
562            vert_contains = (
563                self.bounding_box.up <= shrank_bounding_box.up
564                and shrank_bounding_box.down <= self.bounding_box.down
565            )
566            hori_contains = (
567                self.bounding_box.left <= shrank_bounding_box.left
568                and shrank_bounding_box.right <= self.bounding_box.right
569            )
570            if not (shrank_bounding_box.valid and vert_contains and hori_contains):
571                logger.warning('Invalid shrank_polygon bounding box. Fallback to NOP.')
572                return self
573
574            if 0 < shrank_polygon.area <= self.area:
575                return shrank_polygon
576            else:
577                logger.warning('Invalid shrank_polygon.area. Fallback to NOP.')
578                return self
579
580        except Exception:
581            if no_exception:
582                logger.exception('Failed to shrink. Fallback to NOP.')
583                return self
584            else:
585                raise
586
587    def to_dilated_polygon(
588        self,
589        ratio: float,
590        no_exception: bool = True,
591    ):
592        try:
593            dilated_polygon, _ = self.to_vatti_clipped_polygon(ratio, shrink=False)
594
595            dilated_bounding_box = dilated_polygon.bounding_box
596            vert_contains = (
597                dilated_bounding_box.up <= self.bounding_box.up
598                and self.bounding_box.down <= dilated_bounding_box.down
599            )
600            hori_contains = (
601                dilated_bounding_box.left <= self.bounding_box.left
602                and self.bounding_box.right <= dilated_bounding_box.right
603            )
604            if not (dilated_bounding_box.valid and vert_contains and hori_contains):
605                logger.warning('Invalid dilated_polygon bounding box. Fallback to NOP.')
606                return self
607
608            if dilated_polygon.area >= self.area:
609                return dilated_polygon
610            else:
611                logger.warning('Invalid dilated_polygon.area. Fallback to NOP.')
612                return self
613
614        except Exception:
615            if no_exception:
616                logger.exception('Failed to dilate. Fallback to NOP.')
617                return self
618            else:
619                raise
Polygon(points: vkit.element.point.PointTuple)
2def __init__(self, points):
3    _setattr = _cached_setattr_get(self)
4    _setattr('points', points)
5    _setattr('_internals', attr_dict['_internals'].default)
6    self.__attrs_post_init__()

Method generated by attrs for class Polygon.

def lazy_post_init_internals(self):
106    def lazy_post_init_internals(self):
107        if self._internals is not None:
108            return self._internals
109
110        np_self_relative_points = self.to_smooth_np_array()
111
112        y_min = np_self_relative_points[:, 1].min()
113        y_max = np_self_relative_points[:, 1].max()
114
115        x_min = np_self_relative_points[:, 0].min()
116        x_max = np_self_relative_points[:, 0].max()
117
118        np_self_relative_points[:, 0] -= x_min
119        np_self_relative_points[:, 1] -= y_min
120
121        bounding_box = Box(
122            up=round(y_min),
123            down=round(y_max),
124            left=round(x_min),
125            right=round(x_max),
126        )
127
128        object.__setattr__(
129            self,
130            '_internals',
131            PolygonInternals(
132                bounding_box=bounding_box,
133                np_self_relative_points=np_self_relative_points,
134            ),
135        )
136        return cast(PolygonInternals, self._internals)
@classmethod
def create( cls, points: Union[vkit.element.point.PointList, vkit.element.point.PointTuple, collections.abc.Iterable[vkit.element.point.Point]]):
141    @classmethod
142    def create(cls, points: Union['PointList', 'PointTuple', Iterable['Point']]):
143        return cls(points=PointTuple(points))
@classmethod
def from_xy_pairs(cls, xy_pairs: Iterable[Tuple[Union[float, str], Union[float, str]]]):
175    @classmethod
176    def from_xy_pairs(cls, xy_pairs: Iterable[Tuple[_T, _T]]):
177        return cls(points=PointTuple.from_xy_pairs(xy_pairs))
def to_xy_pairs(self):
179    def to_xy_pairs(self):
180        return self.points.to_xy_pairs()
def to_smooth_xy_pairs(self):
182    def to_smooth_xy_pairs(self):
183        return self.points.to_smooth_xy_pairs()
@classmethod
def from_flatten_xy_pairs(cls, flatten_xy_pairs: Sequence[Union[float, str]]):
185    @classmethod
186    def from_flatten_xy_pairs(cls, flatten_xy_pairs: Sequence[_T]):
187        return cls(points=PointTuple.from_flatten_xy_pairs(flatten_xy_pairs))
def to_flatten_xy_pairs(self):
189    def to_flatten_xy_pairs(self):
190        return self.points.to_flatten_xy_pairs()
def to_smooth_flatten_xy_pairs(self):
192    def to_smooth_flatten_xy_pairs(self):
193        return self.points.to_smooth_flatten_xy_pairs()
@classmethod
def from_np_array(cls, np_points: numpy.ndarray):
195    @classmethod
196    def from_np_array(cls, np_points: np.ndarray):
197        return cls(points=PointTuple.from_np_array(np_points))
def to_np_array(self):
199    def to_np_array(self):
200        return self.points.to_np_array()
def to_smooth_np_array(self):
202    def to_smooth_np_array(self):
203        return self.points.to_smooth_np_array()
@classmethod
def from_shapely_polygon(cls, shapely_polygon: shapely.geometry.polygon.Polygon):
205    @classmethod
206    def from_shapely_polygon(cls, shapely_polygon: ShapelyPolygon):
207        xy_pairs = cls.remove_duplicated_xy_pairs(shapely_polygon.exterior.coords)  # type: ignore
208        return cls.from_xy_pairs(xy_pairs)
def to_shapely_polygon(self):
210    def to_shapely_polygon(self):
211        return ShapelyPolygon(self.to_xy_pairs())
def to_smooth_shapely_polygon(self):
213    def to_smooth_shapely_polygon(self):
214        return ShapelyPolygon(self.to_smooth_xy_pairs())
def get_center_point(self):
219    def get_center_point(self):
220        shapely_polygon = self.to_smooth_shapely_polygon()
221        centroid = shapely_polygon.centroid
222        x, y = centroid.coords[0]
223        return Point.create(y=y, x=x)
def get_rectangular_height(self):
225    def get_rectangular_height(self):
226        # See Box.to_polygon.
227        assert self.num_points == 4
228        (
229            point_up_left,
230            point_up_right,
231            point_down_right,
232            point_down_left,
233        ) = self.points
234        left_side_height = math.hypot(
235            point_up_left.smooth_y - point_down_left.smooth_y,
236            point_up_left.smooth_x - point_down_left.smooth_x,
237        )
238        right_side_height = math.hypot(
239            point_up_right.smooth_y - point_down_right.smooth_y,
240            point_up_right.smooth_x - point_down_right.smooth_x,
241        )
242        return (left_side_height + right_side_height) / 2
def get_rectangular_width(self):
244    def get_rectangular_width(self):
245        # See Box.to_polygon.
246        assert self.num_points == 4
247        (
248            point_up_left,
249            point_up_right,
250            point_down_right,
251            point_down_left,
252        ) = self.points
253        up_side_width = math.hypot(
254            point_up_left.smooth_y - point_up_right.smooth_y,
255            point_up_left.smooth_x - point_up_right.smooth_x,
256        )
257        down_side_width = math.hypot(
258            point_down_left.smooth_y - point_down_right.smooth_y,
259            point_down_left.smooth_x - point_down_right.smooth_x,
260        )
261        return (up_side_width + down_side_width) / 2
def to_clipped_points( self, shapable_or_shape: Union[vkit.element.type.Shapable, Tuple[int, int]]):
263    def to_clipped_points(self, shapable_or_shape: Union[Shapable, Tuple[int, int]]):
264        return self.points.to_clipped_points(shapable_or_shape)
def to_clipped_polygon( self, shapable_or_shape: Union[vkit.element.type.Shapable, Tuple[int, int]]):
266    def to_clipped_polygon(self, shapable_or_shape: Union[Shapable, Tuple[int, int]]):
267        return Polygon(points=self.to_clipped_points(shapable_or_shape))
def to_shifted_points(self, offset_y: int = 0, offset_x: int = 0):
269    def to_shifted_points(self, offset_y: int = 0, offset_x: int = 0):
270        return self.points.to_shifted_points(offset_y=offset_y, offset_x=offset_x)
def to_relative_points(self, origin_y: int, origin_x: int):
272    def to_relative_points(self, origin_y: int, origin_x: int):
273        return self.points.to_relative_points(origin_y=origin_y, origin_x=origin_x)
def to_shifted_polygon(self, offset_y: int = 0, offset_x: int = 0):
275    def to_shifted_polygon(self, offset_y: int = 0, offset_x: int = 0):
276        return Polygon(points=self.to_shifted_points(offset_y=offset_y, offset_x=offset_x))
def to_relative_polygon(self, origin_y: int, origin_x: int):
278    def to_relative_polygon(self, origin_y: int, origin_x: int):
279        return Polygon(points=self.to_relative_points(origin_y=origin_y, origin_x=origin_x))
def to_conducted_resized_polygon( self, shapable_or_shape: Union[vkit.element.type.Shapable, Tuple[int, int]], resized_height: Union[int, NoneType] = None, resized_width: Union[int, NoneType] = None):
281    def to_conducted_resized_polygon(
282        self,
283        shapable_or_shape: Union[Shapable, Tuple[int, int]],
284        resized_height: Optional[int] = None,
285        resized_width: Optional[int] = None,
286    ):
287        return Polygon(
288            points=self.points.to_conducted_resized_points(
289                shapable_or_shape=shapable_or_shape,
290                resized_height=resized_height,
291                resized_width=resized_width,
292            ),
293        )
def to_resized_polygon( self, resized_height: Union[int, NoneType] = None, resized_width: Union[int, NoneType] = None):
295    def to_resized_polygon(
296        self,
297        resized_height: Optional[int] = None,
298        resized_width: Optional[int] = None,
299    ):
300        return self.to_conducted_resized_polygon(
301            shapable_or_shape=self.bounding_box.shape,
302            resized_height=resized_height,
303            resized_width=resized_width,
304        )
@classmethod
def project_polygon_to_unit_vector(cls, np_points: numpy.ndarray, radian: float):
306    @classmethod
307    def project_polygon_to_unit_vector(cls, np_points: np.ndarray, radian: float):
308        np_vector = np.asarray([math.cos(radian), math.sin(radian)])
309
310        np_projected: np.ndarray = np.dot(np_points, np_vector.reshape(2, 1)).flatten()
311        scale_begin = float(np_projected.min())
312        scale_end = float(np_projected.max())
313
314        np_point_begin = np_vector * scale_begin
315        np_point_end = np_vector * scale_end
316
317        return np_point_begin, np_point_end
@classmethod
def calculate_lines_intersection_point( cls, np_point0: numpy.ndarray, radian0: float, np_point1: numpy.ndarray, radian1: float):
319    @classmethod
320    def calculate_lines_intersection_point(
321        cls,
322        np_point0: np.ndarray,
323        radian0: float,
324        np_point1: np.ndarray,
325        radian1: float,
326    ):
327        x0, y0 = np_point0
328        x1, y1 = np_point1
329
330        slope0 = np.tan(radian0)
331        slope1 = np.tan(radian1)
332
333        # Within pi / 2 + k * pi plus or minus 0.1 degree.
334        invalid_slope_abs = 572.9572133543033
335
336        if abs(slope0) > invalid_slope_abs and abs(slope1) > invalid_slope_abs:
337            raise RuntimeError('Lines are vertical.')
338
339        if abs(slope0) > invalid_slope_abs:
340            its_x = float(x0)
341            its_y = float(y1 + slope1 * (x0 - x1))
342
343        elif abs(slope1) > invalid_slope_abs:
344            its_x = float(x1)
345            its_y = float(y0 + slope0 * (x1 - x0))
346
347        else:
348            c0 = y0 - slope0 * x0
349            c1 = y1 - slope1 * x1
350
351            with np.errstate(divide='ignore', invalid='ignore'):
352                its_x = (c1 - c0) / (slope0 - slope1)
353            if its_x == np.inf:
354                raise RuntimeError('Lines not intersected.')
355
356            its_y = slope0 * its_x + c0
357
358        return Point.create(y=float(its_y), x=float(its_x))
def to_bounding_rectangular_polygon(self, shape: Tuple[int, int], angle: Union[float, NoneType] = None):
360    def to_bounding_rectangular_polygon(
361        self,
362        shape: Tuple[int, int],
363        angle: Optional[float] = None,
364    ):
365        if angle is None:
366            shapely_polygon = self.to_smooth_shapely_polygon()
367
368            assert isinstance(shapely_polygon.minimum_rotated_rectangle, ShapelyPolygon)
369            # NOTE: For unknown reasons, the polygon border COULD exceed the bounding box
370            # a little bit. Hence we cannot assume the bounding box contains the polygon.
371            polygon = self.from_shapely_polygon(shapely_polygon.minimum_rotated_rectangle)
372            assert polygon.num_points == 4
373
374        else:
375            # Make sure in range [0, 180).
376            angle = angle % 180
377
378            main_radian = math.radians(angle)
379            orthogonal_radian = math.radians(angle + 90)
380
381            # Project points.
382            np_smooth_points = self.to_smooth_np_array()
383            (
384                np_main_point_begin,
385                np_main_point_end,
386            ) = self.project_polygon_to_unit_vector(
387                np_points=np_smooth_points,
388                radian=main_radian,
389            )
390            (
391                np_orthogonal_point_begin,
392                np_orthogonal_point_end,
393            ) = self.project_polygon_to_unit_vector(
394                np_points=np_smooth_points,
395                radian=orthogonal_radian,
396            )
397
398            # Build polygon.
399            polygon = Polygon.create(
400                points=[
401                    # main begin, orthogonal begin.
402                    self.calculate_lines_intersection_point(
403                        np_point0=np_main_point_begin,
404                        radian0=orthogonal_radian,
405                        np_point1=np_orthogonal_point_begin,
406                        radian1=main_radian,
407                    ),
408                    # main begin, orthogonal end.
409                    self.calculate_lines_intersection_point(
410                        np_point0=np_main_point_begin,
411                        radian0=orthogonal_radian,
412                        np_point1=np_orthogonal_point_end,
413                        radian1=main_radian,
414                    ),
415                    # main end, orthogonal end.
416                    self.calculate_lines_intersection_point(
417                        np_point0=np_main_point_end,
418                        radian0=orthogonal_radian,
419                        np_point1=np_orthogonal_point_end,
420                        radian1=main_radian,
421                    ),
422                    # main end, orthogonal begin.
423                    self.calculate_lines_intersection_point(
424                        np_point0=np_main_point_end,
425                        radian0=orthogonal_radian,
426                        np_point1=np_orthogonal_point_begin,
427                        radian1=main_radian,
428                    ),
429                ]
430            )
431
432        # NOTE: Could be out-of-bound.
433        polygon = polygon.to_clipped_polygon(shape)
434
435        return polygon
def to_bounding_box(self):
437    def to_bounding_box(self):
438        return self.bounding_box
def fill_np_array( self, mat: numpy.ndarray, value: Union[numpy.ndarray, Tuple[float, ...], float], alpha: Union[vkit.element.score_map.ScoreMap, numpy.ndarray, float] = 1.0, keep_max_value: bool = False, keep_min_value: bool = False):
440    def fill_np_array(
441        self,
442        mat: np.ndarray,
443        value: Union[np.ndarray, Tuple[float, ...], float],
444        alpha: Union['ScoreMap', np.ndarray, float] = 1.0,
445        keep_max_value: bool = False,
446        keep_min_value: bool = False,
447    ):
448        self.mask.fill_np_array(
449            mat=mat,
450            value=value,
451            alpha=alpha,
452            keep_max_value=keep_max_value,
453            keep_min_value=keep_min_value,
454        )
def extract_mask(self, mask: vkit.element.mask.Mask):
456    def extract_mask(self, mask: 'Mask'):
457        return self.mask.extract_mask(mask)
def fill_mask( self, mask: vkit.element.mask.Mask, value: Union[vkit.element.mask.Mask, numpy.ndarray, int] = 1, keep_max_value: bool = False, keep_min_value: bool = False):
459    def fill_mask(
460        self,
461        mask: 'Mask',
462        value: Union['Mask', np.ndarray, int] = 1,
463        keep_max_value: bool = False,
464        keep_min_value: bool = False,
465    ):
466        self.mask.fill_mask(
467            mask=mask,
468            value=value,
469            keep_max_value=keep_max_value,
470            keep_min_value=keep_min_value,
471        )
def extract_score_map(self, score_map: vkit.element.score_map.ScoreMap):
473    def extract_score_map(self, score_map: 'ScoreMap'):
474        return self.mask.extract_score_map(score_map)
def fill_score_map( self, score_map: vkit.element.score_map.ScoreMap, value: Union[vkit.element.score_map.ScoreMap, numpy.ndarray, float], keep_max_value: bool = False, keep_min_value: bool = False):
476    def fill_score_map(
477        self,
478        score_map: 'ScoreMap',
479        value: Union['ScoreMap', np.ndarray, float],
480        keep_max_value: bool = False,
481        keep_min_value: bool = False,
482    ):
483        self.mask.fill_score_map(
484            score_map=score_map,
485            value=value,
486            keep_max_value=keep_max_value,
487            keep_min_value=keep_min_value,
488        )
def extract_image(self, image: vkit.element.image.Image):
490    def extract_image(self, image: 'Image'):
491        return self.mask.extract_image(image)
def fill_image( self, image: vkit.element.image.Image, value: Union[vkit.element.image.Image, numpy.ndarray, Tuple[int, ...], int], alpha: Union[vkit.element.score_map.ScoreMap, numpy.ndarray, float] = 1.0):
493    def fill_image(
494        self,
495        image: 'Image',
496        value: Union['Image', np.ndarray, Tuple[int, ...], int],
497        alpha: Union['ScoreMap', np.ndarray, float] = 1.0,
498    ):
499        self.mask.fill_image(
500            image=image,
501            value=value,
502            alpha=alpha,
503        )
@classmethod
def remove_duplicated_xy_pairs(cls, xy_pairs: Sequence[Tuple[int, int]]):
505    @classmethod
506    def remove_duplicated_xy_pairs(cls, xy_pairs: Sequence[Tuple[int, int]]):
507        xy_pairs = tuple(map(tuple, xy_pairs))
508        unique_xy_pairs = []
509
510        idx = 0
511        while idx < len(xy_pairs):
512            unique_xy_pairs.append(xy_pairs[idx])
513
514            next_idx = idx + 1
515            while next_idx < len(xy_pairs) and xy_pairs[idx] == xy_pairs[next_idx]:
516                next_idx += 1
517            idx = next_idx
518
519        # Check head & tail.
520        if len(unique_xy_pairs) > 1 and unique_xy_pairs[0] == unique_xy_pairs[-1]:
521            unique_xy_pairs.pop()
522
523        assert len(unique_xy_pairs) >= 3
524        return unique_xy_pairs
def to_vatti_clipped_polygon(self, ratio: float, shrink: bool):
526    def to_vatti_clipped_polygon(self, ratio: float, shrink: bool):
527        assert 0.0 <= ratio <= 1.0
528        if ratio == 1.0:
529            return self, 0.0
530
531        xy_pairs = self.to_smooth_xy_pairs()
532
533        shapely_polygon = ShapelyPolygon(xy_pairs)
534        if shapely_polygon.area == 0:
535            logger.warning('shapely_polygon.area == 0, this breaks vatti_clip.')
536
537        distance: float = shapely_polygon.area * (1 - np.power(ratio, 2)) / shapely_polygon.length
538        if shrink:
539            distance *= -1
540
541        clipper = pyclipper.PyclipperOffset()  # type: ignore
542        clipper.AddPath(xy_pairs, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON)  # type: ignore
543
544        clipped_paths = clipper.Execute(distance)
545        assert clipped_paths
546        clipped_path: Sequence[Tuple[int, int]] = clipped_paths[0]
547
548        clipped_xy_pairs = self.remove_duplicated_xy_pairs(clipped_path)
549        clipped_polygon = self.from_xy_pairs(clipped_xy_pairs)
550
551        return clipped_polygon, distance
def to_shrank_polygon(self, ratio: float, no_exception: bool = True):
553    def to_shrank_polygon(
554        self,
555        ratio: float,
556        no_exception: bool = True,
557    ):
558        try:
559            shrank_polygon, _ = self.to_vatti_clipped_polygon(ratio, shrink=True)
560
561            shrank_bounding_box = shrank_polygon.bounding_box
562            vert_contains = (
563                self.bounding_box.up <= shrank_bounding_box.up
564                and shrank_bounding_box.down <= self.bounding_box.down
565            )
566            hori_contains = (
567                self.bounding_box.left <= shrank_bounding_box.left
568                and shrank_bounding_box.right <= self.bounding_box.right
569            )
570            if not (shrank_bounding_box.valid and vert_contains and hori_contains):
571                logger.warning('Invalid shrank_polygon bounding box. Fallback to NOP.')
572                return self
573
574            if 0 < shrank_polygon.area <= self.area:
575                return shrank_polygon
576            else:
577                logger.warning('Invalid shrank_polygon.area. Fallback to NOP.')
578                return self
579
580        except Exception:
581            if no_exception:
582                logger.exception('Failed to shrink. Fallback to NOP.')
583                return self
584            else:
585                raise
def to_dilated_polygon(self, ratio: float, no_exception: bool = True):
587    def to_dilated_polygon(
588        self,
589        ratio: float,
590        no_exception: bool = True,
591    ):
592        try:
593            dilated_polygon, _ = self.to_vatti_clipped_polygon(ratio, shrink=False)
594
595            dilated_bounding_box = dilated_polygon.bounding_box
596            vert_contains = (
597                dilated_bounding_box.up <= self.bounding_box.up
598                and self.bounding_box.down <= dilated_bounding_box.down
599            )
600            hori_contains = (
601                dilated_bounding_box.left <= self.bounding_box.left
602                and self.bounding_box.right <= dilated_bounding_box.right
603            )
604            if not (dilated_bounding_box.valid and vert_contains and hori_contains):
605                logger.warning('Invalid dilated_polygon bounding box. Fallback to NOP.')
606                return self
607
608            if dilated_polygon.area >= self.area:
609                return dilated_polygon
610            else:
611                logger.warning('Invalid dilated_polygon.area. Fallback to NOP.')
612                return self
613
614        except Exception:
615            if no_exception:
616                logger.exception('Failed to dilate. Fallback to NOP.')
617                return self
618            else:
619                raise
def get_line_lengths(shapely_polygon: shapely.geometry.polygon.Polygon):
624def get_line_lengths(shapely_polygon: ShapelyPolygon):
625    assert shapely_polygon.exterior is not None
626    points = tuple(shapely_polygon.exterior.coords)
627    for idx, p0 in enumerate(points):
628        p1 = points[(idx + 1) % len(points)]
629        length = math.sqrt((p0[0] - p1[0])**2 + (p0[1] - p1[1])**2)
630        yield length
def estimate_shapely_polygon_height(shapely_polygon: shapely.geometry.polygon.Polygon):
633def estimate_shapely_polygon_height(shapely_polygon: ShapelyPolygon):
634    length = max(get_line_lengths(shapely_polygon))
635    return float(shapely_polygon.area) / length
def calculate_patch_buffer_eps(shapely_polygon: shapely.geometry.polygon.Polygon):
638def calculate_patch_buffer_eps(shapely_polygon: ShapelyPolygon):
639    return estimate_shapely_polygon_height(shapely_polygon) / 10
def patch_unionized_unionized_shapely_polygon(unionized_shapely_polygon: shapely.geometry.polygon.Polygon):
642def patch_unionized_unionized_shapely_polygon(unionized_shapely_polygon: ShapelyPolygon):
643    eps = calculate_patch_buffer_eps(unionized_shapely_polygon)
644    unionized_shapely_polygon = unionized_shapely_polygon.buffer(
645        eps,
646        cap_style=CAP_STYLE.round,  # type: ignore
647        join_style=JOIN_STYLE.round,  # type: ignore
648    )
649    unionized_shapely_polygon = unionized_shapely_polygon.buffer(
650        -eps,
651        cap_style=CAP_STYLE.round,  # type: ignore
652        join_style=JOIN_STYLE.round,  # type: ignore
653    )
654    return unionized_shapely_polygon
def unionize_polygons(polygons: Iterable[vkit.element.polygon.Polygon]):
657def unionize_polygons(polygons: Iterable[Polygon]):
658    shapely_polygons = [polygon.to_smooth_shapely_polygon() for polygon in polygons]
659
660    unionized_shapely_polygons = []
661
662    # Patch unary_union.
663    unary_union_output = unary_union(shapely_polygons)
664    if not isinstance(unary_union_output, ShapelyMultiPolygon):
665        assert isinstance(unary_union_output, ShapelyPolygon)
666        unary_union_output = [unary_union_output]
667
668    for unionized_shapely_polygon in unary_union_output:  # type: ignore
669        unionized_shapely_polygon = patch_unionized_unionized_shapely_polygon(
670            unionized_shapely_polygon
671        )
672        unionized_shapely_polygons.append(unionized_shapely_polygon)
673
674    unionized_polygons = [
675        Polygon.from_xy_pairs(unionized_shapely_polygon.exterior.coords)
676        for unionized_shapely_polygon in unionized_shapely_polygons
677    ]
678
679    scatter_indices: List[int] = []
680    for shapely_polygon in shapely_polygons:
681        best_unionized_polygon_idx = None
682        best_area = 0.0
683        conflict = False
684
685        for unionized_polygon_idx, unionized_shapely_polygon in enumerate(
686            unionized_shapely_polygons
687        ):
688            if not unionized_shapely_polygon.intersects(shapely_polygon):
689                continue
690            area = unionized_shapely_polygon.intersection(shapely_polygon).area
691            if area > best_area:
692                best_area = area
693                best_unionized_polygon_idx = unionized_polygon_idx
694                conflict = False
695            elif area == best_area:
696                conflict = True
697
698        assert not conflict
699        assert best_unionized_polygon_idx is not None
700        scatter_indices.append(best_unionized_polygon_idx)
701
702    return unionized_polygons, scatter_indices
def generate_fill_by_polygons_mask( shape: Tuple[int, int], polygons: Iterable[vkit.element.polygon.Polygon], mode: vkit.element.type.ElementSetOperationMode):
705def generate_fill_by_polygons_mask(
706    shape: Tuple[int, int],
707    polygons: Iterable[Polygon],
708    mode: ElementSetOperationMode,
709):
710    if mode == ElementSetOperationMode.UNION:
711        return None
712    else:
713        return Mask.from_polygons(shape, polygons, mode)