Skip to content

Commit 61f1815

Browse files
authored
Merge pull request #24 from djdameln/update-post-processing-guide
update post-processor guide
2 parents 24a675a + 196b29d commit 61f1815

File tree

2 files changed

+116
-164
lines changed

2 files changed

+116
-164
lines changed

docs/source/conf.py

+2
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
"tasklist",
5151
"deflist",
5252
"fieldlist",
53+
"amsmath",
54+
"dollarmath",
5355
]
5456

5557
# Add separate setting for eval-rst

docs/source/markdown/guides/how_to/models/post_processor.md

+114-164
Original file line numberDiff line numberDiff line change
@@ -8,180 +8,132 @@ This guide explains how post-processing works in Anomalib, its integration with
88

99
## Overview
1010

11-
Post-processing in Anomalib is designed to handle model outputs and convert them into meaningful predictions. The post-processor:
11+
Post-processing in Anomalib refers to any additional operations that are applied after the model generates its raw predictions. Most anomaly detection models do not generate hard classification labels directly. Instead, the models generate an anomaly score, which can be seen as an estimation of the distance from the sample to the learned representation of normality. The raw anomaly scores may consist of a single score per image for anomaly classification, or a pixel-level anomaly map for anomaly localization/segmentation. The raw anomaly scores may be hard to interpret, as they are unbounded, and the range of values may differ between models. To convert the raw anomaly scores into useable predictions, we need to apply a threshold that maps the raw scores to the binary (normal vs. anomalous) classification labels. In addition, we may want to normalize the raw scores to the [0, 1] range for interpretability and visualization.
1212

13-
- Computes anomaly scores from raw model outputs
14-
- Determines optimal thresholds for anomaly detection
15-
- Generates segmentation masks for pixel-level detection
16-
- Gets exported with the model for consistent inference
13+
The thresholding and normalization steps described above are typical post-processing steps in an anomaly detection workflow. The module that is responsible for these operations in Anomalib is the `PostProcessor`. The `PostProcessor` applies a set of post-processing operations on the raw predictions returned by the model. Similar to the {doc}`PreProcessor <./pre_processor>`, the `PostProcessor` also infuses its operations in the model graph during export. This ensures that during deployment:
1714

18-
The `PostProcessor` class is an abstract base class that serves two roles:
15+
- Post-processing is part of the exported model (ONNX, OpenVINO)
16+
- Users don't need to manually apply post-processing steps such as thresholding and normalization
17+
- Edge deployment is simplified with automatic post-processing
18+
19+
To achieve this, the `PostProcessor` class implements the following components:
1920

2021
1. A PyTorch Module for processing model outputs that gets exported with the model
2122
2. A Lightning Callback for managing thresholds during training
2223

23-
Anomalib provides concrete implementations like `OneClassPostProcessor` for specific use cases, such as one-class anomaly detection.
24-
This is based on the `PostProcessor` class. For any other use case, you can create a custom post-processor by inheriting from the `PostProcessor` class.
24+
The `PostProcessor` is an abstract base class that can be implemented to suit different post-processing workflows. In addition, Anomalib also provides a default `OneClassPostProcessor` implementation, which suits most one-class learning algorithms. Other learning types, such as zero-shot learning or VLM-based models may require different post-processing steps.
2525

26-
## Basic Usage
26+
## OneClassPostProcessor
2727

28-
The most common post-processor is `OneClassPostProcessor`:
28+
The `OneClassPostProcessor` is Anomalib's default post-processor class which covers the most common anomaly detection workflow. It is responsible for adaptively computing the optimal threshold value for the dataset, applying this threshold during testing/inference, and normalizing the predicted anomaly scores to the [0, 1] range for interpretability. Thresholding and normalization is applied separately for both image- and pixel-level predictions. The following descriptions focus on the image-level predictions, but the same principles apply for the pixel-level predictions.
2929

30-
```python
31-
from anomalib.post_processing import OneClassPostProcessor
30+
**Thresholding**
3231

33-
# Create a post-processor with sensitivity adjustments
34-
post_processor = OneClassPostProcessor(
35-
image_sensitivity=0.5, # Adjust image-level threshold sensitivity
36-
pixel_sensitivity=0.5 # Adjust pixel-level threshold sensitivity
37-
)
32+
The post-processor adaptively computes the optimal threshold value during the validation sequence. The threshold is computed by collecting the raw anomaly scores and the corresponding ground truth labels for all the images in the validation set, and plotting the Precision-Recall (PR) curve for the range of possible threshold values $\mathbf{\theta}$.
3833

39-
# Apply to model outputs
40-
predictions = post_processor(outputs)
41-
print(predictions.pred_score) # Normalized anomaly scores
42-
print(predictions.pred_label) # Binary predictions (0/1)
43-
print(predictions.pred_mask) # Segmentation masks (if applicable)
44-
print(predictions.anomaly_map) # Normalized anomaly maps
45-
```
34+
The resulting precision and recall values are then used to calculate the F1-score for each threshold value ${\theta}_i$ using the following formula:
4635

