Where is the anomaly: patch memory¶
This tool tells you not only that a picture looks wrong but where: it cuts the picture into small squares, remembers what the squares of normal pictures look like, and marks the squares that look unfamiliar.
Section 7 of the Vision page ends with a camera that notices a change: one number per frame. A robot inspecting an object needs more: which part of the image is wrong. This page cuts each frame into small patches, keeps a memory of the patches seen on normal images, and scores a new image by its most unusual patch. The method is PatchCore (Roth et al. 2021); the patch descriptors are computed by the library in numpy, with no pretrained network.
1. A frame becomes a grid of patches¶
A patch is a small square of the image. Each patch gets a short list of numbers that describes the directions of its edges, its mean brightness and its contrast.
import numpy as np
from dense_armor.utility.vision import PatchFeatures
img = np.zeros((64, 64), dtype=np.float32)
img[:, ::8] = 1.0
pf = PatchFeatures(patch=16, stride=8, n_bins=9, neighbourhood=3)
out = pf.transform_one(img)
print(out["descriptors"].shape, out["positions"].shape)
print(out["positions"][:3].tolist(), out["positions"][-1].tolist())
Patches of 16 × 16 pixels every 8 pixels give a 7 × 7 grid on a 64 × 64 frame, 49 patches. Each
descriptor has 11 numbers: a 9-bin histogram of edge directions (weighted by the edge strength,
L2-normalised), the mean and the standard deviation of the patch. positions gives the (row,
column) of each patch in the grid. Each descriptor is then averaged with its 3 × 3 neighbours, so
it also knows a little about its surroundings; the neighbourhood of PatchCore eq. 1:
$$\mathcal{N}_p^{(h,w)} = {(a,b) \mid a \in [h-\lfloor p/2\rfloor, h+\lfloor p/2\rfloor],\ b \in [w-\lfloor p/2\rfloor, w+\lfloor p/2\rfloor]}$$
with $p = 3$, the value the paper chooses from its Figure 4.
2. Learn what normal looks like, then score a scratch¶
The memory stores the patches of normal images; a new image is scored by the patch that is farthest from everything in memory.
import numpy as np
from dense_armor.utility.vision import PatchFeatures, PatchMemory
rng = np.random.default_rng(0)
pf = PatchFeatures(patch=16, stride=8)
mem = PatchMemory(max_size=5000)
for _ in range(20):
img = np.zeros((64, 64), dtype=np.float32)
img[:, ::8] = 1.0
mem.learn_one(pf.transform_one(img + rng.normal(0, 0.05, img.shape)))
good = np.zeros((64, 64), dtype=np.float32)
good[:, ::8] = 1.0
bad = good.copy()
bad[40:44, 20:40] = 1.0
s_good = mem.score_one(pf.transform_one(good + rng.normal(0, 0.05, good.shape)))
s_bad = mem.score_one(pf.transform_one(bad + rng.normal(0, 0.05, bad.shape)))
print(round(s_good, 3), round(s_bad, 3))
Twenty noisy images of vertical stripes are the normal part. A new striped image scores 0.032; the same image with a horizontal scratch scores 0.424, about thirteen times more. The image score is PatchCore eq. 6: for each test patch, the distance to its nearest neighbour in memory; the score is the largest of these distances:
$$s^* = \max_{m^{\text{test}} \in \mathcal{P}(x^{\text{test}})}\ \min_{m \in \mathcal{M}} \lVert m^{\text{test}} - m \rVert_2$$
3. Where the anomaly is¶
The per-patch distances form a map of the image; the largest one points at the scratch.
import numpy as np
from dense_armor.utility.vision import PatchFeatures, PatchMemory
rng = np.random.default_rng(0)
pf = PatchFeatures(patch=16, stride=8)
mem = PatchMemory(max_size=5000)
for _ in range(20):
img = np.zeros((64, 64), dtype=np.float32)
img[:, ::8] = 1.0
mem.learn_one(pf.transform_one(img + rng.normal(0, 0.05, img.shape)))
bad = np.zeros((64, 64), dtype=np.float32)
bad[:, ::8] = 1.0
bad[40:44, 20:40] = 1.0
scores, pos = mem.patch_scores(pf.transform_one(bad + rng.normal(0, 0.05, bad.shape)))
grid = np.zeros((7, 7))
grid[pos[:, 0], pos[:, 1]] = scores
print(pos[int(np.argmax(scores))].tolist())
print(np.round(grid, 2))
[4, 3]
[[0.01 0.01 0.01 0.01 0.02 0.02 0.03]
[0.01 0.01 0.01 0.01 0.01 0.02 0.01]
[0.02 0.03 0.06 0.06 0.04 0.02 0.02]
[0.04 0.11 0.23 0.25 0.16 0.05 0.01]
[0.07 0.18 0.38 0.42 0.27 0.08 0.01]
[0.06 0.16 0.34 0.37 0.24 0.08 0.01]
[0.04 0.11 0.23 0.26 0.17 0.05 0.02]]
The scratch covers rows 40 to 43 and columns 20 to 39 of the image. Patch (4, 3) starts at pixel row 32 and column 24, so it contains the scratch: it has the largest distance, 0.42. Patches far from the scratch stay near 0.01; the neighbours of the scratch are in between, since each descriptor is averaged over its 3 × 3 neighbourhood.
4. A memory that never grows: reservoir mode¶
A robot sees images all day; the default memory keeps at most max_size patches, and each new
patch replaces a random stored one with probability max_size / n_seen, so the memory stays a
uniform sample of everything seen (reservoir sampling).
import numpy as np
from dense_armor.utility.vision import PatchMemory
rng = np.random.default_rng(0)
mem = PatchMemory(max_size=1000, mode="reservoir")
for _ in range(50):
d = rng.normal(size=(49, 11))
mem.learn_one({"descriptors": d, "positions": None})
print(mem.n_seen, mem.bank_size)
Fifty images of 49 patches each, 2450 patches seen; the memory holds 1000.
5. Covering the normal patches better: coreset mode¶
When training images are collected first and inspection starts afterwards, the memory can keep
every training patch and, once at the end, choose the max_size patches that cover all of them
best.
import numpy as np
from dense_armor.utility.vision import PatchMemory
rng = np.random.default_rng(0)
data = rng.normal(size=(500, 6))
data[::7] += 20.0
mem = PatchMemory(max_size=50, mode="coreset")
mem.learn_one({"descriptors": data, "positions": None})
print(mem.n_seen, mem.bank_size)
mem.finalize()
print(mem.n_seen, mem.bank_size)
Before finalize() the bank is not built yet (size 0, and score_one raises an error); after
it, 50 patches are kept out of 500. The choice is the greedy approximation of PatchCore eq. 5:
at each step add the patch farthest from those already chosen, so that every training patch has a
chosen one close to it:
$$\mathcal{M}C^* = \arg\min{\mathcal{M}C \subset \mathcal{M}}\ \max{m \in \mathcal{M}}\ \min_{n \in \mathcal{M}_C} \lVert m - n \rVert_2$$
On these 500 points the farthest point from its nearest kept patch is at distance 2.678 with the coreset, 3.631 with 50 points chosen at random.
6. Real robot images: the RAD benchmark¶
RAD (Chang et al. 2024) has 13 objects photographed by a robot arm from many poses; the score is
the image-level AUROC (1 = every anomalous image ranked above every normal one, 0.5 = chance).
The script benchmarks/vision_rad_patches.py runs the method on every category with the same
parameters (patch=16, stride=8, n_bins=9, neighbourhood=3, max_size=20000).
| mode | image size | mean AUROC | p50 ms | p99 ms |
|---|---|---|---|---|
| reservoir | 160×120 | 0.593 | 73.2 | 81.2 |
| coreset | 160×120 | 0.585 | 84.3 | 127.5 |
| reservoir | 320×240 | 0.711 | 399.6 | 596.6 |
| coreset | 320×240 | 0.748 | 386.5 | 410.2 |
Reference points: global frame features of the Vision page (incremental PCA + robust Mahalanobis), 0.497; PatchCore as reported in the RAD paper, 0.833.
Per category, coreset at 320×240:
| category | AUROC | category | AUROC | |
|---|---|---|---|---|
| binderclip | 0.658 | phonecase | 0.782 | |
| bowl | 0.702 | rubberduck | 0.860 | |
| box | 0.611 | spoon | 0.799 | |
| can | 0.851 | spraybottle | 0.826 | |
| charger | 0.593 | tennisball | 0.975 | |
| cup1 | 0.674 | |||
| cup2 | 0.724 | |||
| gluebottle | 0.663 |
Patches beat the global features in all four runs, and the larger image helps: 0.711 and 0.748 against 0.593 and 0.585.
At 160×120 each image gives 266 patches. The 9 categories with 59 training images have 15694
patches, fewer than max_size, so the two modes keep the same patches and give the same AUROC on
those 9.
At 320×240 each image gives 1131 patches, so between 70 % (59 training images) and 90 % (cup2, 177 images) of the patches must be dropped. There the coreset is ahead, 0.748 against 0.711.
The latency is per image (patches + scoring) on a Colab CPU. The RAD dataset is
not committed to this repository: benchmarks/vision_rad_patches.py expects it under
RAD_ROOT = /content/rad/Anomaly_refine_msk, so the numbers above are from the run
where the dataset was present, and cannot be regenerated in a Colab that lacks it.

