Skip to content

Commit 587a900

Browse files
authored
Merge branch 'main' into fix/python311_compatibility_issue
2 parents cde02cd + 66b95b8 commit 587a900

File tree

14 files changed

+528
-72
lines changed

14 files changed

+528
-72
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ core = [
5858
"open-clip-torch>=2.23.0,<2.26.1",
5959
]
6060
openvino = ["openvino>=2024.0", "nncf>=2.10.0", "onnx>=1.16.0"]
61-
vlm = ["ollama<0.4.0", "openai", "python-dotenv","transformers"]
61+
vlm = ["ollama>=0.4.0", "openai", "python-dotenv","transformers"]
6262
loggers = [
6363
"comet-ml>=3.31.7",
6464
"gradio>=4",

src/anomalib/metrics/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
from .aupr import AUPR
4545
from .aupro import AUPRO
4646
from .auroc import AUROC
47-
from .base import AnomalibMetric
47+
from .base import AnomalibMetric, create_anomalib_metric
4848
from .evaluator import Evaluator
4949
from .f1_score import F1Max, F1Score
5050
from .min_max import MinMax
@@ -60,6 +60,7 @@
6060
"AnomalibMetric",
6161
"AnomalyScoreDistribution",
6262
"BinaryPrecisionRecallCurve",
63+
"create_anomalib_metric",
6364
"Evaluator",
6465
"F1AdaptiveThreshold",
6566
"F1Max",

src/anomalib/metrics/base.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,34 @@
4040
>>> from anomalib.metrics import create_anomalib_metric
4141
>>> F1Score = create_anomalib_metric(BinaryF1Score)
4242
>>> f1_score = F1Score(fields=["pred_label", "gt_label"])
43+
44+
Strict mode vs non-strict mode::
45+
46+
>>> F1Score = create_anomalib_metric(BinaryF1Score)
47+
>>>
48+
>>> # create metric in strict mode (default), and non-strict mode
49+
>>> f1_score_strict = F1Score(fields=["pred_label", "gt_label"], strict=True)
50+
>>> f1_score_nonstrict = F1Score(fields=["pred_label", "gt_label"], strict=False)
51+
>>>
52+
>>> # create a batch in which 'pred_label' field is None
53+
>>> batch = ImageBatch(
54+
... image=torch.rand(4, 3, 256, 256),
55+
... gt_label=torch.tensor([0, 0, 1, 1])
56+
... )
57+
>>>
58+
>>> f1_score_strict.update(batch) # ValueError
59+
>>> f1_score_strict.compute() # UserWarning, tensor(0.)
60+
>>>
61+
>>> f1_score_nonstrict.update(batch) # No error
62+
>>> f1_score_nonstrict.compute() # None
4363
"""
4464

45-
# Copyright (C) 2024 Intel Corporation
65+
# Copyright (C) 2024-2025 Intel Corporation
4666
# SPDX-License-Identifier: Apache-2.0
4767

4868
from collections.abc import Sequence
4969

70+
import torch
5071
from torchmetrics import Metric, MetricCollection
5172

5273
from anomalib.data import Batch
@@ -67,6 +88,7 @@ class AnomalibMetric:
6788
fields (Sequence[str] | None): Names of fields to extract from batch.
6889
If None, uses class's ``default_fields``. Required if no defaults.
6990
prefix (str): Prefix added to metric name. Defaults to "".
91+
strict (bool): Whether to raise an error if batch is missing fields.
7092
**kwargs: Additional arguments passed to parent metric class.
7193
7294
Raises:
@@ -97,6 +119,7 @@ def __init__(
97119
self,
98120
fields: Sequence[str] | None = None,
99121
prefix: str = "",
122+
strict: bool = True,
100123
**kwargs,
101124
) -> None:
102125
fields = fields or getattr(self, "default_fields", None)
@@ -109,6 +132,7 @@ def __init__(
109132
raise ValueError(msg)
110133
self.fields = fields
111134
self.name = prefix + self.__class__.__name__
135+
self.strict = strict
112136
super().__init__(**kwargs)
113137

114138
def __init_subclass__(cls, **kwargs) -> None:
@@ -132,11 +156,40 @@ def update(self, batch: Batch, *args, **kwargs) -> None:
132156
"""
133157
for key in self.fields:
134158
if getattr(batch, key, None) is None:
135-
msg = f"Batch object is missing required field: {key}"
159+
# We cannot update the metric if the batch is missing required fields,
160+
# so we need to decrement the update count of the super class.
161+
self._update_count -= 1 # type: ignore[attr-defined]
162+
if not self.strict:
163+
# If not in strict mode, skip updating the metric but don't raise an error
164+
return
165+
# otherwise, raise an error
166+
if not hasattr(batch, key):
167+
msg = (
168+
f"Cannot update metric of type {type(self)}. Passed dataclass instance "
169+
f"is missing required field: {key}"
170+
)
171+
else:
172+
msg = (
173+
f"Cannot update metric of type {type(self)}. Passed dataclass instance "
174+
f"does not have a value for field with name {key}."
175+
)
136176
raise ValueError(msg)
177+
137178
values = [getattr(batch, key) for key in self.fields]
138179
super().update(*values, *args, **kwargs) # type: ignore[misc]
139180

181+
def compute(self) -> torch.Tensor:
182+
"""Compute the metric value.
183+
184+
If the metric has not been updated, and metric is not in strict mode, return None.
185+
186+
Returns:
187+
torch.Tensor: Computed metric value or None.
188+
"""
189+
if self._update_count == 0 and not self.strict: # type: ignore[attr-defined]
190+
return None
191+
return super().compute() # type: ignore[misc]
192+
140193

141194
def create_anomalib_metric(metric_cls: type) -> type:
142195
"""Create an Anomalib version of a torchmetrics metric.

src/anomalib/metrics/min_max.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@
3030
import torch
3131
from torchmetrics import Metric
3232

33+
from anomalib.metrics import AnomalibMetric
3334

34-
class MinMax(Metric):
35+
36+
class _MinMax(Metric):
3537
"""Track minimum and maximum values across batches.
3638
3739
This metric maintains running minimum and maximum values across all batches
@@ -48,10 +50,10 @@ class MinMax(Metric):
4850
max (torch.Tensor): Running maximum value seen across all batches
4951
5052
Example:
51-
>>> from anomalib.metrics import MinMax
53+
>>> from anomalib.metrics.min_max import _MinMax
5254
>>> import torch
5355
>>> # Create metric
54-
>>> minmax = MinMax()
56+
>>> minmax = _MinMax()
5557
>>> # Update with batches
5658
>>> batch1 = torch.tensor([0.1, 0.2, 0.3])
5759
>>> batch2 = torch.tensor([0.2, 0.4, 0.5])
@@ -67,8 +69,8 @@ class MinMax(Metric):
6769

6870
def __init__(self, **kwargs) -> None:
6971
super().__init__(**kwargs)
70-
self.add_state("min", torch.tensor(float("inf")), persistent=True, dist_reduce_fx="min")
71-
self.add_state("max", torch.tensor(float("-inf")), persistent=True, dist_reduce_fx="max")
72+
self.add_state("min", torch.tensor(float("inf")), dist_reduce_fx="min")
73+
self.add_state("max", torch.tensor(float("-inf")), dist_reduce_fx="max")
7274

7375
self.min = torch.tensor(float("inf"))
7476
self.max = torch.tensor(float("-inf"))
@@ -94,4 +96,8 @@ def compute(self) -> tuple[torch.Tensor, torch.Tensor]:
9496
tuple[torch.Tensor, torch.Tensor]: Tuple containing the (min, max)
9597
values tracked across all batches
9698
"""
97-
return self.min, self.max
99+
return torch.stack([self.min, self.max])
100+
101+
102+
class MinMax(AnomalibMetric, _MinMax): # type: ignore[misc]
103+
"""Wrapper to add AnomalibMetric functionality to MinMax metric."""

src/anomalib/metrics/threshold/f1_adaptive_threshold.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,15 @@
3333

3434
import torch
3535

36+
from anomalib.metrics import AnomalibMetric
3637
from anomalib.metrics.precision_recall_curve import BinaryPrecisionRecallCurve
3738

3839
from .base import Threshold
3940

4041
logger = logging.getLogger(__name__)
4142

4243

43-
class F1AdaptiveThreshold(BinaryPrecisionRecallCurve, Threshold):
44+
class _F1AdaptiveThreshold(BinaryPrecisionRecallCurve, Threshold):
4445
"""Adaptive threshold that maximizes F1 score.
4546
4647
This class computes and stores the optimal threshold for converting anomaly
@@ -95,3 +96,7 @@ def compute(self) -> torch.Tensor:
9596
# account for special case where recall is 1.0 even for the highest threshold.
9697
# In this case 'thresholds' will be scalar.
9798
return thresholds if thresholds.dim() == 0 else thresholds[torch.argmax(f1_score)]
99+
100+
101+
class F1AdaptiveThreshold(AnomalibMetric, _F1AdaptiveThreshold): # type: ignore[misc]
102+
"""Wrapper to add AnomalibMetric functionality to F1AdaptiveThreshold metric."""

src/anomalib/models/components/base/anomalib_module.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -374,8 +374,8 @@ def configure_evaluator() -> Evaluator:
374374
"""
375375
image_auroc = AUROC(fields=["pred_score", "gt_label"], prefix="image_")
376376
image_f1score = F1Score(fields=["pred_label", "gt_label"], prefix="image_")
377-
pixel_auroc = AUROC(fields=["anomaly_map", "gt_mask"], prefix="pixel_")
378-
pixel_f1score = F1Score(fields=["pred_mask", "gt_mask"], prefix="pixel_")
377+
pixel_auroc = AUROC(fields=["anomaly_map", "gt_mask"], prefix="pixel_", strict=False)
378+
pixel_f1score = F1Score(fields=["pred_mask", "gt_mask"], prefix="pixel_", strict=False)
379379
test_metrics = [image_auroc, image_f1score, pixel_auroc, pixel_f1score]
380380
return Evaluator(test_metrics=test_metrics)
381381

src/anomalib/models/image/vlm_ad/backends/ollama.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@
4444
from .base import Backend
4545

4646
if module_available("ollama"):
47-
from ollama import chat
48-
from ollama._client import _encode_image
47+
from ollama import Image, chat
4948
else:
5049
chat = None
5150

@@ -101,7 +100,7 @@ def add_reference_images(self, image: str | Path) -> None:
101100
Args:
102101
image (str | Path): Path to the reference image file
103102
"""
104-
self._ref_images_encoded.append(_encode_image(image))
103+
self._ref_images_encoded.append(Image(value=image))
105104

106105
@property
107106
def num_reference_images(self) -> int:
@@ -144,7 +143,7 @@ def predict(self, image: str | Path, prompt: Prompt) -> str:
144143
if not chat:
145144
msg = "Ollama is not installed. Please install it using `pip install ollama`."
146145
raise ImportError(msg)
147-
image_encoded = _encode_image(image)
146+
image_encoded = Image(value=image)
148147
messages = []
149148

150149
# few-shot

src/anomalib/models/video/ai_vad/density.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
import torch
3434
from torch import Tensor, nn
3535

36-
from anomalib.metrics.min_max import MinMax
3736
from anomalib.models.components.base import DynamicBufferMixin
3837
from anomalib.models.components.cluster.gmm import GaussianMixture
3938

@@ -296,10 +295,14 @@ def __init__(self, n_neighbors: int) -> None:
296295
self.n_neighbors = n_neighbors
297296
self.feature_collection: dict[str, list[torch.Tensor]] = {}
298297
self.group_index: dict[str, int] = {}
299-
self.normalization_statistics = MinMax()
300298

301299
self.register_buffer("memory_bank", Tensor())
302-
self.memory_bank: torch.Tensor = Tensor()
300+
self.register_buffer("min", torch.tensor(torch.inf))
301+
self.register_buffer("max", torch.tensor(-torch.inf))
302+
303+
self.memory_bank: torch.Tensor
304+
self.min: torch.Tensor
305+
self.max: torch.Tensor
303306

304307
def update(self, features: torch.Tensor, group: str | None = None) -> None:
305308
"""Update the internal feature bank while keeping track of the group.
@@ -428,9 +431,8 @@ def _compute_normalization_statistics(self, grouped_features: dict[str, Tensor])
428431
"""
429432
for group, features in grouped_features.items():
430433
distances = self.predict(features, group, normalize=False)
431-
self.normalization_statistics.update(distances)
432-
433-
self.normalization_statistics.compute()
434+
self.min = torch.min(self.min, torch.min(distances))
435+
self.max = torch.max(self.min, torch.max(distances))
434436

435437
def _normalize(self, distances: torch.Tensor) -> torch.Tensor:
436438
"""Normalize distance predictions.
@@ -441,9 +443,7 @@ def _normalize(self, distances: torch.Tensor) -> torch.Tensor:
441443
Returns:
442444
torch.Tensor: Normalized distances.
443445
"""
444-
return (distances - self.normalization_statistics.min) / (
445-
self.normalization_statistics.max - self.normalization_statistics.min
446-
)
446+
return (distances - self.min) / (self.max - self.min)
447447

448448

449449
class GMMEstimator(BaseDensityEstimator):
@@ -474,7 +474,11 @@ def __init__(self, n_components: int = 2) -> None:
474474
self.gmm = GaussianMixture(n_components=n_components)
475475
self.memory_bank: list[torch.Tensor] | torch.Tensor = []
476476

477-
self.normalization_statistics = MinMax()
477+
self.register_buffer("min", torch.tensor(torch.inf))
478+
self.register_buffer("max", torch.tensor(-torch.inf))
479+
480+
self.min: torch.Tensor
481+
self.max: torch.Tensor
478482

479483
def update(self, features: torch.Tensor, group: str | None = None) -> None:
480484
"""Update the feature bank with new features.
@@ -528,8 +532,8 @@ def _compute_normalization_statistics(self) -> None:
528532
statistics used for score normalization during inference.
529533
"""
530534
training_scores = self.predict(self.memory_bank, normalize=False)
531-
self.normalization_statistics.update(training_scores)
532-
self.normalization_statistics.compute()
535+
self.min = torch.min(self.min, torch.min(training_scores))
536+
self.max = torch.max(self.min, torch.max(training_scores))
533537

534538
def _normalize(self, density: torch.Tensor) -> torch.Tensor:
535539
"""Normalize anomaly scores using min-max statistics.
@@ -540,6 +544,4 @@ def _normalize(self, density: torch.Tensor) -> torch.Tensor:
540544
Returns:
541545
torch.Tensor: Normalized anomaly scores of shape ``(N,)``.
542546
"""
543-
return (density - self.normalization_statistics.min) / (
544-
self.normalization_statistics.max - self.normalization_statistics.min
545-
)
547+
return (density - self.min) / (self.max - self.min)

0 commit comments

Comments
 (0)