47-
## Integration with Models
36+
$$
37+
F1_i = 2 \times \frac{Precision(\theta_i) × Recall(\theta_i)}{Precision(\theta_i) + Recall(\theta_i)}
38+
$$
4839

49-
The post-processor is automatically integrated into Anomalib models:
40+
Finally, the optimal threshold value $\theta^*$ is determined as the threshold value that yields the highest the F1-score:
5041

51-
```python
52-
from anomalib.models import Patchcore
53-
from anomalib.post_processing import OneClassPostProcessor
42+
$$
43+
\theta^* = \text{arg}\max_{i} F1_{i}
44+
$$
5445

55-
# Model creates default post-processor (OneClassPostProcessor)
56-
model = Patchcore()
46+
During testing and predicting, the post-processor computes the binary classification labels by assigning a positive label (anomalous) to all anomaly scores that are higher than the threshold, and a negative label (normal) to all anomaly scores below the threshold. Given an anomaly score $s_{\text{test},i}$, the binary classifical label $\hat{y}_{\text{test},i}$ is given by:
5747

58-
# Or specify custom post-processor
59-
model = Patchcore(
60-
post_processor=OneClassPostProcessor(
61-
image_sensitivity=0.5,
62-
pixel_sensitivity=0.5
63-
)
64-
)
65-
```
48+
$$
49+
\hat{y}_{\text{test},i} =
50+
\begin{cases}
51+
1 & \text{if } s_{\text{test},i} \geq \theta^* \\
52+
0 & \text{if } s_{\text{test},i} < \theta^*
53+
\end{cases}
54+
$$
6655

67-
## Creating Custom Post-processors
56+
**Normalization**
6857

69-
To create a custom post-processor, inherit from the abstract base class `PostProcessor`:
58+
During the validation sequence, the post-processor iterates over the raw anomaly score predictions for the validation set, $\mathbf{s}_{\text{val}}$, and keeps track of the lowest and highest observed values, $\min\mathbf{s}_{\text{val}}$ and $\max \mathbf{s}_{\text{val}}$.
7059

71-
```python
72-
from anomalib.post_processing import PostProcessor
73-
from anomalib.data import InferenceBatch
74-
import torch
75-
76-
class CustomPostProcessor(PostProcessor):
77-
"""Custom post-processor implementation."""
78-
79-
def forward(self, predictions: InferenceBatch) -> InferenceBatch:
80-
"""Post-process predictions.
60+
During testing and predicting, the post-processor uses the stored min and max values, together with the optimal threshold value, to normalize the values to the [0, 1] range. For a raw anomaly score $s_{\text{test},i}$, the normalized score $\tilde{s}_{\text{test},i}$ is given by:
8161

82-
This method must be implemented by all subclasses.
83-
"""
84-
# Implement your post-processing logic here
85-
raise NotImplementedError
86-
```
62+
$$
63+
\tilde{s}_{\text{test},i} = \frac{s_{\text{test},i} - \theta^*}{\max\mathbf{s}_\text{val} - \min\mathbf{s}_\text{val}} + 0.5
64+
$$
8765

88-
### Example: One-Class Post-processor
66+
As a last step, the normalized scores are capped between 0 and 1.
8967

