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
visbench/__init__.py ADDED
@@ -0,0 +1,104 @@
1
+ """VisBench — probing vision backbones across CV tasks through a small API.
2
+
3
+ Two entry points carry the whole library::
4
+
5
+ backbone = visbench.get_backbone("dinov2_vitb14")
6
+ probe = visbench.get_probe("classification")
7
+
8
+ Sibling project to vismatch (https://github.com/gmberton/vismatch): same
9
+ ergonomic philosophy, applied to representation probing instead of matching.
10
+
11
+ Task categorization follows Chen, Marks & Cheng, "Probing the Mid-level Vision
12
+ Capabilities of Self-Supervised Learning" (arXiv:2411.17474). Evaluation
13
+ protocols for depth, surface normal and correspondence follow probe3d
14
+ (El Banani et al., CVPR 2024, arXiv:2404.08476).
15
+ """
16
+
17
+ from typing import Any
18
+
19
+ from visbench import registry
20
+ from visbench.backbones.custom import CustomBackbone
21
+ from visbench.registry import register_backbone, register_task
22
+ from visbench.types import FeatureDict, FeatureMode, Pooling
23
+
24
+ __version__ = "0.2.0"
25
+
26
+ __all__ = [
27
+ "get_backbone",
28
+ "get_probe",
29
+ "list_backbones",
30
+ "list_probes",
31
+ "run",
32
+ "register_backbone",
33
+ "register_task",
34
+ "CustomBackbone",
35
+ "FeatureDict",
36
+ "FeatureMode",
37
+ "Pooling",
38
+ "__version__",
39
+ ]
40
+
41
+
42
+ def get_backbone(name: str, **kwargs: Any) -> "Any":
43
+ """Instantiate a registered backbone by name.
44
+
45
+ Parameters
46
+ ----------
47
+ name:
48
+ Registered backbone name, e.g. ``"dinov2_vitb14"``. See
49
+ :func:`list_backbones`.
50
+ **kwargs:
51
+ Forwarded to the backbone constructor (device, weights variant, ...).
52
+
53
+ Returns
54
+ -------
55
+ BaseBackbone
56
+ A frozen, eval-mode backbone exposing ``.extract_features()``.
57
+ """
58
+ return registry.build_backbone(name, **kwargs)
59
+
60
+
61
+ def get_probe(name: str, **kwargs: Any) -> "Any":
62
+ """Instantiate a registered task/probe by name.
63
+
64
+ Parameters
65
+ ----------
66
+ name:
67
+ Registered task name, e.g. ``"classification"``, ``"retrieval"``,
68
+ ``"correspondence"``. See :func:`list_probes`.
69
+ **kwargs:
70
+ Forwarded to the task constructor (head type, pooling override, ...).
71
+
72
+ Returns
73
+ -------
74
+ BaseTask
75
+ A task exposing ``.fit()`` / ``.predict()`` / ``.evaluate()``.
76
+
77
+ Notes
78
+ -----
79
+ No task is registered yet — tasks land at build step 3, so this currently
80
+ raises ``KeyError`` for every name.
81
+ """
82
+ return registry.build_task(name, **kwargs)
83
+
84
+
85
+ def list_backbones() -> list[str]:
86
+ """Names accepted by :func:`get_backbone`."""
87
+ return registry.list_backbones()
88
+
89
+
90
+ def list_probes() -> list[str]:
91
+ """Names accepted by :func:`get_probe`."""
92
+ return registry.list_tasks()
93
+
94
+
95
+ def run(*args: Any, **kwargs: Any) -> Any:
96
+ """Backbone + task + data -> scored, logged result. See :mod:`visbench.runner`.
97
+
98
+ Imported lazily so that ``import visbench`` stays cheap; the module pulls in
99
+ the cache and result writer, which a caller reaching only for
100
+ ``get_backbone`` does not need.
101
+ """
102
+ from visbench.runner import run as _run
103
+
104
+ return _run(*args, **kwargs)
@@ -0,0 +1,28 @@
1
+ """Backbone implementations.
2
+
3
+ Importing this package runs the ``@register_backbone`` decorators, which is
4
+ what makes names visible to :func:`visbench.get_backbone`.
5
+
6
+ v0.1 ships DINOv2 and CLIP only. ResNet/timm and user-supplied custom
7
+ backbones (arbitrary ``nn.Module`` + preprocessing fn) arrive in v0.2.
8
+ """
9
+
10
+ from visbench.backbones.base import BaseBackbone
11
+ from visbench.backbones.custom import CustomBackbone
12
+ from visbench.backbones.dinov2 import DINOv2
13
+
14
+ __all__ = ["BaseBackbone", "CustomBackbone", "DINOv2", "CLIP", "TimmBackbone"]
15
+
16
+
17
+ #: Backbones behind an optional extra, imported only on attribute access so a
18
+ #: plain ``import visbench.backbones`` does not require every extra.
19
+ _LAZY = {"CLIP": "visbench.backbones.clip", "TimmBackbone": "visbench.backbones.timm_backbone"}
20
+
21
+
22
+ def __getattr__(name: str):
23
+ """Import optional-extra backbones lazily."""
24
+ if name in _LAZY:
25
+ import importlib
26
+
27
+ return getattr(importlib.import_module(_LAZY[name]), name)
28
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,334 @@
1
+ """The backbone abstraction.
2
+
3
+ One method, one return shape, for every architecture family. ViT and CNN
4
+ internals differ completely but callers cannot tell the difference — that is
5
+ the whole point of this class (CLAUDE.md, "Feature extraction design").
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from typing import cast
10
+
11
+ import torch
12
+ import torch.nn as nn
13
+
14
+ from visbench.backbones.pooling import apply_feature_mode, pool_tokens, tokens_to_grid
15
+ from visbench.types import (
16
+ FEATURE_MODE_CHOICES,
17
+ POOLING_CHOICES,
18
+ FeatureDict,
19
+ FeatureMode,
20
+ LayerOutput,
21
+ LayerSpec,
22
+ Pooling,
23
+ )
24
+ from visbench.utils.device import resolve_device
25
+
26
+ __all__ = ["BaseBackbone"]
27
+
28
+
29
+ class BaseBackbone(nn.Module, ABC):
30
+ """Frozen feature extractor with a uniform dual pooled+dense output.
31
+
32
+ Subclasses implement :meth:`_forward_features` (architecture-specific) and
33
+ declare :attr:`has_cls_token` / :attr:`embed_dim`; pooling, reshaping and
34
+ validation are handled once, here.
35
+
36
+ Backbones are deliberately "dumb": they execute whatever ``pooling`` the
37
+ task asks for and hold no opinion about which representation a task needs.
38
+ """
39
+
40
+ #: Registered name. Set per instance in ``__init__`` rather than by the
41
+ #: ``@register_backbone`` decorator, because one class may serve several
42
+ #: registered names (see :mod:`visbench.registry`).
43
+ name: str = ""
44
+
45
+ #: Whether the architecture exposes a CLS token. Drives the default pooling
46
+ #: rule: CLS for ViTs that have one, mean-pooling for everything else.
47
+ has_cls_token: bool = False
48
+
49
+ #: Channel dim of both ``dense`` and ``pooled`` outputs.
50
+ embed_dim: int = 0
51
+
52
+ #: ViT patch size; ``None`` for CNNs, where the stride is architectural.
53
+ patch_size: int | None = None
54
+
55
+ def __init__(self, device: str | None = None) -> None:
56
+ """Record the target device. Subclasses load weights, then call
57
+ :meth:`_finalize` to freeze, ``eval()`` and move the module.
58
+
59
+ Freezing cannot happen here: at this point the subclass has not built
60
+ its weights yet, so there is nothing to freeze.
61
+ """
62
+ super().__init__()
63
+ self.device = resolve_device(device)
64
+
65
+ def _finalize(self) -> None:
66
+ """Freeze every parameter, switch to eval mode, move to the device.
67
+
68
+ Called by subclasses at the end of ``__init__``. Probing evaluates
69
+ *frozen* representations, so a backbone that arrives in train mode
70
+ (BatchNorm updating, dropout active) silently changes the numbers it
71
+ reports — hence one shared implementation rather than per-backbone
72
+ boilerplate.
73
+ """
74
+ for param in self.parameters():
75
+ param.requires_grad_(False)
76
+ self.eval()
77
+ self.to(self.device)
78
+
79
+ @torch.no_grad()
80
+ def extract_features(
81
+ self,
82
+ image: torch.Tensor,
83
+ pooling: str = Pooling.DEFAULT,
84
+ layers: LayerSpec = None,
85
+ feature_mode: str = FeatureMode.DENSE_ONLY,
86
+ ) -> FeatureDict:
87
+ """Extract dense and pooled features in a single forward pass.
88
+
89
+ Parameters
90
+ ----------
91
+ image:
92
+ Preprocessed batch, ``(B, 3, H, W)``. Use :meth:`preprocess` to
93
+ build it from PIL images.
94
+ pooling:
95
+ One of :data:`visbench.types.POOLING_CHOICES`. ``"default"``
96
+ resolves per :meth:`default_pooling`.
97
+ layers:
98
+ Which backbone depths to read, shallowest first. ``None`` means the
99
+ last layer, the single-layer path every v0.1 task uses. Indices may
100
+ be negative (``-1`` is the last layer) and must be strictly
101
+ increasing; see :meth:`resolve_layers`.
102
+
103
+ Passing a list adds ``dense_layers`` and ``layer_indices`` to the
104
+ result, which is what a multiscale head such as
105
+ :class:`~visbench.heads.DPTHead` consumes. All requested layers
106
+ come from **one** forward pass — that is the entire reason this is
107
+ a list rather than a loop over single-layer calls.
108
+ feature_mode:
109
+ How ``dense`` is assembled, one of
110
+ :data:`visbench.types.FEATURE_MODE_CHOICES`:
111
+
112
+ ``dense_only``
113
+ the patch grid alone, ``(B, C, H, W)``. The default.
114
+ ``dense_cls_broadcast``
115
+ CLS repeated at every location, ``(B, C + C_cls, H, W)``.
116
+ ``dense_plus_cls``
117
+ grid and CLS kept separate; ``dense`` is the grid and the CLS
118
+ vector is returned under ``cls``, for a head that fuses them at
119
+ a bottleneck rather than at every pixel.
120
+
121
+ ``pooled`` is unaffected — it answers a different question, and a
122
+ task wanting mean-pooled patches with a broadcast dense grid must
123
+ be able to ask for both.
124
+
125
+ Returns
126
+ -------
127
+ FeatureDict
128
+ ``{"dense": ..., "pooled": (B, C), "grid_hw": (H, W)}``, plus
129
+ ``cls`` when ``feature_mode="dense_plus_cls"`` and
130
+ ``dense_layers``/``layer_indices`` when ``layers`` is given.
131
+
132
+ ``dense``, ``pooled``, ``grid_hw`` and ``cls`` always describe the
133
+ **last** requested layer. A multi-layer call is therefore a superset
134
+ of the single-layer one: a task that only reads ``dense`` behaves
135
+ identically whether or not the layer list was widened underneath it.
136
+ """
137
+ if pooling not in POOLING_CHOICES:
138
+ raise ValueError(f"Unknown pooling {pooling!r}; expected one of {POOLING_CHOICES}")
139
+ if feature_mode not in FEATURE_MODE_CHOICES:
140
+ raise ValueError(
141
+ f"Unknown feature_mode {feature_mode!r}; expected one of {FEATURE_MODE_CHOICES}"
142
+ )
143
+ if not isinstance(image, torch.Tensor):
144
+ raise TypeError(
145
+ f"extract_features expects a preprocessed tensor, got {type(image).__name__}. "
146
+ "Call backbone.preprocess(images) first."
147
+ )
148
+ if image.ndim != 4:
149
+ raise ValueError(
150
+ f"Expected a batch of shape (B, 3, H, W), got {tuple(image.shape)}. "
151
+ "For a single image use image.unsqueeze(0)."
152
+ )
153
+
154
+ resolved = self.default_pooling() if pooling == Pooling.DEFAULT else pooling
155
+ indices = self.resolve_layers(layers)
156
+ image = image.to(self.device)
157
+
158
+ outputs = self._forward_features(image, indices)
159
+ if len(outputs) != len(indices):
160
+ raise RuntimeError(
161
+ f"{type(self).__name__}._forward_features returned {len(outputs)} layers "
162
+ f"for {len(indices)} requested. A backbone must return one per index, in "
163
+ "the order asked, or the caller cannot tell which depth it is holding."
164
+ )
165
+
166
+ assembled_layers = [self._assemble(output, feature_mode) for output in outputs]
167
+
168
+ # The last requested layer is the headline one, so a task reading only
169
+ # `dense` sees the deepest features whether or not shallower ones were
170
+ # also requested.
171
+ patch_tokens, cls_token, grid_hw = outputs[-1]
172
+ features: FeatureDict = {
173
+ "pooled": pool_tokens(patch_tokens, cls_token, resolved),
174
+ "grid_hw": grid_hw,
175
+ "dense": assembled_layers[-1][0],
176
+ }
177
+ if feature_mode == FeatureMode.DENSE_PLUS_CLS:
178
+ # The one mode that returns two things: keeping them separate is
179
+ # the point, so `cls` is a distinct key rather than a tuple the
180
+ # caller has to unpack differently from every other mode.
181
+ cls_vector = assembled_layers[-1][1]
182
+ assert cls_vector is not None # _assemble guarantees it for this mode
183
+ features["cls"] = cls_vector
184
+ if layers is not None:
185
+ features["dense_layers"] = [dense for dense, _ in assembled_layers]
186
+ features["layer_indices"] = indices
187
+ return features
188
+
189
+ def _assemble(
190
+ self,
191
+ output: LayerOutput,
192
+ feature_mode: str,
193
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
194
+ """One layer's tokens to ``(dense, cls_or_None)`` in ``feature_mode``."""
195
+ patch_tokens, cls_token, grid_hw = output
196
+ grid = tokens_to_grid(patch_tokens, grid_hw)
197
+ assembled = apply_feature_mode(grid, cls_token, feature_mode)
198
+ if feature_mode == FeatureMode.DENSE_PLUS_CLS:
199
+ # apply_feature_mode's return type depends on the *value* of
200
+ # feature_mode, which the type system cannot express, and it has
201
+ # already rejected a missing CLS token for this mode.
202
+ return cast(tuple[torch.Tensor, torch.Tensor], assembled)
203
+ return cast(torch.Tensor, assembled), None
204
+
205
+ def resolve_layers(self, layers: LayerSpec) -> list[int]:
206
+ """Normalise a caller's ``layers`` into concrete, in-range indices.
207
+
208
+ ``None`` becomes the last layer. Negative indices count from the end,
209
+ so ``-1`` is the last layer and resolves to the same entry as its
210
+ absolute index — which matters because the resolved value is what
211
+ reaches the cache key, and ``[-1]`` and ``[11]`` on a 12-block model
212
+ must not occupy two entries holding identical features.
213
+
214
+ Indices must be **strictly increasing**. Order carries meaning
215
+ downstream — a multiscale head treats the first layer it is handed as
216
+ the coarsest — so a descending or repeated list would build a pyramid
217
+ that silently disagrees with the caller's intent. Rejecting it is the
218
+ only option that cannot be wrong; reordering would quietly overrule the
219
+ caller, and accepting it would quietly mislabel the output.
220
+ """
221
+ depth = self.num_layers
222
+ if layers is None:
223
+ return [depth - 1]
224
+ if not isinstance(layers, (list, tuple)):
225
+ raise TypeError(f"layers must be a list of ints or None, got {type(layers).__name__}")
226
+ if len(layers) == 0:
227
+ raise ValueError("layers=[] requests nothing; pass None for the last layer.")
228
+
229
+ resolved = []
230
+ for index in layers:
231
+ if not isinstance(index, int) or isinstance(index, bool):
232
+ raise TypeError(f"Layer indices must be ints, got {index!r}")
233
+ absolute = index + depth if index < 0 else index
234
+ if not 0 <= absolute < depth:
235
+ raise ValueError(
236
+ f"Layer index {index} is out of range for {self.name or type(self).__name__}, "
237
+ f"which exposes {depth} layers (valid: 0..{depth - 1}, or -1..-{depth})."
238
+ )
239
+ resolved.append(absolute)
240
+
241
+ # strict=False: the two arguments are deliberately ragged. Pairing a list
242
+ # with its own tail is how consecutive pairs are formed, and the shorter
243
+ # one is meant to end the walk.
244
+ if any(b <= a for a, b in zip(resolved, resolved[1:], strict=False)):
245
+ raise ValueError(
246
+ f"layers must be strictly increasing, shallowest first; got {layers} "
247
+ f"which resolves to {resolved}. A multiscale head reads the first layer "
248
+ "as the coarsest, so the order is part of the request."
249
+ )
250
+ return resolved
251
+
252
+ @property
253
+ def num_layers(self) -> int:
254
+ """How many depths this backbone can be asked for.
255
+
256
+ Transformer blocks for a ViT, feature stages for a CNN. Defined by the
257
+ subclass because only it knows what an index means; the base class uses
258
+ it to resolve negative indices and to reject out-of-range ones once,
259
+ rather than in four places with four different messages.
260
+
261
+ A backbone that exposes only its final output returns 1, which makes
262
+ every multi-layer request fail with a clear message instead of a
263
+ confusing one from inside the model.
264
+ """
265
+ return 1
266
+
267
+ @abstractmethod
268
+ def _forward_features(
269
+ self,
270
+ image: torch.Tensor,
271
+ layers: list[int],
272
+ ) -> list[LayerOutput]:
273
+ """Architecture-specific forward returning one output per requested layer.
274
+
275
+ ``layers`` arrives already resolved by :meth:`resolve_layers`: concrete,
276
+ in-range, strictly increasing, never empty and never negative. A
277
+ subclass never has to interpret ``None`` or normalise an index.
278
+
279
+ Every family normalises to a **token sequence** here: each element is
280
+ ``(patch_tokens, cls, grid_hw)`` with ``patch_tokens`` ``(B, N, C)``,
281
+ ``N == grid_h * grid_w``, and ``cls`` ``(B, C)`` or ``None``. ViTs
282
+ return this natively; CNN subclasses flatten their ``(B, C, H, W)``
283
+ conv map into it and return ``None`` for CLS.
284
+
285
+ Making the *subclass* do that flattening — rather than having the base
286
+ class branch on architecture family — is what keeps
287
+ :meth:`extract_features` a single code path. The flatten/unflatten
288
+ round-trip costs nothing next to a forward pass, and the alternative
289
+ puts an ``if is_vit`` in the one method that exists to hide that
290
+ distinction.
291
+
292
+ All requested layers must come from **one** forward pass. Looping over
293
+ single-layer calls would multiply the cost of the thing the feature
294
+ cache exists to avoid.
295
+
296
+ Register tokens, if the variant has them, must be stripped here.
297
+ """
298
+ raise NotImplementedError
299
+
300
+ def default_pooling(self) -> str:
301
+ """Resolve ``pooling="default"`` for this architecture.
302
+
303
+ CLS when :attr:`has_cls_token`, mean-pooling otherwise.
304
+ """
305
+ return Pooling.CLS if self.has_cls_token else Pooling.MEAN
306
+
307
+ @abstractmethod
308
+ def preprocess(self, images) -> torch.Tensor:
309
+ """Convert PIL image(s) into a normalised, resized batch tensor.
310
+
311
+ Each backbone owns its own normalisation constants and input
312
+ resolution, so preprocessing lives with the backbone rather than in a
313
+ shared transform.
314
+
315
+ Accepts a single PIL image or a sequence of them; always returns
316
+ ``(B, 3, H, W)``.
317
+ """
318
+ raise NotImplementedError
319
+
320
+ @abstractmethod
321
+ def cache_key(self) -> str:
322
+ """Stable identifier for this backbone + weights, used in cache keys.
323
+
324
+ Must change whenever the weights or extraction behaviour change, or
325
+ stale cached features would be silently reused. Abstract rather than
326
+ defaulted: a plausible-looking inherited key that does not actually
327
+ track the weights is exactly how one model's features get served as
328
+ another's.
329
+ """
330
+ raise NotImplementedError
331
+
332
+ def forward(self, image: torch.Tensor) -> FeatureDict:
333
+ """Alias for :meth:`extract_features` with default pooling."""
334
+ return self.extract_features(image)