API reference¶
patches ¶
Local patch descriptors and a bounded patch memory for anomaly detection.
The PatchCore method (Roth et al. 2021) detects anomalies by comparing each local patch of a test image against a memory bank of normal patches. An image is anomalous as soon as one of its patches is anomalous, so the image score is the maximum distance of a patch to its nearest neighbour in the bank; the per-patch distances give a localisation map. The paper builds its patch descriptors from the intermediate feature maps of an ImageNet-pretrained network. This module replaces them with native descriptors and keeps the rest of the method.
- :class:
PatchFeatures(a :class:~dense_armor.roles.Transformer) cuts the grey frame into overlapping patches ofpatchxpatchpixels everystridepixels. Each patch is described by an oriented-gradient histogram (same gradients and L2 normalisation as :class:~dense_armor.utility.vision.features.FrameFeatures), plus the mean and the contrast of the patch. The descriptor of each patch is then averaged with the descriptors of itsneighbourhoodxneighbourhoodneighbours, as in PatchCore equations 1 and 2: the local aggregation widens the effective receptive field and makes the descriptor more robust to small spatial variations.transform_onereturns a dict with the array of patch descriptors (n_patchesxd) under"descriptors"and an integer array of grid positions (n_patchesx 2) under"positions". -
:class:
PatchMemory(an :class:~dense_armor.roles.AnomalyDetector) collects the descriptors of the normal training views (PatchCore eq. 4) and bounds them atmax_sizewith one of two explicit modes: -
mode="reservoir"(default): every descriptor is offered to a reservoir of sizemax_size, replaced with probabilitymax_size / n_seen(reservoir sampling, Algorithm R). Memory never grows pastmax_sizeand the cost is amortisedO(1)per descriptor. This is the streaming default. mode="coreset": :meth:learn_onekeeps every patch of the training images; :meth:finalize(called once at the end of training) reduces the bank tomax_sizewith the greedy minimax facility location selection of PatchCore eq. 5. :meth:score_onebefore :meth:finalizeraises :class:RuntimeError. Cost of :meth:finalizeisO(n * max_size * d). Until :meth:finalizethe memory holds every training patch; this is the batch step of the paper, done once at the end of training.
The nearest-neighbour distances are computed in numpy with a matrix
form for the squared norms (||a - b||^2 = ||a||^2 + ||b||^2 - 2 a.b),
chunked over the queries so the temporary array stays bounded.
References¶
Roth, K., Pemula, L., Zepeda, J., Scholkopf, B., Brox, T., Gehler, P. (2021). Towards total recall in industrial anomaly detection. arXiv:2106.08265. Chang, X., Ye, Z., et al. (2024). RAD: A Realistic Multi-View Benchmark for Pose-Agnostic Anomaly Detection. arXiv:2410.00713. Huang, C., Huang, J. (2017). A fast HOG descriptor using lookup table and integral image. arXiv:1703.06256.
PatchFeatures ¶
PatchFeatures(
patch: int = 16,
stride: int = 8,
n_bins: int = 9,
neighbourhood: int = 3,
eps: float = 1e-08,
)
Bases: Transformer
Cut a frame into patches and describe each one locally.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
patch
|
int
|
side of each patch in pixels. Default 16. |
16
|
stride
|
int
|
step between patch top-left corners, in pixels. Must be
positive and not larger than |
8
|
n_bins
|
int
|
number of angular bins in each patch histogram. Default 9. |
9
|
neighbourhood
|
int
|
side of the neighbourhood used to aggregate each patch descriptor, in patches. Default 3 (the value chosen by PatchCore, Figure 4). Must be >= 1. |
3
|
eps
|
float
|
numerical guard for the histogram normalisation. |
1e-08
|
budget_s = 0.15 s: :meth:transform_one measured 80.7 ms on a
320 x 240 frame on a Colab CPU, about half the budget.
Raises:
| Type | Description |
|---|---|
ValueError
|
if a parameter is out of range. |
Examples:
>>> import numpy as np
>>> from dense_armor.utility.vision.patches import PatchFeatures
>>> pf = PatchFeatures(patch=8, stride=4, n_bins=4, neighbourhood=1)
>>> img = np.zeros((16, 16), dtype=np.float32)
>>> out = pf.transform_one(img)
>>> out["descriptors"].shape
(9, 6)
>>> out["positions"].shape
(9, 2)
Source code in dense_armor/utility/vision/patches.py
learn_one ¶
No-op: the transformer is stateless.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
Any
|
unused. |
required |
y
|
Any
|
unused. |
None
|
t
|
float | None
|
unused. |
None
|
Returns:
| Type | Description |
|---|---|
PatchFeatures
|
|
Source code in dense_armor/utility/vision/patches.py
transform_one ¶
Describe one frame as a set of local patch descriptors.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
Any
|
a Frame, a dict with a |
required |
t
|
float | None
|
unused, kept for the interface. |
None
|
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A dict with keys |
dict[str, Any]
|
former is a float array of shape |
dict[str, Any]
|
the latter an integer array of shape |
dict[str, Any]
|
the grid coordinates |
dict[str, Any]
|
frame is missing or too small for one patch, both values are |
dict[str, Any]
|
|
Source code in dense_armor/utility/vision/patches.py
PatchMemory ¶
Bases: AnomalyDetector
Memory bank of normal patch descriptors with a bounded size.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
max_size
|
int
|
maximum number of descriptors kept. Default 20000. |
20000
|
seed
|
int | None
|
seed of the random generator used by the reservoir. |
0
|
mode
|
str
|
|
'reservoir'
|
budget_s = 0.5 s: :meth:score_one measured 267.8 ms (p50) on
the patches of a 320 x 240 frame against a 20000-row bank on a
Colab CPU, about half the budget.
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
Examples:
>>> import numpy as np
>>> from dense_armor.utility.vision.patches import PatchMemory
>>> m = PatchMemory(max_size=10, seed=0)
>>> bank = np.random.default_rng(0).normal(size=(5, 4))
>>> pos = np.array([[0, i] for i in range(5)])
>>> _ = m.learn_one({"descriptors": bank, "positions": pos})
>>> round(float(m.score_one({"descriptors": bank, "positions": pos})), 6)
0.0
Source code in dense_armor/utility/vision/patches.py
learn_one ¶
Add the patches of one normal image to the bank.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
Any
|
the dict returned by :meth: |
required |
t
|
float | None
|
unused. |
None
|
Returns:
| Type | Description |
|---|---|
PatchMemory
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if a new descriptor has a different dimension than the bank. |
RuntimeError
|
in |
Source code in dense_armor/utility/vision/patches.py
finalize ¶
Reduce the accumulated bank to max_size (coreset mode only).
Applies the greedy approximation of the minimax facility
location coreset of PatchCore eq. 5 (Algorithm 1): at each step
add the descriptor that maximises its distance to the closest
already chosen one, until max_size are picked. The first
descriptor is the one closest to the mean, so the result is
deterministic. The descriptors are used as they are, without
the random projection psi of Algorithm 1, since their
dimension is small. Cost is O(n * max_size * d).
In mode="reservoir" this is a no-op.
Returns:
| Type | Description |
|---|---|
PatchMemory
|
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
if called twice. |
Source code in dense_armor/utility/vision/patches.py
score_one ¶
Score one image by its most unusual patch.
The image score is s* of PatchCore equation 6: the maximum
over the patches of the distance to the nearest neighbour in
the bank. The reweighting w the paper applies after that
equation is not used here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
Any
|
the dict returned by :meth: |
required |
t
|
float | None
|
unused. |
None
|
Returns:
| Type | Description |
|---|---|
float
|
The maximum nearest-neighbour distance over the patches. |
float
|
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
in |
Source code in dense_armor/utility/vision/patches.py
is_outlier ¶
patch_scores ¶
Return the per-patch distances and their grid positions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
Any
|
the dict returned by :meth: |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
|
ndarray
|
shape |
tuple[ndarray, ndarray]
|
array of shape |
tuple[ndarray, ndarray]
|
meth: |
tuple[ndarray, ndarray]
|
bank is empty or the input is invalid. |
Source code in dense_armor/utility/vision/patches.py
Details¶
- Image score: $s^*$ of PatchCore eq. 6, without the reweighting $w$ the paper applies after it.
- Patch descriptor: central-difference gradients, as in
FrameFeatures; a histogram of the gradient direction overn_binsbins in $[0, 2\pi)$, weighted by the gradient magnitude and L2-normalised; then the mean and the standard deviation of the patch. Averaging over the neighbourhood (eq. 1) uses the plain mean; the paper uses adaptive average pooling (eq. 2). - Coreset: the first patch is the one closest to the mean, so the result is deterministic
(Algorithm 1 of the paper starts from an empty set); the patches are used as they are,
without the random projection $\psi$ of Algorithm 1, since they have only
n_bins + 2numbers. Cost offinalize(): $O(n \cdot \texttt{max_size} \cdot d)$; on RAD at 320×240 it took up to 143 s for the largest category (cup2), against 22 s for the reservoir. - Nearest-neighbour distances are computed in numpy with $\lVert a-b\rVert^2 = \lVert a\rVert^2 + \lVert b\rVert^2 - 2\,a\cdot b$, in chunks of 512 queries.
- Time budgets:
PatchFeatures.transform_onemeasured 80.7 ms on a 320 × 240 frame (budget_s = 0.15);PatchMemory.score_onemeasured 267.8 ms (p50) against a 20000-row bank (budget_s = 0.5); both on a Colab CPU. - Sources: Roth, K., Pemula, L., Zepeda, J., Schölkopf, B., Brox, T., Gehler, P. (2021), "Towards total recall in industrial anomaly detection", arXiv:2106.08265 (eq. 1, 5, 6, Algorithm 1, Figure 4). Chang, X., Ye, Z., et al. (2024), "RAD: A Realistic Multi-View Benchmark for Pose-Agnostic Anomaly Detection", arXiv:2410.00713 (image-level AUROC table, PatchCore 0.833 with the standard Anomalib configuration). Huang, C., Huang, J. (2017), "A fast HOG descriptor using lookup table and integral image", arXiv:1703.06256.