90-
Here's a simplified version of how `OneClassPostProcessor` is implemented:
91-
92-
```python
93-
from anomalib.post_processing import PostProcessor
94-
from anomalib.data import InferenceBatch
95-
from anomalib.metrics import F1AdaptiveThreshold, MinMax
68+
The $\theta^*$ term in the formula above ensures that the normalized values are centered around the threshold value, such that a value of 0.5 in the normalized domain corresponds to the value of the threshold in the un-normalized domain. This helps with interpretability of the results, as it asserts that normalized values of 0.5 and higher are labeled anomalous, while values below 0.5 are labeled normal.
9669

97-
class CustomOneClassPostProcessor(PostProcessor):
98-
"""Custom one-class post-processor."""
70+
Centering the threshold value around 0.5 has the additional advantage that it allows us to add a sensitivity parameter $\alpha$ that changes the sensitivity of the anomaly detector. In the normalized domain, the binary classification label is given by:
9971

100-
def __init__(
101-
self,
102-
image_sensitivity: float | None = None,
103-
pixel_sensitivity: float | None = None,
104-
):
105-
super().__init__()
106-
self._image_threshold = F1AdaptiveThreshold()
107-
self._pixel_threshold = F1AdaptiveThreshold()
108-
self._image_normalization = MinMax()
109-
self._pixel_normalization = MinMax()
72+
$$
73+
\hat{y}_{\text{test},i} =
74+
\begin{cases}
75+
1 & \text{if } \tilde{s}_{\text{test},i} \geq 1 - \alpha \\
76+
0 & \text{if } \tilde{s}_{\text{test},i} < 1 - \alpha
77+
\end{cases}
78+
$$
11079

111-
self.image_sensitivity = image_sensitivity
112-
self.pixel_sensitivity = pixel_sensitivity
80+
Where $\alpha$ is a sensitivity parameter that can be varied between 0 and 1, such that a higher sensitivity value lowers the effective anomaly score threshold. The sensitivity parameter can be tuned depending on the use case. For example, use-cases in which false positives should be avoided may benefit from reducing the sensitivity.
11381

114-
def forward(self, predictions: InferenceBatch) -> InferenceBatch:
115-
"""Post-process predictions."""
116-
if predictions.pred_score is None and predictions.anomaly_map is None:
117-
raise ValueError("At least one of pred_score or anomaly_map must be provided")
118-
119-
# Normalize scores
120-
if predictions.pred_score is not None:
121-
predictions.pred_score = self._normalize(
122-
predictions.pred_score,
123-
self._image_normalization.min,
124-
self._image_normalization.max
125-
)
126-
predictions.pred_label = predictions.pred_score > self._image_threshold.value
127-
128-
# Normalize anomaly maps
129-
if predictions.anomaly_map is not None:
130-
predictions.anomaly_map = self._normalize(
131-
predictions.anomaly_map,
132-
self._pixel_normalization.min,
133-
self._pixel_normalization.max
134-
)
135-
predictions.pred_mask = predictions.anomaly_map > self._pixel_threshold.value
136-
137-
return predictions
82+
```{note}
83+
Normalization and thresholding only works when your datamodule contains a validation set, preferably cosisting of both normal and anomalous samples. When your validation set only contains normal samples, the threshold will be set to the value of the highest observed anomaly score in your validation set.
13884
```
13985

140-
## Best Practices
141-
142-
1. **Score Normalization**:
86+
## Basic Usage
14387

144-
- Normalize scores to [0,1] range
145-
- Handle numerical stability
146-
- Consider score distributions
88+
To use the `OneClassPostProcessor`, simply add it to any Anomalib model when creating the model:
14789

148-
2. **Threshold Selection**:
90+
```python
91+
from anomalib.models import Padim
92+
from anomalib.post_processing import OneClassPostProcessor
14993

150-
- Use adaptive thresholding when possible
151-
- Validate thresholds on validation set
152-
- Consider application requirements
94+
post_processor = OneClassPostProcessor()
95+
model = Padim(post_processor=post_processor)
96+
```
15397

154-
3. **Performance**:
98+
The post-processor can be configured using its constructor arguments. In the case of the `OneClassPostProcessor`, the only configuration parameters are the sensitivity for the thresholding operation on the image- and pixel-level:
15599

156-
- Optimize computations for large outputs
157-
- Handle GPU/CPU transitions efficiently
158-
- Cache computed thresholds
100+
```python
101+
post_processor = OneClassPostProcessor(
102+
image_sensitivity=0.4,
103+
pixel_sensitivity=0.4,
104+
)
105+
model = Padim(post_processor=post_processor)
106+
```
159107

160-
4. **Validation**:
161-
- Verify prediction shapes
162-
- Check threshold computation
163-
- Test edge cases
108+
When a post-processor instance is not passed explicitly to the model, the model will automatically configure a default post-processor instance. Let's confirm this by creating a Padim model and printing the `post_processor` attribute:
164109

165-
## Common Pitfalls
110+
```python
111+
model = Padim()
112+
print(model.post_processor)
113+
# OneClassPostProcessor(
114+
# (_image_threshold): F1AdaptiveThreshold() (value=0.50)
115+
# (_pixel_threshold): F1AdaptiveThreshold() (value=0.50)
116+
# (_image_normalization_stats): MinMax()
117+
# (_pixel_normalization_stats): MinMax()
118+
# )
119+
```
166120

167-
1. **Threshold Issues**:
121+
Each model implementation in Anomalib is required to implement the `configure_post_processor` method, which defines the default post-processor for that model. We can use this method to quickly inspect the default post-processing behaviour of an Anomalib model class:
168122

169-
- Not computing thresholds during training
170-
- Incorrect threshold computation
171-
- Not handling score distributions
123+
```python
124+
print(Padim.configure_post_processor())
125+
```
172126

173-
2. **Normalization Problems**:
127+
In some cases it may be desirable to disable post-processing entirely. This is done by passing `False` to the model's `post_processor` argument:
174128

175-
- Inconsistent normalization
176-
- Numerical instability
177-
- Not handling outliers
129+
```python
130+
from anomalib.models import Padim
178131

