Visual defect detection as anomaly detection with OpenCV features, IsolationForest and ONNX
Solution for the use case Autonomous Quality Control in Manufacturing.
- AI tools
- OpenCV, scikit-learn, skl2onnx, ONNX Runtime, NumPy
- Language
- Python
- Published
- 2026-10-26
Approach
A baseline for automated visual inspection when you have many images of good parts and few or no images of defects. The model learns what good surfaces look like and flags parts that deviate:
- OpenCV patch features: each image is converted to grayscale, normalised for brightness, resized to 256 x 256 and cut into overlapping 32 x 32 patches. Each patch is described by brightness, contrast, sharpness (Laplacian variance), gradient strength and edge density.
- IsolationForest (scikit-learn) trained on patches of good parts only. The image score is the score of its most unusual patch, so one small scratch is enough. The patch scores also give a coarse heatmap and a box around the suspicious area.
- Threshold calibration on held-out images: either a quantile of good-part scores or, if you have labeled defects, the threshold with the highest defect recall within an allowed false-reject rate (default 1 %).
- ONNX export with skl2onnx: the scaler and IsolationForest run in onnxruntime on an edge PC; the tests check that the scores match scikit-learn.
This is the classical starting point. Deep-feature methods such as PatchCore (pretrained CNN features with a nearest-neighbour memory bank) usually detect subtler defects; the same train, calibrate, export structure applies to them.
Implementation steps
- Collect images of good parts under fixed lighting and camera position, and split them into a training set and a calibration set; keep any images of real defects for calibration and testing.
- Preprocess each image with OpenCV: grayscale, resize to 256 x 256, shift the median brightness to a fixed level.
- Cut the image into overlapping 32 x 32 patches (stride 16) and compute per-patch features: mean, standard deviation, 2nd and 98th percentile, Laplacian variance, mean and max gradient, Canny edge density.
- Fit a StandardScaler + IsolationForest pipeline on the patches of good training images only.
- Score an image by its most unusual patch and keep the patch scores as a heatmap to show where the defect is.
- Calibrate the decision threshold on held-out images so that at most 1 % of good parts are rejected, maximising defect recall if labeled defects are available.
- Export the scaler and IsolationForest to ONNX with skl2onnx and run them with onnxruntime on the line PC; verify the scores against scikit-learn.
- Evaluate on a public benchmark such as MVTec AD or on your own labeled test set before using the result to reject parts.
Code (Python)
"""Patch-level anomaly detector: StandardScaler + IsolationForest trained on good images only.
Image score = highest patch score, so one small defect is enough to flag a part.
The patch scores also form a coarse heatmap that shows where the defect is.
"""
from __future__ import annotations
from dataclasses import dataclass
import joblib
import numpy as np
from sklearn.ensemble import IsolationForest
from sklearn.metrics import roc_curve
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.preprocessing import StandardScaler
from .features import IMAGE_SIZE, PATCH, STRIDE, patch_features, patch_grid, preprocess
@dataclass
class Threshold:
value: float
false_reject_rate: float # share of good parts above the threshold (on calibration data)
defect_recall: float | None # share of defective parts above it, if defects were given
class PatchAnomalyDetector:
def __init__(self, n_estimators: int = 200, max_samples: int = 1024, seed: int = 0):
# More samples per tree = deeper trees = a wider score range for strong outliers.
self.model: Pipeline = make_pipeline(StandardScaler(), IsolationForest(
n_estimators=n_estimators, max_samples=max_samples, random_state=seed))
self.threshold: Threshold | None = None
# --- training -------------------------------------------------------------
def fit(self, good_images: list[np.ndarray]) -> PatchAnomalyDetector:
X = np.vstack([patch_features(preprocess(img)) for img in good_images])
self.model.fit(X)
return self
def calibrate(self, good_images: list[np.ndarray], defect_images: list[np.ndarray] | None = None,
max_false_reject: float = 0.01) -> Threshold:
"""Pick the decision threshold on images that were NOT used for training.
With good images only: the (1 - max_false_reject) quantile of their scores.
With labeled defects too: the lowest threshold whose false-reject rate stays within
the limit, which maximises defect recall under that constraint.
"""
good = np.array([self.score(img) for img in good_images])
recall = None
if not defect_images:
value = float(np.quantile(good, 1 - max_false_reject))
else:
bad = np.array([self.score(img) for img in defect_images])
y = np.r_[np.zeros(len(good)), np.ones(len(bad))]
fpr, tpr, thr = roc_curve(y, np.r_[good, bad]) # rule: defect if score >= thr
ok = np.where((fpr <= max_false_reject) & np.isfinite(thr))[0]
value = float(thr[ok[np.argmax(tpr[ok])]]) if len(ok) else float(np.nextafter(good.max(), np.inf))
recall = float((bad >= value).mean())
self.threshold = Threshold(value, float((good >= value).mean()), recall)
return self.threshold
# --- inference ------------------------------------------------------------
def patch_scores(self, image: np.ndarray) -> np.ndarray:
"""Anomaly score per patch, higher = more unusual (negated IsolationForest decision)."""
return -self.model.decision_function(patch_features(preprocess(image)))
def score(self, image: np.ndarray) -> float:
return float(self.patch_scores(image).max())
def heatmap(self, image: np.ndarray) -> np.ndarray:
"""Patch scores as a 2-D grid (rows x cols) in image layout."""
n = int(np.sqrt(len(patch_grid())))
return self.patch_scores(image).reshape(n, n)
def predict(self, image: np.ndarray) -> dict:
if self.threshold is None:
raise RuntimeError("call calibrate() first")
scores = self.patch_scores(image)
y, x = patch_grid()[int(scores.argmax())]
return {"score": float(scores.max()), "threshold": self.threshold.value,
"defect": bool(scores.max() >= self.threshold.value),
# location of the most unusual patch, in pixels of the resized image
"box": {"x":
…
Performance
Targets, not measured results. The tests use synthetic surfaces with drawn scratches, spots and cracks and require an image-level ROC AUC above 0.95 and that the most unusual patch lies on the defect. Real parts are harder.
- Suggested acceptance criteria: false-reject rate of good parts at most 1 %, defect recall at least 95 % on a labeled test set of real defects, per defect type.
- Feature extraction plus scoring took roughly 0.1 to 0.15 s per 256 x 256 image (225 patches) in a single test run on a shared desktop CPU. The per-patch Python loop is the bottleneck; vectorise it (box filters on whole-image maps) if the line needs more throughput, and measure on your hardware.
- The resolution is limited by the patch size: defects much smaller than a 32 x 32 patch at 256 x 256 (about 1/8 of the image width) may be missed. Use tiles of the original image or a higher input size for small defects.
Lessons learned
The inspection station calls predict() (or the ONNX scorer) for each image and gets {"score": 0.24, "threshold": 0.19, "defect": true, "box": {"x": 96, "y": 64, "w": 32, "h": 32}}. The box marks the most unusual area for the operator, who confirms or overrides the decision. The CLI trains, calibrates, exports and scores folders of images, and python -m qc.cli demo shows the full flow on synthetic images.