Coverage for collectra/tasks/object_detection/rfdetr_data.py: 93.06%
72 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
1"""Multi-channel TIFF support for the RF-DETR training data pipeline.
3RF-DETR's ``CocoDetection`` decodes every image with ``PIL.Image.convert("RGB")``,
4which drops channels beyond three and cannot open most multispectral TIFFs. This
5module swaps the built datasets for a subclass that decodes TIFFs with
6``read_tiff_channels`` into CHW tensors, and replaces the ImageNet ``Normalize``
7with one that matches the image's channel count.
9Kept separate from ``rfdetr.py`` so that importing the task does not import
10``rfdetr``, and so DataLoader worker processes can unpickle these classes.
11"""
13from __future__ import annotations
15import contextlib
16from pathlib import Path
17from typing import Any, Iterator
19import numpy as np
20import torch
21from rfdetr.datasets.coco import CocoDetection
22from rfdetr.datasets.transforms import AlbumentationsWrapper, Normalize
23from torchvision import tv_tensors
25from collectra.types.images import read_tiff_channels
27TIFF_SUFFIXES = {".tif", ".tiff"}
28IMAGENET_MEAN = (0.485, 0.456, 0.406)
29IMAGENET_STD = (0.229, 0.224, 0.225)
32def channel_stats(channels: int) -> tuple[tuple[float, ...], tuple[float, ...]]:
33 """ImageNet statistics cycled across channels, matching ``RFDETR.predict``."""
34 mean = tuple(IMAGENET_MEAN[i % 3] for i in range(channels))
35 std = tuple(IMAGENET_STD[i % 3] for i in range(channels))
36 return mean, std
39def scale_tiff_pixels(pixels: np.ndarray, keep_uint8: bool = False) -> np.ndarray:
40 """Scale integer pixels to [0, 1] float32 as ``ToDtype(scale=True)`` would.
42 torch lacks CPU kernels (e.g. flip) for uint16/uint32, so wider integer
43 types are scaled here rather than in the transform pipeline. Float
44 pixels are assumed to be in [0, 1] already.
45 """
46 if pixels.dtype == np.uint8 and keep_uint8:
47 return pixels
48 if np.issubdtype(pixels.dtype, np.integer):
49 return pixels.astype(np.float32) / np.iinfo(pixels.dtype).max
50 return pixels.astype(np.float32)
53class ChannelNormalize(Normalize):
54 """RF-DETR ``Normalize`` whose mean/std follow the image's channel count."""
56 def __call__(self, image, target=None):
57 channels = image.shape[-3]
58 if channels != len(IMAGENET_MEAN):
59 mean, std = channel_stats(channels)
60 normalize = Normalize(mean, std)
61 return normalize(image, target)
62 return super().__call__(image, target)
65class _ImageSize:
66 """Stand-in exposing only ``.size``, the one attribute ``ConvertCoco`` reads."""
68 def __init__(self, width: int, height: int):
69 self.size = (width, height)
72class MultiChannelCocoDetection(CocoDetection):
73 """``CocoDetection`` that keeps every channel of TIFF images."""
75 def _image_path(self, image_id: int) -> Path:
76 return Path(self.root) / self.coco.loadImgs(image_id)[0]["file_name"]
78 def __getitem__(self, idx: int) -> tuple[Any, Any]:
79 image_id = self.ids[idx]
80 path = self._image_path(image_id)
81 if path.suffix.lower() not in TIFF_SUFFIXES:
82 return super().__getitem__(idx)
84 pixels = scale_tiff_pixels(read_tiff_channels(path), keep_uint8=True)
85 img = tv_tensors.Image(torch.from_numpy(pixels.transpose(2, 0, 1).copy()))
86 height, width = pixels.shape[:2]
87 target = {"image_id": image_id, "annotations": self._load_target(image_id)}
88 _, target = self.prepare(_ImageSize(width, height), target)
89 if self._transforms is not None:
90 img, target = self._transforms(img, target)
91 return img, target
94def _adapt_dataset(dataset):
95 if type(dataset) is not CocoDetection:
96 return dataset
97 dataset.__class__ = MultiChannelCocoDetection
98 transforms = getattr(dataset._transforms, "transforms", [])
99 has_tiffs = any(
100 Path(image["file_name"]).suffix.lower() in TIFF_SUFFIXES
101 for image in dataset.coco.imgs.values()
102 )
103 if has_tiffs and any(isinstance(t, AlbumentationsWrapper) for t in transforms):
104 # The Albumentations wrapper round-trips images through PIL, which
105 # cannot hold arbitrary channel counts.
106 raise ValueError(
107 "TIFF inputs are only supported with the default (torchvision) "
108 "augmentation pipeline; use augmentation='default'."
109 )
110 for index, transform in enumerate(transforms):
111 if type(transform) is Normalize:
112 transforms[index] = ChannelNormalize()
113 return dataset
116@contextlib.contextmanager
117def multichannel_datasets() -> Iterator[None]:
118 """Make RF-DETR training build datasets that keep all TIFF channels."""
119 import rfdetr.training.module_data as module_data
121 original = module_data.build_dataset
123 def build_dataset(image_set: str, args: Any, resolution: int):
124 return _adapt_dataset(original(image_set, args, resolution))
126 module_data.build_dataset = build_dataset
127 try:
128 yield
129 finally:
130 module_data.build_dataset = original