Coverage for collectra/tasks/object_detection/rfdetr_channels.py: 92.59%
81 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"""Load and train RF-DETR checkpoints whose input layer has more than three channels.
3RF-DETR loads checkpoint weights into a 3-channel model and only afterwards
4widens the input layer to ``num_channels``, so a checkpoint whose input layer is
5already wider (e.g. one written by the ``adapt-input-channels`` task command or by multi-channel
6training) cannot be loaded through ``pretrain_weights``. Its Lightning training
7module also never widens the input layer at all.
9``multichannel_checkpoints()`` wraps ``load_pretrain_weights`` so the model's
10patch projection is resized to the checkpoint's channel count before loading,
11and, for training, widened to ``model_config.num_channels`` afterwards using
12RF-DETR's own filter repetition.
13"""
15from __future__ import annotations
17import contextlib
18import copy
19import os
20from typing import Iterator
22import torch
24from .rfdetr_data import channel_stats
26PROJECTION_KEY = "backbone.0.encoder.encoder.embeddings.patch_embeddings.projection.weight"
29MIN_RFDETR_VERSION = (1, 10, 1)
32def require_multichannel_rfdetr() -> None:
33 """Fail clearly on rfdetr releases whose data pipeline is PIL/RGB-only."""
34 from importlib.metadata import version
36 installed = version("rfdetr")
37 parts = tuple(int(p) for p in installed.split(".")[:3] if p.isdigit())
38 if parts < MIN_RFDETR_VERSION:
39 required = ".".join(map(str, MIN_RFDETR_VERSION))
40 raise RuntimeError(
41 f"Training RF-DETR on TIFF inputs requires rfdetr>={required}, "
42 f"but rfdetr {installed} is installed. Upgrade it, "
43 f"e.g. `pip install 'rfdetr[metrics,train,loggers]>={required}'`."
44 )
47def _patch_embeddings(nn_model):
48 return nn_model.backbone[0].encoder.encoder.embeddings.patch_embeddings
51def checkpoint_channels(path) -> int:
52 """Input channels of a checkpoint's patch projection (3 if it cannot be read)."""
53 if path is None or not os.path.isfile(str(path)):
54 return 3
55 # RF-DETR checkpoints store their args as Python objects, as RF-DETR's
56 # own loader assumes when it reads the same file.
57 checkpoint = torch.load(str(path), map_location="cpu", weights_only=False)
58 state = checkpoint.get("model")
59 if state is None:
60 state = {
61 key.removeprefix("model.").removeprefix("_orig_mod."): value
62 for key, value in checkpoint.get("state_dict", {}).items()
63 }
64 weight = state.get(PROJECTION_KEY)
65 return 3 if weight is None else int(weight.shape[1])
68def set_input_channels(nn_model, channels: int) -> None:
69 """Widen or narrow the patch projection, repeating pretrained filters as RF-DETR does."""
70 from rfdetr.inference import _adapt_input_conv
72 embeddings = _patch_embeddings(nn_model)
73 projection = embeddings.projection
74 if projection.in_channels == channels:
75 return
76 if projection.in_channels != 3:
77 raise ValueError(
78 f"Cannot adapt a {projection.in_channels}-channel input layer to {channels} channels"
79 )
80 new_projection = copy.deepcopy(projection)
81 new_projection.in_channels = channels
82 new_projection.weight = torch.nn.Parameter(
83 _adapt_input_conv(channels, projection.weight),
84 requires_grad=projection.weight.requires_grad,
85 )
86 embeddings.projection = new_projection
87 embeddings.num_channels = channels
90def sync_channels(detector) -> int:
91 """Align an RFDETR wrapper's config and normalization with its input layer.
93 Wrappers without a DINOv2 patch projection (e.g. test doubles) are left unchanged.
94 """
95 try:
96 encoder = detector.model.model.backbone[0].encoder
97 channels = (
98 3 * encoder.num_views if hasattr(encoder, "fusion_spec")
99 else _patch_embeddings(detector.model.model).projection.in_channels
100 )
101 except (AttributeError, IndexError, TypeError):
102 return getattr(getattr(detector, "model_config", None), "num_channels", 3)
103 detector.model_config.num_channels = channels
104 args = getattr(detector.model, "args", None)
105 if args is not None and hasattr(args, "num_channels"):
106 args.num_channels = channels
107 mean, std = channel_stats(channels)
108 detector.means, detector.stds = list(mean), list(std)
109 return channels
112def _channel_aware(load_pretrain_weights, widen_after: bool):
113 def load(nn_model, model_config, *args, **kwargs):
114 # Feature fusion processes each view with an RGB projection. Its builder
115 # installs the fusion modules before loading; never widen that projection.
116 if hasattr(nn_model.backbone[0].encoder, "fusion_spec"):
117 return load_pretrain_weights(nn_model, model_config, *args, **kwargs)
118 stored = checkpoint_channels(model_config.pretrain_weights)
119 if stored != 3:
120 set_input_channels(nn_model, stored)
121 result = load_pretrain_weights(nn_model, model_config, *args, **kwargs)
122 if widen_after and model_config.num_channels != 3:
123 set_input_channels(nn_model, model_config.num_channels)
124 return result
126 return load
129@contextlib.contextmanager
130def multichannel_checkpoints() -> Iterator[None]:
131 """Let RF-DETR load, and train from, checkpoints with any number of input channels."""
132 import rfdetr.inference as inference
134 # The inference builder widens the input layer itself after loading.
135 patches = [(inference, False)]
136 try:
137 import rfdetr.training.module_model as module_model
139 patches.append((module_model, True))
140 except ImportError:
141 pass
143 originals = [(module, module.load_pretrain_weights) for module, _ in patches]
144 for module, widen_after in patches:
145 module.load_pretrain_weights = _channel_aware(
146 module.load_pretrain_weights, widen_after
147 )
148 try:
149 yield
150 finally:
151 for module, original in originals:
152 module.load_pretrain_weights = original