visbench 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. visbench/__init__.py +104 -0
  2. visbench/backbones/__init__.py +28 -0
  3. visbench/backbones/base.py +334 -0
  4. visbench/backbones/clip.py +247 -0
  5. visbench/backbones/custom.py +311 -0
  6. visbench/backbones/dinov2.py +206 -0
  7. visbench/backbones/pooling.py +127 -0
  8. visbench/backbones/timm_backbone.py +224 -0
  9. visbench/cache/__init__.py +7 -0
  10. visbench/cache/feature_cache.py +728 -0
  11. visbench/cache/keys.py +97 -0
  12. visbench/cache/streaming.py +120 -0
  13. visbench/cli/__init__.py +24 -0
  14. visbench/cli/datasets.py +488 -0
  15. visbench/cli/main.py +245 -0
  16. visbench/data/__init__.py +32 -0
  17. visbench/data/base.py +167 -0
  18. visbench/data/dense.py +505 -0
  19. visbench/data/image_folder.py +159 -0
  20. visbench/data/pair_dataset.py +392 -0
  21. visbench/data/triplet.py +220 -0
  22. visbench/heads/__init__.py +20 -0
  23. visbench/heads/base.py +126 -0
  24. visbench/heads/dpt.py +255 -0
  25. visbench/heads/linear.py +87 -0
  26. visbench/metrics/__init__.py +34 -0
  27. visbench/metrics/classification.py +44 -0
  28. visbench/metrics/correspondence.py +162 -0
  29. visbench/metrics/dense.py +525 -0
  30. visbench/metrics/retrieval.py +72 -0
  31. visbench/metrics/similarity.py +85 -0
  32. visbench/registry.py +214 -0
  33. visbench/results/__init__.py +9 -0
  34. visbench/results/schema.py +160 -0
  35. visbench/results/writer.py +75 -0
  36. visbench/runner.py +232 -0
  37. visbench/tasks/__init__.py +17 -0
  38. visbench/tasks/base.py +182 -0
  39. visbench/tasks/dense_base.py +554 -0
  40. visbench/tasks/high_level/__init__.py +13 -0
  41. visbench/tasks/high_level/classification.py +217 -0
  42. visbench/tasks/high_level/detection.py +27 -0
  43. visbench/tasks/high_level/retrieval.py +123 -0
  44. visbench/tasks/high_level/semantic_segmentation.py +189 -0
  45. visbench/tasks/low_level/README.md +26 -0
  46. visbench/tasks/low_level/__init__.py +8 -0
  47. visbench/tasks/mid_level/__init__.py +23 -0
  48. visbench/tasks/mid_level/correspondence.py +347 -0
  49. visbench/tasks/mid_level/depth.py +222 -0
  50. visbench/tasks/mid_level/generic_segmentation.py +171 -0
  51. visbench/tasks/mid_level/similarity.py +149 -0
  52. visbench/tasks/mid_level/surface_normal.py +299 -0
  53. visbench/types.py +131 -0
  54. visbench/utils/__init__.py +6 -0
  55. visbench/utils/device.py +19 -0
  56. visbench/utils/image.py +33 -0
  57. visbench/utils/seed.py +43 -0
  58. visbench-0.2.0.dist-info/METADATA +604 -0
  59. visbench-0.2.0.dist-info/RECORD +63 -0
  60. visbench-0.2.0.dist-info/WHEEL +4 -0
  61. visbench-0.2.0.dist-info/entry_points.txt +2 -0
  62. visbench-0.2.0.dist-info/licenses/LICENSE +21 -0
  63. visbench-0.2.0.dist-info/licenses/NOTICE +96 -0
