diff --git a/pyproject.toml b/pyproject.toml index 57d67ee27..12a659a26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -240,6 +240,7 @@ filterwarnings = [ [tool.codespell] skip = "*.pth" +ignore-words-list = "dota" [tool.mypy] python_version = "3.10" @@ -260,6 +261,7 @@ overrides = [ ], ignore_errors = true }, { module = [ "torchvision", "torchvision.*", + "albumentations", "albumentations.*", "pycocotools", "pycocotools.*", "timm", "timm.*", "einops", "einops.*", diff --git a/src/rfdetr/_namespace.py b/src/rfdetr/_namespace.py index d8eac3968..880c467a0 100644 --- a/src/rfdetr/_namespace.py +++ b/src/rfdetr/_namespace.py @@ -41,6 +41,7 @@ "num_queries", "num_select", "num_windows", + "oriented", "out_feature_indexes", "patch_size", "positional_encoding_size", diff --git a/src/rfdetr/config.py b/src/rfdetr/config.py index b89014634..6dfc95060 100644 --- a/src/rfdetr/config.py +++ b/src/rfdetr/config.py @@ -515,6 +515,7 @@ class ModelConfig(BaseConfig): ia_bce_loss: bool = True cls_loss_coef: float = 1.0 segmentation_head: bool = False + oriented: bool = False use_grouppose_keypoints: bool = False keypoint_cross_attn: bool = True inter_instance_kp_attn: bool = False @@ -1074,9 +1075,10 @@ class TrainConfig(BaseConfig): keypoint_visible_loss_coef: float = 0 keypoint_nll_loss_coef: float = 0 keypoint_oks_sigmas: list[float] | None = None - dataset_file: Literal["coco", "o365", "roboflow", "yolo"] = "roboflow" + dataset_file: Literal["coco", "o365", "roboflow", "yolo", "dota"] = "roboflow" square_resize_div_64: bool = True dataset_dir: PathLikeStr | None + dota_include_difficult: bool = False output_dir: PathLikeStr = "output" multi_scale: bool = True expanded_scales: bool = True diff --git a/src/rfdetr/datasets/__init__.py b/src/rfdetr/datasets/__init__.py index 40261420a..431647490 100644 --- a/src/rfdetr/datasets/__init__.py +++ b/src/rfdetr/datasets/__init__.py @@ -25,6 +25,8 @@ from rfdetr.datasets._keypoint_schema import infer_coco_keypoint_schema as infer_coco_keypoint_schema from rfdetr.datasets._keypoint_schema import infer_yolo_keypoint_schema as infer_yolo_keypoint_schema from rfdetr.datasets.coco import build_coco, build_roboflow_from_coco +from rfdetr.datasets.dota_detection import DOTA_V1_CLASSES as DOTA_V1_CLASSES +from rfdetr.datasets.dota_detection import DotaDetection, build_dota from rfdetr.datasets.o365 import build_o365 from rfdetr.datasets.yolo import YoloDetection, build_roboflow_from_yolo @@ -37,6 +39,8 @@ def get_coco_api_from_dataset(dataset: Dataset[Any]) -> Any | None: return dataset.coco if isinstance(dataset, YoloDetection): return dataset.coco + if isinstance(dataset, DotaDetection): + return None return None @@ -95,4 +99,6 @@ def build_dataset(image_set: str, args: Any, resolution: int) -> Dataset[Any]: return build_roboflow(image_set, args, resolution) if args.dataset_file == "yolo": return build_roboflow_from_yolo(image_set, args, resolution) + if args.dataset_file == "dota": + return build_dota(image_set, args, resolution) raise ValueError(f"dataset {args.dataset_file} not supported") diff --git a/src/rfdetr/datasets/dota_detection.py b/src/rfdetr/datasets/dota_detection.py new file mode 100644 index 000000000..f617eeab3 --- /dev/null +++ b/src/rfdetr/datasets/dota_detection.py @@ -0,0 +1,440 @@ +# ------------------------------------------------------------------------ +# RF-DETR +# Copyright (c) 2025 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ + +"""DOTA dataset loader for oriented object detection.""" + +from pathlib import Path +from typing import Any + +import numpy as np +import torch +from PIL import Image +from torch.utils.data import Dataset +from torchvision.transforms.v2 import Compose, ToDtype, ToImage + +try: + import albumentations as A # noqa: N812 +except ImportError: + A = None + +from rfdetr.datasets.yolo import YOLO_IMAGE_EXTENSIONS +from rfdetr.utilities.logger import get_logger +from rfdetr.utilities.rotated_box_ops import corners_to_cxcywha + +logger = get_logger() + +DOTA_V1_CLASSES = ( + "baseball-diamond", + "basketball-court", + "bridge", + "ground-track-field", + "harbor", + "helicopter", + "large-vehicle", + "plane", + "roundabout", + "ship", + "small-vehicle", + "soccer-ball-field", + "storage-tank", + "swimming-pool", + "tennis-court", +) + + +def parse_dota_annotation(ann_path: Path) -> list[dict[str, Any]]: + """Parse a DOTA annotation text file. + + Each line after the optional header has the format: + ``x1 y1 x2 y2 x3 y3 x4 y4 category difficulty`` + + Args: + ann_path: Path to the annotation ``.txt`` file. + + Returns: + List of annotation dicts with keys ``corners``, ``category`` and ``difficulty``. + """ + annotations: list[dict[str, Any]] = [] + # DOTA writes these two metadata lines at the top of every file. + header_prefixes = ("imagesource:", "gapsize:") + with ann_path.open(encoding="utf-8") as f: + for line_num, raw_line in enumerate(f, start=1): + line = raw_line.strip() + if not line or line.startswith(header_prefixes): + continue + parts = line.split() + if len(parts) < 9: + logger.warning( + f"Skipping malformed line {line_num} in {ann_path}: expected >= 9 fields, got {len(parts)}." + ) + continue + try: + coords = [float(parts[i]) for i in range(8)] + except ValueError: + logger.warning(f"Skipping line {line_num} in {ann_path}: coordinates are not numeric.") + continue + category = parts[8] + try: + difficulty = int(parts[9]) if len(parts) > 9 else 0 + except ValueError: + difficulty = 0 + annotations.append( + { + "corners": coords, + "category": category, + "difficulty": difficulty, + } + ) + return annotations + + +def corners_list_to_tensor(corners: list[float]) -> torch.Tensor: + """Convert flat 8-element corner list to ``(4, 2)`` tensor. + + Args: + corners: Flat list ``[x1, y1, x2, y2, x3, y3, x4, y4]``. + + Returns: + Tensor of shape ``(4, 2)``. + """ + return torch.tensor(corners, dtype=torch.float32).reshape(4, 2) + + +class DotaDetection(Dataset[Any]): + """DOTA v1.0 dataset for oriented object detection. + + Expects the standard DOTA directory layout:: + + root/ + images/ + P0001.png + P0002.png + ... + labelTxt/ + P0001.txt + P0002.txt + ... + + Each annotation file contains one object per line with 4-corner polygon + coordinates, a category name, and a difficulty flag. + + Args: + root: Path to the split directory (e.g. ``dota/train``). + transforms: Transform pipeline applied to ``(image, target)`` pairs. + class_names: Ordered tuple of class names. Defaults to the 15 DOTA v1.0 classes. + include_difficult: If ``True``, include objects marked as difficult. + """ + + def __init__( + self, + root: str | Path, + transforms: Compose | None = None, + class_names: tuple[str, ...] = DOTA_V1_CLASSES, + include_difficult: bool = False, + ) -> None: + self.root = Path(root) + self._transforms = transforms + self.class_names = class_names + self.class_to_idx = {name: i for i, name in enumerate(class_names)} + self.include_difficult = include_difficult + # Categories outside class_names are skipped; track them so the warning fires + # once per dataset rather than once per annotation. + self._unknown_categories: set[str] = set() + + self.images_dir = self.root / "images" + self.labels_dir = self.root / "labelTxt" + + if not self.images_dir.exists(): + raise FileNotFoundError(f"Images directory not found: {self.images_dir}") + if not self.labels_dir.exists(): + raise FileNotFoundError(f"Labels directory not found: {self.labels_dir}") + + self.image_files = sorted( + p for p in self.images_dir.iterdir() if p.is_file() and p.suffix.lower() in YOLO_IMAGE_EXTENSIONS + ) + if not self.image_files: + raise FileNotFoundError(f"No images found in {self.images_dir}") + logger.info(f"DOTA dataset loaded: {len(self.image_files)} images, {len(class_names)} classes") + + def __len__(self) -> int: + """Return the number of images in the split.""" + return len(self.image_files) + + def __getitem__(self, idx: int) -> tuple[Any, dict[str, Any]]: + """Load one image and its oriented-box target. + + Args: + idx: Index into the sorted image list. + + Returns: + Tuple of the (optionally transformed) image and its target dict. + """ + img_path = self.image_files[idx] + ann_path = self.labels_dir / f"{img_path.stem}.txt" + + image = Image.open(img_path).convert("RGB") + + annotations = parse_dota_annotation(ann_path) if ann_path.exists() else [] + + corners_list = [] + labels = [] + for ann in annotations: + if not self.include_difficult and ann["difficulty"] == 1: + continue + cat = ann["category"] + if cat not in self.class_to_idx: + if cat not in self._unknown_categories: + self._unknown_categories.add(cat) + logger.warning( + f"Ignoring unknown DOTA category {cat!r}: it is not among the " + f"{len(self.class_names)} configured classes." + ) + continue + corners_list.append(corners_list_to_tensor(ann["corners"])) + labels.append(self.class_to_idx[cat]) + + if corners_list: + all_corners = torch.stack(corners_list) + boxes_obb = corners_to_cxcywha(all_corners) + else: + all_corners = torch.zeros((0, 4, 2), dtype=torch.float32) + boxes_obb = torch.zeros((0, 5), dtype=torch.float32) + + w, h = image.size + target: dict[str, Any] = { + "boxes_obb": boxes_obb, + "boxes": boxes_obb[..., :4], # [cx, cy, w, h] alias for COCO eval callback + "corners": all_corners, + "labels": torch.tensor(labels, dtype=torch.int64), + "image_id": torch.tensor([idx]), + "orig_size": torch.as_tensor([int(h), int(w)]), + "size": torch.as_tensor([int(h), int(w)]), + } + + if self._transforms is not None: + image, target = self._transforms(image, target) + + return image, target + + +class OBBGeometricTransform: + """Apply an Albumentations geometric transform to an image and its oriented-box corners. + + Treats the 4 corners of each oriented box as individual keypoints so that + geometric augmentations (flip, rotate, crop) correctly update the box + geometry. After the transform the ``corners`` tensor in the target is + updated in place; ``DotaNormalize`` then recomputes ``boxes_obb`` from + the updated corners. + + Args: + transform: An Albumentations ``BasicTransform``, or a list of them to apply + as a single pass. Passing the whole geometric chain at once avoids a + PIL/numpy round-trip and a keypoint-processing pass per operation. + """ + + def __init__(self, transform: "A.BasicTransform | list[A.BasicTransform]") -> None: + if A is None: + raise ImportError("albumentations is required for OBBGeometricTransform") + self._pipeline = A.Compose( + list(transform) if isinstance(transform, list) else [transform], + keypoint_params=A.KeypointParams( + format="xy", + label_fields=["kp_instance_ids", "kp_point_ids"], + remove_invisible=False, + ), + ) + + def __call__(self, image: Image.Image, target: dict[str, Any] | None) -> tuple[Image.Image, dict[str, Any] | None]: + """Apply the geometric transform to image and OBB corners. + + Args: + image: Input PIL image. + target: Target dict with ``corners`` tensor of shape ``(N, 4, 2)`` + in pixel coordinates, plus ``labels``. + + Returns: + Augmented ``(image, target)`` pair with ``corners`` updated. + """ + image_np = np.array(image) + + if target is None or "corners" not in target or len(target["corners"]) == 0: + augmented = self._pipeline(image=image_np, keypoints=[], kp_instance_ids=[], kp_point_ids=[]) + return Image.fromarray(augmented["image"]), target + + corners: torch.Tensor = target["corners"] + n_boxes = corners.shape[0] + + # Corners are passed through unclamped. The four corners of a rotated box are + # not independent, so clipping them individually turns the box into a different + # quadrilateral: a box crossing an image edge loses ~30% of its width and gains + # a double-digit angle error. The pipeline is built with remove_invisible=False, + # so albumentations preserves out-of-bounds keypoints without help. + kp_xy = [] + inst_ids = [] + point_ids = [] + corners_np = corners.cpu().numpy() + for i in range(n_boxes): + for j in range(4): + kp_xy.append((float(corners_np[i, j, 0]), float(corners_np[i, j, 1]))) + inst_ids.append(i) + point_ids.append(j) + + augmented = self._pipeline( + image=image_np, + keypoints=kp_xy, + kp_instance_ids=inst_ids, + kp_point_ids=point_ids, + ) + + new_image_np = augmented["image"] + + if len(augmented["keypoints"]) != len(kp_xy): + # out_corners is seeded with pre-transform coordinates, so a dropped keypoint + # would leave one corner in the original frame while its siblings move to the + # augmented one — a single box spanning two coordinate spaces, silently. + raise ValueError( + f"Albumentations returned {len(augmented['keypoints'])} keypoints for {len(kp_xy)} inputs. " + "OBB corners require a transform that preserves every keypoint." + ) + + out_corners = corners_np.copy() + for kp, inst, pt in zip(augmented["keypoints"], augmented["kp_instance_ids"], augmented["kp_point_ids"]): + out_corners[int(inst), int(pt), 0] = float(kp[0]) + out_corners[int(inst), int(pt), 1] = float(kp[1]) + + target = target.copy() + target["corners"] = torch.from_numpy(out_corners).to(corners.dtype) + return Image.fromarray(new_image_np), target + + +def make_dota_transforms( + image_set: str, + resolution: int, +) -> Compose: + """Build transform pipeline for DOTA dataset. + + Args: + image_set: Split identifier — ``"train"``, ``"val"`` or ``"test"``. + resolution: Target square resolution in pixels. + + Returns: + Composed transform pipeline. + + Raises: + ValueError: If ``image_set`` is not a recognised split. + """ + if A is None: + raise ImportError("albumentations is required for DOTA transforms. Install with: pip install albumentations") + if image_set not in ("train", "val", "test"): + raise ValueError(f"unknown image_set {image_set!r}; expected 'train', 'val' or 'test'") + + # One Compose for the whole geometric chain: each OBBGeometricTransform costs a + # PIL->numpy->PIL round-trip and a full keypoint pass, so chaining four of them + # would convert every sample eight times. + geometric: list[Any] = [A.Resize(height=resolution, width=resolution)] + if image_set == "train": + geometric += [A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomRotate90(p=0.5)] + + return Compose( + [ + OBBGeometricTransform(geometric), + ToImage(), + ToDtype(torch.float32, scale=True), + DotaNormalize(), + ] + ) + + +class DotaNormalize: + """Normalize images and convert OBB corners to normalized cxcywha format. + + After geometric augmentations, recomputes ``boxes_obb`` from the (potentially transformed) ``corners`` keypoints, + then normalizes spatial coordinates by image size. + """ + + def __init__( + self, + mean: tuple[float, ...] = (0.485, 0.456, 0.406), + std: tuple[float, ...] = (0.229, 0.224, 0.225), + ) -> None: + from torchvision.transforms import Normalize as _TVNormalize + + self._normalize = _TVNormalize(mean, std) + + def __call__( + self, image: torch.Tensor, target: dict[str, Any] | None = None + ) -> tuple[torch.Tensor, dict[str, Any] | None]: + image = self._normalize(image) + if target is None: + return image, None + target = target.copy() + h, w = image.shape[-2:] + # ``corners`` stay in post-transform pixel space, so consumers need the + # post-transform size to map them back to original-image coordinates. + # Nothing else in the DOTA pipeline updates this (unlike the torchvision + # transforms used by COCO), so it must be refreshed here. + target["size"] = torch.as_tensor([int(h), int(w)]) + + if "corners" in target and len(target["corners"]) > 0: + boxes_obb = corners_to_cxcywha(target["corners"]) + # Drop zero-area boxes. An augmentation can move a box entirely out of + # frame, and a w=0 or h=0 target is not a crash downstream — _obb_to_gaussian + # clamps it to a minimum size, so the model would silently train against a + # meaningless target instead. ConvertYolo applies the same guard. + keep = (boxes_obb[:, 2] > 0) & (boxes_obb[:, 3] > 0) + if not bool(keep.all()): + boxes_obb = boxes_obb[keep] + target["corners"] = target["corners"][keep] + target["labels"] = target["labels"][keep] + + scale = boxes_obb.new_tensor([w, h, w, h, 1.0]) + target["boxes_obb"] = boxes_obb / scale + target["boxes"] = target["boxes_obb"][..., :4] + + return image, target + + +def build_dota(image_set: str, args: Any, resolution: int) -> DotaDetection: + """Build a DOTA dataset for the given split. + + Args: + image_set: Split identifier — ``"train"`` or ``"val"``. + args: Namespace with a ``dataset_dir`` attribute and an optional + ``dota_include_difficult`` flag. + resolution: Target resolution in pixels. + + Returns: + Configured DotaDetection dataset. + + Raises: + FileNotFoundError: If no directory exists for the requested split. + """ + # multi_scale/expanded_scales default to True in TrainConfig but the OBB pipeline + # resizes to a fixed square, so they would otherwise be accepted and ignored. + unsupported = [name for name in ("multi_scale", "expanded_scales") if getattr(args, name, False)] + if unsupported and image_set == "train": + logger.warning( + "DOTA training ignores %s: the OBB pipeline resizes to a fixed %dx%d square. " + "Set them to False to silence this, or vary `resolution` between runs instead.", + " and ".join(unsupported), + resolution, + resolution, + ) + + dataset_dir = Path(args.dataset_dir) + # Roboflow-style exports name the validation split "valid"; DOTA uses "val". + candidates = [dataset_dir / "valid"] if image_set == "val" else [] + root = dataset_dir / image_set + if not root.exists(): + root = next((c for c in candidates if c.exists()), root) + if not root.exists(): + raise FileNotFoundError(f"No directory found for split {image_set!r} under {dataset_dir}") + + return DotaDetection( + root=root, + transforms=make_dota_transforms(image_set, resolution), + include_difficult=getattr(args, "dota_include_difficult", False), + ) diff --git a/src/rfdetr/inference.py b/src/rfdetr/inference.py index 502cf2b4e..e1977057d 100644 --- a/src/rfdetr/inference.py +++ b/src/rfdetr/inference.py @@ -170,6 +170,9 @@ def _build_model_context(model_config: ModelConfig) -> ModelContext: num_keypoints_per_class=getattr(args, "num_keypoints_per_class", []), # Older detection-only namespaces may omit keypoint postprocess knobs; keep the ModelConfig default. trace_alpha=getattr(args, "postprocess_trace_alpha", 0.2), + # Without this an oriented checkpoint silently postprocesses as axis-aligned, + # dropping the angle and returning only 4 of the 5 predicted box dims. + oriented=getattr(args, "oriented", False), ) return ModelContext( diff --git a/src/rfdetr/models/_types.py b/src/rfdetr/models/_types.py index 3bfa89b53..8ea149297 100644 --- a/src/rfdetr/models/_types.py +++ b/src/rfdetr/models/_types.py @@ -67,6 +67,7 @@ class BuilderArgs(Protocol): ia_bce_loss: bool cls_loss_coef: float segmentation_head: bool + oriented: bool mask_downsample_ratio: int num_queries: int num_select: int diff --git a/src/rfdetr/models/criterion.py b/src/rfdetr/models/criterion.py index 56816e326..c3b4bce37 100644 --- a/src/rfdetr/models/criterion.py +++ b/src/rfdetr/models/criterion.py @@ -28,6 +28,7 @@ from rfdetr.models.math import accuracy from rfdetr.utilities import box_ops from rfdetr.utilities.distributed import get_world_size, is_dist_avail_and_initialized +from rfdetr.utilities.rotated_box_ops import obb_to_aabb, probiou _LossFunction = Callable[..., dict[str, Tensor]] @@ -204,6 +205,7 @@ def __init__( self.use_varifocal_loss = use_varifocal_loss self.use_position_supervised_loss = use_position_supervised_loss self.ia_bce_loss = ia_bce_loss + self.oriented = getattr(matcher, "oriented", False) self.mask_point_sample_ratio = mask_point_sample_ratio self.num_keypoints_per_class = num_keypoints_per_class or [] @@ -300,14 +302,23 @@ def loss_labels( alpha = self.focal_alpha gamma = 2 src_boxes = outputs["pred_boxes"][idx] - target_boxes = torch.cat([t["boxes"][i] for t, (_, i) in zip(targets, indices)], dim=0) + tgt_key = "boxes_obb" if self.oriented else "boxes" + target_boxes = torch.cat([t[tgt_key][i] for t, (_, i) in zip(targets, indices)], dim=0) - iou_targets = torch.diag( - box_ops.box_iou( - box_ops.box_cxcywh_to_xyxy(src_boxes.detach()), - box_ops.box_cxcywh_to_xyxy(target_boxes), - )[0] - ) + if self.oriented and src_boxes.shape[-1] == 5: + iou_targets = probiou(src_boxes.detach(), target_boxes) + else: + # Encoder path: compare against the target's axis-aligned envelope + # (see loss_boxes) so the IoU-aware weighting is not systematically + # understated for rotated ground truth. + if self.oriented and target_boxes.shape[-1] == 5: + target_boxes = obb_to_aabb(target_boxes) + iou_targets = torch.diag( + box_ops.box_iou( + box_ops.box_cxcywh_to_xyxy(src_boxes.detach()[..., :4]), + box_ops.box_cxcywh_to_xyxy(target_boxes[..., :4]), + )[0] + ) pos_ious = iou_targets.clone().detach() prob = src_logits.sigmoid() # init positive weights and negative weights @@ -329,12 +340,13 @@ def loss_labels( elif self.use_position_supervised_loss: src_boxes = outputs["pred_boxes"][idx] - target_boxes = torch.cat([t["boxes"][i] for t, (_, i) in zip(targets, indices)], dim=0) + tgt_key = "boxes_obb" if self.oriented else "boxes" + target_boxes = torch.cat([t[tgt_key][i] for t, (_, i) in zip(targets, indices)], dim=0) iou_targets = torch.diag( box_ops.box_iou( - box_ops.box_cxcywh_to_xyxy(src_boxes.detach()), - box_ops.box_cxcywh_to_xyxy(target_boxes), + box_ops.box_cxcywh_to_xyxy(src_boxes.detach()[..., :4]), + box_ops.box_cxcywh_to_xyxy(target_boxes[..., :4]), )[0] ) pos_ious = iou_targets.clone().detach() @@ -367,12 +379,13 @@ def loss_labels( elif self.use_varifocal_loss: src_boxes = outputs["pred_boxes"][idx] - target_boxes = torch.cat([t["boxes"][i] for t, (_, i) in zip(targets, indices)], dim=0) + tgt_key = "boxes_obb" if self.oriented else "boxes" + target_boxes = torch.cat([t[tgt_key][i] for t, (_, i) in zip(targets, indices)], dim=0) iou_targets = torch.diag( box_ops.box_iou( - box_ops.box_cxcywh_to_xyxy(src_boxes.detach()), - box_ops.box_cxcywh_to_xyxy(target_boxes), + box_ops.box_cxcywh_to_xyxy(src_boxes.detach()[..., :4]), + box_ops.box_cxcywh_to_xyxy(target_boxes[..., :4]), )[0] ) pos_ious = iou_targets.clone().detach() @@ -462,24 +475,39 @@ def loss_boxes( ) -> dict[str, Tensor]: """Compute the losses related to the bounding boxes, the L1 regression loss and the GIoU loss targets dicts must contain the key "boxes" containing a tensor of dim [nb_target_boxes, 4] The target boxes are expected in format - (center_x, center_y, w, h), normalized by the image size.""" + (center_x, center_y, w, h), normalized by the image size. + + In oriented mode the targets are read from "boxes_obb" instead, with dim [nb_target_boxes, 5] as (center_x, + center_y, w, h, angle), and the GIoU term is replaced by ProbIoU under the "loss_kld" key. + """ assert "pred_boxes" in outputs idx = self._get_src_permutation_idx(indices) src_boxes = outputs["pred_boxes"][idx] - target_boxes = torch.cat([t["boxes"][i] for t, (_, i) in zip(targets, indices)], dim=0) - - loss_bbox = F.l1_loss(src_boxes, target_boxes, reduction="none") + tgt_key = "boxes_obb" if self.oriented else "boxes" + target_boxes = torch.cat([t[tgt_key][i] for t, (_, i) in zip(targets, indices)], dim=0) losses = {} - losses["loss_bbox"] = loss_bbox.sum() / num_boxes - loss_giou = 1 - torch.diag( - box_ops.generalized_box_iou( - box_ops.box_cxcywh_to_xyxy(src_boxes), - box_ops.box_cxcywh_to_xyxy(target_boxes), + if self.oriented and src_boxes.shape[-1] == 5: + loss_bbox = F.l1_loss(src_boxes[..., :4], target_boxes[..., :4], reduction="none") + losses["loss_bbox"] = loss_bbox.sum() / num_boxes + losses["loss_kld"] = (1 - probiou(src_boxes, target_boxes)).sum() / num_boxes + else: + # The two-stage encoder predicts 4D proposals even in oriented mode, so + # reduce the 5D target to its axis-aligned envelope. Slicing [..., :4] + # would compare rotated side lengths against axis-aligned extents. + if self.oriented and target_boxes.shape[-1] == 5: + target_boxes = obb_to_aabb(target_boxes) + loss_bbox = F.l1_loss(src_boxes[..., :4], target_boxes[..., :4], reduction="none") + losses["loss_bbox"] = loss_bbox.sum() / num_boxes + loss_giou = 1 - torch.diag( + box_ops.generalized_box_iou( + box_ops.box_cxcywh_to_xyxy(src_boxes[..., :4]), + box_ops.box_cxcywh_to_xyxy(target_boxes[..., :4]), + ) ) - ) - losses["loss_giou"] = loss_giou.sum() / num_boxes + losses["loss_giou"] = loss_giou.sum() / num_boxes + return losses def loss_masks( diff --git a/src/rfdetr/models/lwdetr.py b/src/rfdetr/models/lwdetr.py index 259af7f31..c62f8e8dc 100644 --- a/src/rfdetr/models/lwdetr.py +++ b/src/rfdetr/models/lwdetr.py @@ -133,6 +133,7 @@ def __init__( use_grouppose_keypoints: bool = False, num_keypoints_per_class: list[int] | None = None, grouppose_keypoint_dim_downscale: int = 1, + oriented: bool = False, ) -> None: """Initializes the model. @@ -145,6 +146,7 @@ def __init__( aux_loss: True if auxiliary decoding losses (loss at each decoder layer) are to be used. group_detr: Number of groups to speed detr training. Default is 1. lite_refpoint_refine: TODO + oriented: If True, add an angle prediction head for oriented bounding boxes. """ super().__init__() self.num_queries = num_queries @@ -152,6 +154,8 @@ def __init__( hidden_dim = transformer.d_model self.class_embed = nn.Linear(hidden_dim, num_classes) self.bbox_embed = MLP(hidden_dim, hidden_dim, 4, 3) + self.oriented = oriented + self.angle_embed = MLP(hidden_dim, hidden_dim, 1, 3) if oriented else None self.segmentation_head = segmentation_head query_dim = 4 self.refpoint_embed = nn.Embedding(num_queries * group_detr, query_dim) @@ -211,6 +215,11 @@ def __init__( nn.init.constant_(bbox_out_layer.weight.data, 0) nn.init.constant_(bbox_out_layer.bias.data, 0) + if self.angle_embed is not None: + angle_out_layer = cast(nn.Linear, self.angle_embed.layers[-1]) + nn.init.constant_(angle_out_layer.weight.data, 0) + nn.init.constant_(angle_out_layer.bias.data, 0) + # two_stage self.two_stage = two_stage if self.two_stage: @@ -527,6 +536,10 @@ def forward(self, samples: NestedTensor, targets: list[dict[str, Tensor]] | None else: outputs_coord = (self.bbox_embed(hs) + ref_unsigmoid).sigmoid() + if self.angle_embed is not None: + angle = self.angle_embed(hs).sigmoid() * math.pi + outputs_coord = torch.cat([outputs_coord, angle], dim=-1) + outputs_class = self.class_embed(hs) outputs_keypoints = None @@ -646,6 +659,9 @@ def forward_export(self, tensors: Tensor) -> tuple[Tensor, ...]: outputs_coord = torch.concat([outputs_coord_cxcy, outputs_coord_wh], dim=-1) else: outputs_coord = (self.bbox_embed(hs) + ref_unsigmoid).sigmoid() + if self.angle_embed is not None: + angle = self.angle_embed(hs).sigmoid() * math.pi + outputs_coord = torch.cat([outputs_coord, angle], dim=-1) outputs_class = self.class_embed(hs) if self.use_grouppose_keypoints and self.keypoint_embed is not None: if keypoint_hs is None: @@ -841,6 +857,7 @@ def build_model(args: "BuilderArgs") -> LWDETR | tuple[Any, None, None]: use_grouppose_keypoints=getattr(args, "use_grouppose_keypoints", False), num_keypoints_per_class=getattr(args, "num_keypoints_per_class", []), grouppose_keypoint_dim_downscale=getattr(args, "grouppose_keypoint_dim_downscale", 1), + oriented=getattr(args, "oriented", False), ) return model @@ -850,6 +867,12 @@ def build_criterion_and_postprocessors(args: "BuilderArgs") -> tuple[SetCriterio matcher = build_matcher(args) weight_dict = {"loss_ce": args.cls_loss_coef, "loss_bbox": args.bbox_loss_coef} weight_dict["loss_giou"] = args.giou_loss_coef + if getattr(args, "oriented", False): + # The oriented decoder reports ProbIoU under "loss_kld", but the two-stage + # encoder still reports plain GIoU under "loss_giou". Both need a weight: + # SetCriterion drops any loss key missing from weight_dict, so registering + # only one of them silently excludes the other from the optimised total. + weight_dict["loss_kld"] = args.giou_loss_coef if args.segmentation_head: weight_dict["loss_mask_ce"] = args.mask_ce_loss_coef weight_dict["loss_mask_dice"] = args.mask_dice_loss_coef @@ -909,8 +932,8 @@ def build_criterion_and_postprocessors(args: "BuilderArgs") -> tuple[SetCriterio postprocess = PostProcess( num_select=args.num_select, num_keypoints_per_class=getattr(args, "num_keypoints_per_class", []), - # Older detection-only namespaces may omit keypoint postprocess knobs; keep the ModelConfig default. trace_alpha=getattr(args, "postprocess_trace_alpha", 0.2), + oriented=getattr(args, "oriented", False), ) return criterion, postprocess diff --git a/src/rfdetr/models/matcher.py b/src/rfdetr/models/matcher.py index f3e68f151..dfaf7af38 100644 --- a/src/rfdetr/models/matcher.py +++ b/src/rfdetr/models/matcher.py @@ -33,6 +33,7 @@ from rfdetr.models.heads.segmentation import point_sample from rfdetr.utilities.box_ops import batch_dice_loss, batch_sigmoid_ce_loss, box_cxcywh_to_xyxy, generalized_box_iou from rfdetr.utilities.logger import get_logger +from rfdetr.utilities.rotated_box_ops import obb_to_aabb, probiou_pairwise logger = get_logger() _SANITIZED_COST_MARGIN = 1.0 @@ -69,6 +70,7 @@ def __init__( keypoint_findable_loss_coef: float = 0.0, keypoint_visible_loss_coef: float = 0.0, keypoint_nll_loss_coef: float = 0.0, + oriented: bool = False, ): """Creates the matcher. @@ -82,6 +84,7 @@ def __init__( mask_point_sample_ratio: Downsampling ratio for mask point sampling. cost_mask_ce: Relative weight of the binary cross-entropy mask cost. cost_mask_dice: Relative weight of the Dice mask cost. + oriented: If ``True``, use ProbIoU cost instead of GIoU for rotated boxes. """ super().__init__() self.cost_class = cost_class @@ -97,6 +100,7 @@ def __init__( self.keypoint_findable_loss_coef = keypoint_findable_loss_coef self.keypoint_visible_loss_coef = keypoint_visible_loss_coef self.keypoint_nll_loss_coef = keypoint_nll_loss_coef + self.oriented = oriented self._warned_non_finite_costs = False @staticmethod @@ -172,11 +176,12 @@ def forward( # We flatten to compute the cost matrices in a batch flat_pred_logits = outputs["pred_logits"].flatten(0, 1) out_prob = flat_pred_logits.sigmoid() # [batch_size * num_queries, num_classes] - out_bbox = outputs["pred_boxes"].flatten(0, 1) # [batch_size * num_queries, 4] + out_bbox = outputs["pred_boxes"].flatten(0, 1) # [batch_size * num_queries, 4 or 5] # Also concat the target labels and boxes tgt_ids = torch.cat([v["labels"] for v in targets]) - tgt_bbox = torch.cat([v["boxes"] for v in targets]) + tgt_bbox_key = "boxes_obb" if self.oriented else "boxes" + tgt_bbox = torch.cat([v[tgt_bbox_key] for v in targets]) tgt_keypoints = None masks_present = "masks" in targets[0] @@ -184,9 +189,19 @@ def forward( if keypoints_present: tgt_keypoints = torch.cat([v["keypoints"] for v in targets], dim=0) - # Compute the giou cost between boxes - giou = generalized_box_iou(box_cxcywh_to_xyxy(out_bbox), box_cxcywh_to_xyxy(tgt_bbox)) - cost_giou = -giou + # In oriented mode the encoder (two-stage) emits 4D proposals with no angle, + # while the decoder emits 5D. The target is 5D either way, so on the encoder + # path it must be reduced to its axis-aligned envelope — slicing [..., :4] + # would reuse the rotated side lengths as axis-aligned extents. + oriented_decoder = self.oriented and out_bbox.shape[-1] == 5 + oriented_encoder = self.oriented and out_bbox.shape[-1] != 5 and tgt_bbox.shape[-1] == 5 + tgt_bbox_spatial = obb_to_aabb(tgt_bbox) if oriented_encoder else tgt_bbox[..., :4] + + if oriented_decoder: + cost_giou = probiou_pairwise(out_bbox, tgt_bbox) + else: + giou = generalized_box_iou(box_cxcywh_to_xyxy(out_bbox[..., :4]), box_cxcywh_to_xyxy(tgt_bbox_spatial)) + cost_giou = -giou # Compute the classification cost. alpha = self.focal_alpha @@ -199,8 +214,9 @@ def forward( pos_cost_class = alpha * ((1 - out_prob) ** gamma) * (-F.logsigmoid(flat_pred_logits)) cost_class = pos_cost_class[:, tgt_ids] - neg_cost_class[:, tgt_ids] - # Compute the L1 cost between boxes - cost_bbox = torch.cdist(out_bbox, tgt_bbox, p=1) + # L1 over spatial dims only; tgt_bbox_spatial is the envelope on the encoder path. + out_bbox_spatial = out_bbox[..., :4] if self.oriented else out_bbox + cost_bbox = torch.cdist(out_bbox_spatial, tgt_bbox_spatial, p=1) if masks_present: tgt_masks = torch.cat([v["masks"] for v in targets]) @@ -285,7 +301,7 @@ def forward( self._warned_non_finite_costs = True cost_matrix = self._sanitize_cost_matrix(cost_matrix) - sizes = [len(v["boxes"]) for v in targets] + sizes = [len(v[tgt_bbox_key]) for v in targets] indices = [] if num_queries % group_detr != 0: raise ValueError(f"num_queries ({num_queries}) must be divisible by group_detr ({group_detr})") @@ -322,6 +338,7 @@ def build_matcher(args: Any) -> HungarianMatcher: Configured HungarianMatcher instance. """ # Detection-only matcher args may omit keypoint costs; zero defaults disable keypoint matching terms. + oriented = getattr(args, "oriented", False) common_kwargs = { "cost_class": args.set_cost_class, "cost_bbox": args.set_cost_bbox, @@ -332,6 +349,7 @@ def build_matcher(args: Any) -> HungarianMatcher: "keypoint_findable_loss_coef": getattr(args, "keypoint_findable_loss_coef", 0.0), "keypoint_visible_loss_coef": getattr(args, "keypoint_visible_loss_coef", 0.0), "keypoint_nll_loss_coef": getattr(args, "keypoint_nll_loss_coef", 0.0), + "oriented": oriented, } if args.segmentation_head: return HungarianMatcher( diff --git a/src/rfdetr/models/postprocess.py b/src/rfdetr/models/postprocess.py index 3b96fb8a1..b209f507c 100644 --- a/src/rfdetr/models/postprocess.py +++ b/src/rfdetr/models/postprocess.py @@ -14,6 +14,7 @@ from torch import nn from rfdetr.utilities import box_ops +from rfdetr.utilities.rotated_box_ops import box_cxcywha_to_corners # Number of masks upsampled per interpolation call. Bounds peak memory when many masks are # resized to full image resolution (e.g. K=300 at 1080p would otherwise allocate gigabytes). @@ -23,9 +24,9 @@ class PostProcess(nn.Module): """Convert raw RF-DETR model outputs into per-image prediction tensors. - The postprocessor is shared by detection, segmentation, and keypoint inference. It selects top scoring query/class - pairs, scales boxes back to the requested image sizes, and then delegates to the head-specific private helper for - masks, keypoints, or box-only results. + The postprocessor is shared by detection, segmentation, keypoint, and oriented-box inference. It selects top scoring + query/class pairs, scales boxes back to the requested image sizes, and then delegates to the head-specific private + helper for masks, keypoints, oriented boxes, or box-only results. """ def __init__( @@ -33,11 +34,13 @@ def __init__( num_select: int = 300, num_keypoints_per_class: list[int] | None = None, trace_alpha: float = 0.2, + oriented: bool = False, ) -> None: super().__init__() self.num_select = num_select self.num_keypoints_per_class = num_keypoints_per_class or [] self.trace_alpha = trace_alpha + self.oriented = oriented @torch.no_grad() def forward(self, outputs: dict[str, torch.Tensor], target_sizes: torch.Tensor) -> list[dict[str, torch.Tensor]]: @@ -59,7 +62,17 @@ def forward(self, outputs: dict[str, torch.Tensor], target_sizes: torch.Tensor) out_keypoints = outputs.get("pred_keypoints") self._validate_outputs(out_logits, out_masks, out_keypoints, target_sizes) + if self.oriented and out_masks is not None: + raise ValueError( + "Segmentation head is not supported together with oriented=True. " + "Disable the segmentation head or set oriented=False." + ) + scores, labels, topk_boxes = self._select_topk(out_logits) + + if self.oriented: + return self._postprocess_oriented(out_bbox, scores, labels, topk_boxes, target_sizes) + boxes = self._gather_and_scale_boxes(out_bbox, topk_boxes, target_sizes) if out_masks is not None: @@ -116,6 +129,43 @@ def _select_topk(self, out_logits: torch.Tensor) -> tuple[torch.Tensor, torch.Te labels = topk_indexes % out_logits.shape[2] return scores, labels, topk_boxes + def _postprocess_oriented( + self, + out_bbox: torch.Tensor, + scores: torch.Tensor, + labels: torch.Tensor, + topk_boxes: torch.Tensor, + target_sizes: torch.Tensor, + ) -> list[dict[str, torch.Tensor]]: + """Gather oriented boxes and return corner representations. + + Args: + out_bbox: Normalized ``[cx, cy, w, h, angle]`` boxes with shape ``(B, Q, 5)``. + scores: Selected scores with shape ``(B, K)``. + labels: Selected class labels with shape ``(B, K)``. + topk_boxes: Query indices selected by :meth:`_select_topk`. + target_sizes: Per-image ``(height, width)`` tensor. + + Returns: + One dict per image with ``scores``, ``labels``, ``boxes_obb`` (scaled), and ``corners``. + """ + box_dim = out_bbox.shape[-1] + obb = torch.gather(out_bbox, 1, topk_boxes.unsqueeze(-1).repeat(1, 1, box_dim)).clone() + img_h, img_w = target_sizes.unbind(1) + scale_fct = torch.stack([img_w, img_h, img_w, img_h], dim=1).to(obb.dtype) + obb[..., :4] = obb[..., :4] * scale_fct[:, None, :] + corners = box_cxcywha_to_corners(obb) + # Axis-aligned xyxy envelope of each OBB — required by COCO eval callback and torchmetrics + x_min = corners[..., 0].min(dim=-1).values + y_min = corners[..., 1].min(dim=-1).values + x_max = corners[..., 0].max(dim=-1).values + y_max = corners[..., 1].max(dim=-1).values + boxes_xyxy = torch.stack([x_min, y_min, x_max, y_max], dim=-1) + return [ + {"scores": sc, "labels": lb, "boxes_obb": ob, "corners": cn, "boxes": bx} + for sc, lb, ob, cn, bx in zip(scores, labels, obb, corners, boxes_xyxy) + ] + @staticmethod def _gather_and_scale_boxes( out_bbox: torch.Tensor, diff --git a/src/rfdetr/training/callbacks/coco_eval.py b/src/rfdetr/training/callbacks/coco_eval.py index 7297ac31d..1004f8a9a 100644 --- a/src/rfdetr/training/callbacks/coco_eval.py +++ b/src/rfdetr/training/callbacks/coco_eval.py @@ -1143,6 +1143,11 @@ def _convert_preds(self, preds: list[dict[str, Tensor]]) -> list[dict[str, Tenso def _convert_targets(self, targets: list[dict[str, Tensor]]) -> list[dict[str, Tensor]]: """Convert targets from normalised CxCyWH to absolute xyxy boxes. + For oriented boxes (``corners`` key present), the axis-aligned envelope + of the 4 OBB corners is used instead of treating the rotated-box dims as + an axis-aligned rectangle (which would produce wrong/smaller GT boxes and + inflate IoU scores above 1.0). + Also passes ``iscrowd`` and ``masks`` through unchanged. Args: @@ -1155,8 +1160,27 @@ def _convert_targets(self, targets: list[dict[str, Tensor]]) -> list[dict[str, T out = [] for t in targets: h, w = t["orig_size"].tolist() - scale = t["boxes"].new_tensor([w, h, w, h]) - boxes = box_cxcywh_to_xyxy(t["boxes"]) * scale + if "corners" in t and t["corners"].numel() > 0: + # OBB path: corners are in *post-transform* pixel space, but PostProcess + # scales predictions to orig_size. Rescale so GT and predictions share a + # coordinate system — otherwise IoU is computed across two different + # spaces and mAP is meaningless whenever size != orig_size. + corners = t["corners"].float() # (N, 4, 2) + size = t.get("size") + if size is not None: + sh, sw = (int(v) for v in size.tolist()) + if (sh, sw) != (h, w) and sh > 0 and sw > 0: + corners = corners * corners.new_tensor([w / sw, h / sh]) + x_min = corners[..., 0].min(dim=-1).values + y_min = corners[..., 1].min(dim=-1).values + x_max = corners[..., 0].max(dim=-1).values + y_max = corners[..., 1].max(dim=-1).values + boxes = torch.stack([x_min, y_min, x_max, y_max], dim=-1) + elif len(t["boxes"]) == 0: + boxes = t["boxes"].new_zeros((0, 4)) + else: + scale = t["boxes"].new_tensor([w, h, w, h]) + boxes = box_cxcywh_to_xyxy(t["boxes"]) * scale entry: dict[str, Tensor] = {"boxes": boxes, "labels": t["labels"]} if "masks" in t: masks = t["masks"].bool() diff --git a/src/rfdetr/utilities/rotated_box_ops.py b/src/rfdetr/utilities/rotated_box_ops.py new file mode 100644 index 000000000..4e6916bb2 --- /dev/null +++ b/src/rfdetr/utilities/rotated_box_ops.py @@ -0,0 +1,331 @@ +# ------------------------------------------------------------------------ +# RF-DETR +# Copyright (c) 2025 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ + +"""Utilities for oriented (rotated) bounding box manipulation, IoU, and losses.""" + +import math + +import torch + +# Lower bound on box side length before it is treated as degenerate. Sizes are +# normalised to [0, 1] during training, so 1e-4 is a tenth of a pixel at 1024px. +_SIZE_EPS = 1e-4 +# Pure division guard for covariance determinants. This must stay far below any +# legitimate value: a 10px box at 1024px normalises to a determinant of ~6e-10, so +# a larger floor silently rescales the inverse covariance and collapses the +# Mahalanobis term — making distant small boxes score as near-identical. +_DET_EPS = 1e-20 + + +def normalize_angle(angle: torch.Tensor) -> torch.Tensor: + """Normalize angles to [0, pi) with pi-periodicity. + + Args: + angle: Angles in radians, arbitrary range. + + Returns: + Angles in [0, pi). + """ + out = angle - torch.floor(angle / math.pi) * math.pi + # Float32 rounding can push the result outside the half-open interval: a tiny + # negative input (which atan2 produces for near-axis-aligned boxes) rounds up to + # exactly pi, and large-magnitude inputs lose enough precision in the subtraction + # to land slightly below zero. Fold both back in — pi is equivalent to 0 here. + out = torch.where(out >= math.pi, torch.zeros_like(out), out) + return out.clamp(min=0.0) + + +def box_cxcywha_to_corners(boxes: torch.Tensor) -> torch.Tensor: + """Convert oriented boxes from center format to four corner points. + + Args: + boxes: Tensor of shape ``(..., 5)`` as ``[cx, cy, w, h, angle]`` + where angle is in radians. + + Returns: + Tensor of shape ``(..., 4, 2)`` with four corner coordinates. + """ + cx, cy, w, h, angle = boxes.unbind(-1) + cos_a = torch.cos(angle) + sin_a = torch.sin(angle) + + hw = w / 2 + hh = h / 2 + + dx_w = hw * cos_a + dy_w = hw * sin_a + dx_h = hh * -sin_a + dy_h = hh * cos_a + + c1 = torch.stack([cx - dx_w - dx_h, cy - dy_w - dy_h], dim=-1) + c2 = torch.stack([cx + dx_w - dx_h, cy + dy_w - dy_h], dim=-1) + c3 = torch.stack([cx + dx_w + dx_h, cy + dy_w + dy_h], dim=-1) + c4 = torch.stack([cx - dx_w + dx_h, cy - dy_w + dy_h], dim=-1) + + return torch.stack([c1, c2, c3, c4], dim=-2) + + +def corners_to_cxcywha(corners: torch.Tensor) -> torch.Tensor: + """Convert four corner points to oriented box center format. + + ``w`` is taken from the first listed edge and ``h`` from the second, so the + result follows the annotation's own corner order rather than a canonical + convention. + + Known limitation: this makes the parameterisation depend on corner order and + winding. The same physical box yields ``(w=10, h=4, angle=0)`` or + ``(w=4, h=10, angle=pi/2)`` depending on which corner the file lists first, and + DOTA annotations contain both windings. ProbIoU is invariant to the difference, + but the L1 term regresses ``w`` and ``h`` directly, so this injects some noise + into ``loss_bbox``. Canonicalising to a long-edge convention (``w >= h``) would + fix it, but it re-parameterises ~90% of DOTA ground truth and therefore requires + training from scratch — see ``test_winding_changes_parameterisation``. + + Args: + corners: Tensor of shape ``(..., 4, 2)`` with four corner points + ordered sequentially around the box. + + Returns: + Tensor of shape ``(..., 5)`` as ``[cx, cy, w, h, angle]``. + """ + cx = corners[..., :, 0].mean(dim=-1) + cy = corners[..., :, 1].mean(dim=-1) + + edge_w = corners[..., 1, :] - corners[..., 0, :] + edge_h = corners[..., 3, :] - corners[..., 0, :] + + w = torch.linalg.norm(edge_w, dim=-1) + h = torch.linalg.norm(edge_h, dim=-1) + + angle = normalize_angle(torch.atan2(edge_w[..., 1], edge_w[..., 0])) + + return torch.stack([cx, cy, w, h, angle], dim=-1) + + +def obb_to_aabb(boxes: torch.Tensor) -> torch.Tensor: + """Reduce oriented boxes to their axis-aligned bounding envelope. + + Needed wherever a 5D oriented box has to be compared against a 4D + axis-aligned one — notably the two-stage encoder, whose proposals carry no + angle. Slicing ``boxes[..., :4]`` instead is wrong: it reuses the rotated + side lengths as if they were axis-aligned extents, so a 40x10 box at 45 + degrees is compared as 40x10 rather than its true 35.36x35.36 envelope. + + Args: + boxes: Oriented boxes of shape ``(..., 5)`` as ``[cx, cy, w, h, angle]``. + + Returns: + Tensor of shape ``(..., 4)`` as ``[cx, cy, w, h]``, centre preserved. + """ + cx, cy, w, h, angle = boxes.unbind(-1) + cos_a = torch.cos(angle).abs() + sin_a = torch.sin(angle).abs() + return torch.stack([cx, cy, w * cos_a + h * sin_a, w * sin_a + h * cos_a], dim=-1) + + +def _obb_to_gaussian( + boxes: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor]: + """Convert oriented boxes to 2D Gaussian distributions. + + Each box ``[cx, cy, w, h, angle]`` maps to a Gaussian with mean + ``(cx, cy)`` and covariance ``R @ diag(w^2/4, h^2/4) @ R^T``. + + Args: + boxes: Tensor of shape ``(..., 5)``. + + Returns: + Tuple of ``(mu, sigma)`` where ``mu`` has shape ``(..., 2)`` and + ``sigma`` has shape ``(..., 2, 2)``. + """ + cx, cy, w, h, angle = boxes.unbind(-1) + mu = torch.stack([cx, cy], dim=-1) + + cos_a = torch.cos(angle) + sin_a = torch.sin(angle) + + w = w.clamp(min=_SIZE_EPS) + h = h.clamp(min=_SIZE_EPS) + var_w = (w * w) / 4 + var_h = (h * h) / 4 + + a = var_w * cos_a * cos_a + var_h * sin_a * sin_a + b = (var_w - var_h) * cos_a * sin_a + d = var_w * sin_a * sin_a + var_h * cos_a * cos_a + + sigma = torch.stack([a, b, b, d], dim=-1).reshape(*boxes.shape[:-1], 2, 2) + + return mu, sigma + + +def gwd_loss(pred: torch.Tensor, target: torch.Tensor, tau: float = 1.0) -> torch.Tensor: + """Gaussian Wasserstein Distance between paired oriented boxes. + + Uses the closed-form 2nd Wasserstein distance between two 2D Gaussians + derived from the box parameters. + + Not wired into training: SetCriterion uses :func:`probiou` for the oriented + regression term. Kept as a validated alternative for experimentation. + + Args: + pred: Predicted boxes ``(..., 5)`` as ``[cx, cy, w, h, angle]``. + target: Target boxes ``(..., 5)``, same shape as pred. + tau: Temperature parameter for loss normalization. + + Returns: + Per-box GWD loss, same leading shape as inputs. + """ + mu_p, sigma_p = _obb_to_gaussian(pred) + mu_t, sigma_t = _obb_to_gaussian(target) + + diff_mu = mu_p - mu_t + term_center = (diff_mu * diff_mu).sum(dim=-1) + + trace_p = sigma_p[..., 0, 0] + sigma_p[..., 1, 1] + trace_t = sigma_t[..., 0, 0] + sigma_t[..., 1, 1] + + product = torch.bmm(sigma_p.reshape(-1, 2, 2), sigma_t.reshape(-1, 2, 2)).reshape(*sigma_p.shape) + trace_product = product[..., 0, 0] + product[..., 1, 1] + det_p = sigma_p[..., 0, 0] * sigma_p[..., 1, 1] - sigma_p[..., 0, 1] * sigma_p[..., 1, 0] + det_t = sigma_t[..., 0, 0] * sigma_t[..., 1, 1] - sigma_t[..., 0, 1] * sigma_t[..., 1, 0] + det_sqrt = (det_p.clamp(min=_DET_EPS) * det_t.clamp(min=_DET_EPS)).sqrt() + trace_sqrt = (trace_product + 2 * det_sqrt).clamp(min=_DET_EPS).sqrt() + + w2 = (term_center + trace_p + trace_t - 2 * trace_sqrt).clamp(min=0) + + return 1 - 1 / (tau + torch.log1p(w2)) + + +def kld_loss(pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + """KL Divergence loss between oriented boxes modeled as 2D Gaussians. + + Scale-invariant and aspect-ratio adaptive: elongated objects receive + stronger angular gradients. + + Not wired into training, despite the name of the ``"loss_kld"`` key that + SetCriterion reports — that key carries :func:`probiou`. Kept as a validated + alternative for experimentation. + + Args: + pred: Predicted boxes ``(..., 5)`` as ``[cx, cy, w, h, angle]``. + target: Target boxes ``(..., 5)``, same shape as pred. + + Returns: + Per-box KLD loss, same leading shape as inputs. + """ + mu_p, sigma_p = _obb_to_gaussian(pred) + mu_t, sigma_t = _obb_to_gaussian(target) + + det_p = (sigma_p[..., 0, 0] * sigma_p[..., 1, 1] - sigma_p[..., 0, 1] ** 2).clamp(min=_DET_EPS) + det_t = (sigma_t[..., 0, 0] * sigma_t[..., 1, 1] - sigma_t[..., 0, 1] ** 2).clamp(min=_DET_EPS) + + inv_t00 = sigma_t[..., 1, 1] / det_t + inv_t01 = -sigma_t[..., 0, 1] / det_t + inv_t11 = sigma_t[..., 0, 0] / det_t + + trace_term = inv_t00 * sigma_p[..., 0, 0] + 2 * inv_t01 * sigma_p[..., 0, 1] + inv_t11 * sigma_p[..., 1, 1] + + diff = mu_p - mu_t + mahal_term = inv_t00 * diff[..., 0] ** 2 + 2 * inv_t01 * diff[..., 0] * diff[..., 1] + inv_t11 * diff[..., 1] ** 2 + + log_det_term = torch.log(det_t) - torch.log(det_p) + + kld = 0.5 * (trace_term + mahal_term + log_det_term - 2) + + # Clamp before log1p: raw KLD can go slightly negative from floating-point + # error when the predicted covariance is near-degenerate. + return torch.log1p(kld.clamp(min=0)) + + +def probiou(pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor: + """Probabilistic IoU via Bhattacharyya coefficient between Gaussian-encoded boxes. + + Returns a similarity score in ``[0, 1]`` where 1 means identical boxes. + + Args: + pred: Predicted boxes ``(..., 5)`` as ``[cx, cy, w, h, angle]``. + target: Target boxes ``(..., 5)``, same shape as pred. + + Returns: + Per-box ProbIoU similarity, same leading shape as inputs. + """ + mu_p, sigma_p = _obb_to_gaussian(pred) + mu_t, sigma_t = _obb_to_gaussian(target) + + sigma_avg = (sigma_p + sigma_t) / 2 + det_avg = (sigma_avg[..., 0, 0] * sigma_avg[..., 1, 1] - sigma_avg[..., 0, 1] ** 2).clamp(min=_DET_EPS) + det_p = (sigma_p[..., 0, 0] * sigma_p[..., 1, 1] - sigma_p[..., 0, 1] ** 2).clamp(min=_DET_EPS) + det_t = (sigma_t[..., 0, 0] * sigma_t[..., 1, 1] - sigma_t[..., 0, 1] ** 2).clamp(min=_DET_EPS) + + inv_avg00 = sigma_avg[..., 1, 1] / det_avg + inv_avg01 = -sigma_avg[..., 0, 1] / det_avg + inv_avg11 = sigma_avg[..., 0, 0] / det_avg + + diff = mu_p - mu_t + mahal = inv_avg00 * diff[..., 0] ** 2 + 2 * inv_avg01 * diff[..., 0] * diff[..., 1] + inv_avg11 * diff[..., 1] ** 2 + + log_coeff = 0.5 * (torch.log(det_avg) - 0.5 * (torch.log(det_p) + torch.log(det_t))) + bd = 0.125 * mahal + log_coeff + + hd_squared = (1 - torch.exp(-bd.clamp(max=50))).clamp(min=0) + return 1 - hd_squared + + +def probiou_pairwise(boxes1: torch.Tensor, boxes2: torch.Tensor) -> torch.Tensor: + """Pairwise ProbIoU cost matrix for Hungarian matching. + + Args: + boxes1: Predicted boxes of shape ``(N, 5)`` as ``[cx, cy, w, h, angle]``. + boxes2: Target boxes of shape ``(M, 5)``. + + Returns: + Cost matrix of shape ``(N, M)`` with values in ``[0, 1]``; + 0 = identical boxes, 1 = no overlap. + """ + return 1 - probiou(boxes1[:, None, :], boxes2[None, :, :]) + + +def gwd_pairwise(boxes1: torch.Tensor, boxes2: torch.Tensor, tau: float = 1.0) -> torch.Tensor: + """Pairwise GWD cost matrix for Hungarian matching. + + Not wired into training: HungarianMatcher uses :func:`probiou_pairwise` for + the oriented cost. Kept as a validated alternative for experimentation, + alongside :func:`gwd_loss` and :func:`kld_loss`. + + Args: + boxes1: Predicted boxes of shape ``(N, 5)``. + boxes2: Target boxes of shape ``(M, 5)``. + tau: Temperature parameter. + + Returns: + Cost matrix of shape ``(N, M)``. + """ + mu_p, sigma_p = _obb_to_gaussian(boxes1) + mu_t, sigma_t = _obb_to_gaussian(boxes2) + + diff_mu = mu_p[:, None, :] - mu_t[None, :, :] + term_center = (diff_mu * diff_mu).sum(dim=-1) + + trace_p = sigma_p[..., 0, 0] + sigma_p[..., 1, 1] + trace_t = sigma_t[..., 0, 0] + sigma_t[..., 1, 1] + + n, m = boxes1.shape[0], boxes2.shape[0] + + sp_exp = sigma_p[:, None, :, :].expand(n, m, 2, 2).reshape(n * m, 2, 2) + st_exp = sigma_t[None, :, :, :].expand(n, m, 2, 2).reshape(n * m, 2, 2) + + product = torch.bmm(sp_exp, st_exp).reshape(n, m, 2, 2) + trace_product = product[..., 0, 0] + product[..., 1, 1] + + det_p = (sigma_p[..., 0, 0] * sigma_p[..., 1, 1] - sigma_p[..., 0, 1] ** 2).clamp(min=_DET_EPS) + det_t = (sigma_t[..., 0, 0] * sigma_t[..., 1, 1] - sigma_t[..., 0, 1] ** 2).clamp(min=_DET_EPS) + + det_sqrt = (det_p[:, None] * det_t[None, :]).sqrt() + trace_sqrt = (trace_product + 2 * det_sqrt).clamp(min=_DET_EPS).sqrt() + + w2 = (term_center + trace_p[:, None] + trace_t[None, :] - 2 * trace_sqrt).clamp(min=0) + + return 1 - 1 / (tau + torch.log1p(w2)) diff --git a/tests/datasets/test_dota_detection.py b/tests/datasets/test_dota_detection.py new file mode 100644 index 000000000..8334b4a16 --- /dev/null +++ b/tests/datasets/test_dota_detection.py @@ -0,0 +1,334 @@ +# ------------------------------------------------------------------------ +# RF-DETR +# Copyright (c) 2025 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ + +import math +from pathlib import Path + +import pytest +import torch +from PIL import Image + +from rfdetr.datasets.dota_detection import ( + DOTA_V1_CLASSES, + DotaDetection, + DotaNormalize, + OBBGeometricTransform, + build_dota, + corners_list_to_tensor, + make_dota_transforms, + parse_dota_annotation, +) +from rfdetr.utilities.rotated_box_ops import corners_to_cxcywha + + +@pytest.fixture() +def dota_root(tmp_path: Path) -> Path: + """Create a minimal DOTA directory with one image and annotation.""" + images_dir = tmp_path / "images" + labels_dir = tmp_path / "labelTxt" + images_dir.mkdir() + labels_dir.mkdir() + + img = Image.new("RGB", (100, 100), color="red") + img.save(images_dir / "P0001.png") + + ann_text = "10 10 50 10 50 40 10 40 plane 0\n60 60 90 60 90 90 60 90 ship 0\n20 20 30 20 30 30 20 30 plane 1\n" + (labels_dir / "P0001.txt").write_text(ann_text) + + return tmp_path + + +class TestParseDotaAnnotation: + def test_parses_valid_lines(self, dota_root: Path) -> None: + ann_path = dota_root / "labelTxt" / "P0001.txt" + annotations = parse_dota_annotation(ann_path) + assert len(annotations) == 3 + + def test_annotation_fields(self, dota_root: Path) -> None: + ann_path = dota_root / "labelTxt" / "P0001.txt" + ann = parse_dota_annotation(ann_path)[0] + assert ann["category"] == "plane" + assert ann["difficulty"] == 0 + assert len(ann["corners"]) == 8 + + def test_skips_short_lines(self, tmp_path: Path) -> None: + ann_path = tmp_path / "test.txt" + ann_path.write_text("10 20 30\n10 10 50 10 50 40 10 40 plane 0\n") + annotations = parse_dota_annotation(ann_path) + assert len(annotations) == 1 + + def test_empty_file(self, tmp_path: Path) -> None: + ann_path = tmp_path / "empty.txt" + ann_path.write_text("") + assert parse_dota_annotation(ann_path) == [] + + def test_difficulty_defaults_to_zero(self, tmp_path: Path) -> None: + ann_path = tmp_path / "no_diff.txt" + ann_path.write_text("10 10 50 10 50 40 10 40 plane\n") + ann = parse_dota_annotation(ann_path)[0] + assert ann["difficulty"] == 0 + + +class TestCornersListToTensor: + def test_shape(self) -> None: + result = corners_list_to_tensor([0, 0, 10, 0, 10, 5, 0, 5]) + assert result.shape == (4, 2) + + def test_values(self) -> None: + result = corners_list_to_tensor([1, 2, 3, 4, 5, 6, 7, 8]) + expected = torch.tensor([[1, 2], [3, 4], [5, 6], [7, 8]], dtype=torch.float32) + assert torch.equal(result, expected) + + +class TestDotaDetection: + def test_len(self, dota_root: Path) -> None: + dataset = DotaDetection(root=dota_root) + assert len(dataset) == 1 + + def test_getitem_returns_image_and_target(self, dota_root: Path) -> None: + dataset = DotaDetection(root=dota_root) + image, target = dataset[0] + assert isinstance(image, Image.Image) + assert "boxes_obb" in target + assert "labels" in target + assert "corners" in target + + def test_filters_difficult_by_default(self, dota_root: Path) -> None: + dataset = DotaDetection(root=dota_root) + _, target = dataset[0] + assert target["labels"].shape[0] == 2 + + def test_includes_difficult_when_flag_set(self, dota_root: Path) -> None: + dataset = DotaDetection(root=dota_root, include_difficult=True) + _, target = dataset[0] + assert target["labels"].shape[0] == 3 + + def test_boxes_obb_shape(self, dota_root: Path) -> None: + dataset = DotaDetection(root=dota_root) + _, target = dataset[0] + assert target["boxes_obb"].shape == (2, 5) + + def test_corners_shape(self, dota_root: Path) -> None: + dataset = DotaDetection(root=dota_root) + _, target = dataset[0] + assert target["corners"].shape == (2, 4, 2) + + def test_labels_are_valid_indices(self, dota_root: Path) -> None: + dataset = DotaDetection(root=dota_root) + _, target = dataset[0] + assert (target["labels"] >= 0).all() + assert (target["labels"] < len(DOTA_V1_CLASSES)).all() + + def test_skips_unknown_categories(self, dota_root: Path) -> None: + (dota_root / "labelTxt" / "P0001.txt").write_text("10 10 50 10 50 40 10 40 unknown_category 0\n") + dataset = DotaDetection(root=dota_root) + _, target = dataset[0] + assert target["labels"].shape[0] == 0 + + def test_missing_images_dir_raises(self, tmp_path: Path) -> None: + (tmp_path / "labelTxt").mkdir() + with pytest.raises(FileNotFoundError): + DotaDetection(root=tmp_path) + + def test_missing_annotation_file_returns_empty(self, dota_root: Path) -> None: + (dota_root / "labelTxt" / "P0001.txt").unlink() + dataset = DotaDetection(root=dota_root) + _, target = dataset[0] + assert target["labels"].shape[0] == 0 + + def test_axis_aligned_box_angle_near_zero(self, dota_root: Path) -> None: + (dota_root / "labelTxt" / "P0001.txt").write_text("0 0 10 0 10 5 0 5 plane 0\n") + dataset = DotaDetection(root=dota_root) + _, target = dataset[0] + angle = target["boxes_obb"][0, 4].item() + assert abs(angle) < 0.01 or abs(angle - math.pi) < 0.01 + + +class TestDotaNormalize: + def test_normalizes_boxes(self) -> None: + """Centre and size scale by width and height respectively. + + Asserts exact values: a w/h scale swap would keep both components below 1.0, + so a bounds-only check cannot detect it. + """ + normalize = DotaNormalize() + image = torch.rand(3, 100, 200) # H=100, W=200 + corners = torch.tensor([[[10, 10], [50, 10], [50, 40], [10, 40]]], dtype=torch.float32) + target = {"corners": corners, "boxes_obb": torch.zeros(1, 5)} + + _, target_out = normalize(image, target) + # cx=30/200, cy=25/100, w=40/200, h=30/100 + expected = torch.tensor([0.15, 0.25, 0.20, 0.30]) + assert torch.allclose(target_out["boxes_obb"][0, :4], expected, atol=1e-5) + + def test_boxes_alias_matches_boxes_obb(self) -> None: + """The ``boxes`` alias consumed by the COCO eval callback stays in sync.""" + normalize = DotaNormalize() + image = torch.rand(3, 100, 200) + corners = torch.tensor([[[10, 10], [50, 10], [50, 40], [10, 40]]], dtype=torch.float32) + target = {"corners": corners, "boxes_obb": torch.zeros(1, 5)} + + _, target_out = normalize(image, target) + assert torch.allclose(target_out["boxes"], target_out["boxes_obb"][..., :4]) + + def test_zero_area_box_is_dropped(self) -> None: + """A box collapsed to zero area by augmentation must not reach the loss. + + _obb_to_gaussian clamps degenerate sizes to a floor, so an unfiltered w=0 box trains silently against a + meaningless target instead of failing loudly. + """ + normalize = DotaNormalize() + image = torch.rand(3, 100, 100) + corners = torch.tensor( + [ + [[10, 10], [50, 10], [50, 40], [10, 40]], + [[0, 0], [0, 0], [0, 0], [0, 0]], + ], + dtype=torch.float32, + ) + target = {"corners": corners, "boxes_obb": torch.zeros(2, 5), "labels": torch.tensor([3, 7])} + + _, target_out = normalize(image, target) + assert target_out["boxes_obb"].shape == (1, 5) + assert target_out["labels"].tolist() == [3] + assert target_out["corners"].shape == (1, 4, 2) + + def test_size_refreshed_to_post_transform_shape(self) -> None: + """``size`` must track the transformed image, not the original.""" + normalize = DotaNormalize() + image = torch.rand(3, 64, 128) + _, target_out = normalize(image, {"size": torch.as_tensor([999, 999])}) + assert target_out["size"].tolist() == [64, 128] + + def test_none_target_passthrough(self) -> None: + normalize = DotaNormalize() + image = torch.rand(3, 100, 100) + image_out, target_out = normalize(image, None) + assert target_out is None + + def test_empty_corners(self) -> None: + normalize = DotaNormalize() + image = torch.rand(3, 100, 100) + target = {"corners": torch.zeros(0, 4, 2), "boxes_obb": torch.zeros(0, 5)} + _, target_out = normalize(image, target) + assert target_out["boxes_obb"].shape == (0, 5) + + +class TestOBBGeometricTransform: + """Corners must survive augmentation as a rigid quadrilateral.""" + + def test_horizontal_flip_mirrors_x_only(self) -> None: + alb = pytest.importorskip("albumentations") + transform = OBBGeometricTransform(alb.HorizontalFlip(p=1.0)) + image = Image.new("RGB", (100, 50)) + corners = torch.tensor([[[10.0, 5.0], [40.0, 5.0], [40.0, 20.0], [10.0, 20.0]]]) + + _, out = transform(image, {"corners": corners, "labels": torch.tensor([0])}) + + assert torch.allclose(out["corners"][0, :, 0], torch.tensor([89.0, 59.0, 59.0, 89.0])) + assert torch.allclose(out["corners"][0, :, 1], corners[0, :, 1]) + + def test_out_of_bounds_corners_are_not_clamped(self) -> None: + """Clipping corners individually would deform the box. + + The four corners of a rotated box are not independent, so clamping each one into the frame produces a different + quadrilateral rather than a cropped box. + """ + alb = pytest.importorskip("albumentations") + transform = OBBGeometricTransform(alb.NoOp()) + image = Image.new("RGB", (100, 100)) + corners = torch.tensor([[[-20.0, 10.0], [60.0, -30.0], [100.0, 20.0], [20.0, 60.0]]]) + + _, out = transform(image, {"corners": corners, "labels": torch.tensor([0])}) + + assert torch.allclose(out["corners"], corners, atol=1e-4) + + def test_geometry_preserved_for_box_crossing_edge(self) -> None: + """Width and angle of a partially out-of-frame box survive the transform.""" + alb = pytest.importorskip("albumentations") + transform = OBBGeometricTransform(alb.NoOp()) + image = Image.new("RGB", (100, 100)) + corners = torch.tensor([[[-20.0, 10.0], [60.0, -30.0], [100.0, 20.0], [20.0, 60.0]]]) + + _, out = transform(image, {"corners": corners, "labels": torch.tensor([0])}) + + assert torch.allclose(corners_to_cxcywha(out["corners"]), corners_to_cxcywha(corners), atol=1e-4) + + def test_two_boxes_keep_their_own_corners(self) -> None: + """Instance ids must route each corner back to the box it came from.""" + alb = pytest.importorskip("albumentations") + transform = OBBGeometricTransform(alb.HorizontalFlip(p=1.0)) + image = Image.new("RGB", (100, 100)) + corners = torch.tensor( + [ + [[0.0, 0.0], [10.0, 0.0], [10.0, 5.0], [0.0, 5.0]], + [[50.0, 50.0], [80.0, 50.0], [80.0, 70.0], [50.0, 70.0]], + ] + ) + + _, out = transform(image, {"corners": corners, "labels": torch.tensor([0, 1])}) + + assert torch.allclose(out["corners"][0, :, 0], torch.tensor([99.0, 89.0, 89.0, 99.0])) + assert torch.allclose(out["corners"][1, :, 0], torch.tensor([49.0, 19.0, 19.0, 49.0])) + + def test_empty_corners_passthrough(self) -> None: + alb = pytest.importorskip("albumentations") + transform = OBBGeometricTransform(alb.HorizontalFlip(p=1.0)) + image = Image.new("RGB", (32, 32)) + target = {"corners": torch.zeros(0, 4, 2), "labels": torch.zeros(0, dtype=torch.int64)} + + image_out, out = transform(image, target) + + assert image_out.size == (32, 32) + assert out["corners"].shape == (0, 4, 2) + + +class TestMakeDotaTransforms: + def test_train_returns_compose(self) -> None: + transforms = make_dota_transforms("train", 512) + assert transforms is not None + + def test_val_returns_compose(self) -> None: + transforms = make_dota_transforms("val", 512) + assert transforms is not None + + +class TestBuildDota: + def test_builds_dataset(self, dota_root: Path) -> None: + import types + + args = types.SimpleNamespace(dataset_dir=str(dota_root.parent)) + root_with_split = dota_root.parent / "train" + root_with_split.mkdir(exist_ok=True) + (root_with_split / "images").mkdir(exist_ok=True) + (root_with_split / "labelTxt").mkdir(exist_ok=True) + img = Image.new("RGB", (50, 50), color="blue") + img.save(root_with_split / "images" / "test.png") + (root_with_split / "labelTxt" / "test.txt").write_text("5 5 20 5 20 20 5 20 plane 0\n") + args.dataset_dir = str(dota_root.parent) + dataset = build_dota("train", args, 256) + assert isinstance(dataset, DotaDetection) + + def test_getitem_after_transforms_normalizes_coords(self, tmp_path: Path) -> None: + """Geometric transforms must update corners; normalized box coords must be in [0, 1].""" + import types + + root = tmp_path / "val" + (root / "images").mkdir(parents=True) + (root / "labelTxt").mkdir() + img = Image.new("RGB", (100, 100), color="green") + img.save(root / "images" / "img.png") + (root / "labelTxt" / "img.txt").write_text("5 5 40 5 40 40 5 40 plane 0\n") + + args = types.SimpleNamespace(dataset_dir=str(tmp_path)) + dataset = build_dota("val", args, 64) + + image_tensor, target = dataset[0] + + assert target["boxes_obb"].shape[0] == 1, "one box should survive transforms" + obb = target["boxes_obb"][0] + assert (obb[:4] >= 0).all() and (obb[:4] <= 1).all(), "normalized box coords out of [0, 1]" + assert 0.0 <= obb[4].item() < math.pi, "angle out of [0, pi)" diff --git a/tests/models/test_obb_export.py b/tests/models/test_obb_export.py new file mode 100644 index 000000000..6e6428ef7 --- /dev/null +++ b/tests/models/test_obb_export.py @@ -0,0 +1,69 @@ +# ------------------------------------------------------------------------ +# RF-DETR +# Copyright (c) 2025 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ + +import math +from unittest.mock import MagicMock + +import torch + +from rfdetr.models.lwdetr import LWDETR + + +def _make_exportable_oriented_lwdetr() -> LWDETR: + """Build a minimal oriented LWDETR that can run forward_export.""" + hidden_dim = 8 + num_queries = 4 + num_classes = 3 + + backbone = MagicMock() + src = torch.randn(1, hidden_dim, 4, 4) + mask = torch.zeros(1, 4, 4, dtype=torch.bool) + pos = torch.randn(1, hidden_dim, 4, 4) + # forward_export() calls the *exported* backbone, whose Joiner.forward_export + # returns (feats, masks, poss, cross_attn_feats) — 4 values, unlike the 3-value + # Joiner.forward used on the training path. + backbone.return_value = ([src], [mask], [pos], None) + + transformer = MagicMock() + transformer.d_model = hidden_dim + hs = torch.randn(1, 1, num_queries, hidden_dim) + ref = torch.randn(1, 1, num_queries, 4) + transformer.return_value = (hs, ref, None, None) + transformer.decoder = MagicMock() + transformer.decoder.bbox_embed = None + + model = LWDETR( + backbone=backbone, + transformer=transformer, + segmentation_head=None, + num_classes=num_classes, + num_queries=num_queries, + group_detr=1, + oriented=True, + bbox_reparam=False, + ) + return model + + +class TestOrientedExportForward: + def test_forward_export_output_has_angle(self) -> None: + model = _make_exportable_oriented_lwdetr() + model.eval() + model._export = True + tensors = torch.randn(1, 3, 32, 32) + dets, labels = model.forward_export(tensors) + assert dets.shape[-1] == 5 + assert labels.shape[-1] == 3 + + def test_forward_export_angle_range(self) -> None: + model = _make_exportable_oriented_lwdetr() + model.eval() + model._export = True + tensors = torch.randn(1, 3, 32, 32) + dets, _ = model.forward_export(tensors) + angles = dets[..., 4] + assert (angles >= 0).all() + assert (angles <= math.pi).all() diff --git a/tests/models/test_obb_head.py b/tests/models/test_obb_head.py new file mode 100644 index 000000000..c464022ae --- /dev/null +++ b/tests/models/test_obb_head.py @@ -0,0 +1,94 @@ +# ------------------------------------------------------------------------ +# RF-DETR +# Copyright (c) 2025 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ + +import math +from unittest.mock import MagicMock + +import pytest +import torch + +from rfdetr.models.lwdetr import LWDETR + + +def _make_lwdetr(*, oriented: bool, num_classes: int = 91) -> LWDETR: + """Construct a minimal LWDETR for testing the angle head.""" + hidden_dim = 8 + backbone = MagicMock() + transformer = MagicMock() + transformer.d_model = hidden_dim + transformer.decoder = MagicMock() + transformer.decoder.bbox_embed = None + return LWDETR( + backbone=backbone, + transformer=transformer, + segmentation_head=None, + num_classes=num_classes, + num_queries=4, + group_detr=1, + oriented=oriented, + ) + + +def _predict_angle(model: LWDETR, hs: torch.Tensor) -> torch.Tensor: + """Apply the angle projection exactly as ``LWDETR.forward`` does. + + Mirrors lwdetr.py:532 and lwdetr.py:654, which share this expression. + """ + assert model.angle_embed is not None + return model.angle_embed(hs).sigmoid() * math.pi + + +class TestLWDETRAngleHead: + """The angle head lives inline in LWDETR, not in a separate head module.""" + + @pytest.mark.parametrize( + ("oriented", "expected"), + [pytest.param(True, True, id="oriented"), pytest.param(False, False, id="axis-aligned")], + ) + def test_angle_embed_present_only_when_oriented(self, oriented: bool, expected: bool) -> None: + model = _make_lwdetr(oriented=oriented) + assert (model.angle_embed is not None) is expected + assert model.oriented is expected + + def test_angle_embed_is_zero_initialised(self) -> None: + """The final layer is zero-init'd (lwdetr.py:213-215) so training starts neutral.""" + model = _make_lwdetr(oriented=True) + assert model.angle_embed is not None + assert torch.count_nonzero(model.angle_embed.layers[-1].weight) == 0 + assert torch.count_nonzero(model.angle_embed.layers[-1].bias) == 0 + + def test_initial_angle_is_pi_over_two(self) -> None: + """Zero-init means sigmoid(0)*pi, i.e. every box starts at 90 degrees. + + This is a consequence of the zero-init above, not an arbitrary constant: a non-zero init would bias every query + toward some other orientation. + """ + model = _make_lwdetr(oriented=True) + angle = _predict_angle(model, torch.randn(2, 5, 8)) + assert torch.allclose(angle, torch.full_like(angle, math.pi / 2), atol=1e-6) + + def test_angle_output_shape(self) -> None: + model = _make_lwdetr(oriented=True) + assert _predict_angle(model, torch.randn(2, 5, 8)).shape == (2, 5, 1) + + def test_angle_stays_in_range_for_extreme_features(self) -> None: + """The sigmoid()*pi projection bounds the angle whatever the feature magnitude.""" + model = _make_lwdetr(oriented=True) + torch.nn.init.normal_(model.angle_embed.layers[-1].weight, std=10.0) # type: ignore[union-attr] + angle = _predict_angle(model, torch.randn(4, 6, 8) * 100) + assert bool((angle >= 0).all()) + assert bool((angle <= math.pi).all()) + + def test_angle_gradients_reach_the_features(self) -> None: + model = _make_lwdetr(oriented=True) + torch.nn.init.normal_(model.angle_embed.layers[-1].weight, std=0.1) # type: ignore[union-attr] + hs = torch.randn(2, 5, 8, requires_grad=True) + + _predict_angle(model, hs).sum().backward() + + assert hs.grad is not None + assert torch.isfinite(hs.grad).all() + assert torch.count_nonzero(hs.grad) > 0 diff --git a/tests/models/test_obb_matcher_criterion.py b/tests/models/test_obb_matcher_criterion.py new file mode 100644 index 000000000..f5659a9c8 --- /dev/null +++ b/tests/models/test_obb_matcher_criterion.py @@ -0,0 +1,233 @@ +# ------------------------------------------------------------------------ +# RF-DETR +# Copyright (c) 2025 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ + +import math +from types import SimpleNamespace + +import pytest +import torch + +from rfdetr.models.criterion import SetCriterion +from rfdetr.models.lwdetr import build_criterion_and_postprocessors +from rfdetr.models.matcher import HungarianMatcher + +_BUILDER_ARGS = dict( + device="cpu", + two_stage=True, + aux_loss=True, + dec_layers=3, + cls_loss_coef=1.0, + bbox_loss_coef=5.0, + giou_loss_coef=2.0, + segmentation_head=None, + use_grouppose_keypoints=False, + sum_group_losses=False, + focal_alpha=0.25, + set_cost_class=2.0, + set_cost_bbox=5.0, + set_cost_giou=2.0, + num_classes=15, + ia_bce_loss=True, + group_detr=13, + use_varifocal_loss=False, + use_position_supervised_loss=False, + num_select=300, + use_focal_loss=True, +) + + +def _make_oriented_matcher() -> HungarianMatcher: + return HungarianMatcher(cost_class=1, cost_bbox=5, cost_giou=2, oriented=True) + + +def _make_oriented_criterion(*, ia_bce_loss: bool = False) -> SetCriterion: + matcher = _make_oriented_matcher() + weight_dict = {"loss_ce": 1.0, "loss_bbox": 5.0, "loss_giou": 2.0} + return SetCriterion( + num_classes=16, + matcher=matcher, + weight_dict=weight_dict, + focal_alpha=0.25, + losses=["labels", "boxes"], + ia_bce_loss=ia_bce_loss, + ) + + +class TestOrientedWeightDict: + """SetCriterion drops any loss key absent from weight_dict, without warning.""" + + @pytest.mark.parametrize( + "key", + [ + pytest.param("loss_giou_enc", id="encoder-giou"), + pytest.param("loss_kld", id="decoder-probiou"), + pytest.param("loss_bbox", id="l1"), + ], + ) + def test_oriented_two_stage_weights_every_reported_loss(self, key: str) -> None: + criterion, _ = build_criterion_and_postprocessors(SimpleNamespace(oriented=True, **_BUILDER_ARGS)) + assert criterion.weight_dict.get(key) is not None + + def test_non_oriented_weight_dict_has_no_probiou_term(self) -> None: + """The oriented-only loss_kld key must not leak into axis-aligned training.""" + criterion, _ = build_criterion_and_postprocessors(SimpleNamespace(oriented=False, **_BUILDER_ARGS)) + assert "loss_kld" not in criterion.weight_dict + assert criterion.weight_dict["loss_giou_enc"] == 2.0 + + +class TestOrientedMatcher: + def test_returns_valid_indices(self) -> None: + matcher = _make_oriented_matcher() + outputs = { + "pred_logits": torch.randn(1, 10, 16), + "pred_boxes": torch.rand(1, 10, 5), + } + targets = [ + { + "labels": torch.tensor([0, 3]), + "boxes_obb": torch.tensor( + [ + [0.5, 0.5, 0.2, 0.1, 0.3], + [0.3, 0.7, 0.15, 0.1, 1.0], + ] + ), + } + ] + indices = matcher(outputs, targets) + assert len(indices) == 1 + src_idx, tgt_idx = indices[0] + assert len(src_idx) == 2 + assert len(tgt_idx) == 2 + + def test_oriented_flag_stored(self) -> None: + matcher = _make_oriented_matcher() + assert matcher.oriented is True + + def test_non_oriented_default(self) -> None: + matcher = HungarianMatcher() + assert matcher.oriented is False + + +class TestOrientedCriterion: + def test_loss_boxes_returns_kld(self) -> None: + criterion = _make_oriented_criterion() + outputs = { + "pred_logits": torch.randn(1, 10, 16), + "pred_boxes": torch.rand(1, 10, 5) * 0.5 + 0.1, + } + outputs["pred_boxes"][..., 4] = outputs["pred_boxes"][..., 4] * math.pi + targets = [ + { + "labels": torch.tensor([0, 3]), + "boxes_obb": torch.tensor( + [ + [0.5, 0.5, 0.2, 0.1, 0.3], + [0.3, 0.7, 0.15, 0.1, 1.0], + ] + ), + } + ] + indices = [(torch.tensor([0, 1]), torch.tensor([0, 1]))] + losses = criterion.loss_boxes(outputs, targets, indices, num_boxes=2) + # 5D decoder boxes take the oriented branch: ProbIoU under the "loss_kld" + # key, with no "loss_giou" term (which is registered in weight_dict only + # for the non-oriented path). + assert set(losses) == {"loss_bbox", "loss_kld"} + assert losses["loss_bbox"].item() >= 0 + assert losses["loss_kld"].item() >= 0 + + def test_loss_kld_is_zero_for_perfect_predictions(self) -> None: + """A ProbIoU stub returning a constant would not distinguish these two cases.""" + criterion = _make_oriented_criterion() + boxes = torch.tensor([[0.5, 0.5, 0.2, 0.1, 0.3], [0.3, 0.7, 0.15, 0.1, 1.0]]) + outputs = {"pred_logits": torch.randn(1, 2, 16), "pred_boxes": boxes[None]} + targets = [{"labels": torch.tensor([0, 3]), "boxes_obb": boxes}] + indices = [(torch.tensor([0, 1]), torch.tensor([0, 1]))] + + losses = criterion.loss_boxes(outputs, targets, indices, num_boxes=2) + + assert losses["loss_kld"].item() == pytest.approx(0.0, abs=1e-4) + assert losses["loss_bbox"].item() == pytest.approx(0.0, abs=1e-6) + + def test_loss_kld_is_positive_for_mismatched_angle(self) -> None: + """An angle-only error must still register — L1 does not see the angle.""" + criterion = _make_oriented_criterion() + target_boxes = torch.tensor([[0.5, 0.5, 0.3, 0.05, 0.0]]) + pred_boxes = torch.tensor([[0.5, 0.5, 0.3, 0.05, math.pi / 2]]) + outputs = {"pred_logits": torch.randn(1, 1, 16), "pred_boxes": pred_boxes[None]} + targets = [{"labels": torch.tensor([0]), "boxes_obb": target_boxes}] + indices = [(torch.tensor([0]), torch.tensor([0]))] + + losses = criterion.loss_boxes(outputs, targets, indices, num_boxes=1) + + assert losses["loss_bbox"].item() == pytest.approx(0.0, abs=1e-6) + assert losses["loss_kld"].item() > 0.5 + + def test_ia_bce_oriented_branch_runs(self) -> None: + """The IA-BCE classification loss uses ProbIoU when oriented. + + criterion.py takes a separate oriented path here; the default fixture has ia_bce_loss=False, so this branch was + previously never executed. + """ + criterion = _make_oriented_criterion(ia_bce_loss=True) + boxes = torch.tensor([[0.5, 0.5, 0.2, 0.1, 0.3], [0.3, 0.7, 0.15, 0.1, 1.0]]) + outputs = {"pred_logits": torch.randn(1, 10, 16), "pred_boxes": torch.rand(1, 10, 5) * 0.5 + 0.1} + outputs["pred_boxes"][0, :2] = boxes + targets = [{"labels": torch.tensor([0, 3]), "boxes_obb": boxes}] + indices = [(torch.tensor([0, 1]), torch.tensor([0, 1]))] + + losses = criterion.loss_labels(outputs, targets, indices, num_boxes=2) + + assert "loss_ce" in losses + assert torch.isfinite(losses["loss_ce"]).all() + + def test_oriented_flag_propagated(self) -> None: + criterion = _make_oriented_criterion() + assert criterion.oriented is True + + def test_encoder_path_compares_against_the_axis_aligned_envelope(self) -> None: + """4D encoder proposals must be scored against the target's envelope. + + A 0.4x0.1 box at 45 degrees has a 0.354x0.354 envelope. A prediction that matches that envelope exactly should + incur ~0 L1; slicing [..., :4] instead would score it against the raw 0.4x0.1 sides and report a large error. + """ + criterion = _make_oriented_criterion() + angle = math.pi / 4 + target = torch.tensor([[0.5, 0.5, 0.4, 0.1, angle]]) + side = 0.4 * math.cos(angle) + 0.1 * math.sin(angle) + pred = torch.tensor([[0.5, 0.5, side, side]]) + outputs = {"pred_logits": torch.randn(1, 1, 16), "pred_boxes": pred[None]} + targets = [{"labels": torch.tensor([0]), "boxes_obb": target}] + + losses = criterion.loss_boxes(outputs, targets, [(torch.tensor([0]), torch.tensor([0]))], num_boxes=1) + + assert losses["loss_bbox"].item() == pytest.approx(0.0, abs=1e-5) + assert losses["loss_giou"].item() == pytest.approx(0.0, abs=1e-5) + + def test_encoder_matcher_prefers_the_envelope_match(self) -> None: + """The matcher must rank an envelope-shaped proposal above a raw-sides one.""" + matcher = _make_oriented_matcher() + angle = math.pi / 4 + side = 0.4 * math.cos(angle) + 0.1 * math.sin(angle) + # Query 0 matches the raw rotated sides, query 1 matches the true envelope. + pred = torch.tensor([[[0.5, 0.5, 0.4, 0.1], [0.5, 0.5, side, side]]]) + outputs = {"pred_logits": torch.zeros(1, 2, 16), "pred_boxes": pred} + targets = [{"labels": torch.tensor([0]), "boxes_obb": torch.tensor([[0.5, 0.5, 0.4, 0.1, angle]])}] + + src_idx, _ = matcher(outputs, targets)[0] + + assert src_idx.tolist() == [1] + + def test_non_oriented_criterion(self) -> None: + matcher = HungarianMatcher() + criterion = SetCriterion( + num_classes=91, + matcher=matcher, + weight_dict={"loss_ce": 1.0, "loss_bbox": 5.0, "loss_giou": 2.0}, + focal_alpha=0.25, + losses=["labels", "boxes"], + ) + assert criterion.oriented is False diff --git a/tests/models/test_obb_postprocess.py b/tests/models/test_obb_postprocess.py new file mode 100644 index 000000000..50cce2f80 --- /dev/null +++ b/tests/models/test_obb_postprocess.py @@ -0,0 +1,133 @@ +# ------------------------------------------------------------------------ +# RF-DETR +# Copyright (c) 2025 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ + +import math + +import pytest +import torch + +from rfdetr.models.postprocess import PostProcess + + +class TestPostProcessOriented: + def test_oriented_output_keys(self) -> None: + pp = PostProcess(num_select=5, oriented=True) + outputs = { + "pred_logits": torch.randn(1, 10, 3), + "pred_boxes": torch.rand(1, 10, 5), + } + target_sizes = torch.tensor([[480, 640]]) + results = pp(outputs, target_sizes) + assert len(results) == 1 + assert set(results[0]) == {"scores", "labels", "boxes_obb", "corners", "boxes"} + + def test_oriented_obb_shape(self) -> None: + pp = PostProcess(num_select=5, oriented=True) + outputs = { + "pred_logits": torch.randn(1, 10, 3), + "pred_boxes": torch.rand(1, 10, 5), + } + target_sizes = torch.tensor([[480, 640]]) + results = pp(outputs, target_sizes) + assert results[0]["boxes_obb"].shape == (5, 5) + assert results[0]["corners"].shape == (5, 4, 2) + + def test_oriented_scales_spatial_dims(self) -> None: + pp = PostProcess(num_select=5, oriented=True) + outputs = { + "pred_logits": torch.randn(1, 10, 3), + "pred_boxes": torch.full((1, 10, 5), 0.5), + } + target_sizes = torch.tensor([[100, 200]]) + results = pp(outputs, target_sizes) + obb = results[0]["boxes_obb"] + assert torch.allclose(obb[0, 0], torch.tensor(100.0), atol=1.0) + assert torch.allclose(obb[0, 1], torch.tensor(50.0), atol=1.0) + + def test_standard_postprocess_unchanged(self) -> None: + pp = PostProcess(num_select=5, oriented=False) + outputs = { + "pred_logits": torch.randn(1, 10, 3), + "pred_boxes": torch.rand(1, 10, 4), + } + target_sizes = torch.tensor([[480, 640]]) + results = pp(outputs, target_sizes) + assert "boxes" in results[0] + assert "boxes_obb" not in results[0] + + def test_batch_support(self) -> None: + pp = PostProcess(num_select=5, oriented=True) + outputs = { + "pred_logits": torch.randn(3, 10, 3), + "pred_boxes": torch.rand(3, 10, 5), + } + target_sizes = torch.tensor([[480, 640], [320, 320], [600, 800]]) + results = pp(outputs, target_sizes) + assert len(results) == 3 + + def test_each_image_scales_by_its_own_target_size(self) -> None: + """Per-image scale factors must not leak across the batch.""" + pp = PostProcess(num_select=1, oriented=True) + outputs = { + "pred_logits": torch.zeros(2, 1, 3), + "pred_boxes": torch.full((2, 1, 5), 0.5), + } + results = pp(outputs, torch.tensor([[100, 200], [400, 800]])) + assert results[0]["boxes_obb"][0, 0].item() == pytest.approx(100.0, abs=1.0) + assert results[1]["boxes_obb"][0, 0].item() == pytest.approx(400.0, abs=1.0) + + +class TestPostProcessOrientedEnvelope: + """The ``boxes`` xyxy envelope feeds the COCO eval callback and torchmetrics.""" + + def test_envelope_matches_corner_extremes(self) -> None: + pp = PostProcess(num_select=5, oriented=True) + outputs = { + "pred_logits": torch.randn(1, 10, 3), + "pred_boxes": torch.rand(1, 10, 5), + } + results = pp(outputs, torch.tensor([[480, 640]])) + + corners, boxes = results[0]["corners"], results[0]["boxes"] + expected = torch.stack( + [ + corners[..., 0].min(dim=-1).values, + corners[..., 1].min(dim=-1).values, + corners[..., 0].max(dim=-1).values, + corners[..., 1].max(dim=-1).values, + ], + dim=-1, + ) + assert torch.allclose(boxes, expected, atol=1e-5) + + def test_rotated_box_envelope_matches_projection_formula(self) -> None: + """A rotated box projects to w*|cos| + h*|sin| on x, and w*|sin| + h*|cos| on y. + + Reusing the raw w/h instead of the corner extremes would give 40x10 here rather than the correct 35.36x35.36, + mis-stating IoU against axis-aligned ground truth in both directions. + """ + pp = PostProcess(num_select=1, oriented=True) + angle = math.pi / 4 + boxes = torch.zeros(1, 1, 5) + boxes[0, 0] = torch.tensor([0.5, 0.5, 0.4, 0.1, angle]) + results = pp({"pred_logits": torch.zeros(1, 1, 3), "pred_boxes": boxes}, torch.tensor([[100, 100]])) + + box = results[0]["boxes"][0] + w_px, h_px = 40.0, 10.0 + expected_w = w_px * abs(math.cos(angle)) + h_px * abs(math.sin(angle)) + expected_h = w_px * abs(math.sin(angle)) + h_px * abs(math.cos(angle)) + assert (box[2] - box[0]).item() == pytest.approx(expected_w, abs=1e-3) + assert (box[3] - box[1]).item() == pytest.approx(expected_h, abs=1e-3) + + def test_axis_aligned_envelope_equals_box_dimensions(self) -> None: + """At angle 0 the envelope is exactly the box.""" + pp = PostProcess(num_select=1, oriented=True) + boxes = torch.zeros(1, 1, 5) + boxes[0, 0] = torch.tensor([0.5, 0.5, 0.4, 0.2, 0.0]) + results = pp({"pred_logits": torch.zeros(1, 1, 3), "pred_boxes": boxes}, torch.tensor([[100, 100]])) + + box = results[0]["boxes"][0] + assert box.tolist() == pytest.approx([30.0, 40.0, 70.0, 60.0], abs=1e-3) diff --git a/tests/training/callbacks/test_coco_eval_callback.py b/tests/training/callbacks/test_coco_eval_callback.py index 8188d936e..c4215f2d8 100644 --- a/tests/training/callbacks/test_coco_eval_callback.py +++ b/tests/training/callbacks/test_coco_eval_callback.py @@ -1265,6 +1265,79 @@ def test_no_masks_no_iscrowd_keys_absent(self) -> None: assert set(out[0].keys()) == {"boxes", "labels"} +class TestConvertTargetsOriented: + """_convert_targets() maps OBB corners into the same space as postprocessed predictions. + + ``corners`` are left in post-transform pixel space by ``DotaNormalize`` while ``PostProcess`` scales predictions to + ``orig_size``, so the callback must rescale by ``orig_size / size`` or IoU is computed across two coordinate + systems. + """ + + def test_corners_rescaled_from_size_to_orig_size(self) -> None: + """A 100x100 corner box at size=512 maps to 200x200 at orig_size=1024.""" + cb = COCOEvalCallback() + corners = torch.tensor([[[10.0, 10.0], [110.0, 10.0], [110.0, 110.0], [10.0, 110.0]]]) + targets = [ + { + "boxes": torch.zeros(1, 4), + "corners": corners, + "labels": torch.tensor([0]), + "orig_size": torch.tensor([1024, 1024]), + "size": torch.tensor([512, 512]), + } + ] + out = cb._convert_targets(targets) + assert out[0]["boxes"][0].tolist() == pytest.approx([20.0, 20.0, 220.0, 220.0]) + + def test_no_rescale_when_size_matches_orig_size(self) -> None: + """Corners pass through untouched when the image was never resized.""" + cb = COCOEvalCallback() + corners = torch.tensor([[[10.0, 20.0], [110.0, 20.0], [110.0, 120.0], [10.0, 120.0]]]) + targets = [ + { + "boxes": torch.zeros(1, 4), + "corners": corners, + "labels": torch.tensor([0]), + "orig_size": torch.tensor([256, 256]), + "size": torch.tensor([256, 256]), + } + ] + out = cb._convert_targets(targets) + assert out[0]["boxes"][0].tolist() == pytest.approx([10.0, 20.0, 110.0, 120.0]) + + def test_rotated_corners_use_axis_aligned_envelope(self) -> None: + """A diamond-shaped OBB yields its bounding envelope, not its edge lengths.""" + cb = COCOEvalCallback() + corners = torch.tensor([[[50.0, 0.0], [100.0, 50.0], [50.0, 100.0], [0.0, 50.0]]]) + targets = [ + { + "boxes": torch.zeros(1, 4), + "corners": corners, + "labels": torch.tensor([0]), + "orig_size": torch.tensor([100, 100]), + "size": torch.tensor([100, 100]), + } + ] + out = cb._convert_targets(targets) + assert out[0]["boxes"][0].tolist() == pytest.approx([0.0, 0.0, 100.0, 100.0]) + + def test_anisotropic_rescale_uses_per_axis_ratio(self) -> None: + """Width and height scale independently when the resize was not square.""" + cb = COCOEvalCallback() + corners = torch.tensor([[[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]]]) + targets = [ + { + "boxes": torch.zeros(1, 4), + "corners": corners, + "labels": torch.tensor([0]), + "orig_size": torch.tensor([400, 200]), # H=400, W=200 + "size": torch.tensor([100, 100]), + } + ] + out = cb._convert_targets(targets) + assert out[0]["boxes"][0].tolist() == pytest.approx([0.0, 0.0, 200.0, 400.0]) + + def _ema_callback() -> MagicMock: """Return a mock that ``_get_ema_callback`` recognises (has ``get_ema_model_state_dict``).""" cb = MagicMock(name="ema_callback") diff --git a/tests/training/test_obb_integration.py b/tests/training/test_obb_integration.py new file mode 100644 index 000000000..531eaad09 --- /dev/null +++ b/tests/training/test_obb_integration.py @@ -0,0 +1,36 @@ +# ------------------------------------------------------------------------ +# RF-DETR +# Copyright (c) 2025 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ + +from rfdetr._namespace import _namespace_from_configs +from rfdetr.config import RFDETRBaseConfig, TrainConfig + + +class TestOrientedNamespace: + def test_oriented_false_by_default(self) -> None: + mc = RFDETRBaseConfig(pretrain_weights=None) + tc = TrainConfig(dataset_dir="/tmp/fake") + ns = _namespace_from_configs(mc, tc) + assert ns.oriented is False + + def test_oriented_forwarded_to_namespace(self) -> None: + mc = RFDETRBaseConfig(pretrain_weights=None, oriented=True) + tc = TrainConfig(dataset_dir="/tmp/fake") + ns = _namespace_from_configs(mc, tc) + assert ns.oriented is True + + +class TestOrientedConfig: + def test_oriented_default_false(self) -> None: + mc = RFDETRBaseConfig(pretrain_weights=None) + assert mc.oriented is False + + def test_oriented_can_be_set(self) -> None: + mc = RFDETRBaseConfig(pretrain_weights=None, oriented=True) + assert mc.oriented is True + + def test_dota_dataset_file_accepted(self) -> None: + tc = TrainConfig(dataset_dir="/tmp/fake", dataset_file="dota") + assert tc.dataset_file == "dota" diff --git a/tests/utilities/test_rotated_box_ops.py b/tests/utilities/test_rotated_box_ops.py new file mode 100644 index 000000000..0ebf513bf --- /dev/null +++ b/tests/utilities/test_rotated_box_ops.py @@ -0,0 +1,406 @@ +# ------------------------------------------------------------------------ +# RF-DETR +# Copyright (c) 2025 Roboflow. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 [see LICENSE for details] +# ------------------------------------------------------------------------ + +import math + +import pytest +import torch + +from rfdetr.utilities.rotated_box_ops import ( + box_cxcywha_to_corners, + corners_to_cxcywha, + gwd_loss, + gwd_pairwise, + kld_loss, + normalize_angle, + probiou, + probiou_pairwise, +) + + +class TestNormalizeAngle: + def test_already_in_range(self) -> None: + angles = torch.tensor([0.0, 0.5, 1.0, math.pi - 0.01]) + result = normalize_angle(angles) + assert torch.allclose(result, angles, atol=1e-6) + + def test_negative_angles(self) -> None: + result = normalize_angle(torch.tensor([-0.5])) + assert 0 <= result.item() < math.pi + + def test_angles_beyond_pi(self) -> None: + result = normalize_angle(torch.tensor([math.pi + 0.5])) + expected = torch.tensor([0.5]) + assert torch.allclose(result, expected, atol=1e-6) + + def test_pi_periodicity(self) -> None: + angle = torch.tensor([0.3]) + shifted = torch.tensor([0.3 + math.pi]) + assert torch.allclose(normalize_angle(angle), normalize_angle(shifted), atol=1e-6) + + def test_two_pi(self) -> None: + result = normalize_angle(torch.tensor([2 * math.pi])) + assert torch.allclose(result, torch.tensor([0.0]), atol=1e-6) + + @pytest.mark.parametrize( + "angle", + [ + pytest.param(-0.5, id="small_negative"), + pytest.param(-1e-8, id="tiny_negative_rounds_up_to_pi"), + pytest.param(-1e-7, id="tiny_negative_larger"), + pytest.param(math.pi, id="exactly_pi"), + pytest.param(-math.pi, id="exactly_negative_pi"), + pytest.param(863140.0, id="large_positive_precision_loss"), + pytest.param(-1e6, id="large_negative"), + ], + ) + def test_stays_within_documented_range(self, angle: float) -> None: + """Output must satisfy the documented [0, pi) contract for any finite input. + + Float32 rounding previously pushed tiny negative inputs up to exactly pi and large-magnitude inputs below zero. + """ + result = normalize_angle(torch.tensor([angle])).item() + assert 0.0 <= result < math.pi, f"normalize_angle({angle}) = {result!r} escaped [0, pi)" + + def test_tiny_negative_from_atan2_maps_to_zero(self) -> None: + """A near-axis-aligned box must normalize to ~0, not wrap around to pi.""" + result = normalize_angle(torch.tensor([-1e-8])).item() + assert result == pytest.approx(0.0, abs=1e-6) + + +class TestBoxCxcywhaToCorners: + def test_axis_aligned_box(self) -> None: + box = torch.tensor([[10.0, 20.0, 6.0, 4.0, 0.0]]) + corners = box_cxcywha_to_corners(box) + assert corners.shape == (1, 4, 2) + expected = torch.tensor([[[7.0, 18.0], [13.0, 18.0], [13.0, 22.0], [7.0, 22.0]]]) + assert torch.allclose(corners, expected, atol=1e-5) + + def test_90_degree_rotation(self) -> None: + box = torch.tensor([[0.0, 0.0, 4.0, 2.0, math.pi / 2]]) + corners = box_cxcywha_to_corners(box) + assert corners.shape == (1, 4, 2) + xs = corners[0, :, 0] + ys = corners[0, :, 1] + assert torch.allclose(xs.min(), torch.tensor(-1.0), atol=1e-5) + assert torch.allclose(xs.max(), torch.tensor(1.0), atol=1e-5) + assert torch.allclose(ys.min(), torch.tensor(-2.0), atol=1e-5) + assert torch.allclose(ys.max(), torch.tensor(2.0), atol=1e-5) + + def test_batch_shape(self) -> None: + boxes = torch.rand(5, 3, 5) + corners = box_cxcywha_to_corners(boxes) + assert corners.shape == (5, 3, 4, 2) + + def test_center_is_mean_of_corners(self) -> None: + box = torch.tensor([[5.0, 10.0, 8.0, 3.0, 0.7]]) + corners = box_cxcywha_to_corners(box) + center = corners[0].mean(dim=0) + assert torch.allclose(center, torch.tensor([5.0, 10.0]), atol=1e-5) + + +class TestCornersToBoxCxcywha: + def test_roundtrip(self) -> None: + original = torch.tensor([[10.0, 20.0, 6.0, 4.0, 0.5]]) + corners = box_cxcywha_to_corners(original) + recovered = corners_to_cxcywha(corners) + assert torch.allclose(recovered, original, atol=1e-4) + + def test_roundtrip_batch(self) -> None: + """A batch of mixed orientations roundtrips exactly.""" + original = torch.tensor( + [ + [5.0, 5.0, 10.0, 4.0, 0.0], + [15.0, 25.0, 8.0, 6.0, 1.0], + [0.0, 0.0, 7.0, 3.0, 2.5], + ] + ) + corners = box_cxcywha_to_corners(original) + recovered = corners_to_cxcywha(corners) + assert torch.allclose(recovered, original, atol=1e-4) + + def test_roundtrip_axis_aligned(self) -> None: + original = torch.tensor([[0.0, 0.0, 4.0, 2.0, 0.0]]) + corners = box_cxcywha_to_corners(original) + recovered = corners_to_cxcywha(corners) + assert torch.allclose(recovered, original, atol=1e-4) + + def test_tall_box_keeps_its_own_parameterisation(self) -> None: + """A w < h box is preserved as given, not swapped onto a long-edge convention.""" + original = torch.tensor([[0.0, 0.0, 3.0, 7.0, 0.0]]) + recovered = corners_to_cxcywha(box_cxcywha_to_corners(original)) + assert torch.allclose(recovered, original, atol=1e-4) + + def test_decoded_rectangle_is_unchanged(self) -> None: + """Whatever the parameterisation, the decoded corners describe the same box.""" + original = torch.tensor([[1.0, 2.0, 3.0, 7.0, 0.4]]) + corners = box_cxcywha_to_corners(original) + redecoded = box_cxcywha_to_corners(corners_to_cxcywha(corners)) + assert torch.allclose(corners.sort(dim=-2).values, redecoded.sort(dim=-2).values, atol=1e-4) + + def test_winding_changes_parameterisation(self) -> None: + """Regression test documenting a known limitation, not desired behaviour. + + Clockwise and counter-clockwise corner order describe the *same rectangle* with different (w, h, angle) triples. + DOTA files contain both windings. ProbIoU is invariant to the difference, but the L1 term regresses w and h + directly, so this injects noise into loss_bbox. + + Long-edge canonicalisation would remove it at the cost of re-parameterising ~90% of DOTA ground truth, + invalidating any existing checkpoint. This test pins the current behaviour so the trade-off is a deliberate + choice. + """ + ccw = torch.tensor([[[0.0, 0.0], [10.0, 0.0], [10.0, 4.0], [0.0, 4.0]]]) + cw = torch.tensor([[[0.0, 0.0], [0.0, 4.0], [10.0, 4.0], [10.0, 0.0]]]) + + assert corners_to_cxcywha(ccw)[0].tolist() == pytest.approx([5.0, 2.0, 10.0, 4.0, 0.0], abs=1e-4) + assert corners_to_cxcywha(cw)[0].tolist() == pytest.approx([5.0, 2.0, 4.0, 10.0, math.pi / 2], abs=1e-4) + + def test_random_boxes_roundtrip_exactly(self) -> None: + """Encode/decode is lossless for arbitrary orientations and aspect ratios.""" + boxes = torch.rand(64, 5) * 10 + 1 + boxes[:, 4] = boxes[:, 4] % math.pi + recovered = corners_to_cxcywha(box_cxcywha_to_corners(boxes)) + assert torch.allclose(recovered, boxes, atol=1e-4) + + +class TestGwdLoss: + def test_identical_boxes_near_zero(self) -> None: + boxes = torch.tensor([[10.0, 20.0, 6.0, 4.0, 0.5]]) + loss = gwd_loss(boxes, boxes) + assert loss.shape == (1,) + assert loss.item() < 0.01 + + def test_different_boxes_positive(self) -> None: + pred = torch.tensor([[10.0, 20.0, 6.0, 4.0, 0.5]]) + target = torch.tensor([[15.0, 25.0, 8.0, 6.0, 1.0]]) + loss = gwd_loss(pred, target) + assert loss.item() > 0 + + def test_angle_boundary_symmetry(self) -> None: + box_a = torch.tensor([[10.0, 10.0, 6.0, 4.0, 0.01]]) + box_b = torch.tensor([[10.0, 10.0, 6.0, 4.0, math.pi - 0.01]]) + loss = gwd_loss(box_a, box_b) + assert loss.item() < 0.05 + + def test_batch(self) -> None: + pred = torch.rand(10, 5) * 10 + pred[..., 4] = pred[..., 4] % math.pi + target = torch.rand(10, 5) * 10 + target[..., 4] = target[..., 4] % math.pi + loss = gwd_loss(pred, target) + assert loss.shape == (10,) + + def test_gradients_flow(self) -> None: + pred = torch.tensor([[10.0, 20.0, 6.0, 4.0, 0.5]], requires_grad=True) + target = torch.tensor([[15.0, 25.0, 8.0, 6.0, 1.0]]) + loss = gwd_loss(pred, target).sum() + loss.backward() + assert pred.grad is not None + assert torch.isfinite(pred.grad).all() + + def test_angle_receives_gradient(self) -> None: + """The angle component must get a non-zero gradient. + + ``isfinite`` alone is satisfied by a constant loss; delivering angular gradient is the entire reason for using + GWD over plain L1. + """ + pred = torch.tensor([[10.0, 20.0, 8.0, 2.0, 0.2]], requires_grad=True) + target = torch.tensor([[10.0, 20.0, 8.0, 2.0, 1.1]]) + gwd_loss(pred, target).sum().backward() + assert pred.grad is not None + assert abs(pred.grad[0, 4].item()) > 1e-6 + + +class TestKldLoss: + def test_identical_boxes_zero(self) -> None: + boxes = torch.tensor([[10.0, 20.0, 6.0, 4.0, 0.5]]) + loss = kld_loss(boxes, boxes) + assert loss.shape == (1,) + assert torch.allclose(loss, torch.tensor([0.0]), atol=1e-5) + + def test_different_boxes_positive(self) -> None: + pred = torch.tensor([[10.0, 20.0, 6.0, 4.0, 0.5]]) + target = torch.tensor([[15.0, 25.0, 8.0, 6.0, 1.0]]) + loss = kld_loss(pred, target) + assert loss.item() > 0 + + def test_angle_sensitivity_scales_with_aspect_ratio(self) -> None: + thin_box = torch.tensor([[10.0, 10.0, 20.0, 2.0, 0.0]]) + thin_box_rotated = torch.tensor([[10.0, 10.0, 20.0, 2.0, 0.3]]) + square_box = torch.tensor([[10.0, 10.0, 5.0, 5.0, 0.0]]) + square_box_rotated = torch.tensor([[10.0, 10.0, 5.0, 5.0, 0.3]]) + + loss_thin = kld_loss(thin_box, thin_box_rotated) + loss_square = kld_loss(square_box, square_box_rotated) + + assert loss_thin.item() > loss_square.item() + + def test_gradients_flow(self) -> None: + pred = torch.tensor([[10.0, 20.0, 6.0, 4.0, 0.5]], requires_grad=True) + target = torch.tensor([[15.0, 25.0, 8.0, 6.0, 1.0]]) + loss = kld_loss(pred, target).sum() + loss.backward() + assert pred.grad is not None + assert torch.isfinite(pred.grad).all() + + def test_angle_receives_gradient(self) -> None: + """A finite gradient is not enough — the angle must actually be driven.""" + pred = torch.tensor([[10.0, 20.0, 8.0, 2.0, 0.2]], requires_grad=True) + target = torch.tensor([[10.0, 20.0, 8.0, 2.0, 1.1]]) + kld_loss(pred, target).sum().backward() + assert pred.grad is not None + assert abs(pred.grad[0, 4].item()) > 1e-6 + + +class TestProbiou: + def test_identical_boxes_one(self) -> None: + boxes = torch.tensor([[10.0, 20.0, 6.0, 4.0, 0.5]]) + score = probiou(boxes, boxes) + assert score.shape == (1,) + assert torch.allclose(score, torch.tensor([1.0]), atol=1e-4) + + def test_far_apart_boxes_near_zero(self) -> None: + pred = torch.tensor([[0.0, 0.0, 2.0, 2.0, 0.0]]) + target = torch.tensor([[1000.0, 1000.0, 2.0, 2.0, 0.0]]) + score = probiou(pred, target) + assert score.item() < 0.01 + + def test_range_zero_to_one(self) -> None: + pred = torch.rand(20, 5) * 10 + 1 + pred[..., 4] = pred[..., 4] % math.pi + target = torch.rand(20, 5) * 10 + 1 + target[..., 4] = target[..., 4] % math.pi + scores = probiou(pred, target) + assert bool((scores >= 0.0).all()) + assert bool((scores <= 1.0).all()) + + def test_batch(self) -> None: + pred = torch.rand(8, 5) * 10 + 1 + target = torch.rand(8, 5) * 10 + 1 + scores = probiou(pred, target) + assert scores.shape == (8,) + + +class TestProbiouPairwise: + """probiou_pairwise() is the Hungarian matching cost for oriented boxes. + + It returns a *cost* (0 = identical, 1 = no overlap), the complement of the + similarity returned by probiou(). + """ + + def test_output_shape(self) -> None: + boxes1 = torch.rand(5, 5) * 10 + 1 + boxes2 = torch.rand(3, 5) * 10 + 1 + boxes1[..., 4] = boxes1[..., 4] % math.pi + boxes2[..., 4] = boxes2[..., 4] % math.pi + cost = probiou_pairwise(boxes1, boxes2) + assert cost.shape == (5, 3) + + def test_self_cost_near_zero_on_diagonal(self) -> None: + boxes = torch.tensor( + [ + [10.0, 20.0, 6.0, 4.0, 0.5], + [5.0, 5.0, 3.0, 7.0, 1.2], + ] + ) + cost = probiou_pairwise(boxes, boxes) + assert torch.allclose(torch.diag(cost), torch.zeros(2), atol=1e-5) + + def test_diagonal_complements_paired_probiou(self) -> None: + """Diagonal of the cost matrix equals 1 - probiou() on the same pairs.""" + boxes1 = torch.tensor( + [ + [10.0, 20.0, 6.0, 4.0, 0.5], + [5.0, 5.0, 3.0, 7.0, 1.2], + ] + ) + boxes2 = torch.tensor( + [ + [11.0, 21.0, 6.0, 4.0, 0.6], + [5.0, 6.0, 3.5, 7.0, 1.0], + ] + ) + cost_matrix = probiou_pairwise(boxes1, boxes2) + assert torch.allclose(torch.diag(cost_matrix), 1 - probiou(boxes1, boxes2), atol=1e-5) + + def test_cost_in_unit_range(self) -> None: + boxes1 = torch.tensor([[10.0, 10.0, 5.0, 3.0, 0.0]]) + boxes2 = torch.tensor([[500.0, 500.0, 5.0, 3.0, 1.5]]) + cost = probiou_pairwise(boxes1, boxes2) + assert bool(((cost >= 0) & (cost <= 1)).all()) + + def test_disjoint_boxes_cost_near_one(self) -> None: + boxes1 = torch.tensor([[10.0, 10.0, 5.0, 3.0, 0.0]]) + boxes2 = torch.tensor([[900.0, 900.0, 5.0, 3.0, 0.0]]) + assert probiou_pairwise(boxes1, boxes2).item() == pytest.approx(1.0, abs=1e-4) + + +class TestGwdPairwise: + """gwd_pairwise() is an alternative matching cost, not wired into training.""" + + def test_output_shape(self) -> None: + boxes1 = torch.rand(5, 5) * 10 + 1 + boxes2 = torch.rand(3, 5) * 10 + 1 + boxes1[..., 4] = boxes1[..., 4] % math.pi + boxes2[..., 4] = boxes2[..., 4] % math.pi + cost = gwd_pairwise(boxes1, boxes2) + assert cost.shape == (5, 3) + + def test_diagonal_matches_paired(self) -> None: + """The pairwise diagonal must agree with the paired gwd_loss.""" + boxes = torch.tensor( + [ + [10.0, 20.0, 6.0, 4.0, 0.5], + [5.0, 5.0, 3.0, 7.0, 1.2], + ] + ) + cost_matrix = gwd_pairwise(boxes, boxes) + paired = gwd_loss(boxes, boxes) + assert torch.allclose(torch.diag(cost_matrix), paired, atol=1e-5) + + def test_self_cost_near_zero_on_diagonal(self) -> None: + boxes = torch.tensor( + [ + [10.0, 20.0, 6.0, 4.0, 0.5], + [5.0, 5.0, 3.0, 7.0, 1.2], + ] + ) + cost = gwd_pairwise(boxes, boxes) + assert torch.allclose(torch.diag(cost), torch.zeros(2), atol=0.01) + + +class TestEdgeCases: + def test_zero_size_box_gwd_no_crash(self) -> None: + pred = torch.tensor([[5.0, 5.0, 0.0, 0.0, 0.5]]) + target = torch.tensor([[5.0, 5.0, 3.0, 2.0, 0.5]]) + loss = gwd_loss(pred, target) + assert torch.isfinite(loss).all() + + def test_zero_size_box_kld_no_crash(self) -> None: + pred = torch.tensor([[5.0, 5.0, 0.0, 0.0, 0.5]]) + target = torch.tensor([[5.0, 5.0, 3.0, 2.0, 0.5]]) + loss = kld_loss(pred, target) + assert torch.isfinite(loss).all() + + def test_zero_size_box_probiou_no_crash(self) -> None: + pred = torch.tensor([[5.0, 5.0, 0.0, 0.0, 0.5]]) + target = torch.tensor([[5.0, 5.0, 3.0, 2.0, 0.5]]) + score = probiou(pred, target) + assert torch.isfinite(score).all() + + def test_very_large_boxes(self) -> None: + pred = torch.tensor([[500.0, 500.0, 1000.0, 800.0, 0.5]]) + target = torch.tensor([[500.0, 500.0, 1000.0, 800.0, 0.5]]) + assert gwd_loss(pred, target).item() < 0.01 + assert kld_loss(pred, target).item() < 0.01 + assert probiou(pred, target).item() > 0.99 + + def test_single_element_tensors(self) -> None: + pred = torch.tensor([5.0, 5.0, 3.0, 2.0, 0.5]).unsqueeze(0) + target = torch.tensor([5.0, 5.0, 3.0, 2.0, 0.5]).unsqueeze(0) + assert gwd_loss(pred, target).shape == (1,) + assert kld_loss(pred, target).shape == (1,) + assert probiou(pred, target).shape == (1,)