# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license import itertools from glob import glob from math import ceil from pathlib import Path from typing import Any, Dict, List, Tuple import cv2 import numpy as np from PIL import Image from ultralytics.data.utils import exif_size, img2label_paths from ultralytics.utils import TQDM from ultralytics.utils.checks import check_requirements def bbox_iof(polygon1: np.ndarray, bbox2: np.ndarray, eps: float = 1e-6) -> np.ndarray: """ Calculate Intersection over Foreground (IoF) between polygons and bounding boxes. Args: polygon1 (np.ndarray): Polygon coordinates with shape (N, 8). bbox2 (np.ndarray): Bounding boxes with shape (N, 4). eps (float, optional): Small value to prevent division by zero. Returns: (np.ndarray): IoF scores with shape (N, 1) or (N, M) if bbox2 is (M, 4). Notes: Polygon format: [x1, y1, x2, y2, x3, y3, x4, y4]. Bounding box format: [x_min, y_min, x_max, y_max]. """ check_requirements("shapely>=2.0.0") from shapely.geometry import Polygon polygon1 = polygon1.reshape(-1, 4, 2) lt_point = np.min(polygon1, axis=-2) # left-top rb_point = np.max(polygon1, axis=-2) # right-bottom bbox1 = np.concatenate([lt_point, rb_point], axis=-1) lt = np.maximum(bbox1[:, None, :2], bbox2[..., :2]) rb = np.minimum(bbox1[:, None, 2:], bbox2[..., 2:]) wh = np.clip(rb - lt, 0, np.inf) h_overlaps = wh[..., 0] * wh[..., 1] left, top, right, bottom = (bbox2[..., i] for i in range(4)) polygon2 = np.stack([left, top, right, top, right, bottom, left, bottom], axis=-1).reshape(-1, 4, 2) sg_polys1 = [Polygon(p) for p in polygon1] sg_polys2 = [Polygon(p) for p in polygon2] overlaps = np.zeros(h_overlaps.shape) for p in zip(*np.nonzero(h_overlaps)): overlaps[p] = sg_polys1[p[0]].intersection(sg_polys2[p[-1]]).area unions = np.array([p.area for p in sg_polys1], dtype=np.float32) unions = unions[..., None] unions = np.clip(unions, eps, np.inf) outputs = overlaps / unions if outputs.ndim == 1: outputs = outputs[..., None] return outputs def load_yolo_dota(data_root: str, split: str = "train") -> List[Dict[str, Any]]: """ Load DOTA dataset annotations and image information. Args: data_root (str): Data root directory. split (str, optional): The split data set, could be 'train' or 'val'. Returns: (List[Dict[str, Any]]): List of annotation dictionaries containing image information. Notes: The directory structure assumed for the DOTA dataset: - data_root - images - train - val - labels - train - val """ assert split in {"train", "val"}, f"Split must be 'train' or 'val', not {split}." im_dir = Path(data_root) / "images" / split assert im_dir.exists(), f"Can't find {im_dir}, please check your data root." im_files = glob(str(Path(data_root) / "images" / split / "*")) lb_files = img2label_paths(im_files) annos = [] for im_file, lb_file in zip(im_files, lb_files): w, h = exif_size(Image.open(im_file)) with open(lb_file, encoding="utf-8") as f: lb = [x.split() for x in f.read().strip().splitlines() if len(x)] lb = np.array(lb, dtype=np.float32) annos.append(dict(ori_size=(h, w), label=lb, filepath=im_file)) return annos def get_windows( im_size: Tuple[int, int], crop_sizes: Tuple[int, ...] = (1024,), gaps: Tuple[int, ...] = (200,), im_rate_thr: float = 0.6, eps: float = 0.01, ) -> np.ndarray: """ Get the coordinates of sliding windows for image cropping. Args: im_size (Tuple[int, int]): Original image size, (H, W). crop_sizes (Tuple[int, ...], optional): Crop size of windows. gaps (Tuple[int, ...], optional): Gap between crops. im_rate_thr (float, optional): Threshold of windows areas divided by image areas. eps (float, optional): Epsilon value for math operations. Returns: (np.ndarray): Array of window coordinates with shape (N, 4) where each row is [x_start, y_start, x_stop, y_stop]. """ h, w = im_size windows = [] for crop_size, gap in zip(crop_sizes, gaps): assert crop_size > gap, f"invalid crop_size gap pair [{crop_size} {gap}]" step = crop_size - gap xn = 1 if w <= crop_size else ceil((w - crop_size) / step + 1) xs = [step * i for i in range(xn)] if len(xs) > 1 and xs[-1] + crop_size > w: xs[-1] = w - crop_size yn = 1 if h <= crop_size else ceil((h - crop_size) / step + 1) ys = [step * i for i in range(yn)] if len(ys) > 1 and ys[-1] + crop_size > h: ys[-1] = h - crop_size start = np.array(list(itertools.product(xs, ys)), dtype=np.int64) stop = start + crop_size windows.append(np.concatenate([start, stop], axis=1)) windows = np.concatenate(windows, axis=0) im_in_wins = windows.copy() im_in_wins[:, 0::2] = np.clip(im_in_wins[:, 0::2], 0, w) im_in_wins[:, 1::2] = np.clip(im_in_wins[:, 1::2], 0, h) im_areas = (im_in_wins[:, 2] - im_in_wins[:, 0]) * (im_in_wins[:, 3] - im_in_wins[:, 1]) win_areas = (windows[:, 2] - windows[:, 0]) * (windows[:, 3] - windows[:, 1]) im_rates = im_areas / win_areas if not (im_rates > im_rate_thr).any(): max_rate = im_rates.max() im_rates[abs(im_rates - max_rate) < eps] = 1 return windows[im_rates > im_rate_thr] def get_window_obj(anno: Dict[str, Any], windows: np.ndarray, iof_thr: float = 0.7) -> List[np.ndarray]: """Get objects for each window based on IoF threshold.""" h, w = anno["ori_size"] label = anno["label"] if len(label): label[:, 1::2] *= w label[:, 2::2] *= h iofs = bbox_iof(label[:, 1:], windows) # Unnormalized and misaligned coordinates return [(label[iofs[:, i] >= iof_thr]) for i in range(len(windows))] # window_anns else: return [np.zeros((0, 9), dtype=np.float32) for _ in range(len(windows))] # window_anns def crop_and_save( anno: Dict[str, Any], windows: np.ndarray, window_objs: List[np.ndarray], im_dir: str, lb_dir: str, allow_background_images: bool = True, ) -> None: """ Crop images and save new labels for each window. Args: anno (Dict[str, Any]): Annotation dict, including 'filepath', 'label', 'ori_size' as its keys. windows (np.ndarray): Array of windows coordinates with shape (N, 4). window_objs (List[np.ndarray]): A list of labels inside each window. im_dir (str): The output directory path of images. lb_dir (str): The output directory path of labels. allow_background_images (bool, optional): Whether to include background images without labels. Notes: The directory structure assumed for the DOTA dataset: - data_root - images - train - val - labels - train - val """ im = cv2.imread(anno["filepath"]) name = Path(anno["filepath"]).stem for i, window in enumerate(windows): x_start, y_start, x_stop, y_stop = window.tolist() new_name = f"{name}__{x_stop - x_start}__{x_start}___{y_start}" patch_im = im[y_start:y_stop, x_start:x_stop] ph, pw = patch_im.shape[:2] label = window_objs[i] if len(label) or allow_background_images: cv2.imwrite(str(Path(im_dir) / f"{new_name}.jpg"), patch_im) if len(label): label[:, 1::2] -= x_start label[:, 2::2] -= y_start label[:, 1::2] /= pw label[:, 2::2] /= ph with open(Path(lb_dir) / f"{new_name}.txt", "w", encoding="utf-8") as f: for lb in label: formatted_coords = [f"{coord:.6g}" for coord in lb[1:]] f.write(f"{int(lb[0])} {' '.join(formatted_coords)}\n") def split_images_and_labels( data_root: str, save_dir: str, split: str = "train", crop_sizes: Tuple[int, ...] = (1024,), gaps: Tuple[int, ...] = (200,), ) -> None: """ Split both images and labels for a given dataset split. Args: data_root (str): Root directory of the dataset. save_dir (str): Directory to save the split dataset. split (str, optional): The split data set, could be 'train' or 'val'. crop_sizes (Tuple[int, ...], optional): Tuple of crop sizes. gaps (Tuple[int, ...], optional): Tuple of gaps between crops. Notes: The directory structure assumed for the DOTA dataset: - data_root - images - split - labels - split and the output directory structure is: - save_dir - images - split - labels - split """ im_dir = Path(save_dir) / "images" / split im_dir.mkdir(parents=True, exist_ok=True) lb_dir = Path(save_dir) / "labels" / split lb_dir.mkdir(parents=True, exist_ok=True) annos = load_yolo_dota(data_root, split=split) for anno in TQDM(annos, total=len(annos), desc=split): windows = get_windows(anno["ori_size"], crop_sizes, gaps) window_objs = get_window_obj(anno, windows) crop_and_save(anno, windows, window_objs, str(im_dir), str(lb_dir)) def split_trainval( data_root: str, save_dir: str, crop_size: int = 1024, gap: int = 200, rates: Tuple[float, ...] = (1.0,) ) -> None: """ Split train and val sets of DOTA dataset with multiple scaling rates. Args: data_root (str): Root directory of the dataset. save_dir (str): Directory to save the split dataset. crop_size (int, optional): Base crop size. gap (int, optional): Base gap between crops. rates (Tuple[float, ...], optional): Scaling rates for crop_size and gap. Notes: The directory structure assumed for the DOTA dataset: - data_root - images - train - val - labels - train - val and the output directory structure is: - save_dir - images - train - val - labels - train - val """ crop_sizes, gaps = [], [] for r in rates: crop_sizes.append(int(crop_size / r)) gaps.append(int(gap / r)) for split in {"train", "val"}: split_images_and_labels(data_root, save_dir, split, crop_sizes, gaps) def split_test( data_root: str, save_dir: str, crop_size: int = 1024, gap: int = 200, rates: Tuple[float, ...] = (1.0,) ) -> None: """ Split test set of DOTA dataset, labels are not included within this set. Args: data_root (str): Root directory of the dataset. save_dir (str): Directory to save the split dataset. crop_size (int, optional): Base crop size. gap (int, optional): Base gap between crops. rates (Tuple[float, ...], optional): Scaling rates for crop_size and gap. Notes: The directory structure assumed for the DOTA dataset: - data_root - images - test and the output directory structure is: - save_dir - images - test """ crop_sizes, gaps = [], [] for r in rates: crop_sizes.append(int(crop_size / r)) gaps.append(int(gap / r)) save_dir = Path(save_dir) / "images" / "test" save_dir.mkdir(parents=True, exist_ok=True) im_dir = Path(data_root) / "images" / "test" assert im_dir.exists(), f"Can't find {im_dir}, please check your data root." im_files = glob(str(im_dir / "*")) for im_file in TQDM(im_files, total=len(im_files), desc="test"): w, h = exif_size(Image.open(im_file)) windows = get_windows((h, w), crop_sizes=crop_sizes, gaps=gaps) im = cv2.imread(im_file) name = Path(im_file).stem for window in windows: x_start, y_start, x_stop, y_stop = window.tolist() new_name = f"{name}__{x_stop - x_start}__{x_start}___{y_start}" patch_im = im[y_start:y_stop, x_start:x_stop] cv2.imwrite(str(save_dir / f"{new_name}.jpg"), patch_im) if __name__ == "__main__": split_trainval(data_root="DOTAv2", save_dir="DOTAv2-split") split_test(data_root="DOTAv2", save_dir="DOTAv2-split")