Coverage for collectra/tasks/image_classifier/yolo_multichannel.py: 94.62%
93 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"""Tensor-based Ultralytics classification for TIFF and non-RGB images.
3Imported only when needed so CLI task discovery does not load ML backends.
4"""
6from copy import copy, deepcopy
7from pathlib import Path
9import numpy as np
10import torch
11from PIL import Image as PillowImage
12from torchvision import transforms as T
13from torchvision.datasets import ImageFolder
14from ultralytics.models.yolo.classify import (
15 ClassificationTrainer,
16 ClassificationValidator,
17)
18from ultralytics.nn.tasks import yaml_model_load
19from ultralytics.utils import LOGGER
21from collectra.types.images import read_tiff_channels
22from .torchvision import _target_tiff_pixels, _tiff_tensor
23from .yolo import _classification_channels
26def load_channels(path):
27 """Load CHW float32; unsigned integers scale by their dtype's full range."""
28 if Path(path).suffix.lower() in {".tif", ".tiff"}:
29 pixels = read_tiff_channels(path)
30 else:
31 with PillowImage.open(path) as image:
32 if image.mode == "P":
33 image = image.convert("RGBA" if "transparency" in image.info else "RGB")
34 pixels = np.array(image)
35 if pixels.ndim == 2:
36 pixels = pixels[..., None]
37 return _tiff_tensor(pixels)
40def channel_transforms(size, args=None):
41 """Use shared spatial transforms on all bands, with identity normalization."""
42 if args is None:
43 return T.Compose([T.Resize(size), T.CenterCrop(size)])
44 return T.Compose(
45 [
46 T.RandomResizedCrop(size, scale=(1.0 - args.scale, 1.0)),
47 T.RandomHorizontalFlip(args.fliplr),
48 T.RandomVerticalFlip(args.flipud),
49 T.RandomErasing(p=args.erasing),
50 ]
51 )
54class MultichannelClassificationDataset:
55 """Ultralytics batch contract backed by ImageFolder and lossless TIFF reading."""
57 def __init__(self, root, args, names, augment=False):
58 self.base = ImageFolder(root, allow_empty=True)
59 mapping = {name: index for index, name in names.items()}
60 unknown = set(self.base.classes) - set(mapping)
61 if unknown:
62 raise ValueError(
63 f"Unknown classification directories in {root}: {sorted(unknown)}"
64 )
65 self.samples = [
66 (path, mapping[self.base.classes[index]])
67 for path, index in self.base.samples
68 ]
69 if augment and args.fraction < 1.0:
70 self.samples = self.samples[: round(len(self.samples) * args.fraction)]
71 self.torch_transforms = channel_transforms(
72 args.imgsz, args if augment else None
73 )
75 def __len__(self):
76 return len(self.samples)
78 def __getitem__(self, index):
79 path, label = self.samples[index]
80 return {"img": self.torch_transforms(load_channels(path)), "cls": label}
83class MultichannelClassificationValidator(ClassificationValidator):
84 def __call__(self, trainer=None, model=None):
85 # Standalone/final validation rechecks classification metadata (upstream
86 # defaults to RGB). Rebuild the loader so channels are corrected before
87 # AutoBackend's warmup, including on GPU.
88 if trainer is None:
89 self.dataloader = None
90 return super().__call__(trainer=trainer, model=model)
92 def build_dataset(self, img_path):
93 self.data["channels"], _ = _classification_channels(
94 self.data.get("train"), self.data.get("val"), self.data.get("test")
95 )
96 return MultichannelClassificationDataset(
97 img_path, self.args, self.data["names"]
98 )
100 def plot_val_samples(self, batch, ni):
101 """RGB sample plots cannot represent spectral bands; keep metric plots."""
103 def plot_predictions(self, batch, preds, ni):
104 """Do not pass spectral tensors to RGB visualization routines."""
107class MultichannelClassificationTrainer(ClassificationTrainer):
108 def get_dataset(self):
109 data = super().get_dataset()
110 data["channels"], _ = _classification_channels(
111 data.get("train"), data.get("val"), data.get("test")
112 )
113 LOGGER.info(
114 f"Collectra: preserving {data['channels']} image channels; using spatial "
115 "augmentation and erasing, identity normalization, and no RGB colour/auto augmentation."
116 )
117 return data
119 def get_model(self, cfg=None, weights=None, verbose=True):
120 # ClassificationModel otherwise lets an old YAML's channels override ch.
121 # Dataset metadata is authoritative, including when transferring weights.
122 cfg = deepcopy(cfg) if isinstance(cfg, dict) else yaml_model_load(cfg)
123 cfg["channels"] = self.data["channels"]
124 model = super().get_model(cfg=cfg, weights=weights, verbose=verbose)
125 model.collectra_tensor_classification = True
126 return model
128 def build_dataset(self, img_path, mode="train", batch=None):
129 return MultichannelClassificationDataset(
130 img_path, self.args, self.data["names"], augment=mode == "train"
131 )
133 def get_validator(self):
134 self.loss_names = ["loss"]
135 return MultichannelClassificationValidator(
136 self.test_loader,
137 self.save_dir,
138 args=copy(self.args),
139 _callbacks=self.callbacks,
140 )
142 def plot_training_samples(self, batch, ni):
143 """RGB sample plots cannot represent spectral bands; keep metric plots."""
146@torch.inference_mode()
147def classify_multichannel(yolo, image):
148 """Classify directly from tensors, bypassing Ultralytics' RGB predictor."""
149 if image.get_path().suffix.lower() in {".tif", ".tiff"}:
150 pixels = _tiff_tensor(_target_tiff_pixels(image))
151 else:
152 # Pillow supports the ordinary grayscale/RGBA crop and orientation path.
153 with image.pil() as source:
154 if source.mode == "P":
155 source = source.convert(
156 "RGBA" if "transparency" in source.info else "RGB"
157 )
158 values = np.array(source)
159 pixels = _tiff_tensor(values[..., None] if values.ndim == 2 else values)
160 model = yolo.model
161 first_conv = next(m for m in model.modules() if isinstance(m, torch.nn.Conv2d))
162 if pixels.shape[0] != first_conv.in_channels:
163 raise ValueError(
164 f"{image.get_path()}: {pixels.shape[0]} image channels, "
165 f"but the classification model expects {first_conv.in_channels}"
166 )
167 transform = getattr(model, "transforms", None)
168 # Models trained via Collectra store tensor transforms in the checkpoint.
169 # A separately prepared model may still carry upstream PIL/RGB transforms.
170 if transform is None or any(
171 isinstance(t, T.ToTensor) for t in getattr(transform, "transforms", [])
172 ):
173 transform = channel_transforms(yolo.overrides.get("imgsz", 224))
174 parameter = next(model.parameters())
175 batch = (
176 transform(pixels)
177 .unsqueeze(0)
178 .to(device=parameter.device, dtype=parameter.dtype)
179 )
180 model.eval()
181 predictions = model(batch)
182 predictions = (
183 predictions[0] if isinstance(predictions, (tuple, list)) else predictions
184 )
185 return model.names[predictions.argmax(dim=1).item()]