vkit.element.mask
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, List, Iterable, TypeVar, Sequence 15from contextlib import ContextDecorator 16import logging 17 18import attrs 19import numpy as np 20import cv2 as cv 21from shapely.geometry import ( 22 Polygon as ShapelyPolygon, 23 MultiPolygon as ShapelyMultiPolygon, 24 GeometryCollection as ShapelyGeometryCollection, 25) 26from shapely.validation import make_valid as shapely_make_valid 27 28from vkit.utility import attrs_lazy_field 29from .type import Shapable, ElementSetOperationMode 30from .opt import generate_resized_shape 31 32logger = logging.getLogger(__name__) 33 34 35@attrs.define 36class MaskSetItemConfig: 37 value: Union['Mask', np.ndarray, int] = 1 38 keep_max_value: bool = False 39 keep_min_value: bool = False 40 41 42class WritableMaskContextDecorator(ContextDecorator): 43 44 def __init__(self, mask: 'Mask'): 45 super().__init__() 46 self.mask = mask 47 48 def __enter__(self): 49 if self.mask.mat.flags.c_contiguous: 50 assert not self.mask.mat.flags.writeable 51 52 try: 53 self.mask.mat.flags.writeable = True 54 except ValueError: 55 # Copy on write. 56 object.__setattr__( 57 self.mask, 58 'mat', 59 np.array(self.mask.mat), 60 ) 61 assert self.mask.mat.flags.writeable 62 63 def __exit__(self, *exc): # type: ignore 64 self.mask.mat.flags.writeable = False 65 self.mask.set_np_mask_out_of_date() 66 67 68_E = TypeVar('_E', 'Box', 'Polygon') 69 70 71@attrs.define(frozen=True, eq=False) 72class Mask(Shapable): 73 mat: np.ndarray 74 box: Optional['Box'] = None 75 76 _np_mask: Optional[np.ndarray] = attrs_lazy_field() 77 78 def __attrs_post_init__(self): 79 if self.mat.dtype != np.uint8: 80 raise RuntimeError('mat.dtype != np.uint8') 81 if self.mat.ndim != 2: 82 raise RuntimeError('ndim should == 2.') 83 84 # For the control of write. 85 self.mat.flags.writeable = False 86 87 if self.box and self.shape != self.box.shape: 88 raise RuntimeError('self.shape != box.shape.') 89 90 def lazy_post_init_np_mask(self): 91 if self._np_mask is not None: 92 return self._np_mask 93 94 object.__setattr__(self, '_np_mask', (self.mat > 0)) 95 return cast(np.ndarray, self._np_mask) 96 97 ############### 98 # Constructor # 99 ############### 100 @classmethod 101 def from_shape(cls, shape: Tuple[int, int], value: int = 0): 102 height, width = shape 103 if value == 0: 104 np_init_func = np.zeros 105 else: 106 assert value == 1 107 np_init_func = np.ones 108 mat = np_init_func((height, width), dtype=np.uint8) 109 return cls(mat=mat) 110 111 @classmethod 112 def from_shapable(cls, shapable: Shapable, value: int = 0): 113 return cls.from_shape(shape=shapable.shape, value=value) 114 115 @classmethod 116 def _unpack_shape_or_box(cls, shape_or_box: Union[Tuple[int, int], 'Box']): 117 if isinstance(shape_or_box, Box): 118 attached_box = shape_or_box 119 shape = attached_box.shape 120 else: 121 attached_box = None 122 shape = shape_or_box 123 return shape, attached_box 124 125 @classmethod 126 def _from_np_active_count( 127 cls, 128 shape: Tuple[int, int], 129 mode: ElementSetOperationMode, 130 np_active_count: np.ndarray, 131 attached_box: Optional['Box'], 132 ): 133 mask = Mask.from_shape(shape) 134 135 with mask.writable_context: 136 if mode == ElementSetOperationMode.UNION: 137 mask.mat[np_active_count > 0] = 1 138 139 elif mode == ElementSetOperationMode.DISTINCT: 140 mask.mat[np_active_count == 1] = 1 141 142 elif mode == ElementSetOperationMode.INTERSECT: 143 mask.mat[np_active_count > 1] = 1 144 145 else: 146 raise NotImplementedError() 147 148 if attached_box: 149 mask = mask.to_box_attached(attached_box) 150 151 return mask 152 153 @classmethod 154 def from_boxes( 155 cls, 156 shape_or_box: Union[Tuple[int, int], 'Box'], 157 boxes: Iterable['Box'], 158 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 159 ): 160 shape, attached_box = cls._unpack_shape_or_box(shape_or_box) 161 np_active_count = np.zeros(shape, dtype=np.int32) 162 163 for box in boxes: 164 if attached_box: 165 box = box.to_relative_box( 166 origin_y=attached_box.up, 167 origin_x=attached_box.left, 168 ) 169 np_boxed_active_count = box.extract_np_array(np_active_count) 170 np_boxed_active_count += 1 171 172 return cls._from_np_active_count(shape, mode, np_active_count, attached_box) 173 174 @classmethod 175 def from_polygons( 176 cls, 177 shape_or_box: Union[Tuple[int, int], 'Box'], 178 polygons: Iterable['Polygon'], 179 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 180 ): 181 shape, attached_box = cls._unpack_shape_or_box(shape_or_box) 182 np_active_count = np.zeros(shape, dtype=np.int32) 183 184 for polygon in polygons: 185 box = polygon.bounding_box 186 if attached_box: 187 box = box.to_relative_box( 188 origin_y=attached_box.up, 189 origin_x=attached_box.left, 190 ) 191 np_boxed_active_count = box.extract_np_array(np_active_count) 192 np_boxed_active_count[polygon.internals.np_mask] += 1 193 194 return cls._from_np_active_count(shape, mode, np_active_count, attached_box) 195 196 @classmethod 197 def from_masks( 198 cls, 199 shape_or_box: Union[Tuple[int, int], 'Box'], 200 masks: Iterable['Mask'], 201 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 202 ): 203 shape, attached_box = cls._unpack_shape_or_box(shape_or_box) 204 np_active_count = np.zeros(shape, dtype=np.int32) 205 206 for mask in masks: 207 if mask.box: 208 box = mask.box 209 if attached_box: 210 box = box.to_relative_box( 211 origin_y=attached_box.up, 212 origin_x=attached_box.left, 213 ) 214 np_boxed_active_count = box.extract_np_array(np_active_count) 215 else: 216 np_boxed_active_count = np_active_count 217 np_boxed_active_count[mask.np_mask] += 1 218 219 return cls._from_np_active_count(shape, mode, np_active_count, attached_box) 220 221 @classmethod 222 def from_score_maps( 223 cls, 224 shape_or_box: Union[Tuple[int, int], 'Box'], 225 score_maps: Iterable['ScoreMap'], 226 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 227 ): 228 shape, attached_box = cls._unpack_shape_or_box(shape_or_box) 229 np_active_count = np.zeros(shape, dtype=np.int32) 230 231 for score_map in score_maps: 232 if score_map.box: 233 box = score_map.box 234 if attached_box: 235 box = box.to_relative_box( 236 origin_y=attached_box.up, 237 origin_x=attached_box.left, 238 ) 239 np_boxed_active_count = box.extract_np_array(np_active_count) 240 else: 241 np_boxed_active_count = np_active_count 242 np_boxed_active_count[score_map.to_mask().np_mask] += 1 243 244 return cls._from_np_active_count(shape, mode, np_active_count, attached_box) 245 246 ############ 247 # Property # 248 ############ 249 @property 250 def height(self): 251 return self.mat.shape[0] 252 253 @property 254 def width(self): 255 return self.mat.shape[1] 256 257 @property 258 def equivalent_box(self): 259 return self.box or Box.from_shapable(self) 260 261 @property 262 def np_mask(self): 263 return self.lazy_post_init_np_mask() 264 265 @property 266 def writable_context(self): 267 return WritableMaskContextDecorator(self) 268 269 ############ 270 # Operator # 271 ############ 272 def copy(self): 273 return attrs.evolve(self, mat=self.mat.copy()) 274 275 def set_np_mask_out_of_date(self): 276 object.__setattr__(self, '_np_mask', None) 277 278 def assign_mat(self, mat: np.ndarray): 279 with self.writable_context: 280 object.__setattr__(self, 'mat', mat) 281 282 @classmethod 283 def unpack_element_value_pairs( 284 cls, 285 element_value_pairs: Iterable[Tuple[_E, Union['Mask', np.ndarray, int]]], 286 ): 287 elements: List[_E] = [] 288 values: List[Union[Mask, np.ndarray, int]] = [] 289 for element, value in element_value_pairs: 290 elements.append(element) 291 values.append(value) 292 return elements, values 293 294 def fill_by_box_value_pairs( 295 self, 296 box_value_pairs: Iterable[Tuple['Box', Union['Mask', np.ndarray, int]]], 297 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 298 keep_max_value: bool = False, 299 keep_min_value: bool = False, 300 skip_values_uniqueness_check: bool = False, 301 ): 302 boxes, values = self.unpack_element_value_pairs(box_value_pairs) 303 304 boxes_mask = generate_fill_by_boxes_mask(self.shape, boxes, mode) 305 if boxes_mask is None: 306 for box, value in zip(boxes, values): 307 box.fill_mask( 308 mask=self, 309 value=value, 310 keep_max_value=keep_max_value, 311 keep_min_value=keep_min_value, 312 ) 313 314 else: 315 unique = True 316 if not skip_values_uniqueness_check: 317 unique = check_elements_uniqueness(values) 318 319 if unique: 320 boxes_mask.fill_mask( 321 mask=self, 322 value=values[0], 323 keep_max_value=keep_max_value, 324 keep_min_value=keep_min_value, 325 ) 326 else: 327 for box, value in zip(boxes, values): 328 box_mask = box.extract_mask(boxes_mask).to_box_attached(box) 329 box_mask.fill_mask( 330 mask=self, 331 value=value, 332 keep_max_value=keep_max_value, 333 keep_min_value=keep_min_value, 334 ) 335 336 def fill_by_boxes( 337 self, 338 boxes: Iterable['Box'], 339 value: Union['Mask', np.ndarray, int] = 1, 340 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 341 keep_max_value: bool = False, 342 keep_min_value: bool = False, 343 ): 344 self.fill_by_box_value_pairs( 345 box_value_pairs=((box, value) for box in boxes), 346 mode=mode, 347 keep_max_value=keep_max_value, 348 keep_min_value=keep_min_value, 349 skip_values_uniqueness_check=True, 350 ) 351 352 def fill_by_polygon_value_pairs( 353 self, 354 polygon_value_pairs: Iterable[Tuple['Polygon', Union['Mask', np.ndarray, int]]], 355 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 356 keep_max_value: bool = False, 357 keep_min_value: bool = False, 358 skip_values_uniqueness_check: bool = False, 359 ): 360 polygons, values = self.unpack_element_value_pairs(polygon_value_pairs) 361 362 polygons_mask = generate_fill_by_polygons_mask(self.shape, polygons, mode) 363 if polygons_mask is None: 364 for polygon, value in zip(polygons, values): 365 polygon.fill_mask( 366 mask=self, 367 value=value, 368 keep_max_value=keep_max_value, 369 keep_min_value=keep_min_value, 370 ) 371 372 else: 373 unique = True 374 if not skip_values_uniqueness_check: 375 unique = check_elements_uniqueness(values) 376 377 if unique: 378 polygons_mask.fill_mask( 379 mask=self, 380 value=values[0], 381 keep_max_value=keep_max_value, 382 keep_min_value=keep_min_value, 383 ) 384 else: 385 for polygon, value in zip(polygons, values): 386 bounding_box = polygon.to_bounding_box() 387 polygon_mask = bounding_box.extract_mask(polygons_mask) 388 polygon_mask = polygon_mask.to_box_attached(bounding_box) 389 polygon_mask.fill_mask( 390 mask=self, 391 value=value, 392 keep_max_value=keep_max_value, 393 keep_min_value=keep_min_value, 394 ) 395 396 def fill_by_polygons( 397 self, 398 polygons: Iterable['Polygon'], 399 value: Union['Mask', np.ndarray, int] = 1, 400 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 401 keep_max_value: bool = False, 402 keep_min_value: bool = False, 403 ): 404 self.fill_by_polygon_value_pairs( 405 polygon_value_pairs=((polygon, value) for polygon in polygons), 406 mode=mode, 407 keep_max_value=keep_max_value, 408 keep_min_value=keep_min_value, 409 skip_values_uniqueness_check=True, 410 ) 411 412 def __setitem__( 413 self, 414 element: Union['Box', 'Polygon'], 415 config: Union[ 416 'Mask', 417 np.ndarray, 418 int, 419 MaskSetItemConfig, 420 ], 421 ): # yapf: disable 422 if not isinstance(config, MaskSetItemConfig): 423 value = config 424 keep_max_value = False 425 keep_min_value = False 426 else: 427 assert isinstance(config, MaskSetItemConfig) 428 value = config.value 429 keep_max_value = config.keep_max_value 430 keep_min_value = config.keep_min_value 431 432 element.fill_mask( 433 mask=self, 434 value=value, 435 keep_min_value=keep_min_value, 436 keep_max_value=keep_max_value, 437 ) 438 439 def __getitem__( 440 self, 441 element: Union['Box', 'Polygon'], 442 ): 443 return element.extract_mask(self) 444 445 def to_inverted_mask(self): 446 mat = (~self.np_mask).astype(np.uint8) 447 return attrs.evolve(self, mat=mat) 448 449 def to_shifted_mask(self, offset_y: int = 0, offset_x: int = 0): 450 assert self.box 451 shifted_box = self.box.to_shifted_box(offset_y=offset_y, offset_x=offset_x) 452 return attrs.evolve(self, box=shifted_box) 453 454 def to_resized_mask( 455 self, 456 resized_height: Optional[int] = None, 457 resized_width: Optional[int] = None, 458 cv_resize_interpolation: int = cv.INTER_CUBIC, 459 binarization_threshold: int = 0, 460 ): 461 assert not self.box 462 resized_height, resized_width = generate_resized_shape( 463 height=self.height, 464 width=self.width, 465 resized_height=resized_height, 466 resized_width=resized_width, 467 ) 468 469 # Deal with precision loss. 470 mat = self.np_mask.astype(np.uint8) * 255 471 mat = cv.resize( 472 mat, 473 (resized_width, resized_height), 474 interpolation=cv_resize_interpolation, 475 ) 476 mat = cast(np.ndarray, mat) 477 mat = (mat > binarization_threshold).astype(np.uint8) 478 479 return Mask(mat=mat) 480 481 def to_conducted_resized_mask( 482 self, 483 shapable_or_shape: Union[Shapable, Tuple[int, int]], 484 resized_height: Optional[int] = None, 485 resized_width: Optional[int] = None, 486 cv_resize_interpolation: int = cv.INTER_CUBIC, 487 binarization_threshold: int = 0, 488 ): 489 assert self.box 490 resized_box = self.box.to_conducted_resized_box( 491 shapable_or_shape=shapable_or_shape, 492 resized_height=resized_height, 493 resized_width=resized_width, 494 ) 495 resized_mask = self.to_box_detached().to_resized_mask( 496 resized_height=resized_box.height, 497 resized_width=resized_box.width, 498 cv_resize_interpolation=cv_resize_interpolation, 499 binarization_threshold=binarization_threshold, 500 ) 501 resized_mask = resized_mask.to_box_attached(resized_box) 502 return resized_mask 503 504 def to_cropped_mask( 505 self, 506 up: Optional[int] = None, 507 down: Optional[int] = None, 508 left: Optional[int] = None, 509 right: Optional[int] = None, 510 ): 511 assert not self.box 512 513 up = up or 0 514 down = down or self.height - 1 515 left = left or 0 516 right = right or self.width - 1 517 518 return attrs.evolve(self, mat=self.mat[up:down + 1, left:right + 1]) 519 520 def to_box_attached(self, box: 'Box'): 521 assert self.height == box.height 522 assert self.width == box.width 523 return attrs.evolve(self, box=box) 524 525 def to_box_detached(self): 526 assert self.box 527 return attrs.evolve(self, box=None) 528 529 def fill_np_array( 530 self, 531 mat: np.ndarray, 532 value: Union[np.ndarray, Tuple[float, ...], float], 533 alpha: Union['ScoreMap', np.ndarray, float] = 1.0, 534 keep_max_value: bool = False, 535 keep_min_value: bool = False, 536 ): 537 self.equivalent_box.fill_np_array( 538 mat=mat, 539 value=value, 540 np_mask=self.np_mask, 541 alpha=alpha, 542 keep_max_value=keep_max_value, 543 keep_min_value=keep_min_value, 544 ) 545 546 def extract_mask(self, mask: 'Mask'): 547 mask = self.equivalent_box.extract_mask(mask) 548 549 mask = mask.copy() 550 self.to_inverted_mask().fill_mask(mask, value=0) 551 return mask 552 553 def fill_mask( 554 self, 555 mask: 'Mask', 556 value: Union['Mask', np.ndarray, int] = 1, 557 keep_max_value: bool = False, 558 keep_min_value: bool = False, 559 ): 560 self.equivalent_box.fill_mask( 561 mask=mask, 562 value=value, 563 mask_mask=self, 564 keep_max_value=keep_max_value, 565 keep_min_value=keep_min_value, 566 ) 567 568 def extract_score_map(self, score_map: 'ScoreMap'): 569 score_map = self.equivalent_box.extract_score_map(score_map) 570 571 score_map = score_map.copy() 572 self.to_inverted_mask().fill_score_map(score_map, value=0.0) 573 return score_map 574 575 def fill_score_map( 576 self, 577 score_map: 'ScoreMap', 578 value: Union['ScoreMap', np.ndarray, float], 579 keep_max_value: bool = False, 580 keep_min_value: bool = False, 581 ): 582 self.equivalent_box.fill_score_map( 583 score_map=score_map, 584 value=value, 585 score_map_mask=self, 586 keep_max_value=keep_max_value, 587 keep_min_value=keep_min_value, 588 ) 589 590 def to_score_map(self): 591 mat = self.np_mask.astype(np.float32) 592 return ScoreMap(mat=mat, box=self.box) 593 594 def extract_image(self, image: 'Image'): 595 image = self.equivalent_box.extract_image(image) 596 597 image = image.copy() 598 self.to_inverted_mask().fill_image(image, value=0) 599 return image 600 601 def fill_image( 602 self, 603 image: 'Image', 604 value: Union['Image', np.ndarray, Tuple[int, ...], int], 605 alpha: Union['ScoreMap', np.ndarray, float] = 1.0, 606 ): 607 self.equivalent_box.fill_image( 608 image=image, 609 value=value, 610 image_mask=self, 611 alpha=alpha, 612 ) 613 614 def to_external_box(self): 615 np_mask = self.np_mask 616 617 np_vert_max: np.ndarray = np.amax(np_mask, axis=1) 618 np_vert_nonzero = np.nonzero(np_vert_max)[0] 619 if len(np_vert_nonzero) == 0: 620 raise RuntimeError('to_external_box: empty np_mask.') 621 622 up = int(np_vert_nonzero[0]) 623 down = int(np_vert_nonzero[-1]) 624 625 np_hori_max: np.ndarray = np.amax(np_mask, axis=0) 626 np_hori_nonzero = np.nonzero(np_hori_max)[0] 627 if len(np_hori_nonzero) == 0: 628 raise RuntimeError('to_external_box: empty np_mask.') 629 630 left = int(np_hori_nonzero[0]) 631 right = int(np_hori_nonzero[-1]) 632 633 return Box(up=up, down=down, left=left, right=right) 634 635 def to_external_polygon( 636 self, 637 cv_find_contours_method: int = cv.CHAIN_APPROX_SIMPLE, 638 ): 639 polygons = self.to_disconnected_polygons(cv_find_contours_method=cv_find_contours_method) 640 if not polygons: 641 raise RuntimeError('Cannot find any contour.') 642 elif len(polygons) > 1: 643 logger.warning( 644 'More than one polygons is detected, keep the largest one as the external polygon.' 645 ) 646 area_max = 0 647 best_polygon = None 648 for polygon in polygons: 649 if polygon.area > area_max: 650 area_max = polygon.area 651 best_polygon = polygon 652 assert best_polygon 653 return best_polygon 654 else: 655 return polygons[0] 656 657 def to_disconnected_polygons( 658 self, 659 cv_find_contours_method: int = cv.CHAIN_APPROX_SIMPLE, 660 keep_internal: bool = False, 661 ) -> Sequence['Polygon']: 662 # cv_contours: [ (N, 1, 2), ... ], M contours. 663 # cv_hierarchy: [ (prev, next, child, parent), ... ], M relations. 664 # https://stackoverflow.com/a/8830981 665 # https://docs.opencv.org/4.x/d9/d8b/tutorial_py_contours_hierarchy.html 666 # https://docs.opencv.org/4.x/d3/dc0/group__imgproc__shape.html#gadf1ad6a0b82947fa1fe3c3d497f260e0 667 cv_contours, cv_hierarchy = cv.findContours( 668 (self.np_mask.astype(np.uint8) * 255), 669 cv.RETR_TREE, 670 cv_find_contours_method, 671 ) 672 if not cv_contours: 673 return [] 674 675 assert len(cv_hierarchy) == 1 676 assert len(cv_contours) == len(cv_hierarchy[0]) 677 cv_hierarchy = cv_hierarchy[0] 678 679 polygons: List[Polygon] = [] 680 681 # Ignore logging of shapely.geos. 682 shapely_geos_logger = logging.getLogger('shapely.geos') 683 shapely_geos_logger_level = shapely_geos_logger.level 684 shapely_geos_logger.setLevel(logging.WARNING) 685 686 for cv_contour, cv_contour_hierarchy in zip(cv_contours, cv_hierarchy): 687 assert len(cv_contour_hierarchy) == 4 688 cv_contour_parent = cv_contour_hierarchy[-1] 689 if not keep_internal and cv_contour_parent >= 0: 690 continue 691 692 assert cv_contour.shape[1] == 1 693 np_points = np.squeeze(cv_contour, axis=1) 694 695 if self.box: 696 np_points[:, 0] += self.box.left 697 np_points[:, 1] += self.box.up 698 699 if np_points.shape[0] < 3: 700 # If less than 3 points, ignore. 701 continue 702 703 polygon = Polygon.from_np_array(np_points) 704 705 # Split further based on shapley library, 706 # since some contours generated by opencv is consider invalid in shapely. 707 shapely_polygon = polygon.to_shapely_polygon() 708 shapely_valid_geom = shapely_make_valid(shapely_polygon) 709 710 if isinstance(shapely_valid_geom, ShapelyPolygon): 711 polygons.append(polygon) 712 713 elif isinstance(shapely_valid_geom, (ShapelyMultiPolygon, ShapelyGeometryCollection)): 714 for shapely_geom in shapely_valid_geom.geoms: 715 if isinstance(shapely_geom, ShapelyPolygon): 716 polygons.append(Polygon.from_shapely_polygon(shapely_geom)) 717 elif isinstance(shapely_geom, ShapelyMultiPolygon): 718 # I don't know why, but this do happen. 719 for shapely_sub_geom in shapely_geom.geoms: 720 if isinstance(shapely_sub_geom, ShapelyPolygon): 721 polygons.append(Polygon.from_shapely_polygon(shapely_sub_geom)) 722 else: 723 logger.debug(f'ignore shapely_sub_geom={shapely_sub_geom}') 724 else: 725 logger.debug(f'ignore shapely_geom={shapely_geom}') 726 727 else: 728 logger.debug(f'ignore shapely_valid_geom={shapely_valid_geom}') 729 730 # Reset logging level. 731 shapely_geos_logger.setLevel(shapely_geos_logger_level) 732 733 return polygons 734 735 def to_disconnected_polygon_mask_pairs( 736 self, 737 cv_find_contours_method: int = cv.CHAIN_APPROX_SIMPLE, 738 ) -> Sequence[Tuple['Polygon', 'Mask']]: 739 pairs: List[Tuple[Polygon, Mask]] = [] 740 741 for polygon in self.to_disconnected_polygons( 742 cv_find_contours_method=cv_find_contours_method, 743 ): 744 bounding_box = polygon.to_bounding_box() 745 boxed_mask = Mask.from_shapable(bounding_box).to_box_attached(bounding_box) 746 polygon.fill_mask(boxed_mask) 747 pairs.append((polygon, boxed_mask)) 748 749 return pairs 750 751 752def generate_fill_by_masks_mask( 753 shape: Tuple[int, int], 754 masks: Iterable[Mask], 755 mode: ElementSetOperationMode, 756): 757 if mode == ElementSetOperationMode.UNION: 758 return None 759 else: 760 return Mask.from_masks(shape, masks, mode) 761 762 763# Cyclic dependency, by design. 764from .uniqueness import check_elements_uniqueness # noqa: E402 765from .image import Image # noqa: E402 766from .box import Box, generate_fill_by_boxes_mask # noqa: E402 767from .polygon import Polygon, generate_fill_by_polygons_mask # noqa: E402 768from .score_map import ScoreMap # noqa: E402
class
MaskSetItemConfig:
37class MaskSetItemConfig: 38 value: Union['Mask', np.ndarray, int] = 1 39 keep_max_value: bool = False 40 keep_min_value: bool = False
MaskSetItemConfig( value: Union[vkit.element.mask.Mask, numpy.ndarray, int] = 1, keep_max_value: bool = False, keep_min_value: bool = False)
2def __init__(self, value=attr_dict['value'].default, keep_max_value=attr_dict['keep_max_value'].default, keep_min_value=attr_dict['keep_min_value'].default): 3 self.value = value 4 self.keep_max_value = keep_max_value 5 self.keep_min_value = keep_min_value
Method generated by attrs for class MaskSetItemConfig.
class
WritableMaskContextDecorator(contextlib.ContextDecorator):
43class WritableMaskContextDecorator(ContextDecorator): 44 45 def __init__(self, mask: 'Mask'): 46 super().__init__() 47 self.mask = mask 48 49 def __enter__(self): 50 if self.mask.mat.flags.c_contiguous: 51 assert not self.mask.mat.flags.writeable 52 53 try: 54 self.mask.mat.flags.writeable = True 55 except ValueError: 56 # Copy on write. 57 object.__setattr__( 58 self.mask, 59 'mat', 60 np.array(self.mask.mat), 61 ) 62 assert self.mask.mat.flags.writeable 63 64 def __exit__(self, *exc): # type: ignore 65 self.mask.mat.flags.writeable = False 66 self.mask.set_np_mask_out_of_date()
A base class or mixin that enables context managers to work as decorators.
WritableMaskContextDecorator(mask: vkit.element.mask.Mask)
73class Mask(Shapable): 74 mat: np.ndarray 75 box: Optional['Box'] = None 76 77 _np_mask: Optional[np.ndarray] = attrs_lazy_field() 78 79 def __attrs_post_init__(self): 80 if self.mat.dtype != np.uint8: 81 raise RuntimeError('mat.dtype != np.uint8') 82 if self.mat.ndim != 2: 83 raise RuntimeError('ndim should == 2.') 84 85 # For the control of write. 86 self.mat.flags.writeable = False 87 88 if self.box and self.shape != self.box.shape: 89 raise RuntimeError('self.shape != box.shape.') 90 91 def lazy_post_init_np_mask(self): 92 if self._np_mask is not None: 93 return self._np_mask 94 95 object.__setattr__(self, '_np_mask', (self.mat > 0)) 96 return cast(np.ndarray, self._np_mask) 97 98 ############### 99 # Constructor # 100 ############### 101 @classmethod 102 def from_shape(cls, shape: Tuple[int, int], value: int = 0): 103 height, width = shape 104 if value == 0: 105 np_init_func = np.zeros 106 else: 107 assert value == 1 108 np_init_func = np.ones 109 mat = np_init_func((height, width), dtype=np.uint8) 110 return cls(mat=mat) 111 112 @classmethod 113 def from_shapable(cls, shapable: Shapable, value: int = 0): 114 return cls.from_shape(shape=shapable.shape, value=value) 115 116 @classmethod 117 def _unpack_shape_or_box(cls, shape_or_box: Union[Tuple[int, int], 'Box']): 118 if isinstance(shape_or_box, Box): 119 attached_box = shape_or_box 120 shape = attached_box.shape 121 else: 122 attached_box = None 123 shape = shape_or_box 124 return shape, attached_box 125 126 @classmethod 127 def _from_np_active_count( 128 cls, 129 shape: Tuple[int, int], 130 mode: ElementSetOperationMode, 131 np_active_count: np.ndarray, 132 attached_box: Optional['Box'], 133 ): 134 mask = Mask.from_shape(shape) 135 136 with mask.writable_context: 137 if mode == ElementSetOperationMode.UNION: 138 mask.mat[np_active_count > 0] = 1 139 140 elif mode == ElementSetOperationMode.DISTINCT: 141 mask.mat[np_active_count == 1] = 1 142 143 elif mode == ElementSetOperationMode.INTERSECT: 144 mask.mat[np_active_count > 1] = 1 145 146 else: 147 raise NotImplementedError() 148 149 if attached_box: 150 mask = mask.to_box_attached(attached_box) 151 152 return mask 153 154 @classmethod 155 def from_boxes( 156 cls, 157 shape_or_box: Union[Tuple[int, int], 'Box'], 158 boxes: Iterable['Box'], 159 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 160 ): 161 shape, attached_box = cls._unpack_shape_or_box(shape_or_box) 162 np_active_count = np.zeros(shape, dtype=np.int32) 163 164 for box in boxes: 165 if attached_box: 166 box = box.to_relative_box( 167 origin_y=attached_box.up, 168 origin_x=attached_box.left, 169 ) 170 np_boxed_active_count = box.extract_np_array(np_active_count) 171 np_boxed_active_count += 1 172 173 return cls._from_np_active_count(shape, mode, np_active_count, attached_box) 174 175 @classmethod 176 def from_polygons( 177 cls, 178 shape_or_box: Union[Tuple[int, int], 'Box'], 179 polygons: Iterable['Polygon'], 180 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 181 ): 182 shape, attached_box = cls._unpack_shape_or_box(shape_or_box) 183 np_active_count = np.zeros(shape, dtype=np.int32) 184 185 for polygon in polygons: 186 box = polygon.bounding_box 187 if attached_box: 188 box = box.to_relative_box( 189 origin_y=attached_box.up, 190 origin_x=attached_box.left, 191 ) 192 np_boxed_active_count = box.extract_np_array(np_active_count) 193 np_boxed_active_count[polygon.internals.np_mask] += 1 194 195 return cls._from_np_active_count(shape, mode, np_active_count, attached_box) 196 197 @classmethod 198 def from_masks( 199 cls, 200 shape_or_box: Union[Tuple[int, int], 'Box'], 201 masks: Iterable['Mask'], 202 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 203 ): 204 shape, attached_box = cls._unpack_shape_or_box(shape_or_box) 205 np_active_count = np.zeros(shape, dtype=np.int32) 206 207 for mask in masks: 208 if mask.box: 209 box = mask.box 210 if attached_box: 211 box = box.to_relative_box( 212 origin_y=attached_box.up, 213 origin_x=attached_box.left, 214 ) 215 np_boxed_active_count = box.extract_np_array(np_active_count) 216 else: 217 np_boxed_active_count = np_active_count 218 np_boxed_active_count[mask.np_mask] += 1 219 220 return cls._from_np_active_count(shape, mode, np_active_count, attached_box) 221 222 @classmethod 223 def from_score_maps( 224 cls, 225 shape_or_box: Union[Tuple[int, int], 'Box'], 226 score_maps: Iterable['ScoreMap'], 227 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 228 ): 229 shape, attached_box = cls._unpack_shape_or_box(shape_or_box) 230 np_active_count = np.zeros(shape, dtype=np.int32) 231 232 for score_map in score_maps: 233 if score_map.box: 234 box = score_map.box 235 if attached_box: 236 box = box.to_relative_box( 237 origin_y=attached_box.up, 238 origin_x=attached_box.left, 239 ) 240 np_boxed_active_count = box.extract_np_array(np_active_count) 241 else: 242 np_boxed_active_count = np_active_count 243 np_boxed_active_count[score_map.to_mask().np_mask] += 1 244 245 return cls._from_np_active_count(shape, mode, np_active_count, attached_box) 246 247 ############ 248 # Property # 249 ############ 250 @property 251 def height(self): 252 return self.mat.shape[0] 253 254 @property 255 def width(self): 256 return self.mat.shape[1] 257 258 @property 259 def equivalent_box(self): 260 return self.box or Box.from_shapable(self) 261 262 @property 263 def np_mask(self): 264 return self.lazy_post_init_np_mask() 265 266 @property 267 def writable_context(self): 268 return WritableMaskContextDecorator(self) 269 270 ############ 271 # Operator # 272 ############ 273 def copy(self): 274 return attrs.evolve(self, mat=self.mat.copy()) 275 276 def set_np_mask_out_of_date(self): 277 object.__setattr__(self, '_np_mask', None) 278 279 def assign_mat(self, mat: np.ndarray): 280 with self.writable_context: 281 object.__setattr__(self, 'mat', mat) 282 283 @classmethod 284 def unpack_element_value_pairs( 285 cls, 286 element_value_pairs: Iterable[Tuple[_E, Union['Mask', np.ndarray, int]]], 287 ): 288 elements: List[_E] = [] 289 values: List[Union[Mask, np.ndarray, int]] = [] 290 for element, value in element_value_pairs: 291 elements.append(element) 292 values.append(value) 293 return elements, values 294 295 def fill_by_box_value_pairs( 296 self, 297 box_value_pairs: Iterable[Tuple['Box', Union['Mask', np.ndarray, int]]], 298 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 299 keep_max_value: bool = False, 300 keep_min_value: bool = False, 301 skip_values_uniqueness_check: bool = False, 302 ): 303 boxes, values = self.unpack_element_value_pairs(box_value_pairs) 304 305 boxes_mask = generate_fill_by_boxes_mask(self.shape, boxes, mode) 306 if boxes_mask is None: 307 for box, value in zip(boxes, values): 308 box.fill_mask( 309 mask=self, 310 value=value, 311 keep_max_value=keep_max_value, 312 keep_min_value=keep_min_value, 313 ) 314 315 else: 316 unique = True 317 if not skip_values_uniqueness_check: 318 unique = check_elements_uniqueness(values) 319 320 if unique: 321 boxes_mask.fill_mask( 322 mask=self, 323 value=values[0], 324 keep_max_value=keep_max_value, 325 keep_min_value=keep_min_value, 326 ) 327 else: 328 for box, value in zip(boxes, values): 329 box_mask = box.extract_mask(boxes_mask).to_box_attached(box) 330 box_mask.fill_mask( 331 mask=self, 332 value=value, 333 keep_max_value=keep_max_value, 334 keep_min_value=keep_min_value, 335 ) 336 337 def fill_by_boxes( 338 self, 339 boxes: Iterable['Box'], 340 value: Union['Mask', np.ndarray, int] = 1, 341 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 342 keep_max_value: bool = False, 343 keep_min_value: bool = False, 344 ): 345 self.fill_by_box_value_pairs( 346 box_value_pairs=((box, value) for box in boxes), 347 mode=mode, 348 keep_max_value=keep_max_value, 349 keep_min_value=keep_min_value, 350 skip_values_uniqueness_check=True, 351 ) 352 353 def fill_by_polygon_value_pairs( 354 self, 355 polygon_value_pairs: Iterable[Tuple['Polygon', Union['Mask', np.ndarray, int]]], 356 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 357 keep_max_value: bool = False, 358 keep_min_value: bool = False, 359 skip_values_uniqueness_check: bool = False, 360 ): 361 polygons, values = self.unpack_element_value_pairs(polygon_value_pairs) 362 363 polygons_mask = generate_fill_by_polygons_mask(self.shape, polygons, mode) 364 if polygons_mask is None: 365 for polygon, value in zip(polygons, values): 366 polygon.fill_mask( 367 mask=self, 368 value=value, 369 keep_max_value=keep_max_value, 370 keep_min_value=keep_min_value, 371 ) 372 373 else: 374 unique = True 375 if not skip_values_uniqueness_check: 376 unique = check_elements_uniqueness(values) 377 378 if unique: 379 polygons_mask.fill_mask( 380 mask=self, 381 value=values[0], 382 keep_max_value=keep_max_value, 383 keep_min_value=keep_min_value, 384 ) 385 else: 386 for polygon, value in zip(polygons, values): 387 bounding_box = polygon.to_bounding_box() 388 polygon_mask = bounding_box.extract_mask(polygons_mask) 389 polygon_mask = polygon_mask.to_box_attached(bounding_box) 390 polygon_mask.fill_mask( 391 mask=self, 392 value=value, 393 keep_max_value=keep_max_value, 394 keep_min_value=keep_min_value, 395 ) 396 397 def fill_by_polygons( 398 self, 399 polygons: Iterable['Polygon'], 400 value: Union['Mask', np.ndarray, int] = 1, 401 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 402 keep_max_value: bool = False, 403 keep_min_value: bool = False, 404 ): 405 self.fill_by_polygon_value_pairs( 406 polygon_value_pairs=((polygon, value) for polygon in polygons), 407 mode=mode, 408 keep_max_value=keep_max_value, 409 keep_min_value=keep_min_value, 410 skip_values_uniqueness_check=True, 411 ) 412 413 def __setitem__( 414 self, 415 element: Union['Box', 'Polygon'], 416 config: Union[ 417 'Mask', 418 np.ndarray, 419 int, 420 MaskSetItemConfig, 421 ], 422 ): # yapf: disable 423 if not isinstance(config, MaskSetItemConfig): 424 value = config 425 keep_max_value = False 426 keep_min_value = False 427 else: 428 assert isinstance(config, MaskSetItemConfig) 429 value = config.value 430 keep_max_value = config.keep_max_value 431 keep_min_value = config.keep_min_value 432 433 element.fill_mask( 434 mask=self, 435 value=value, 436 keep_min_value=keep_min_value, 437 keep_max_value=keep_max_value, 438 ) 439 440 def __getitem__( 441 self, 442 element: Union['Box', 'Polygon'], 443 ): 444 return element.extract_mask(self) 445 446 def to_inverted_mask(self): 447 mat = (~self.np_mask).astype(np.uint8) 448 return attrs.evolve(self, mat=mat) 449 450 def to_shifted_mask(self, offset_y: int = 0, offset_x: int = 0): 451 assert self.box 452 shifted_box = self.box.to_shifted_box(offset_y=offset_y, offset_x=offset_x) 453 return attrs.evolve(self, box=shifted_box) 454 455 def to_resized_mask( 456 self, 457 resized_height: Optional[int] = None, 458 resized_width: Optional[int] = None, 459 cv_resize_interpolation: int = cv.INTER_CUBIC, 460 binarization_threshold: int = 0, 461 ): 462 assert not self.box 463 resized_height, resized_width = generate_resized_shape( 464 height=self.height, 465 width=self.width, 466 resized_height=resized_height, 467 resized_width=resized_width, 468 ) 469 470 # Deal with precision loss. 471 mat = self.np_mask.astype(np.uint8) * 255 472 mat = cv.resize( 473 mat, 474 (resized_width, resized_height), 475 interpolation=cv_resize_interpolation, 476 ) 477 mat = cast(np.ndarray, mat) 478 mat = (mat > binarization_threshold).astype(np.uint8) 479 480 return Mask(mat=mat) 481 482 def to_conducted_resized_mask( 483 self, 484 shapable_or_shape: Union[Shapable, Tuple[int, int]], 485 resized_height: Optional[int] = None, 486 resized_width: Optional[int] = None, 487 cv_resize_interpolation: int = cv.INTER_CUBIC, 488 binarization_threshold: int = 0, 489 ): 490 assert self.box 491 resized_box = self.box.to_conducted_resized_box( 492 shapable_or_shape=shapable_or_shape, 493 resized_height=resized_height, 494 resized_width=resized_width, 495 ) 496 resized_mask = self.to_box_detached().to_resized_mask( 497 resized_height=resized_box.height, 498 resized_width=resized_box.width, 499 cv_resize_interpolation=cv_resize_interpolation, 500 binarization_threshold=binarization_threshold, 501 ) 502 resized_mask = resized_mask.to_box_attached(resized_box) 503 return resized_mask 504 505 def to_cropped_mask( 506 self, 507 up: Optional[int] = None, 508 down: Optional[int] = None, 509 left: Optional[int] = None, 510 right: Optional[int] = None, 511 ): 512 assert not self.box 513 514 up = up or 0 515 down = down or self.height - 1 516 left = left or 0 517 right = right or self.width - 1 518 519 return attrs.evolve(self, mat=self.mat[up:down + 1, left:right + 1]) 520 521 def to_box_attached(self, box: 'Box'): 522 assert self.height == box.height 523 assert self.width == box.width 524 return attrs.evolve(self, box=box) 525 526 def to_box_detached(self): 527 assert self.box 528 return attrs.evolve(self, box=None) 529 530 def fill_np_array( 531 self, 532 mat: np.ndarray, 533 value: Union[np.ndarray, Tuple[float, ...], float], 534 alpha: Union['ScoreMap', np.ndarray, float] = 1.0, 535 keep_max_value: bool = False, 536 keep_min_value: bool = False, 537 ): 538 self.equivalent_box.fill_np_array( 539 mat=mat, 540 value=value, 541 np_mask=self.np_mask, 542 alpha=alpha, 543 keep_max_value=keep_max_value, 544 keep_min_value=keep_min_value, 545 ) 546 547 def extract_mask(self, mask: 'Mask'): 548 mask = self.equivalent_box.extract_mask(mask) 549 550 mask = mask.copy() 551 self.to_inverted_mask().fill_mask(mask, value=0) 552 return mask 553 554 def fill_mask( 555 self, 556 mask: 'Mask', 557 value: Union['Mask', np.ndarray, int] = 1, 558 keep_max_value: bool = False, 559 keep_min_value: bool = False, 560 ): 561 self.equivalent_box.fill_mask( 562 mask=mask, 563 value=value, 564 mask_mask=self, 565 keep_max_value=keep_max_value, 566 keep_min_value=keep_min_value, 567 ) 568 569 def extract_score_map(self, score_map: 'ScoreMap'): 570 score_map = self.equivalent_box.extract_score_map(score_map) 571 572 score_map = score_map.copy() 573 self.to_inverted_mask().fill_score_map(score_map, value=0.0) 574 return score_map 575 576 def fill_score_map( 577 self, 578 score_map: 'ScoreMap', 579 value: Union['ScoreMap', np.ndarray, float], 580 keep_max_value: bool = False, 581 keep_min_value: bool = False, 582 ): 583 self.equivalent_box.fill_score_map( 584 score_map=score_map, 585 value=value, 586 score_map_mask=self, 587 keep_max_value=keep_max_value, 588 keep_min_value=keep_min_value, 589 ) 590 591 def to_score_map(self): 592 mat = self.np_mask.astype(np.float32) 593 return ScoreMap(mat=mat, box=self.box) 594 595 def extract_image(self, image: 'Image'): 596 image = self.equivalent_box.extract_image(image) 597 598 image = image.copy() 599 self.to_inverted_mask().fill_image(image, value=0) 600 return image 601 602 def fill_image( 603 self, 604 image: 'Image', 605 value: Union['Image', np.ndarray, Tuple[int, ...], int], 606 alpha: Union['ScoreMap', np.ndarray, float] = 1.0, 607 ): 608 self.equivalent_box.fill_image( 609 image=image, 610 value=value, 611 image_mask=self, 612 alpha=alpha, 613 ) 614 615 def to_external_box(self): 616 np_mask = self.np_mask 617 618 np_vert_max: np.ndarray = np.amax(np_mask, axis=1) 619 np_vert_nonzero = np.nonzero(np_vert_max)[0] 620 if len(np_vert_nonzero) == 0: 621 raise RuntimeError('to_external_box: empty np_mask.') 622 623 up = int(np_vert_nonzero[0]) 624 down = int(np_vert_nonzero[-1]) 625 626 np_hori_max: np.ndarray = np.amax(np_mask, axis=0) 627 np_hori_nonzero = np.nonzero(np_hori_max)[0] 628 if len(np_hori_nonzero) == 0: 629 raise RuntimeError('to_external_box: empty np_mask.') 630 631 left = int(np_hori_nonzero[0]) 632 right = int(np_hori_nonzero[-1]) 633 634 return Box(up=up, down=down, left=left, right=right) 635 636 def to_external_polygon( 637 self, 638 cv_find_contours_method: int = cv.CHAIN_APPROX_SIMPLE, 639 ): 640 polygons = self.to_disconnected_polygons(cv_find_contours_method=cv_find_contours_method) 641 if not polygons: 642 raise RuntimeError('Cannot find any contour.') 643 elif len(polygons) > 1: 644 logger.warning( 645 'More than one polygons is detected, keep the largest one as the external polygon.' 646 ) 647 area_max = 0 648 best_polygon = None 649 for polygon in polygons: 650 if polygon.area > area_max: 651 area_max = polygon.area 652 best_polygon = polygon 653 assert best_polygon 654 return best_polygon 655 else: 656 return polygons[0] 657 658 def to_disconnected_polygons( 659 self, 660 cv_find_contours_method: int = cv.CHAIN_APPROX_SIMPLE, 661 keep_internal: bool = False, 662 ) -> Sequence['Polygon']: 663 # cv_contours: [ (N, 1, 2), ... ], M contours. 664 # cv_hierarchy: [ (prev, next, child, parent), ... ], M relations. 665 # https://stackoverflow.com/a/8830981 666 # https://docs.opencv.org/4.x/d9/d8b/tutorial_py_contours_hierarchy.html 667 # https://docs.opencv.org/4.x/d3/dc0/group__imgproc__shape.html#gadf1ad6a0b82947fa1fe3c3d497f260e0 668 cv_contours, cv_hierarchy = cv.findContours( 669 (self.np_mask.astype(np.uint8) * 255), 670 cv.RETR_TREE, 671 cv_find_contours_method, 672 ) 673 if not cv_contours: 674 return [] 675 676 assert len(cv_hierarchy) == 1 677 assert len(cv_contours) == len(cv_hierarchy[0]) 678 cv_hierarchy = cv_hierarchy[0] 679 680 polygons: List[Polygon] = [] 681 682 # Ignore logging of shapely.geos. 683 shapely_geos_logger = logging.getLogger('shapely.geos') 684 shapely_geos_logger_level = shapely_geos_logger.level 685 shapely_geos_logger.setLevel(logging.WARNING) 686 687 for cv_contour, cv_contour_hierarchy in zip(cv_contours, cv_hierarchy): 688 assert len(cv_contour_hierarchy) == 4 689 cv_contour_parent = cv_contour_hierarchy[-1] 690 if not keep_internal and cv_contour_parent >= 0: 691 continue 692 693 assert cv_contour.shape[1] == 1 694 np_points = np.squeeze(cv_contour, axis=1) 695 696 if self.box: 697 np_points[:, 0] += self.box.left 698 np_points[:, 1] += self.box.up 699 700 if np_points.shape[0] < 3: 701 # If less than 3 points, ignore. 702 continue 703 704 polygon = Polygon.from_np_array(np_points) 705 706 # Split further based on shapley library, 707 # since some contours generated by opencv is consider invalid in shapely. 708 shapely_polygon = polygon.to_shapely_polygon() 709 shapely_valid_geom = shapely_make_valid(shapely_polygon) 710 711 if isinstance(shapely_valid_geom, ShapelyPolygon): 712 polygons.append(polygon) 713 714 elif isinstance(shapely_valid_geom, (ShapelyMultiPolygon, ShapelyGeometryCollection)): 715 for shapely_geom in shapely_valid_geom.geoms: 716 if isinstance(shapely_geom, ShapelyPolygon): 717 polygons.append(Polygon.from_shapely_polygon(shapely_geom)) 718 elif isinstance(shapely_geom, ShapelyMultiPolygon): 719 # I don't know why, but this do happen. 720 for shapely_sub_geom in shapely_geom.geoms: 721 if isinstance(shapely_sub_geom, ShapelyPolygon): 722 polygons.append(Polygon.from_shapely_polygon(shapely_sub_geom)) 723 else: 724 logger.debug(f'ignore shapely_sub_geom={shapely_sub_geom}') 725 else: 726 logger.debug(f'ignore shapely_geom={shapely_geom}') 727 728 else: 729 logger.debug(f'ignore shapely_valid_geom={shapely_valid_geom}') 730 731 # Reset logging level. 732 shapely_geos_logger.setLevel(shapely_geos_logger_level) 733 734 return polygons 735 736 def to_disconnected_polygon_mask_pairs( 737 self, 738 cv_find_contours_method: int = cv.CHAIN_APPROX_SIMPLE, 739 ) -> Sequence[Tuple['Polygon', 'Mask']]: 740 pairs: List[Tuple[Polygon, Mask]] = [] 741 742 for polygon in self.to_disconnected_polygons( 743 cv_find_contours_method=cv_find_contours_method, 744 ): 745 bounding_box = polygon.to_bounding_box() 746 boxed_mask = Mask.from_shapable(bounding_box).to_box_attached(bounding_box) 747 polygon.fill_mask(boxed_mask) 748 pairs.append((polygon, boxed_mask)) 749 750 return pairs
Mask( mat: numpy.ndarray, box: Union[vkit.element.box.Box, NoneType] = None)
2def __init__(self, mat, box=attr_dict['box'].default): 3 _setattr = _cached_setattr_get(self) 4 _setattr('mat', mat) 5 _setattr('box', box) 6 _setattr('_np_mask', attr_dict['_np_mask'].default) 7 self.__attrs_post_init__()
Method generated by attrs for class Mask.
@classmethod
def
from_boxes( cls, shape_or_box: Union[Tuple[int, int], vkit.element.box.Box], boxes: collections.abc.Iterable[vkit.element.box.Box], mode: vkit.element.type.ElementSetOperationMode = <ElementSetOperationMode.UNION: 'union'>):
154 @classmethod 155 def from_boxes( 156 cls, 157 shape_or_box: Union[Tuple[int, int], 'Box'], 158 boxes: Iterable['Box'], 159 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 160 ): 161 shape, attached_box = cls._unpack_shape_or_box(shape_or_box) 162 np_active_count = np.zeros(shape, dtype=np.int32) 163 164 for box in boxes: 165 if attached_box: 166 box = box.to_relative_box( 167 origin_y=attached_box.up, 168 origin_x=attached_box.left, 169 ) 170 np_boxed_active_count = box.extract_np_array(np_active_count) 171 np_boxed_active_count += 1 172 173 return cls._from_np_active_count(shape, mode, np_active_count, attached_box)
@classmethod
def
from_polygons( cls, shape_or_box: Union[Tuple[int, int], vkit.element.box.Box], polygons: collections.abc.Iterable[vkit.element.polygon.Polygon], mode: vkit.element.type.ElementSetOperationMode = <ElementSetOperationMode.UNION: 'union'>):
175 @classmethod 176 def from_polygons( 177 cls, 178 shape_or_box: Union[Tuple[int, int], 'Box'], 179 polygons: Iterable['Polygon'], 180 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 181 ): 182 shape, attached_box = cls._unpack_shape_or_box(shape_or_box) 183 np_active_count = np.zeros(shape, dtype=np.int32) 184 185 for polygon in polygons: 186 box = polygon.bounding_box 187 if attached_box: 188 box = box.to_relative_box( 189 origin_y=attached_box.up, 190 origin_x=attached_box.left, 191 ) 192 np_boxed_active_count = box.extract_np_array(np_active_count) 193 np_boxed_active_count[polygon.internals.np_mask] += 1 194 195 return cls._from_np_active_count(shape, mode, np_active_count, attached_box)
@classmethod
def
from_masks( cls, shape_or_box: Union[Tuple[int, int], vkit.element.box.Box], masks: collections.abc.Iterable[vkit.element.mask.Mask], mode: vkit.element.type.ElementSetOperationMode = <ElementSetOperationMode.UNION: 'union'>):
197 @classmethod 198 def from_masks( 199 cls, 200 shape_or_box: Union[Tuple[int, int], 'Box'], 201 masks: Iterable['Mask'], 202 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 203 ): 204 shape, attached_box = cls._unpack_shape_or_box(shape_or_box) 205 np_active_count = np.zeros(shape, dtype=np.int32) 206 207 for mask in masks: 208 if mask.box: 209 box = mask.box 210 if attached_box: 211 box = box.to_relative_box( 212 origin_y=attached_box.up, 213 origin_x=attached_box.left, 214 ) 215 np_boxed_active_count = box.extract_np_array(np_active_count) 216 else: 217 np_boxed_active_count = np_active_count 218 np_boxed_active_count[mask.np_mask] += 1 219 220 return cls._from_np_active_count(shape, mode, np_active_count, attached_box)
@classmethod
def
from_score_maps( cls, shape_or_box: Union[Tuple[int, int], vkit.element.box.Box], score_maps: collections.abc.Iterable[vkit.element.score_map.ScoreMap], mode: vkit.element.type.ElementSetOperationMode = <ElementSetOperationMode.UNION: 'union'>):
222 @classmethod 223 def from_score_maps( 224 cls, 225 shape_or_box: Union[Tuple[int, int], 'Box'], 226 score_maps: Iterable['ScoreMap'], 227 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 228 ): 229 shape, attached_box = cls._unpack_shape_or_box(shape_or_box) 230 np_active_count = np.zeros(shape, dtype=np.int32) 231 232 for score_map in score_maps: 233 if score_map.box: 234 box = score_map.box 235 if attached_box: 236 box = box.to_relative_box( 237 origin_y=attached_box.up, 238 origin_x=attached_box.left, 239 ) 240 np_boxed_active_count = box.extract_np_array(np_active_count) 241 else: 242 np_boxed_active_count = np_active_count 243 np_boxed_active_count[score_map.to_mask().np_mask] += 1 244 245 return cls._from_np_active_count(shape, mode, np_active_count, attached_box)
@classmethod
def
unpack_element_value_pairs( cls, element_value_pairs: collections.abc.Iterable[tuple[~_E, typing.Union[vkit.element.mask.Mask, numpy.ndarray, int]]]):
283 @classmethod 284 def unpack_element_value_pairs( 285 cls, 286 element_value_pairs: Iterable[Tuple[_E, Union['Mask', np.ndarray, int]]], 287 ): 288 elements: List[_E] = [] 289 values: List[Union[Mask, np.ndarray, int]] = [] 290 for element, value in element_value_pairs: 291 elements.append(element) 292 values.append(value) 293 return elements, values
def
fill_by_box_value_pairs( self, box_value_pairs: collections.abc.Iterable[tuple[vkit.element.box.Box, typing.Union[vkit.element.mask.Mask, numpy.ndarray, int]]], mode: vkit.element.type.ElementSetOperationMode = <ElementSetOperationMode.UNION: 'union'>, keep_max_value: bool = False, keep_min_value: bool = False, skip_values_uniqueness_check: bool = False):
295 def fill_by_box_value_pairs( 296 self, 297 box_value_pairs: Iterable[Tuple['Box', Union['Mask', np.ndarray, int]]], 298 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 299 keep_max_value: bool = False, 300 keep_min_value: bool = False, 301 skip_values_uniqueness_check: bool = False, 302 ): 303 boxes, values = self.unpack_element_value_pairs(box_value_pairs) 304 305 boxes_mask = generate_fill_by_boxes_mask(self.shape, boxes, mode) 306 if boxes_mask is None: 307 for box, value in zip(boxes, values): 308 box.fill_mask( 309 mask=self, 310 value=value, 311 keep_max_value=keep_max_value, 312 keep_min_value=keep_min_value, 313 ) 314 315 else: 316 unique = True 317 if not skip_values_uniqueness_check: 318 unique = check_elements_uniqueness(values) 319 320 if unique: 321 boxes_mask.fill_mask( 322 mask=self, 323 value=values[0], 324 keep_max_value=keep_max_value, 325 keep_min_value=keep_min_value, 326 ) 327 else: 328 for box, value in zip(boxes, values): 329 box_mask = box.extract_mask(boxes_mask).to_box_attached(box) 330 box_mask.fill_mask( 331 mask=self, 332 value=value, 333 keep_max_value=keep_max_value, 334 keep_min_value=keep_min_value, 335 )
def
fill_by_boxes( self, boxes: collections.abc.Iterable[vkit.element.box.Box], value: Union[vkit.element.mask.Mask, numpy.ndarray, int] = 1, mode: vkit.element.type.ElementSetOperationMode = <ElementSetOperationMode.UNION: 'union'>, keep_max_value: bool = False, keep_min_value: bool = False):
337 def fill_by_boxes( 338 self, 339 boxes: Iterable['Box'], 340 value: Union['Mask', np.ndarray, int] = 1, 341 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 342 keep_max_value: bool = False, 343 keep_min_value: bool = False, 344 ): 345 self.fill_by_box_value_pairs( 346 box_value_pairs=((box, value) for box in boxes), 347 mode=mode, 348 keep_max_value=keep_max_value, 349 keep_min_value=keep_min_value, 350 skip_values_uniqueness_check=True, 351 )
def
fill_by_polygon_value_pairs( self, polygon_value_pairs: collections.abc.Iterable[tuple[vkit.element.polygon.Polygon, typing.Union[vkit.element.mask.Mask, numpy.ndarray, int]]], mode: vkit.element.type.ElementSetOperationMode = <ElementSetOperationMode.UNION: 'union'>, keep_max_value: bool = False, keep_min_value: bool = False, skip_values_uniqueness_check: bool = False):
353 def fill_by_polygon_value_pairs( 354 self, 355 polygon_value_pairs: Iterable[Tuple['Polygon', Union['Mask', np.ndarray, int]]], 356 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 357 keep_max_value: bool = False, 358 keep_min_value: bool = False, 359 skip_values_uniqueness_check: bool = False, 360 ): 361 polygons, values = self.unpack_element_value_pairs(polygon_value_pairs) 362 363 polygons_mask = generate_fill_by_polygons_mask(self.shape, polygons, mode) 364 if polygons_mask is None: 365 for polygon, value in zip(polygons, values): 366 polygon.fill_mask( 367 mask=self, 368 value=value, 369 keep_max_value=keep_max_value, 370 keep_min_value=keep_min_value, 371 ) 372 373 else: 374 unique = True 375 if not skip_values_uniqueness_check: 376 unique = check_elements_uniqueness(values) 377 378 if unique: 379 polygons_mask.fill_mask( 380 mask=self, 381 value=values[0], 382 keep_max_value=keep_max_value, 383 keep_min_value=keep_min_value, 384 ) 385 else: 386 for polygon, value in zip(polygons, values): 387 bounding_box = polygon.to_bounding_box() 388 polygon_mask = bounding_box.extract_mask(polygons_mask) 389 polygon_mask = polygon_mask.to_box_attached(bounding_box) 390 polygon_mask.fill_mask( 391 mask=self, 392 value=value, 393 keep_max_value=keep_max_value, 394 keep_min_value=keep_min_value, 395 )
def
fill_by_polygons( self, polygons: collections.abc.Iterable[vkit.element.polygon.Polygon], value: Union[vkit.element.mask.Mask, numpy.ndarray, int] = 1, mode: vkit.element.type.ElementSetOperationMode = <ElementSetOperationMode.UNION: 'union'>, keep_max_value: bool = False, keep_min_value: bool = False):
397 def fill_by_polygons( 398 self, 399 polygons: Iterable['Polygon'], 400 value: Union['Mask', np.ndarray, int] = 1, 401 mode: ElementSetOperationMode = ElementSetOperationMode.UNION, 402 keep_max_value: bool = False, 403 keep_min_value: bool = False, 404 ): 405 self.fill_by_polygon_value_pairs( 406 polygon_value_pairs=((polygon, value) for polygon in polygons), 407 mode=mode, 408 keep_max_value=keep_max_value, 409 keep_min_value=keep_min_value, 410 skip_values_uniqueness_check=True, 411 )
def
to_resized_mask( self, resized_height: Union[int, NoneType] = None, resized_width: Union[int, NoneType] = None, cv_resize_interpolation: int = 2, binarization_threshold: int = 0):
455 def to_resized_mask( 456 self, 457 resized_height: Optional[int] = None, 458 resized_width: Optional[int] = None, 459 cv_resize_interpolation: int = cv.INTER_CUBIC, 460 binarization_threshold: int = 0, 461 ): 462 assert not self.box 463 resized_height, resized_width = generate_resized_shape( 464 height=self.height, 465 width=self.width, 466 resized_height=resized_height, 467 resized_width=resized_width, 468 ) 469 470 # Deal with precision loss. 471 mat = self.np_mask.astype(np.uint8) * 255 472 mat = cv.resize( 473 mat, 474 (resized_width, resized_height), 475 interpolation=cv_resize_interpolation, 476 ) 477 mat = cast(np.ndarray, mat) 478 mat = (mat > binarization_threshold).astype(np.uint8) 479 480 return Mask(mat=mat)
def
to_conducted_resized_mask( self, shapable_or_shape: Union[vkit.element.type.Shapable, Tuple[int, int]], resized_height: Union[int, NoneType] = None, resized_width: Union[int, NoneType] = None, cv_resize_interpolation: int = 2, binarization_threshold: int = 0):
482 def to_conducted_resized_mask( 483 self, 484 shapable_or_shape: Union[Shapable, Tuple[int, int]], 485 resized_height: Optional[int] = None, 486 resized_width: Optional[int] = None, 487 cv_resize_interpolation: int = cv.INTER_CUBIC, 488 binarization_threshold: int = 0, 489 ): 490 assert self.box 491 resized_box = self.box.to_conducted_resized_box( 492 shapable_or_shape=shapable_or_shape, 493 resized_height=resized_height, 494 resized_width=resized_width, 495 ) 496 resized_mask = self.to_box_detached().to_resized_mask( 497 resized_height=resized_box.height, 498 resized_width=resized_box.width, 499 cv_resize_interpolation=cv_resize_interpolation, 500 binarization_threshold=binarization_threshold, 501 ) 502 resized_mask = resized_mask.to_box_attached(resized_box) 503 return resized_mask
def
to_cropped_mask( self, up: Union[int, NoneType] = None, down: Union[int, NoneType] = None, left: Union[int, NoneType] = None, right: Union[int, NoneType] = None):
505 def to_cropped_mask( 506 self, 507 up: Optional[int] = None, 508 down: Optional[int] = None, 509 left: Optional[int] = None, 510 right: Optional[int] = None, 511 ): 512 assert not self.box 513 514 up = up or 0 515 down = down or self.height - 1 516 left = left or 0 517 right = right or self.width - 1 518 519 return attrs.evolve(self, mat=self.mat[up:down + 1, left:right + 1])
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):
530 def fill_np_array( 531 self, 532 mat: np.ndarray, 533 value: Union[np.ndarray, Tuple[float, ...], float], 534 alpha: Union['ScoreMap', np.ndarray, float] = 1.0, 535 keep_max_value: bool = False, 536 keep_min_value: bool = False, 537 ): 538 self.equivalent_box.fill_np_array( 539 mat=mat, 540 value=value, 541 np_mask=self.np_mask, 542 alpha=alpha, 543 keep_max_value=keep_max_value, 544 keep_min_value=keep_min_value, 545 )
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):
554 def fill_mask( 555 self, 556 mask: 'Mask', 557 value: Union['Mask', np.ndarray, int] = 1, 558 keep_max_value: bool = False, 559 keep_min_value: bool = False, 560 ): 561 self.equivalent_box.fill_mask( 562 mask=mask, 563 value=value, 564 mask_mask=self, 565 keep_max_value=keep_max_value, 566 keep_min_value=keep_min_value, 567 )
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):
576 def fill_score_map( 577 self, 578 score_map: 'ScoreMap', 579 value: Union['ScoreMap', np.ndarray, float], 580 keep_max_value: bool = False, 581 keep_min_value: bool = False, 582 ): 583 self.equivalent_box.fill_score_map( 584 score_map=score_map, 585 value=value, 586 score_map_mask=self, 587 keep_max_value=keep_max_value, 588 keep_min_value=keep_min_value, 589 )
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):
def
to_external_box(self):
615 def to_external_box(self): 616 np_mask = self.np_mask 617 618 np_vert_max: np.ndarray = np.amax(np_mask, axis=1) 619 np_vert_nonzero = np.nonzero(np_vert_max)[0] 620 if len(np_vert_nonzero) == 0: 621 raise RuntimeError('to_external_box: empty np_mask.') 622 623 up = int(np_vert_nonzero[0]) 624 down = int(np_vert_nonzero[-1]) 625 626 np_hori_max: np.ndarray = np.amax(np_mask, axis=0) 627 np_hori_nonzero = np.nonzero(np_hori_max)[0] 628 if len(np_hori_nonzero) == 0: 629 raise RuntimeError('to_external_box: empty np_mask.') 630 631 left = int(np_hori_nonzero[0]) 632 right = int(np_hori_nonzero[-1]) 633 634 return Box(up=up, down=down, left=left, right=right)
def
to_external_polygon(self, cv_find_contours_method: int = 2):
636 def to_external_polygon( 637 self, 638 cv_find_contours_method: int = cv.CHAIN_APPROX_SIMPLE, 639 ): 640 polygons = self.to_disconnected_polygons(cv_find_contours_method=cv_find_contours_method) 641 if not polygons: 642 raise RuntimeError('Cannot find any contour.') 643 elif len(polygons) > 1: 644 logger.warning( 645 'More than one polygons is detected, keep the largest one as the external polygon.' 646 ) 647 area_max = 0 648 best_polygon = None 649 for polygon in polygons: 650 if polygon.area > area_max: 651 area_max = polygon.area 652 best_polygon = polygon 653 assert best_polygon 654 return best_polygon 655 else: 656 return polygons[0]
def
to_disconnected_polygons( self, cv_find_contours_method: int = 2, keep_internal: bool = False) -> collections.abc.Sequence[vkit.element.polygon.Polygon]:
658 def to_disconnected_polygons( 659 self, 660 cv_find_contours_method: int = cv.CHAIN_APPROX_SIMPLE, 661 keep_internal: bool = False, 662 ) -> Sequence['Polygon']: 663 # cv_contours: [ (N, 1, 2), ... ], M contours. 664 # cv_hierarchy: [ (prev, next, child, parent), ... ], M relations. 665 # https://stackoverflow.com/a/8830981 666 # https://docs.opencv.org/4.x/d9/d8b/tutorial_py_contours_hierarchy.html 667 # https://docs.opencv.org/4.x/d3/dc0/group__imgproc__shape.html#gadf1ad6a0b82947fa1fe3c3d497f260e0 668 cv_contours, cv_hierarchy = cv.findContours( 669 (self.np_mask.astype(np.uint8) * 255), 670 cv.RETR_TREE, 671 cv_find_contours_method, 672 ) 673 if not cv_contours: 674 return [] 675 676 assert len(cv_hierarchy) == 1 677 assert len(cv_contours) == len(cv_hierarchy[0]) 678 cv_hierarchy = cv_hierarchy[0] 679 680 polygons: List[Polygon] = [] 681 682 # Ignore logging of shapely.geos. 683 shapely_geos_logger = logging.getLogger('shapely.geos') 684 shapely_geos_logger_level = shapely_geos_logger.level 685 shapely_geos_logger.setLevel(logging.WARNING) 686 687 for cv_contour, cv_contour_hierarchy in zip(cv_contours, cv_hierarchy): 688 assert len(cv_contour_hierarchy) == 4 689 cv_contour_parent = cv_contour_hierarchy[-1] 690 if not keep_internal and cv_contour_parent >= 0: 691 continue 692 693 assert cv_contour.shape[1] == 1 694 np_points = np.squeeze(cv_contour, axis=1) 695 696 if self.box: 697 np_points[:, 0] += self.box.left 698 np_points[:, 1] += self.box.up 699 700 if np_points.shape[0] < 3: 701 # If less than 3 points, ignore. 702 continue 703 704 polygon = Polygon.from_np_array(np_points) 705 706 # Split further based on shapley library, 707 # since some contours generated by opencv is consider invalid in shapely. 708 shapely_polygon = polygon.to_shapely_polygon() 709 shapely_valid_geom = shapely_make_valid(shapely_polygon) 710 711 if isinstance(shapely_valid_geom, ShapelyPolygon): 712 polygons.append(polygon) 713 714 elif isinstance(shapely_valid_geom, (ShapelyMultiPolygon, ShapelyGeometryCollection)): 715 for shapely_geom in shapely_valid_geom.geoms: 716 if isinstance(shapely_geom, ShapelyPolygon): 717 polygons.append(Polygon.from_shapely_polygon(shapely_geom)) 718 elif isinstance(shapely_geom, ShapelyMultiPolygon): 719 # I don't know why, but this do happen. 720 for shapely_sub_geom in shapely_geom.geoms: 721 if isinstance(shapely_sub_geom, ShapelyPolygon): 722 polygons.append(Polygon.from_shapely_polygon(shapely_sub_geom)) 723 else: 724 logger.debug(f'ignore shapely_sub_geom={shapely_sub_geom}') 725 else: 726 logger.debug(f'ignore shapely_geom={shapely_geom}') 727 728 else: 729 logger.debug(f'ignore shapely_valid_geom={shapely_valid_geom}') 730 731 # Reset logging level. 732 shapely_geos_logger.setLevel(shapely_geos_logger_level) 733 734 return polygons
def
to_disconnected_polygon_mask_pairs( self, cv_find_contours_method: int = 2) -> collections.abc.Sequence[tuple[vkit.element.polygon.Polygon, vkit.element.mask.Mask]]:
736 def to_disconnected_polygon_mask_pairs( 737 self, 738 cv_find_contours_method: int = cv.CHAIN_APPROX_SIMPLE, 739 ) -> Sequence[Tuple['Polygon', 'Mask']]: 740 pairs: List[Tuple[Polygon, Mask]] = [] 741 742 for polygon in self.to_disconnected_polygons( 743 cv_find_contours_method=cv_find_contours_method, 744 ): 745 bounding_box = polygon.to_bounding_box() 746 boxed_mask = Mask.from_shapable(bounding_box).to_box_attached(bounding_box) 747 polygon.fill_mask(boxed_mask) 748 pairs.append((polygon, boxed_mask)) 749 750 return pairs
def
generate_fill_by_masks_mask( shape: Tuple[int, int], masks: Iterable[vkit.element.mask.Mask], mode: vkit.element.type.ElementSetOperationMode):