@@ -0,0 +1,247 @@
1
+ """OpenCLIP ViT-B image-tower backbone — v0.1.
2
+
3
+ Only the visual tower is exposed; the text tower is out of scope for probing.
4
+ Note that CLIP uses its own normalisation constants, not ImageNet's.
5
+ """
6
+
7
+ import warnings
8
+
9
+ import torch
10
+ from PIL import Image
11
+ from torchvision import transforms
12
+
13
+ from visbench.backbones.base import BaseBackbone
14
+ from visbench.registry import register_backbone
15
+ from visbench.types import LayerOutput
16
+ from visbench.utils.image import CLIP_MEAN, CLIP_STD
17
+
18
+ __all__ = ["CLIP"]
19
+
20
+ #: Registered name -> (open_clip model name, pretrained tag, embed dim, patch size).
21
+ #:
22
+ #: The ``-quickgelu`` suffix is not cosmetic. OpenAI's original CLIP weights
23
+ #: were trained with QuickGELU, and open_clip pairs them with a plain-GELU
24
+ #: architecture unless asked otherwise — it warns and continues, producing a
25
+ #: model that loads cleanly and computes subtly wrong activations. The pairing
26
+ #: is validated in ``__init__`` so that combination raises instead.
27
+ _VARIANTS = {
28
+ "clip_vitb16": ("ViT-B-16-quickgelu", "openai", 768, 16),
29
+ "clip_vitb32": ("ViT-B-32-quickgelu", "openai", 768, 32),
30
+ }
31
+
32
+ #: Dim of the shared image-text embedding for ViT-B, after ``visual.proj``.
33
+ _PROJECTED_DIM = 512
34
+
35
+ #: Substring identifying open_clip's QuickGELU warnings, matched case-insensitively.
36
+ #:
37
+ #: Deliberately one token rather than a sentence. This guard was originally a
38
+ #: ``warnings.filterwarnings("error", message=".*QuickGELU mismatch.*")``, and
39
+ #: open_clip has never emitted the phrase "QuickGELU mismatch" — so the filter
40
+ #: never matched, the warning was never promoted, and the guard was dead code
41
+ #: from the day it was written. Its own test only caught it under ``-m slow``,
42
+ #: which CI does not run.
43
+ #:
44
+ #: open_clip warns in *both* directions — weights trained with QuickGELU loaded
45
+ #: under a plain-GELU config, and the reverse — with different wording each way
46
+ #: (see ``open_clip/factory.py``). Both are genuine mismatches. This is the one
47
+ #: token common to the two, and the only part of the wording worth depending on.
48
+ _QUICKGELU_MARKER = "quickgelu"
49
+
50
+
51
+ def _promote_quickgelu_warning(
52
+ caught: "list[warnings.WarningMessage]", model_name: str, pretrained: str
53
+ ) -> None:
54
+ """Turn open_clip's QuickGELU warning into an error; re-emit anything else.
55
+
56
+ open_clip only warns on an activation mismatch and hands back a model that
57
+ loads cleanly and computes subtly wrong features. A warning in the middle of
58
+ a benchmark run is a warning nobody reads, and this is the one failure mode
59
+ here that silently changes a number rather than raising.
60
+
61
+ Every other warning is re-emitted rather than swallowed: recording warnings
62
+ suppresses them, and a guard against one specific problem has no business
63
+ hiding unrelated deprecation notices from the caller.
64
+ """
65
+ mismatch: str | None = None
66
+ for entry in caught:
67
+ text = str(entry.message)
68
+ if mismatch is None and _QUICKGELU_MARKER in text.lower():
69
+ mismatch = text
70
+ continue
71
+ # stacklevel=2 points at CLIP.__init__ rather than this helper. The true
72
+ # origin inside open_clip is not recoverable once a warning is recorded.
73
+ warnings.warn(entry.message, stacklevel=2)
74
+
75
+ if mismatch is not None:
76
+ raise RuntimeError(
77
+ f"{model_name} + {pretrained!r} is a QuickGELU mismatch: {mismatch} "
78
+ "That combination loads cleanly and computes wrong activations."
79
+ )
80
+
81
+
82
+ @register_backbone("clip_vitb16", variant="clip_vitb16")
83
+ @register_backbone("clip_vitb32", variant="clip_vitb32")
84
+ class CLIP(BaseBackbone):
85
+ """CLIP visual tower.
86
+
87
+ Has a CLS token, so default pooling is CLS — but note this is the
88
+ pre-projection CLS, i.e. the representation used for probing, not the
89
+ projected image embedding used for text alignment. Which of the two a task
90
+ wants is a real decision; document it wherever it is made.
91
+
92
+ **Pre-projection is the default here.** CLIP's visual tower ends with a
93
+ linear projection into the space shared with the text encoder, and
94
+ ``encode_image`` returns that 512-d vector. VisBench returns the 768-d CLS
95
+ token from *before* it, because the projection is trained to discard
96
+ whatever does not help match a caption — which is exactly the visual detail
97
+ a mid-level probe exists to measure. It is also the wrong comparison to
98
+ draw against DINOv2, which has no such head. Pass ``use_projection=True``
99
+ for the 512-d embedding, which is what a published zero-shot number used.
100
+
101
+ Patch size is 16 or 32 against DINOv2's 14, so the two produce different
102
+ grids at the same input resolution. That is why correspondence measures
103
+ error in patch widths rather than pixels.
104
+ """
105
+
106
+ has_cls_token = True
107
+
108
+ def __init__(
109
+ self,
110
+ variant: str = "clip_vitb16",
111
+ device: str | None = None,
112
+ image_size: int = 224,
113
+ use_projection: bool = False,
114
+ ) -> None:
115
+ """Load the OpenCLIP visual tower for ``variant``, freeze it, set eval mode."""
116
+ super().__init__(device)
117
+
118
+ if variant not in _VARIANTS:
119
+ raise ValueError(
120
+ f"Unknown CLIP variant {variant!r}; expected one of {sorted(_VARIANTS)}"
121
+ )
122
+ model_name, pretrained, width, patch_size = _VARIANTS[variant]
123
+
124
+ if image_size % patch_size != 0:
125
+ raise ValueError(
126
+ f"image_size={image_size} is not a multiple of the patch size {patch_size}. "
127
+ "A ragged final patch would silently change the grid shape."
128
+ )
129
+
130
+ self.name = variant
131
+ self.variant = variant
132
+ self.model_name = model_name
133
+ self.pretrained = pretrained
134
+ self.patch_size = patch_size
135
+ self.image_size = image_size
136
+ self.use_projection = use_projection
137
+ self.embed_dim = _PROJECTED_DIM if use_projection else width
138
+
139
+ try:
140
+ import open_clip
141
+ except ImportError as exc: # pragma: no cover - needs the extra uninstalled
142
+ raise ImportError(
143
+ "The CLIP backbone needs open_clip_torch. Install it with "
144
+ "`pip install visbench[clip]`."
145
+ ) from exc
146
+
147
+ # Record rather than filter-to-error: open_clip's wording is the only
148
+ # signal available, and matching it as a regex is what broke this guard
149
+ # once. See _promote_quickgelu_warning.
150
+ with warnings.catch_warnings(record=True) as caught:
151
+ warnings.simplefilter("always")
152
+ model = open_clip.create_model(model_name, pretrained=pretrained)
153
+ _promote_quickgelu_warning(caught, model_name, pretrained)
154
+
155
+ self.model = model.visual
156
+ self._transform = transforms.Compose(
157
+ [
158
+ transforms.Resize(image_size, interpolation=transforms.InterpolationMode.BICUBIC),
159
+ transforms.CenterCrop(image_size),
160
+ transforms.ToTensor(),
161
+ # CLIP's own constants, not ImageNet's — see visbench.utils.image.
162
+ transforms.Normalize(mean=CLIP_MEAN, std=CLIP_STD),
163
+ ]
164
+ )
165
+ self._finalize()
166
+
167
+ @property
168
+ def num_layers(self) -> int:
169
+ """Residual attention blocks in the visual transformer."""
170
+ return len(self.model.transformer.resblocks)
171
+
172
+ def _forward_features(
173
+ self,
174
+ image: torch.Tensor,
175
+ layers: list[int],
176
+ ) -> list[LayerOutput]:
177
+ """Run the visual transformer once, one output per requested layer.
178
+
179
+ Uses ``forward_intermediates``, open_clip's counterpart to DINOv2's
180
+ ``get_intermediate_layers``, which takes the whole index list.
181
+ """
182
+ _, _, height, width = image.shape
183
+ patch = self.patch_size
184
+ assert patch is not None # set in __init__; Optional only on the base class
185
+ if height % patch or width % patch:
186
+ raise ValueError(f"Input {height}x{width} is not a multiple of patch size {patch}")
187
+ grid_hw = (height // patch, width // patch)
188
+
189
+ outputs = self.model.forward_intermediates(
190
+ image,
191
+ indices=list(layers),
192
+ output_fmt="NLC",
193
+ output_extra_tokens=True,
194
+ intermediates_only=True,
195
+ )
196
+
197
+ expected = grid_hw[0] * grid_hw[1]
198
+ result: list[LayerOutput] = []
199
+ for position, index in enumerate(layers):
200
+ patch_tokens = outputs["image_intermediates"][position]
201
+ cls_token = outputs["image_intermediates_prefix"][position][:, 0]
202
+
203
+ # forward_intermediates returns raw block outputs, so the final
204
+ # LayerNorm has not been applied. DINOv2's equivalent normalises by
205
+ # default; applying it here keeps "the features" meaning the same
206
+ # thing across backbones, and it is what `proj` expects downstream —
207
+ # ln_post(cls) @ proj reproduces encode_image exactly.
208
+ #
209
+ # Applied to every layer, not just the last. ln_post is trained for
210
+ # the final block's scale, so on an intermediate layer it is a
211
+ # convention rather than a reconstruction — but an unnormalised
212
+ # layer sitting next to normalised ones in the same pyramid would
213
+ # hand a multiscale head stages whose magnitudes differ by a factor
214
+ # the head would have to unlearn.
215
+ patch_tokens = self.model.ln_post(patch_tokens)
216
+ cls_token = self.model.ln_post(cls_token)
217
+
218
+ if self.use_projection:
219
+ if self.model.proj is None:
220
+ raise RuntimeError(f"{self.model_name} has no visual projection")
221
+ patch_tokens = patch_tokens @ self.model.proj
222
+ cls_token = cls_token @ self.model.proj
223
+
224
+ if patch_tokens.shape[1] != expected:
225
+ raise RuntimeError(
226
+ f"CLIP layer {index} returned {patch_tokens.shape[1]} patch tokens, "
227
+ f"expected {expected} for a {grid_hw[0]}x{grid_hw[1]} grid"
228
+ )
229
+ result.append((patch_tokens, cls_token, grid_hw))
230
+ return result
231
+
232
+ def preprocess(self, images: Image.Image | list) -> torch.Tensor:
233
+ """Resize to 224 and apply CLIP normalisation constants."""
234
+ if isinstance(images, Image.Image):
235
+ images = [images]
236
+ return torch.stack([self._transform(img.convert("RGB")) for img in images])
237
+
238
+ def cache_key(self) -> str:
239
+ """``"clip/<model>/<pretrained-tag>/<resolution>/<head>"``.
240
+
241
+ The pretrained tag is in the key because ``openai`` and ``laion2b``
242
+ weights are different models behind one name, and ``head`` because the
243
+ projected and pre-projection vectors are different representations of
244
+ the same forward pass.
245
+ """
246
+ head = "proj" if self.use_projection else "preproj"
247
+ return f"clip/{self.model_name}/{self.pretrained}/{self.image_size}/{head}"
@@ -0,0 +1,311 @@
1
+ """Wrap an arbitrary ``nn.Module`` as a VisBench backbone. v0.2.
2
+
3
+ The escape hatch: any model a user already has — a fine-tuned checkpoint, an
4
+ architecture VisBench has never heard of, something from a paper's repo — probed
5
+ by the same tasks as DINOv2 and CLIP, without adding a module to this package.
6
+
7
+ Constructed directly rather than looked up by name, since a registry name
8
+ cannot carry an ``nn.Module``::
9
+
10
+ backbone = CustomBackbone(my_model, preprocess=my_transform, name="mine")
11
+ visbench.run(backbone, "retrieval", dataset)
12
+
13
+ To give a custom backbone a registry name of its own, subclass
14
+ :class:`~visbench.backbones.base.BaseBackbone` and apply
15
+ :func:`visbench.register_backbone` — that path is unchanged and is how the
16
+ built-in backbones work.
17
+ """
18
+
19
+ import hashlib
20
+ from collections.abc import Callable
21
+ from typing import Any
22
+
23
+ import torch
24
+ import torch.nn as nn
25
+ from PIL import Image
26
+
27
+ from visbench.backbones.base import BaseBackbone
28
+ from visbench.types import LayerOutput
29
+
30
+ __all__ = ["CustomBackbone"]
31
+
32
+ #: Return signature of a user-supplied feature function.
33
+ FeatureFn = Callable[[nn.Module, torch.Tensor], tuple]
34
+
35
+ #: Multi-layer counterpart: ``(module, image_batch, layers) -> list`` of
36
+ #: ``(patch_tokens, cls_token, grid_hw)``, one per requested index. Kept
37
+ #: separate from :data:`FeatureFn` rather than overloading its signature, so a
38
+ #: function written for one convention can never be called under the other.
39
+ LayerFeatureFn = Callable[[nn.Module, torch.Tensor, list], list]
40
+
41
+
42
+ def hash_weights(module: nn.Module, sample_bytes: int = 1 << 20) -> str:
43
+ """Short hash of a module's parameters, for the cache key.
44
+
45
+ A custom backbone has no upstream commit or pretrained tag to point at, so
46
+ the weights themselves are the only honest identifier. Hashing them means a
47
+ fine-tuned checkpoint automatically gets a different cache key from the one
48
+ it was fine-tuned from — the alternative is a user-supplied string that is
49
+ correct only if they remember to change it.
50
+
51
+ Large tensors are sampled rather than read whole: a full pass over a
52
+ multi-GB state dict on every construction would cost more than the forward
53
+ passes it protects. Shapes and dtypes are always folded in, so a change in
54
+ architecture is caught even when the sampled bytes collide.
55
+ """
56
+ digest = hashlib.sha256()
57
+ for name, tensor in sorted(module.state_dict().items()):
58
+ flat = tensor.detach().cpu().flatten()
59
+ digest.update(f"{name}|{tuple(tensor.shape)}|{tensor.dtype}".encode())
60
+ if flat.numel() == 0:
61
+ continue
62
+ values = flat.to(torch.float64)
63
+ # A cheap moment-based summary plus a byte sample: catches retraining
64
+ # and fine-tuning, which is what this needs to detect.
65
+ digest.update(f"{values.sum().item():.6e}|{values.abs().max().item():.6e}".encode())
66
+ digest.update(flat[: sample_bytes // flat.element_size()].numpy().tobytes())
67
+ return digest.hexdigest()[:16]
68
+
69
+
70
+ class CustomBackbone(BaseBackbone):
71
+ """Adapt a user-supplied ``nn.Module`` to the VisBench backbone contract.
72
+
73
+ Parameters
74
+ ----------
75
+ module:
76
+ Any ``nn.Module``. It is frozen and set to eval mode — probing measures
77
+ fixed representations, and unfreezing is v0.3 scope.
78
+ preprocess:
79
+ Callable turning one PIL image into a ``(3, H, W)`` tensor, typically a
80
+ torchvision ``Compose``. Required, and deliberately so: normalisation
81
+ constants cannot be guessed, and getting them wrong is a silent
82
+ accuracy loss rather than an error.
83
+ feature_fn:
84
+ Optional ``(module, image_batch) -> (patch_tokens, cls_token, grid_hw)``
85
+ for full control. Without it, the module's own ``forward`` is called and
86
+ its output interpreted — see :meth:`_forward_features`.
87
+ has_cls_token:
88
+ Whether the token sequence starts with a CLS token. Only consulted when
89
+ the module returns tokens; ignored for a conv map.
90
+ layer_feature_fn:
91
+ Optional ``(module, image_batch, layers) -> [(tokens, cls, grid_hw), ...]``
92
+ enabling multi-layer extraction. Requires ``num_layers``. VisBench
93
+ cannot tap the intermediate activations of an arbitrary module — there
94
+ is no equivalent of ``get_intermediate_layers`` to call — so this is
95
+ the seam where a user says how their own model exposes depth.
96
+ num_layers:
97
+ How many depths ``layer_feature_fn`` can serve. Left at 1, the module
98
+ exposes only its final output and any multi-layer request is rejected,
99
+ which is the right default: silently returning the same map several
100
+ times would let a multiscale head report a single-layer result.
101
+ weights_id:
102
+ Identifier for these weights in the cache key. Defaults to a hash of
103
+ the module's parameters, which is usually what you want.
104
+ """
105
+
106
+ def __init__(
107
+ self,
108
+ module: nn.Module,
109
+ preprocess: Callable[[Image.Image], torch.Tensor],
110
+ name: str = "custom",
111
+ embed_dim: int | None = None,
112
+ has_cls_token: bool = False,
113
+ patch_size: int | None = None,
114
+ feature_fn: FeatureFn | None = None,
115
+ weights_id: str | None = None,
116
+ image_size: int = 224,
117
+ device: str | None = None,
118
+ layer_feature_fn: LayerFeatureFn | None = None,
119
+ num_layers: int = 1,
120
+ ) -> None:
121
+ super().__init__(device)
122
+
123
+ if not isinstance(module, nn.Module):
124
+ raise TypeError(f"module must be an nn.Module, got {type(module).__name__}")
125
+ if not callable(preprocess):
126
+ raise TypeError(
127
+ "preprocess must be callable (PIL image -> (3, H, W) tensor). "
128
+ "VisBench cannot guess a model's normalisation constants."
129
+ )
130
+
131
+ if num_layers < 1:
132
+ raise ValueError(f"num_layers must be >= 1, got {num_layers}")
133
+ if num_layers > 1 and layer_feature_fn is None:
134
+ raise ValueError(
135
+ f"num_layers={num_layers} promises depths VisBench cannot reach on its own. "
136
+ "Pass layer_feature_fn=(module, images, layers) -> [(tokens, cls, grid_hw), ...]; "
137
+ "an arbitrary nn.Module has no interface for tapping intermediate activations."
138
+ )
139
+ if layer_feature_fn is not None and num_layers == 1:
140
+ raise ValueError(
141
+ "layer_feature_fn was given but num_layers=1, so no multi-layer request "
142
+ "can ever reach it. Pass num_layers= to say how many depths it serves."
143
+ )
144
+
145
+ self.name = name
146
+ self.model = module
147
+ self._num_layers = num_layers
148
+ self._layer_feature_fn = layer_feature_fn
149
+ self.has_cls_token = has_cls_token
150
+ self.patch_size = patch_size
151
+ self.image_size = image_size
152
+ self.embed_dim = embed_dim or 0
153
+ self._preprocess = preprocess
154
+ self._feature_fn = feature_fn
155
+ self.weights_id = weights_id if weights_id is not None else hash_weights(module)
156
+
157
+ self._finalize()
158
+
159
+ @property
160
+ def num_layers(self) -> int:
161
+ """Depths this module exposes; 1 unless ``layer_feature_fn`` was given."""
162
+ return self._num_layers
163
+
164
+ def _forward_features(
165
+ self,
166
+ image: torch.Tensor,
167
+ layers: list[int],
168
+ ) -> list[LayerOutput]:
169
+ """Interpret the module's output as ``(patch_tokens, cls, grid_hw)``.
170
+
171
+ With ``layer_feature_fn`` supplied, that handles the whole request.
172
+ Otherwise exactly one layer can be asked for — the base class has
173
+ already rejected anything else against :attr:`num_layers`.
174
+
175
+ With ``feature_fn`` supplied, that does the work and this only
176
+ validates. Otherwise the module's ``forward`` output is read:
177
+
178
+ ``(B, C, H, W)``
179
+ A conv map. Unambiguous — the grid is the spatial shape.
180
+ ``(B, N, C)``
181
+ A token sequence. The grid is **not** recoverable from ``N`` alone,
182
+ so ``patch_size`` must be set, or a square grid is assumed and a
183
+ non-square input rejected rather than silently misaligned.
184
+
185
+ Anything else raises, because the alternative is a feature map whose
186
+ spatial layout is wrong in a way no shape check downstream would catch.
187
+ """
188
+ if self._layer_feature_fn is not None:
189
+ outputs = list(self._layer_feature_fn(self.model, image, list(layers)))
190
+ if len(outputs) != len(layers):
191
+ raise ValueError(
192
+ f"layer_feature_fn returned {len(outputs)} outputs for layers={layers}. "
193
+ "It must return one (tokens, cls, grid_hw) per requested index, in order."
194
+ )
195
+ result: list[LayerOutput] = []
196
+ for index, output in zip(layers, outputs, strict=True):
197
+ try:
198
+ tokens, cls_token, grid_hw = output
199
+ except (TypeError, ValueError) as error:
200
+ raise ValueError(
201
+ f"layer_feature_fn's output for layer {index} is not a "
202
+ f"(tokens, cls, grid_hw) triple: {output!r}"
203
+ ) from error
204
+ self._note_embed_dim(tokens)
205
+ result.append((tokens, cls_token, tuple(grid_hw)))
206
+ return result
207
+
208
+ if self._feature_fn is not None:
209
+ tokens, cls_token, grid_hw = self._feature_fn(self.model, image)
210
+ self._note_embed_dim(tokens)
211
+ return [(tokens, cls_token, tuple(grid_hw))]
212
+
213
+ output = self.model(image)
214
+ if isinstance(output, (tuple, list)):
215
+ output = output[0]
216
+ if not isinstance(output, torch.Tensor):
217
+ raise TypeError(
218
+ f"The module returned {type(output).__name__}, not a tensor. "
219
+ "Pass feature_fn= to extract features yourself."
220
+ )
221
+
222
+ if output.ndim == 4:
223
+ _, _, grid_h, grid_w = output.shape
224
+ tokens = output.flatten(2).transpose(1, 2)
225
+ self._note_embed_dim(tokens)
226
+ return [(tokens, None, (grid_h, grid_w))]
227
+
228
+ if output.ndim == 3:
229
+ tokens, cls_token, grid_hw = self._tokens_to_features(output, image)
230
+ self._note_embed_dim(tokens)
231
+ return [(tokens, cls_token, grid_hw)]
232
+
233
+ raise ValueError(
234
+ f"The module returned a {output.ndim}D tensor {tuple(output.shape)}. "
235
+ "VisBench understands (B, C, H, W) conv maps and (B, N, C) token "
236
+ "sequences; for anything else pass feature_fn=."
237
+ )
238
+
239
+ def _tokens_to_features(
240
+ self,
241
+ output: torch.Tensor,
242
+ image: torch.Tensor,
243
+ ) -> tuple[torch.Tensor, torch.Tensor | None, tuple[int, int]]:
244
+ """Split a token sequence into patches, CLS, and a spatial grid."""
245
+ cls_token = None
246
+ tokens = output
247
+ if self.has_cls_token:
248
+ cls_token = output[:, 0]
249
+ tokens = output[:, 1:]
250
+
251
+ num_tokens = tokens.shape[1]
252
+ _, _, height, width = image.shape
253
+
254
+ if self.patch_size is not None:
255
+ grid_hw = (height // self.patch_size, width // self.patch_size)
256
+ if grid_hw[0] * grid_hw[1] != num_tokens:
257
+ raise ValueError(
258
+ f"patch_size={self.patch_size} implies a {grid_hw[0]}x{grid_hw[1]} grid "
259
+ f"({grid_hw[0] * grid_hw[1]} tokens) but the module returned {num_tokens}. "
260
+ "Check has_cls_token, or pass feature_fn=."
261
+ )
262
+ return tokens, cls_token, grid_hw
263
+
264
+ side = int(round(num_tokens**0.5))
265
+ if side * side != num_tokens:
266
+ raise ValueError(
267
+ f"{num_tokens} patch tokens is not a square grid, and patch_size is unset, "
268
+ "so the spatial layout is unknown. Set patch_size= or pass feature_fn=."
269
+ )
270
+ if height != width:
271
+ raise ValueError(
272
+ f"A square token grid was assumed, but the input is {height}x{width}. "
273
+ "Set patch_size= so the grid can be derived rather than guessed."
274
+ )
275
+ return tokens, cls_token, (side, side)
276
+
277
+ def _note_embed_dim(self, tokens: torch.Tensor) -> None:
278
+ """Fill in ``embed_dim`` from the first forward pass if it was not given.
279
+
280
+ Metadata rather than machinery — nothing depends on it — but a result
281
+ record saying the feature width is 0 is worse than one saying 2048.
282
+ """
283
+ if not self.embed_dim:
284
+ self.embed_dim = int(tokens.shape[-1])
285
+
286
+ def preprocess(self, images: Image.Image | list) -> torch.Tensor:
287
+ """Apply the user's transform to one image or a sequence of them."""
288
+ if isinstance(images, Image.Image):
289
+ images = [images]
290
+ return torch.stack([self._preprocess(img) for img in images])
291
+
292
+ def cache_key(self) -> str:
293
+ """``"custom/<name>/<weights_id>/<resolution>"``.
294
+
295
+ The weights id is what keeps two different checkpoints of the same
296
+ architecture from sharing cached features — the failure a custom
297
+ backbone is most exposed to, since there is no upstream ref to lean on.
298
+ """
299
+ return f"custom/{self.name}/{self.weights_id}/{self.image_size}"
300
+
301
+ def extra_repr(self) -> str:
302
+ return f"name={self.name!r}, weights_id={self.weights_id!r}"
303
+
304
+ def describe(self) -> dict[str, Any]:
305
+ """Metadata for a result record."""
306
+ return {
307
+ "backbone": self.name,
308
+ "backbone_key": self.cache_key(),
309
+ "has_cls_token": self.has_cls_token,
310
+ "embed_dim": self.embed_dim,
311
+ }