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.
- visbench/__init__.py +104 -0
- visbench/backbones/__init__.py +28 -0
- visbench/backbones/base.py +334 -0
- visbench/backbones/clip.py +247 -0
- visbench/backbones/custom.py +311 -0
- visbench/backbones/dinov2.py +206 -0
- visbench/backbones/pooling.py +127 -0
- visbench/backbones/timm_backbone.py +224 -0
- visbench/cache/__init__.py +7 -0
- visbench/cache/feature_cache.py +728 -0
- visbench/cache/keys.py +97 -0
- visbench/cache/streaming.py +120 -0
- visbench/cli/__init__.py +24 -0
- visbench/cli/datasets.py +488 -0
- visbench/cli/main.py +245 -0
- visbench/data/__init__.py +32 -0
- visbench/data/base.py +167 -0
- visbench/data/dense.py +505 -0
- visbench/data/image_folder.py +159 -0
- visbench/data/pair_dataset.py +392 -0
- visbench/data/triplet.py +220 -0
- visbench/heads/__init__.py +20 -0
- visbench/heads/base.py +126 -0
- visbench/heads/dpt.py +255 -0
- visbench/heads/linear.py +87 -0
- visbench/metrics/__init__.py +34 -0
- visbench/metrics/classification.py +44 -0
- visbench/metrics/correspondence.py +162 -0
- visbench/metrics/dense.py +525 -0
- visbench/metrics/retrieval.py +72 -0
- visbench/metrics/similarity.py +85 -0
- visbench/registry.py +214 -0
- visbench/results/__init__.py +9 -0
- visbench/results/schema.py +160 -0
- visbench/results/writer.py +75 -0
- visbench/runner.py +232 -0
- visbench/tasks/__init__.py +17 -0
- visbench/tasks/base.py +182 -0
- visbench/tasks/dense_base.py +554 -0
- visbench/tasks/high_level/__init__.py +13 -0
- visbench/tasks/high_level/classification.py +217 -0
- visbench/tasks/high_level/detection.py +27 -0
- visbench/tasks/high_level/retrieval.py +123 -0
- visbench/tasks/high_level/semantic_segmentation.py +189 -0
- visbench/tasks/low_level/README.md +26 -0
- visbench/tasks/low_level/__init__.py +8 -0
- visbench/tasks/mid_level/__init__.py +23 -0
- visbench/tasks/mid_level/correspondence.py +347 -0
- visbench/tasks/mid_level/depth.py +222 -0
- visbench/tasks/mid_level/generic_segmentation.py +171 -0
- visbench/tasks/mid_level/similarity.py +149 -0
- visbench/tasks/mid_level/surface_normal.py +299 -0
- visbench/types.py +131 -0
- visbench/utils/__init__.py +6 -0
- visbench/utils/device.py +19 -0
- visbench/utils/image.py +33 -0
- visbench/utils/seed.py +43 -0
- visbench-0.2.0.dist-info/METADATA +604 -0
- visbench-0.2.0.dist-info/RECORD +63 -0
- visbench-0.2.0.dist-info/WHEEL +4 -0
- visbench-0.2.0.dist-info/entry_points.txt +2 -0
- visbench-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
}
|