Skip to content

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())
(49, 11) (49, 2)
[[0, 0], [0, 1], [0, 2]] [6, 6]

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

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

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)
500 0
500 50

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.

The anomaly map over a RAD image from the patch memory

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 of patch x patch pixels every stride pixels. 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 its neighbourhood x neighbourhood neighbours, 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_one returns a dict with the array of patch descriptors (n_patches x d) under "descriptors" and an integer array of grid positions (n_patches x 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 at max_size with one of two explicit modes:

  • mode="reservoir" (default): every descriptor is offered to a reservoir of size max_size, replaced with probability max_size / n_seen (reservoir sampling, Algorithm R). Memory never grows past max_size and the cost is amortised O(1) per descriptor. This is the streaming default.

  • mode="coreset": :meth:learn_one keeps every patch of the training images; :meth:finalize (called once at the end of training) reduces the bank to max_size with the greedy minimax facility location selection of PatchCore eq. 5. :meth:score_one before :meth:finalize raises :class:RuntimeError. Cost of :meth:finalize is O(n * max_size * d). Until :meth:finalize the 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 patch (overlap is allowed). Default 8.

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
def __init__(
    self,
    patch: int = 16,
    stride: int = 8,
    n_bins: int = 9,
    neighbourhood: int = 3,
    eps: float = 1e-8,
) -> None:
    if patch < 1:
        raise ValueError(f"patch must be >= 1, got {patch}")
    if stride < 1:
        raise ValueError(f"stride must be >= 1, got {stride}")
    if stride > patch:
        raise ValueError(
            f"stride ({stride}) must not be larger than patch ({patch})"
        )
    if n_bins < 1:
        raise ValueError(f"n_bins must be >= 1, got {n_bins}")
    if neighbourhood < 1:
        raise ValueError(f"neighbourhood must be >= 1, got {neighbourhood}")
    self.patch = patch
    self.stride = stride
    self.n_bins = n_bins
    self.neighbourhood = neighbourhood
    self.eps = eps
    self.n_missing_ = 0

learn_one

learn_one(
    x: Any, y: Any = None, t: float | None = None
) -> PatchFeatures

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

self.

Source code in dense_armor/utility/vision/patches.py
def learn_one(
    self, x: Any, y: Any = None, t: float | None = None
) -> "PatchFeatures":
    """No-op: the transformer is stateless.

    Args:
        x: unused.
        y: unused.
        t: unused.

    Returns:
        ``self``.
    """
    self._time_step(t)
    return self

transform_one

transform_one(
    x: Any, t: float | None = None
) -> dict[str, Any]

Describe one frame as a set of local patch descriptors.

Parameters:

Name Type Description Default
x Any

a Frame, a dict with a frame key, or a 2D array.

required
t float | None

unused, kept for the interface.

None

Returns:

Type Description
dict[str, Any]

A dict with keys "descriptors" and "positions". The

dict[str, Any]

former is a float array of shape (n_patches, n_bins + 2),

dict[str, Any]

the latter an integer array of shape (n_patches, 2) with

dict[str, Any]

the grid coordinates (row, col) of each patch. If the

dict[str, Any]

frame is missing or too small for one patch, both values are

dict[str, Any]

None and n_missing is incremented.

Source code in dense_armor/utility/vision/patches.py
def transform_one(self, x: Any, t: float | None = None) -> dict[str, Any]:
    """Describe one frame as a set of local patch descriptors.

    Args:
        x: a Frame, a dict with a ``frame`` key, or a 2D array.
        t: unused, kept for the interface.

    Returns:
        A dict with keys ``"descriptors"`` and ``"positions"``. The
        former is a float array of shape ``(n_patches, n_bins + 2)``,
        the latter an integer array of shape ``(n_patches, 2)`` with
        the grid coordinates ``(row, col)`` of each patch. If the
        frame is missing or too small for one patch, both values are
        ``None`` and ``n_missing`` is incremented.
    """
    img = _as_array(x)
    if img is None or img.ndim not in (2, 3):
        self.n_missing_ += 1
        return {"descriptors": None, "positions": None}
    if not np.isfinite(img).all():
        self.n_missing_ += 1
        return {"descriptors": None, "positions": None}
    lum = _to_gray(img).astype(np.float64, copy=False)
    grid = self._patch_grid(lum.shape)
    if grid is None:
        self.n_missing_ += 1
        return {"descriptors": None, "positions": None}
    ny, nx = grid
    raw = self._describe_all(lum, ny, nx)
    agg = self._aggregate(raw)
    flat = agg.reshape(ny * nx, -1)
    positions = np.array(
        [(j, i) for j in range(ny) for i in range(nx)], dtype=np.intp
    )
    return {"descriptors": flat, "positions": positions}

PatchMemory

PatchMemory(
    max_size: int = 20000,
    seed: int | None = 0,
    mode: str = "reservoir",
)

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" (default) or "coreset". In reservoir mode :meth:learn_one bounds the bank online with Algorithm R; in coreset mode :meth:learn_one keeps every patch and :meth:finalize reduces the bank to max_size with the greedy selection of PatchCore eq. 5.

'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 max_size < 1 or mode is not one of the two allowed values.

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
def __init__(
    self,
    max_size: int = 20000,
    seed: int | None = 0,
    mode: str = "reservoir",
) -> None:
    if max_size < 1:
        raise ValueError(f"max_size must be >= 1, got {max_size}")
    if mode not in self._MODES:
        raise ValueError(f"mode must be one of {self._MODES}, got {mode!r}")
    self.max_size = max_size
    self.seed = seed
    self.mode = mode
    self._bank: np.ndarray | None = None
    self._sq_bank: np.ndarray | None = None
    self._accumulator: list[np.ndarray] | None = None
    self._n_seen = 0
    self._finalized = False
    self._rng = np.random.default_rng(seed)
    self.last_patch_scores_: np.ndarray | None = None
    self.last_positions_: np.ndarray | None = None
    self.n_missing_ = 0
    self.n_reductions_ = 0

learn_one

learn_one(x: Any, t: float | None = None) -> PatchMemory

Add the patches of one normal image to the bank.

Parameters:

Name Type Description Default
x Any

the dict returned by :meth:PatchFeatures.transform_one, with keys "descriptors" and "positions".

required
t float | None

unused.

None

Returns:

Type Description
PatchMemory

self.

Raises:

Type Description
ValueError

if a new descriptor has a different dimension than the bank.

RuntimeError

in mode="coreset" after :meth:finalize.

Source code in dense_armor/utility/vision/patches.py
def learn_one(self, x: Any, t: float | None = None) -> "PatchMemory":
    """Add the patches of one normal image to the bank.

    Args:
        x: the dict returned by :meth:`PatchFeatures.transform_one`,
            with keys ``"descriptors"`` and ``"positions"``.
        t: unused.

    Returns:
        ``self``.

    Raises:
        ValueError: if a new descriptor has a different dimension
            than the bank.
        RuntimeError: in ``mode="coreset"`` after :meth:`finalize`.
    """
    self._time_step(t)
    desc, _ = self._unpack(x)
    if desc is None:
        self.n_missing_ += 1
        return self
    if self._bank is not None:
        self._check_dim(desc)
    elif self._accumulator:
        first = self._accumulator[0]
        if first.shape[1] != desc.shape[1]:
            raise ValueError(
                f"descriptor dimension changed from {first.shape[1]} "
                f"to {desc.shape[1]}"
            )
    if self.mode == "reservoir":
        self._learn_reservoir(desc)
    else:
        if self._finalized:
            raise RuntimeError("learn_one after finalize in coreset mode")
        if self._accumulator is None:
            self._accumulator = []
        self._accumulator.append(desc.copy())
        self._n_seen += desc.shape[0]
    return self

finalize

finalize() -> PatchMemory

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

self.

Raises:

Type Description
RuntimeError

if called twice.

Source code in dense_armor/utility/vision/patches.py
def finalize(self) -> "PatchMemory":
    """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:
        ``self``.

    Raises:
        RuntimeError: if called twice.
    """
    if self.mode == "reservoir":
        return self
    if self._finalized:
        raise RuntimeError("finalize called twice")
    if not self._accumulator:
        self._bank = None
        self._finalized = True
        return self
    bank = np.vstack(self._accumulator)
    self._accumulator = None
    n = bank.shape[0]
    if n <= self.max_size:
        self._bank = bank
        self._sq_bank = None
        self._finalized = True
        return self
    sq = np.einsum("ij,ij->i", bank, bank)
    mean = bank.mean(axis=0)
    d0 = bank - mean
    i0 = int(np.argmin(np.einsum("ij,ij->i", d0, d0)))
    chosen = np.zeros(n, dtype=bool)
    chosen[i0] = True
    min_d2 = sq + sq[i0] - 2.0 * (bank @ bank[i0])
    min_d2[chosen] = -np.inf
    out = np.empty(self.max_size, dtype=np.intp)
    out[0] = i0
    for k in range(1, self.max_size):
        j = int(np.argmax(min_d2))
        out[k] = j
        chosen[j] = True
        d2 = sq + sq[j] - 2.0 * (bank @ bank[j])
        np.minimum(min_d2, d2, out=min_d2)
        min_d2[chosen] = -np.inf
    self._bank = bank[out]
    self._sq_bank = None
    self._finalized = True
    self.n_reductions_ += 1
    return self

score_one

score_one(x: Any, t: float | None = None) -> float

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:PatchFeatures.transform_one.

required
t float | None

unused.

None

Returns:

Type Description
float

The maximum nearest-neighbour distance over the patches.

float

0.0 if the bank is empty or the input is invalid.

Raises:

Type Description
RuntimeError

in mode="coreset" before :meth:finalize.

Source code in dense_armor/utility/vision/patches.py
def score_one(self, x: Any, t: float | None = None) -> float:
    """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.

    Args:
        x: the dict returned by :meth:`PatchFeatures.transform_one`.
        t: unused.

    Returns:
        The maximum nearest-neighbour distance over the patches.
        ``0.0`` if the bank is empty or the input is invalid.

    Raises:
        RuntimeError: in ``mode="coreset"`` before :meth:`finalize`.
    """
    self._time_step(t)
    if self.mode == "coreset" and not self._finalized:
        raise RuntimeError("score_one before finalize in coreset mode")
    desc, pos = self._unpack(x)
    if desc is None or self._bank is None or self._bank.shape[0] == 0:
        self.last_patch_scores_ = None
        self.last_positions_ = None
        return 0.0
    scores = self._nn_distances(desc)
    self.last_patch_scores_ = scores
    self.last_positions_ = pos
    return float(scores.max())

is_outlier

is_outlier(x: Any, threshold: float = 1.0) -> bool

Boolean wrapper around :meth:score_one.

Source code in dense_armor/utility/vision/patches.py
def is_outlier(self, x: Any, threshold: float = 1.0) -> bool:
    """Boolean wrapper around :meth:`score_one`."""
    return self.score_one(x) > threshold

patch_scores

patch_scores(x: Any) -> tuple[np.ndarray, np.ndarray]

Return the per-patch distances and their grid positions.

Parameters:

Name Type Description Default
x Any

the dict returned by :meth:PatchFeatures.transform_one.

required

Returns:

Type Description
ndarray

(scores, positions) where scores is a float array of

ndarray

shape (n_patches,) and positions is the integer

tuple[ndarray, ndarray]

array of shape (n_patches, 2) returned by

tuple[ndarray, ndarray]

meth:PatchFeatures.transform_one; two empty arrays if the

tuple[ndarray, ndarray]

bank is empty or the input is invalid.

Source code in dense_armor/utility/vision/patches.py
def patch_scores(self, x: Any) -> tuple[np.ndarray, np.ndarray]:
    """Return the per-patch distances and their grid positions.

    Args:
        x: the dict returned by :meth:`PatchFeatures.transform_one`.

    Returns:
        ``(scores, positions)`` where ``scores`` is a float array of
        shape ``(n_patches,)`` and ``positions`` is the integer
        array of shape ``(n_patches, 2)`` returned by
        :meth:`PatchFeatures.transform_one`; two empty arrays if the
        bank is empty or the input is invalid.
    """
    _ = self.score_one(x)
    if self.last_patch_scores_ is None or self.last_positions_ is None:
        return np.zeros(0), np.zeros((0, 2), dtype=np.intp)
    return self.last_patch_scores_, self.last_positions_

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 over n_bins bins 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 + 2 numbers. Cost of finalize(): $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_one measured 80.7 ms on a 320 × 240 frame (budget_s = 0.15); PatchMemory.score_one measured 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.