# OBSS SAHI Tool # Code written by Fatih C Akyon, 2020. import time from typing import Dict, List, Optional, Union, Tuple import numpy as np import requests from PIL import Image from numpy import ndarray def read_image_as_pil(image: Union[Image.Image, str, np.ndarray]): """ Loads an image as PIL.Image.Image. Args: image : Can be image path or url (str), numpy image (np.ndarray) or PIL.Image """ # https://stackoverflow.com/questions/56174099/how-to-load-images-larger-than-max-image-pixels-with-pil Image.MAX_IMAGE_PIXELS = None if isinstance(image, Image.Image): image_pil = image elif isinstance(image, str): # read image if str image path is provided try: image_pil = Image.open( requests.get(image, stream=True).raw if str(image).startswith("http") else image ).convert("RGB") except: # handle large/tiff image reading try: import skimage.io except ImportError: raise ImportError("Please run 'pip install -U scikit-image imagecodecs' for large image handling.") image_sk = skimage.io.imread(image).astype(np.uint8) if len(image_sk.shape) == 2: # b&w image_pil = Image.fromarray(image_sk, mode="1") elif image_sk.shape[2] == 4: # rgba image_pil = Image.fromarray(image_sk, mode="RGBA") elif image_sk.shape[2] == 3: # rgb image_pil = Image.fromarray(image_sk, mode="RGB") else: raise TypeError(f"image with shape: {image_sk.shape[3]} is not supported.") elif isinstance(image, np.ndarray): if image.shape[0] < 5: # image in CHW image = image[:, :, ::-1] image_pil = Image.fromarray(image) else: raise TypeError("read image with 'pillow' using 'Image.open()'") return image_pil def get_slice_bboxes( image_height: int, image_width: int, slice_height: int = None, slice_width: int = None, auto_slice_resolution: bool = True, overlap_height_ratio: float = 0.2, overlap_width_ratio: float = 0.2, ) -> List[List[int]]: """Slices `image_pil` in crops. Corner values of each slice will be generated using the `slice_height`, `slice_width`, `overlap_height_ratio` and `overlap_width_ratio` arguments. Args: image_height (int): Height of the original image. image_width (int): Width of the original image. slice_height (int): Height of each slice. Default 512. slice_width (int): Width of each slice. Default 512. overlap_height_ratio(float): Fractional overlap in height of each slice (e.g. an overlap of 0.2 for a slice of size 100 yields an overlap of 20 pixels). Default 0.2. overlap_width_ratio(float): Fractional overlap in width of each slice (e.g. an overlap of 0.2 for a slice of size 100 yields an overlap of 20 pixels). Default 0.2. auto_slice_resolution (bool): if not set slice parameters such as slice_height and slice_width, it enables automatically calculate these params from image resolution and orientation. Returns: List[List[int]]: List of 4 corner coordinates for each N slices. [ [slice_0_left, slice_0_top, slice_0_right, slice_0_bottom], ... [slice_N_left, slice_N_top, slice_N_right, slice_N_bottom] ] """ slice_bboxes = [] y_max = y_min = 0 if slice_height and slice_width: y_overlap = int(overlap_height_ratio * slice_height) x_overlap = int(overlap_width_ratio * slice_width) elif auto_slice_resolution: x_overlap, y_overlap, slice_width, slice_height = get_auto_slice_params(height=image_height, width=image_width) else: raise ValueError("Compute type is not auto and slice width and height are not provided.") while y_max < image_height: x_min = x_max = 0 y_max = y_min + slice_height while x_max < image_width: x_max = x_min + slice_width if y_max > image_height or x_max > image_width: xmax = min(image_width, x_max) ymax = min(image_height, y_max) xmin = max(0, xmax - slice_width) ymin = max(0, ymax - slice_height) slice_bboxes.append([xmin, ymin, xmax, ymax]) else: slice_bboxes.append([x_min, y_min, x_max, y_max]) x_min = x_max - x_overlap y_min = y_max - y_overlap return slice_bboxes class SlicedImage: def __init__(self, image, starting_pixel): """ image: np.array Sliced image. starting_pixel: list of list of int Starting pixel coordinates of the sliced image. """ self.image = image self.starting_pixel = starting_pixel class SliceImageResult: def __init__(self, original_image_size=None): """ sliced_image_list: list of SlicedImage image_dir: str Directory of the sliced image exports. original_image_size: list of int Size of the unsliced original image in [height, width] """ self._sliced_image_list: List[SlicedImage] = [] self.original_image_height = original_image_size[0] self.original_image_width = original_image_size[1] def add_sliced_image(self, sliced_image: SlicedImage): if not isinstance(sliced_image, SlicedImage): raise TypeError("sliced_image must be a SlicedImage instance") self._sliced_image_list.append(sliced_image) @property def sliced_image_list(self): return self._sliced_image_list @property def images(self): """Returns sliced images. Returns: images: a list of np.array """ images = [] for sliced_image in self._sliced_image_list: images.append(sliced_image.image) return images @property def starting_pixels(self) -> List[int]: """Returns a list of starting pixels for each slice. Returns: starting_pixels: a list of starting pixel coords [x,y] """ starting_pixels = [] for sliced_image in self._sliced_image_list: starting_pixels.append(sliced_image.starting_pixel) return starting_pixels def __len__(self): return len(self._sliced_image_list) def slice_image( image: Union[str, Image.Image], slice_height: int = None, slice_width: int = None, overlap_height_ratio: float = None, overlap_width_ratio: float = None, auto_slice_resolution: bool = True, ) -> Tuple[ndarray, ndarray]: """Slice a large image into smaller windows. If output_file_name is given export sliced images. Args: auto_slice_resolution: image (str or PIL.Image): File path of image or Pillow Image to be sliced. coco_annotation_list (CocoAnnotation): List of CocoAnnotation objects. output_file_name (str, optional): Root name of output files (coordinates will be appended to this) output_dir (str, optional): Output directory slice_height (int): Height of each slice. Default 512. slice_width (int): Width of each slice. Default 512. overlap_height_ratio (float): Fractional overlap in height of each slice (e.g. an overlap of 0.2 for a slice of size 100 yields an overlap of 20 pixels). Default 0.2. overlap_width_ratio (float): Fractional overlap in width of each slice (e.g. an overlap of 0.2 for a slice of size 100 yields an overlap of 20 pixels). Default 0.2. min_area_ratio (float): If the cropped annotation area to original annotation ratio is smaller than this value, the annotation is filtered out. Default 0.1. out_ext (str, optional): Extension of saved images. Default is the original suffix. verbose (bool, optional): Switch to print relevant values to screen. Default 'False'. Returns: sliced_image_result: SliceImageResult: sliced_image_list: list of SlicedImage image_dir: str Directory of the sliced image exports. original_image_size: list of int Size of the unsliced original image in [height, width] num_total_invalid_segmentation: int Number of invalid segmentation annotations. """ # read image image_pil = read_image_as_pil(image) image_width, image_height = image_pil.size if not (image_width != 0 and image_height != 0): raise RuntimeError(f"invalid image size: {image_pil.size} for 'slice_image'.") slice_bboxes = get_slice_bboxes( image_height=image_height, image_width=image_width, auto_slice_resolution=auto_slice_resolution, slice_height=slice_height, slice_width=slice_width, overlap_height_ratio=overlap_height_ratio, overlap_width_ratio=overlap_width_ratio, ) t0 = time.time() n_ims = 0 # init images and annotations lists sliced_image_result = SliceImageResult(original_image_size=[image_height, image_width]) image_pil_arr = np.asarray(image_pil) # iterate over slices for slice_bbox in slice_bboxes: n_ims += 1 # extract image tlx = slice_bbox[0] tly = slice_bbox[1] brx = slice_bbox[2] bry = slice_bbox[3] image_pil_slice = image_pil_arr[tly:bry, tlx:brx] # create sliced image and append to sliced_image_result sliced_image = SlicedImage( image=image_pil_slice, starting_pixel=[slice_bbox[0], slice_bbox[1]] ) sliced_image_result.add_sliced_image(sliced_image) image_numpy = np.array(sliced_image_result.images) shift_amount = np.array(sliced_image_result.starting_pixels) return image_numpy, shift_amount def calc_ratio_and_slice(orientation, slide=1, ratio=0.1): """ According to image resolution calculation overlap params Args: orientation: image capture angle slide: sliding window ratio: buffer value Returns: overlap params """ if orientation == "vertical": slice_row, slice_col, overlap_height_ratio, overlap_width_ratio = slide, slide * 2, ratio, ratio elif orientation == "horizontal": slice_row, slice_col, overlap_height_ratio, overlap_width_ratio = slide * 2, slide, ratio, ratio elif orientation == "square": slice_row, slice_col, overlap_height_ratio, overlap_width_ratio = slide, slide, ratio, ratio return slice_row, slice_col, overlap_height_ratio, overlap_width_ratio # noqa def calc_resolution_factor(resolution: int) -> int: """ According to image resolution calculate power(2,n) and return the closest smaller `n`. Args: resolution: the width and height of the image multiplied. such as 1024x720 = 737280 Returns: """ expo = 0 while np.power(2, expo) < resolution: expo += 1 return expo - 1 def calc_aspect_ratio_orientation(width: int, height: int) -> str: """ Args: width: height: Returns: image capture orientation """ if width < height: return "vertical" elif width > height: return "horizontal" else: return "square" def calc_slice_and_overlap_params(resolution: str, height: int, width: int, orientation: str) -> List: """ This function calculate according to image resolution slice and overlap params. Args: resolution: str height: int width: int orientation: str Returns: x_overlap, y_overlap, slice_width, slice_height """ if resolution == "medium": split_row, split_col, overlap_height_ratio, overlap_width_ratio = calc_ratio_and_slice( orientation, slide=1, ratio=0.8 ) elif resolution == "high": split_row, split_col, overlap_height_ratio, overlap_width_ratio = calc_ratio_and_slice( orientation, slide=2, ratio=0.4 ) elif resolution == "ultra-high": split_row, split_col, overlap_height_ratio, overlap_width_ratio = calc_ratio_and_slice( orientation, slide=4, ratio=0.4 ) else: # low condition split_col = 1 split_row = 1 overlap_width_ratio = 1 overlap_height_ratio = 1 slice_height = height // split_col slice_width = width // split_row x_overlap = int(slice_width * overlap_width_ratio) y_overlap = int(slice_height * overlap_height_ratio) return x_overlap, y_overlap, slice_width, slice_height # noqa def get_resolution_selector(res: str, height: int, width: int): """ Args: res: resolution of image such as low, medium height: width: Returns: trigger slicing params function and return overlap params """ orientation = calc_aspect_ratio_orientation(width=width, height=height) x_overlap, y_overlap, slice_width, slice_height = calc_slice_and_overlap_params( resolution=res, height=height, width=width, orientation=orientation ) return x_overlap, y_overlap, slice_width, slice_height def get_auto_slice_params(height: int, width: int): """ According to Image HxW calculate overlap sliding window and buffer params factor is the power value of 2 closest to the image resolution. factor <= 18: low resolution image such as 300x300, 640x640 18 < factor <= 21: medium resolution image such as 1024x1024, 1336x960 21 < factor <= 24: high resolution image such as 2048x2048, 2048x4096, 4096x4096 factor > 24: ultra-high resolution image such as 6380x6380, 4096x8192 Args: height: width: Returns: slicing overlap params x_overlap, y_overlap, slice_width, slice_height """ resolution = height * width factor = calc_resolution_factor(resolution) if factor <= 18: return get_resolution_selector("low", height=height, width=width) elif 18 <= factor < 21: return get_resolution_selector("medium", height=height, width=width) elif 21 <= factor < 24: return get_resolution_selector("high", height=height, width=width) else: return get_resolution_selector("ultra-high", height=height, width=width)