Coverage for imcluster/features.py: 100.00%
120 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-12 01:58 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-12 01:58 +0000
1"""Feature extraction using pretrained Hugging Face vision models."""
3from enum import Enum
4from pathlib import Path
5from typing import Any
7import numpy as np
8import torch
9from huggingface_hub import hf_hub_download, snapshot_download
10from huggingface_hub.errors import HfHubHTTPError
11from numpy.typing import NDArray
12from PIL import Image, ImageOps, UnidentifiedImageError
13from rich.console import Console
14from rich.progress import track
15from transformers import pipeline
17from .io import ImclusterIO
19console = Console()
22class ModelArchitecture(str, Enum):
23 """Supported DINOv3 architecture families."""
25 VIT = "vit"
26 CONVNEXT = "convnext"
29class DinoVersion(str, Enum):
30 """Supported DINO model generations."""
32 AUTO = "auto"
33 TWO = "2"
34 THREE = "3"
37class ModelSize(str, Enum):
38 """Supported model-size tiers."""
40 TINY = "tiny"
41 SMALL = "small"
42 BASE = "base"
43 LARGE = "large"
44 HUGE = "huge"
45 MAX = "max"
48class Device(str, Enum):
49 """Supported inference devices."""
51 AUTO = "auto"
52 CPU = "cpu"
53 CUDA = "cuda"
54 MPS = "mps"
57VIT_MODELS = {
58 ModelSize.TINY: "facebook/dinov3-vits16-pretrain-lvd1689m",
59 ModelSize.SMALL: "facebook/dinov3-vits16plus-pretrain-lvd1689m",
60 ModelSize.BASE: "facebook/dinov3-vitb16-pretrain-lvd1689m",
61 ModelSize.LARGE: "facebook/dinov3-vitl16-pretrain-lvd1689m",
62 ModelSize.HUGE: "facebook/dinov3-vith16plus-pretrain-lvd1689m",
63 ModelSize.MAX: "facebook/dinov3-vit7b16-pretrain-lvd1689m",
64}
66CONVNEXT_MODELS = {
67 ModelSize.TINY: "facebook/dinov3-convnext-tiny-pretrain-lvd1689m",
68 ModelSize.SMALL: "facebook/dinov3-convnext-small-pretrain-lvd1689m",
69 ModelSize.BASE: "facebook/dinov3-convnext-base-pretrain-lvd1689m",
70 ModelSize.LARGE: "facebook/dinov3-convnext-large-pretrain-lvd1689m",
71}
73DINOV2_MODELS = {
74 ModelSize.SMALL: "facebook/dinov2-small",
75 ModelSize.BASE: "facebook/dinov2-base",
76 ModelSize.LARGE: "facebook/dinov2-large",
77 ModelSize.MAX: "facebook/dinov2-giant",
78}
80DINOV2_FALLBACK_MODELS = {
81 ModelSize.TINY: DINOV2_MODELS[ModelSize.SMALL],
82 ModelSize.SMALL: DINOV2_MODELS[ModelSize.SMALL],
83 ModelSize.BASE: DINOV2_MODELS[ModelSize.BASE],
84 ModelSize.LARGE: DINOV2_MODELS[ModelSize.LARGE],
85 ModelSize.HUGE: DINOV2_MODELS[ModelSize.MAX],
86 ModelSize.MAX: DINOV2_MODELS[ModelSize.MAX],
87}
89DEFAULT_MODEL = DINOV2_MODELS[ModelSize.BASE]
90DINOV3_ACCESS_DOCS = (
91 "https://rbturnbull.github.io/imcluster/models.html#getting-access-to-dinov3"
92)
95def dinov3_available(model_name: str) -> bool:
96 """Return whether a DINOv3 model is cached or accessible from the Hub."""
97 try:
98 snapshot = Path(snapshot_download(model_name, local_files_only=True))
99 if any(snapshot.glob("*.safetensors")) or any(
100 snapshot.glob("pytorch_model*.bin")
101 ):
102 return True
103 except (HfHubHTTPError, OSError):
104 pass
106 try:
107 hf_hub_download(model_name, "config.json")
108 except (HfHubHTTPError, OSError):
109 return False
110 return True
113def resolve_device(device: Device) -> str:
114 """Resolve automatic inference device selection."""
115 if device is not Device.AUTO:
116 return device.value
117 if torch.cuda.is_available():
118 return Device.CUDA.value
119 if torch.backends.mps.is_available():
120 return Device.MPS.value
121 return Device.CPU.value
124def resolve_model(
125 model: str | None,
126 dino_version: DinoVersion,
127 architecture: ModelArchitecture,
128 size: ModelSize,
129) -> str:
130 """Resolve a custom model or DINO architecture-size preset.
132 Args:
133 model: Explicit Hugging Face model ID, which takes precedence when set.
134 dino_version: DINO model generation used for preset selection.
135 architecture: ViT or ConvNeXt architecture family.
136 size: Requested model-size tier.
138 Returns:
139 A Hugging Face model identifier.
141 Raises:
142 ValueError: If the requested size is unavailable for the architecture.
143 """
144 if model:
145 return model
147 if dino_version is DinoVersion.TWO:
148 models = DINOV2_MODELS
149 else:
150 models = (
151 VIT_MODELS if architecture is ModelArchitecture.VIT else CONVNEXT_MODELS
152 )
153 try:
154 selected_model = models[size]
155 except KeyError as error:
156 if dino_version is DinoVersion.AUTO:
157 fallback = DINOV2_FALLBACK_MODELS[size]
158 console.print(
159 f"DINOv3 has no {architecture.value}/{size.value} preset; "
160 f"using {fallback}"
161 )
162 return fallback
163 selection = (
164 "DINOv2"
165 if dino_version is DinoVersion.TWO
166 else f"DINOv3 architecture '{architecture.value}'"
167 )
168 raise ValueError(
169 f"Size '{size.value}' is not available for {selection}"
170 ) from error
172 if dino_version is DinoVersion.AUTO:
173 with console.status("[cyan]Checking DINOv3 model access...[/cyan]"):
174 available = dinov3_available(selected_model)
175 else:
176 available = True
177 if not available:
178 fallback = DINOV2_FALLBACK_MODELS[size]
179 console.print(
180 "[bold yellow]Warning:[/bold yellow] DINOv3 is not available to "
181 "the active Hugging Face account or local cache, so imcluster is "
182 f"falling back to {fallback}. DINOv3 requires access approval and "
183 "authentication. See the "
184 f"[link={DINOV3_ACCESS_DOCS}]imcluster DINOv3 access instructions[/link]."
185 )
186 return fallback
187 return selected_model
190def build_features(
191 imcluster_io: ImclusterIO,
192 model_name: str = DEFAULT_MODEL,
193 device: Device = Device.AUTO,
194 batch_size: int = 8,
195 force: bool = False,
196) -> NDArray[Any]:
197 """Build or load normalized image feature vectors.
199 Args:
200 imcluster_io: Image collection and its persisted result table.
201 model_name: Hugging Face image-feature-extraction model identifier.
202 device: Device used for model inference.
203 batch_size: Number of images submitted for each inference call.
204 force: Rebuild vectors even when a cached model column exists.
206 Returns:
207 A two-dimensional array with one normalized feature vector per image.
209 Notes:
210 Results are cached in a column named after ``model_name``.
211 """
212 model_name = str(model_name)
213 if batch_size < 1:
214 raise ValueError("batch_size must be at least 1")
216 if not imcluster_io.has_column(model_name) or force:
217 resolved_device = resolve_device(device)
218 console.print(
219 "[cyan]Loading feature model:[/cyan] "
220 f"{model_name} on {resolved_device}; processing "
221 f"{len(imcluster_io.images)} images in batches of {batch_size}."
222 )
223 with console.status(f"[cyan]Loading model '{model_name}'...[/cyan]"):
224 feature_extractor = pipeline(
225 model=model_name,
226 task="image-feature-extraction",
227 device=resolved_device,
228 )
230 results: list[NDArray[Any]] = []
231 image_batches = [
232 imcluster_io.images[index : index + batch_size]
233 for index in range(0, len(imcluster_io.images), batch_size)
234 ]
235 for paths in track(image_batches, description="Generating feature vectors:"):
236 images = []
237 for path in paths:
238 try:
239 with Image.open(path) as image:
240 images.append(
241 ImageOps.exif_transpose(image).convert("RGB").copy()
242 )
243 except (OSError, UnidentifiedImageError) as error:
244 raise ValueError(f"Cannot read image '{path}': {error}") from error
246 outputs = feature_extractor(images, pool=True, batch_size=batch_size)
247 for output in outputs:
248 result = np.asarray(output)
249 while result.ndim > 1 and result.shape[0] == 1:
250 result = result[0]
251 if result.ndim != 1:
252 raise ValueError(
253 f"Model '{model_name}' did not return pooled image embeddings"
254 )
255 results.append(result)
257 feature_vectors = np.vstack(results)
258 norms = np.linalg.norm(
259 feature_vectors,
260 axis=1,
261 keepdims=True,
262 )
263 if np.any(norms == 0):
264 raise ValueError(f"Model '{model_name}' returned a zero-length embedding")
265 feature_vectors /= norms
267 imcluster_io.save_column(
268 model_name, [feature_vectors[x] for x in range(feature_vectors.shape[0])]
269 )
270 console.print(
271 "[green]Cached feature vectors:[/green] "
272 f"wrote {len(feature_vectors)} embeddings to '{imcluster_io.output}'."
273 )
274 else:
275 console.print(
276 "[green]Using cached feature vectors:[/green] "
277 f"loaded model '{model_name}' embeddings from '{imcluster_io.output}'."
278 )
280 feature_vectors = np.array(imcluster_io.get_column(model_name).to_list())
282 return feature_vectors