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
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)
|