-AnomaVision is a computer vision project for finding **defects and unusual patterns** in images.
+AnomaVision is a production-oriented computer vision toolkit for detecting **defects and unusual patterns** from normal images.
-It supports two anomaly detection methods:
+It supports three anomaly detection methods:
-- **PaDiM** — a simple and fast baseline.
-- **PatchCore** — a lightweight memory-based method.
+- **PaDiM** — a simple, fast feature-distribution baseline.
+- **PatchCore** — a lightweight memory-based method designed for efficient inference.
+- **EfficientAD** — a lightweight student-teacher method designed for fast industrial anomaly detection.
-You only need **normal (`good`) images** to train the anomaly detector.
+Training requires only **normal (`good`) images**. Labeled test images can then be used for evaluation, threshold calibration, and production model selection.
@@ -39,50 +40,46 @@ You only need **normal (`good`) images** to train the anomaly detector.
## What can AnomaVision do?
- Train anomaly detection models using normal images.
-- Detect image-level anomalies.
-- Create anomaly heatmaps showing where the problem is.
-- Export models to **ONNX, OpenVINO, and TensorRT**.
+- Detect image-level anomalies and generate anomaly heatmaps.
+- Evaluate anomaly detection and localization performance.
+- Calibrate anomaly thresholds from validation data.
+- Export models to **ONNX, OpenVINO, and TensorRT** where supported.
+- Run production model selection with **Production Autopilot**.
- Export and compile **PaDiM and PatchCore to XModel for the AMD/Xilinx Kria KV260**.
## Quick start
### 1. Install
-#### Option A — From Source (development)
+#### Option A — From Source
```bash
git clone https://github.com/DeepKnowledge1/AnomaVision.git
cd AnomaVision
-# Create and activate a virtual environment
uv venv --python 3.11 .venv
source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1
-# Install with your hardware extra
uv sync --extra cpu # CPU
uv sync --extra cu121 # CUDA 12.1
```
----
-
-#### Option B — From PyPI (production / quick start)
+#### Option B — From PyPI
```bash
-# CPU · Mac, CI runners, edge devices
uv pip install "anomavision[cpu]"
-# NVIDIA GPU · pick your CUDA version
-uv pip install "anomavision[cu118]" # CUDA 11.8
-uv pip install "anomavision[cu121]" # CUDA 12.1
-uv pip install "anomavision[cu124]" # CUDA 12.4
+# NVIDIA GPU
+uv pip install "anomavision[cu118]"
+uv pip install "anomavision[cu121]"
+uv pip install "anomavision[cu124]"
```
-
For other environments, see [Installation](docs/installation.md).
-### 2. Prepare your images
+### 2. Prepare your dataset
-Use a simple MVTec-style folder structure:
+Use an MVTec-style structure:
```text
dataset/
@@ -100,19 +97,22 @@ dataset/
└── good/
```
-Training uses the **good** images. Test images can contain defects.
+Training uses only `train/good`. Test images may contain defects.
### 3. Train
-Create or edit `config.yml` and point `dataset_path` to your dataset.
+Create or edit `config.yml` and set `dataset_path` to your dataset.
+
+Select the algorithm in the configuration:
-Then run:
+```yaml
+algorithm: padim # padim | patchcore | efficientad
+```
```bash
anomavision train --config config.yml
```
-PaDiM is the default model. For PatchCore, set `algorithm: patchcore` in the configuration.
### 4. Detect
@@ -122,52 +122,72 @@ anomavision detect --config config.yml --img_path ./dataset/bottle/test
### 5. Export
-For a portable model, ONNX is a good place to start:
-
```bash
anomavision export --config config.yml --format onnx
```
-For more export options, see [Export and deployment](docs/production_deployment.md).
-
-## KV260 support
-
-AnomaVision also supports a **Vitis AI workflow for PaDiM and PatchCore on the AMD/Xilinx Kria KV260**.
-
-The workflow is:
-
-```text
-PyTorch → INT8 quantization → XModel → KV260 DPU compilation
-```
-
-Both PaDiM and PatchCore currently compile with **1 DPU subgraph** in the KV260 compiler.
-
-The complete setup and commands are in:
-
-**[KV260 XModel Guide](docs/kv260_xmodel.md)**
-
-> XModel compilation has been validated in the Vitis AI environment. Final on-device KV260 validation requires the physical hardware.
-
+See [Export and deployment](docs/production_deployment.md) for deployment-specific options.
## Production Autopilot
-**Production Autopilot is the easiest way to move from two trained models to one deployable choice.** It compares PaDiM and ultra-light PatchCore on the same labeled test split, calibrates a separate threshold for each, profiles median and P95 latency on your hardware, checks localization health, and packages the selected artifact with a self-contained HTML dashboard.
+Production Autopilot compares trained anomaly models on the **same validation data**, measures their performance and latency on the target device, calibrates thresholds, and selects the best candidate for deployment.
-Train both candidate models first, then run the complete labeled split on CPU:
+PaDiM, PatchCore, and EfficientAD can be supplied as independent candidate models. The model paths are provided directly through the CLI:
```bash
anomavision autopilot \
--config config.yml \
--padim_model ./distributions/padim/bottle/anomav_exp/model.pt \
--patchcore_model ./distributions/patchcore/bottle/anomav_exp/model.pt \
+ --efficientad_model ./distributions/efficientad/bottle/anomav_exp/model.pt \
--device cpu \
--validation_split 1.0 \
--target_latency_ms 50 \
--output_dir ./production_package
```
-Open `production_package/production_autopilot_report.html` to see the selected model, AUROC, calibrated threshold, localization diagnostics, memory, median latency, P95 latency, and deployment recommendation. The package also contains `deployment_manifest.json`, `localization_report.md`, and the selected model artifact. See [`docs/production_deployment.md`](docs/production_deployment.md) for GPU, TensorRT, INT8, and packaging details.
+### How selection works
+
+For every supplied model, Autopilot:
+
+1. Evaluates the model on the validation split.
+2. Calibrates an image-level anomaly threshold.
+3. Measures inference latency on the selected device.
+4. Calculates image-level and pixel-level metrics when localization maps are available.
+5. Checks localization quality and false-positive behavior.
+6. Applies the target latency constraint when selecting the production candidate.
+7. Packages the selected model and writes a deployment manifest.
+
+If multiple models satisfy the latency target, the model with the strongest image-level AUROC is preferred, with latency used as a tie-breaker.
+
+### Output
+
+Autopilot creates a production package containing:
+
+```text
+production_package/
+├── model.pt
+├── deployment_manifest.json
+├── localization_report.md
+└── production_autopilot_report.html
+```
+
+The HTML report is a self-contained dashboard showing the candidate comparison, selected model, AUROC, calibrated threshold, latency, localization diagnostics, and deployment recommendation.
+
+
+## KV260 support
+AnomaVision supports a **Vitis AI workflow for PaDiM and PatchCore on the AMD/Xilinx Kria KV260**.
+
+```text
+PyTorch → INT8 quantization → XModel → KV260 DPU compilation
+```
+
+Both PaDiM and PatchCore currently compile with **1 DPU subgraph** in the KV260 compiler.
+
+See the complete [KV260 XModel Guide](docs/kv260_xmodel.md).
+
+> XModel compilation has been validated in the Vitis AI environment. Final on-device KV260 validation requires the physical hardware.
## Documentation
@@ -186,8 +206,6 @@ Open `production_package/production_autopilot_report.html` to see the selected m
## Python example
-You can also use AnomaVision directly from Python:
-
```python
import torch
from torch.utils.data import DataLoader
diff --git a/anomavision/__init__.py b/anomavision/__init__.py
index 6eff355..117757e 100644
--- a/anomavision/__init__.py
+++ b/anomavision/__init__.py
@@ -9,6 +9,7 @@
"""
from .algorithm.common.feature_extraction import ResnetEmbeddingsExtractor
+from .algorithm.efficientad import EfficientAD
from .algorithm.padim import Padim
from .algorithm.patchcore import PatchCore
from .datasets.dataset import AnodetDataset
diff --git a/anomavision/algorithm/efficientad/__init__.py b/anomavision/algorithm/efficientad/__init__.py
new file mode 100644
index 0000000..470fc91
--- /dev/null
+++ b/anomavision/algorithm/efficientad/__init__.py
@@ -0,0 +1,5 @@
+"""Lightweight EfficientAD anomaly detection."""
+
+from .efficientad import EfficientAD, build_efficientad_from_stats
+
+__all__ = ["EfficientAD", "build_efficientad_from_stats"]
diff --git a/anomavision/algorithm/efficientad/efficientad.py b/anomavision/algorithm/efficientad/efficientad.py
new file mode 100644
index 0000000..e7a4ee3
--- /dev/null
+++ b/anomavision/algorithm/efficientad/efficientad.py
@@ -0,0 +1,210 @@
+"""Lightweight EfficientAD-style teacher-student anomaly detection."""
+
+from __future__ import annotations
+
+from typing import Dict, List, Optional, Tuple
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+
+from ..common.feature_extraction import ResnetEmbeddingsExtractor
+
+
+class _Student(nn.Module):
+ """Small CNN that learns normal ResNet layer-1 features."""
+
+ def __init__(self, channels: int = 64) -> None:
+ super().__init__()
+ self.net = nn.Sequential(
+ nn.Conv2d(3, 32, 5, stride=2, padding=2),
+ nn.ReLU(inplace=True),
+ nn.Conv2d(32, channels, 5, stride=2, padding=2),
+ nn.ReLU(inplace=True),
+ nn.Conv2d(channels, channels, 3, padding=1),
+ )
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ return self.net(x)
+
+
+class EfficientAD(torch.nn.Module):
+ """Minimal teacher-student EfficientAD implementation."""
+
+ def __init__(
+ self,
+ backbone: str = "resnet18",
+ device: torch.device = torch.device("cpu"),
+ layer_indices: Optional[List[int]] = None,
+ epochs: int = 5,
+ learning_rate: float = 1e-3,
+ threshold_percentile: float = 99.5,
+ ) -> None:
+ super().__init__()
+ if backbone != "resnet18":
+ raise ValueError(
+ "EfficientAD lightweight supports backbone='resnet18' only."
+ )
+ self.device = torch.device(device)
+ self.backbone = backbone
+ self.layer_indices = [0]
+ self.epochs = max(1, int(epochs))
+ self.learning_rate = float(learning_rate)
+ self.threshold_percentile = float(threshold_percentile)
+ if not 0.0 < self.threshold_percentile <= 100.0:
+ raise ValueError("threshold_percentile must be in (0, 100].")
+ self.threshold = 0.0
+
+ self.teacher = ResnetEmbeddingsExtractor(backbone, self.device)
+ for parameter in self.teacher.parameters():
+ parameter.requires_grad_(False)
+ self.teacher.eval()
+
+ self.student = _Student(64).to(self.device)
+ self._fitted = False
+
+ @torch.no_grad()
+ def _teacher_features(self, batch: torch.Tensor) -> torch.Tensor:
+ features, width, height = self.teacher(batch, layer_indices=[0])
+ return features.reshape(batch.shape[0], width, height, 64).permute(0, 3, 1, 2)
+
+ def _loss(self, teacher: torch.Tensor, batch: torch.Tensor) -> torch.Tensor:
+ teacher = F.normalize(teacher, dim=1)
+ student = F.normalize(self.student(batch), dim=1)
+ return F.mse_loss(student, teacher)
+
+ @torch.no_grad()
+ def _raw_scores(
+ self, batch: torch.Tensor, teacher: Optional[torch.Tensor] = None
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
+ batch = batch.to(self.device, non_blocking=True)
+ if teacher is None:
+ teacher = self._teacher_features(batch)
+ teacher = F.normalize(teacher, dim=1)
+ student = F.normalize(self.student(batch), dim=1)
+ score = (teacher - student).pow(2).mean(dim=1)
+
+ # Mean of the highest-scoring 1% of locations is more robust than a
+ # single maximum while remaining sensitive to small defects.
+ flat = score.flatten(1)
+ k = max(1, int(flat.shape[1] * 0.01))
+ image_scores = flat.topk(k, dim=1).values.mean(dim=1)
+
+ score_map = F.interpolate(
+ score.unsqueeze(1),
+ size=batch.shape[-2:],
+ mode="bilinear",
+ align_corners=False,
+ ).squeeze(1)
+ return image_scores, score_map
+
+ def fit(
+ self, dataloader: torch.utils.data.DataLoader, extractions: int = 1
+ ) -> None:
+ """Train the student and calibrate an adaptive threshold on normal images."""
+ optimizer = torch.optim.Adam(self.student.parameters(), lr=self.learning_rate)
+
+ # Compute frozen teacher features once. This avoids running ResNet for
+ # every epoch and makes CPU training considerably faster.
+ cached = []
+ self.teacher.eval()
+ with torch.no_grad():
+ for item in dataloader:
+ batch = item[0] if isinstance(item, (tuple, list)) else item
+ batch = batch.to(self.device, non_blocking=True)
+ cached.append((batch.detach(), self._teacher_features(batch).detach()))
+
+ self.student.train()
+ for _ in range(self.epochs):
+ for batch, teacher in cached:
+ optimizer.zero_grad(set_to_none=True)
+ loss = self._loss(teacher, batch)
+ loss.backward()
+ optimizer.step()
+
+ self.student.eval()
+
+ # Calibrate from normal training scores using the exact score that is
+ # used for inference.
+ normal_scores = []
+ for batch, teacher in cached:
+ scores, _ = self._raw_scores(batch, teacher)
+ normal_scores.append(scores.cpu())
+ self.threshold = float(
+ torch.quantile(torch.cat(normal_scores), self.threshold_percentile / 100.0)
+ )
+ self.threshold = max(self.threshold, 1e-8)
+ self._fitted = True
+ self.eval()
+
+ @torch.no_grad()
+ def forward(
+ self, x: torch.Tensor, return_map: bool = True, export: bool = False
+ ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
+ if not self._fitted:
+ raise RuntimeError("EfficientAD is not fitted. Call fit() first.")
+ image_scores, score_map = self._raw_scores(x)
+
+ # Normalize scores by the training-derived threshold. This embeds the
+ # adaptive threshold into the exported model: >= 1.0 means anomalous.
+ scale = image_scores.new_tensor(self.threshold)
+ image_scores = image_scores / scale
+ score_map = score_map / scale
+ return image_scores, score_map if return_map else None
+
+ def predict(
+ self, batch: torch.Tensor, export: bool = False
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
+ return self.forward(batch, return_map=True, export=export)
+
+ def to_device(self, device: torch.device) -> None:
+ self.device = torch.device(device)
+ self.teacher.to_device(self.device)
+ self.student.to(self.device)
+
+ def save_statistics(self, path: str, half: Optional[bool] = False) -> None:
+ if not self._fitted:
+ raise RuntimeError("EfficientAD is not fitted. Call fit() first.")
+ student_state = {
+ key: (
+ value.detach().cpu().half()
+ if half and value.is_floating_point()
+ else value.detach().cpu()
+ )
+ for key, value in self.student.state_dict().items()
+ }
+ torch.save(
+ {
+ "student": student_state,
+ "backbone": self.backbone,
+ "layer_indices": self.layer_indices,
+ "epochs": self.epochs,
+ "learning_rate": self.learning_rate,
+ "threshold_percentile": self.threshold_percentile,
+ "threshold": self.threshold,
+ "model_type": "efficientad",
+ "dtype": "fp16" if half else "fp32",
+ },
+ path,
+ )
+
+
+def build_efficientad_from_stats(
+ stats: Dict, device: str = "cpu", force_precision: Optional[str] = None
+) -> EfficientAD:
+ """Build EfficientAD from a compact statistics artifact."""
+ model = EfficientAD(
+ backbone=str(stats.get("backbone", "resnet18")),
+ device=torch.device(device),
+ layer_indices=[0],
+ epochs=int(stats.get("epochs", 5)),
+ learning_rate=float(stats.get("learning_rate", 1e-3)),
+ threshold_percentile=float(stats.get("threshold_percentile", 99.5)),
+ )
+ state = stats["student"]
+ model.student.load_state_dict({key: value.float() for key, value in state.items()})
+ model.student.eval()
+ model.threshold = max(float(stats.get("threshold", 0.0)), 1e-8)
+ model._fitted = True
+ model.eval()
+ return model
diff --git a/anomavision/autopilot.py b/anomavision/autopilot.py
index 9ac4ad2..642db7f 100644
--- a/anomavision/autopilot.py
+++ b/anomavision/autopilot.py
@@ -10,7 +10,7 @@
import time
from html import escape
from pathlib import Path
-from typing import Any, Dict, Iterable, Optional
+from typing import Any, Dict, Optional
import numpy as np
import torch
@@ -27,9 +27,10 @@
make_localization_mask,
)
+ALGORITHMS = ("padim", "patchcore", "efficientad")
+
def create_parser(add_help: bool = True) -> argparse.ArgumentParser:
- """Build the ``anomavision autopilot`` argument parser."""
parser = argparse.ArgumentParser(
description="Select, calibrate, profile, and package a production anomaly model.",
add_help=add_help,
@@ -55,18 +56,19 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser:
default=None,
help="PatchCore model artifact (.pt/.pth/.onnx).",
)
+ parser.add_argument(
+ "--efficientad_model",
+ type=str,
+ default=None,
+ help="EfficientAD model artifact (.pt/.pth/.onnx).",
+ )
parser.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto")
parser.add_argument("--batch_size", type=int, default=1)
parser.add_argument("--num_workers", type=int, default=0)
parser.add_argument("--warmup", type=int, default=3)
parser.add_argument("--timing_batches", type=int, default=20)
parser.add_argument("--target_latency_ms", type=float, default=None)
- parser.add_argument(
- "--validation_split",
- type=float,
- default=1.0,
- help="Fraction of the complete labeled test split used for calibration; 1.0 uses every sample.",
- )
+ parser.add_argument("--validation_split", type=float, default=1.0)
parser.add_argument("--output_dir", type=str, default="./production_package")
parser.add_argument("--copy_config", action="store_true", default=True)
return parser
@@ -94,9 +96,8 @@ def _profile_model(
timing_batches: int,
) -> Dict[str, Any]:
wrapper = ModelWrapper(model_path, device)
- iterator = iter(dataloader)
try:
- first = next(iterator)
+ first = next(iter(dataloader))
except StopIteration:
wrapper.close()
raise ValueError("The evaluation dataset is empty.")
@@ -105,10 +106,10 @@ def _profile_model(
wrapper.predict(first_batch)
if device.startswith("cuda") and torch.cuda.is_available():
torch.cuda.synchronize()
+
timings = []
all_scores, all_maps, all_labels, all_masks = [], [], [], []
- count = 0
- for item in dataloader:
+ for count, item in enumerate(dataloader):
batch, _, labels, masks = item
batch = batch.to(device)
measure = count < max(1, timing_batches)
@@ -124,8 +125,8 @@ def _profile_model(
all_maps.extend(_to_numpy(maps))
all_labels.extend(_to_numpy(labels).reshape(-1).tolist())
all_masks.extend(_to_numpy(masks))
- count += 1
wrapper.close()
+
scores_np = np.asarray(all_scores, dtype=np.float32)
labels_np = np.asarray(all_labels, dtype=np.int64)
maps_np = (
@@ -142,10 +143,11 @@ def _profile_model(
image_auroc = (
image_metrics.get("auc_score") if len(np.unique(labels_np)) > 1 else None
)
- pixel_auroc = None
+
masks_np = np.asarray(all_masks, dtype=np.float32)
if masks_np.ndim == 4 and masks_np.shape[1] == 1:
masks_np = masks_np[:, 0]
+ pixel_auroc = None
localization = {
"available": bool(len(maps_np) == len(labels_np) and maps_np.ndim == 3),
"non_empty_fraction": None,
@@ -166,16 +168,16 @@ def _profile_model(
roc_auc_score(masks_np.reshape(-1) > 0.5, maps_np.reshape(-1))
)
except ValueError:
- pixel_auroc = None
+ pass
image_metrics["image_auroc"] = image_auroc
image_metrics["pixel_auroc"] = pixel_auroc
- anomaly_labels = (scores_np >= threshold).astype(np.uint8)
+
if localization["available"]:
+ anomaly_labels = (scores_np >= threshold).astype(np.uint8)
loc_masks = make_localization_mask(maps_np, anomaly_labels).astype(bool)
non_empty = loc_masks.reshape(len(loc_masks), -1).any(axis=1)
area = loc_masks.reshape(len(loc_masks), -1).mean(axis=1)
- anomaly_idx = labels_np == 1
- normal_idx = labels_np == 0
+ anomaly_idx, normal_idx = labels_np == 1, labels_np == 0
localization["non_empty_fraction"] = float(non_empty.mean())
localization["mean_mask_area_fraction"] = float(area.mean())
localization["anomaly_non_empty_fraction"] = (
@@ -190,31 +192,26 @@ def _profile_model(
localization["normal_mean_mask_area_fraction"] = (
float(area[normal_idx].mean()) if normal_idx.any() else None
)
- if pixel_auroc is not None:
- localization["verdict"] = (
+ localization["verdict"] = (
+ (
"healthy"
if (localization["normal_false_positive_fraction"] or 0.0) <= 0.10
else "review false positives"
)
- else:
- localization["verdict"] = "maps available; pixel AUROC unavailable"
- median_ms = (
- float(np.median(timings) * 1000 / max(1, dataloader.batch_size))
- if timings
- else 0.0
- )
- p95_ms = (
- float(np.percentile(timings, 95) * 1000 / max(1, dataloader.batch_size))
- if timings
- else 0.0
- )
+ if pixel_auroc is not None
+ else "maps available; pixel AUROC unavailable"
+ )
+
+ batch_size = max(1, dataloader.batch_size or 1)
+ median_ms = float(np.median(timings) * 1000 / batch_size) if timings else 0.0
+ p95_ms = float(np.percentile(timings, 95) * 1000 / batch_size) if timings else 0.0
return {
"model_path": str(Path(model_path).resolve()),
"model_format": Path(model_path).suffix.lower(),
"threshold": float(threshold),
"threshold_f1": float(threshold_f1),
"metrics": {
- k: (float(v) if isinstance(v, (float, np.floating)) else v)
+ k: float(v) if isinstance(v, (float, np.floating)) else v
for k, v in image_metrics.items()
},
"latency_ms": {"median": median_ms, "p95": p95_ms},
@@ -248,25 +245,21 @@ def _select(
def _write_report(manifest: Dict[str, Any], output_dir: Path) -> None:
- """Write a self-contained HTML dashboard and a Markdown fallback report."""
selected = manifest["selected_model"]
selected_result = manifest["candidates"][selected]
target = manifest.get("target_latency_ms")
- cards = []
- rows = []
+ cards, rows = [], []
for name, result in manifest["candidates"].items():
- metrics = result["metrics"]
- loc = result["localization"]
- is_selected = name == selected
- status = "Selected" if is_selected else "Candidate"
- status_class = "selected" if is_selected else "candidate"
+ metrics, loc = result["metrics"], result["localization"]
+ active = name == selected
+ status = "Selected" if active else "Candidate"
cards.append(
- f'
Recommendation Deploy {escape(selected)} with threshold {selected_result["threshold"]:.6f}. It was selected {escape(target_text)}. Recheck this threshold on a production validation set before release.
Candidate overview
Measured on the same validation data
{"".join(cards)}
-
Detailed comparison
Higher AUROC and anomaly coverage are better; lower false positives and latency are better
Model
Image AUROC
Pixel AUROC
Median ms
P95 ms
Anomaly coverage
Normal false positives
Threshold
{"".join(rows)}
-
Localization health
Selected model maps available{"PASS" if selected_result["localization"]["available"] else "CHECK"}
Anomaly images with localization{_format_percent(selected_result["localization"].get("anomaly_non_empty_fraction"))}
Normal images with false-positive maps{_format_percent(selected_result["localization"].get("normal_false_positive_fraction"))}
Anomaly mean mask area{_format_percent(selected_result["localization"].get("anomaly_mean_mask_area_fraction"))}