179-
3. **Memory Issues**:
180-
- Large intermediate tensors
181-
- Unnecessary CPU-GPU transfers
182-
- Memory leaks in custom implementations
132+
model = Padim(post_processor=False)
133+
print(model.post_processor is None) # True
134+
```
183135

184-
## Edge Deployment
136+
### Exporting
185137

186138
One key advantage of Anomalib's post-processor design is that it becomes part of the model graph during export. This means:
187139

@@ -230,47 +182,45 @@ pred_labels = results[..., 2] # Already thresholded (0/1)
230182
pred_masks = results[..., 3] # Already thresholded masks (if applicable)
231183
```
232184

233-
### Benefits for Edge Deployment
185+
## Creating Custom Post-processors
234186

235-
1. **Simplified Deployment**:
187+
Advanced users may want to define their own post-processing pipeline. This can be useful when the default post-processing behaviour of the `OneClassPostProcessor` is not suitable for the model and its predictions. To create a custom post-processor, inherit from the abstract base class `PostProcessor`:
236188

237-
```python
238-
# Before: Manual post-processing needed
239-
core = Core()
240-
model = core.read_model("model_without_postprocessing.xml")
241-
compiled_model = core.compile_model(model)
242-
raw_outputs = compiled_model([image])[output_key]
243-
normalized = normalize_scores(raw_outputs)
244-
predictions = apply_threshold(normalized)
189+
```python
190+
from anomalib.post_processing import PostProcessor
191+
from anomalib.data import InferenceBatch
192+
import torch
193+
194+
class CustomPostProcessor(PostProcessor):
195+
"""Custom post-processor implementation."""
196+
197+
def forward(self, predictions: InferenceBatch) -> InferenceBatch:
198+
"""Post-process predictions.
245199
246-
# After: Everything included in OpenVINO model
247-
core = Core()
248-
model = core.read_model("model.xml")
249-
compiled_model = core.compile_model(model)
250-
results = compiled_model([image])[output_key] # Ready to use!
251-
```
200+
This method must be implemented by all subclasses.
201+
"""
202+
# Implement your post-processing logic here
203+
raise NotImplementedError
204+
```
252205

253-
2. **Consistent Results**:
206+
After defining the class, it can be used in any Anomalib workflow by passing it to the model:
254207

255-
- Same normalization across environments
256-
- Same thresholds as training
257-
- No implementation differences
208+
```python
209+
from anomalib.models import Padim
210+
211+
post_processor = CustomPostProcessor()
212+
model = Padim(post_processor=post_processor)
213+
```
258214

259-
3. **Optimized Performance**:
215+
## Best Practices
260216

261-
- Post-processing operations are optimized by OpenVINO
262-
- Hardware acceleration for all operations
263-
- Reduced memory overhead
264-
- Fewer host-device transfers
217+
**Validation**:
265218

266-
4. **Reduced Deployment Complexity**:
267-
- No need to port post-processing code
268-
- Single model file contains everything
269-
- Simpler deployment pipeline
270-
- Ready for edge devices (CPU, GPU, VPU)
219+
- Ensure that your validation set contains both normal and anomalous samples.
220+
- Ensure that your validation set contains sufficient representative samples.
271221

272222
```{seealso}
273223
For more information:
224+
- {doc}`PreProcessing guide <./pre_processing>`
274225
- {doc}`AnomalibModule Documentation <../../reference/models/base>`
275-
- {doc}`Metrics Guide <../metrics/index>`
276226
```

0 commit comments

Comments
 (0)