diff --git a/README.md b/README.md index 8e93371..b7d3e28 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,11 @@ AnomaVision is a computer vision project for finding **defects and unusual patterns** in 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. +- **EfficientAD** — a student/teacher model with a compact reconstruction branch for fast anomaly detection. You only need **normal (`good`) images** to train the anomaly detector. @@ -43,6 +44,7 @@ You only need **normal (`good`) images** to train the anomaly detector. - Create anomaly heatmaps showing where the problem is. - Export models to **ONNX, OpenVINO, and TensorRT**. - Export and compile **PaDiM and PatchCore to XModel for the AMD/Xilinx Kria KV260**. +- Switch between PaDiM, PatchCore, and EfficientAD without changing the CLI workflow. ## Quick start @@ -53,31 +55,19 @@ You only need **normal (`good`) images** to train the anomaly detector. ```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 +source .venv/bin/activate # Windows: .venv\\Scripts\\Activate.ps1 uv sync --extra cpu # CPU uv sync --extra cu121 # CUDA 12.1 ``` ---- - #### Option B — From PyPI (production / quick start) ```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 +uv pip install "anomavision[cu121]" ``` - For other environments, see [Installation](docs/installation.md). ### 2. Prepare your images @@ -88,9 +78,6 @@ Use a simple MVTec-style folder structure: dataset/ └── bottle/ ├── ground_truth/ - │ ├── broken_large/ - │ ├── broken_small/ - │ └── contamination/ ├── test/ │ ├── broken_large/ │ ├── broken_small/ @@ -102,33 +89,56 @@ dataset/ Training uses the **good** images. Test images can contain defects. -### 3. Train +### 3. Choose an algorithm + +The existing configuration format works unchanged: + +```yaml +algorithm: padim +``` + +Switch to EfficientAD by changing one value: + +```yaml +algorithm: efficientad +``` + +You can also use the native model selector: + +```yaml +model: + name: efficientad +``` -Create or edit `config.yml` and point `dataset_path` to your dataset. +The CLI commands remain the same. -Then run: +### 4. Train ```bash anomavision train --config config.yml ``` -PaDiM is the default model. For PatchCore, set `algorithm: patchcore` in the configuration. +For a quick EfficientAD configuration, see [`examples/efficientad_cpu.yml`](examples/efficientad_cpu.yml). -### 4. Detect +### 5. Detect ```bash -anomavision detect --config config.yml --img_path ./dataset/bottle/test +anomavision detect --config config.yml --model model.pt --img_path ./test_images ``` -### 5. Export +### 6. Export -For a portable model, ONNX is a good place to start: +```bash +anomavision export --config config.yml --model model.pt --format onnx +``` + +### 7. Evaluate ```bash -anomavision export --config config.yml --format onnx +anomavision eval --config config.yml --model model.pt --class_name bottle ``` -For more export options, see [Export and deployment](docs/production_deployment.md). +For EfficientAD-specific options and limitations, see [EfficientAD](docs/efficientad.md). ## KV260 support @@ -142,13 +152,10 @@ 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)** +The complete setup and commands are in the [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. - ## 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. @@ -156,18 +163,18 @@ The complete setup and commands are in: Train both candidate models first, then run the complete labeled split on CPU: ```bash -anomavision autopilot \ - --config config.yml \ - --padim_model ./distributions/padim/bottle/anomav_exp/model.pt \ - --patchcore_model ./distributions/patchcore/bottle/anomav_exp/model.pt \ - --device cpu \ - --validation_split 1.0 \ - --target_latency_ms 50 \ +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.onnx ` + --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. - +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. See [`docs/production_deployment.md`](docs/production_deployment.md) for details. ## Documentation @@ -176,6 +183,7 @@ Open `production_package/production_autopilot_report.html` to see the selected m | Quick start | [`docs/quickstart.md`](docs/quickstart.md) | | Installation | [`docs/installation.md`](docs/installation.md) | | CLI and configuration | [`docs/cli.md`](docs/cli.md), [`docs/config.md`](docs/config.md) | +| EfficientAD | [`docs/efficientad.md`](docs/efficientad.md) | | Python API | [`docs/api.md`](docs/api.md) | | KV260 / XModel | [`docs/kv260_xmodel.md`](docs/kv260_xmodel.md) | | Production deployment | [`docs/production_deployment.md`](docs/production_deployment.md) | @@ -186,23 +194,18 @@ 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 import anomavision +from torch.utils.data import DataLoader train_set = anomavision.AnodetDataset("./dataset/bottle/train/good") -train_loader = DataLoader(train_set, batch_size=16, shuffle=False) - -model = anomavision.Padim(backbone="resnet18", device=torch.device("cpu")) -model.fit(train_loader) +train_loader = DataLoader(train_set, batch_size=1, shuffle=False) -batch = next(iter(train_loader)) -if isinstance(batch, (tuple, list)): - batch = batch[0] +model = anomavision.EfficientAD(device=torch.device("cpu")) +model.fit(train_loader, epochs=1) +batch = next(iter(train_loader))[0] scores, maps = model.predict(batch) ``` 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..eb7f485 --- /dev/null +++ b/anomavision/algorithm/efficientad/__init__.py @@ -0,0 +1,5 @@ +"""EfficientAD anomaly detection algorithm.""" + +from .efficientad import EfficientAD + +__all__ = ["EfficientAD"] diff --git a/anomavision/algorithm/efficientad/efficientad.py b/anomavision/algorithm/efficientad/efficientad.py new file mode 100644 index 0000000..2cfae73 --- /dev/null +++ b/anomavision/algorithm/efficientad/efficientad.py @@ -0,0 +1,227 @@ +"""EfficientAD anomaly detection algorithm.""" + +from __future__ import annotations + +from typing import Optional, Tuple + +import torch +import torch.nn.functional as F +from torch import nn +from torchvision.models import EfficientNet_B0_Weights, efficientnet_b0 + + +class _FeatureTeacher(nn.Module): + def __init__(self, pretrained: bool = True) -> None: + super().__init__() + weights = EfficientNet_B0_Weights.DEFAULT if pretrained else None + net = efficientnet_b0(weights=weights) + self.features = nn.Sequential(*list(net.features[:6])) + self.out_channels = 112 + for p in self.parameters(): + p.requires_grad_(False) + self.eval() + + @torch.no_grad() + def forward(self, x): + return self.features(x) + + +class _Student(nn.Module): + def __init__(self, out_channels=112): + super().__init__() + self.net = nn.Sequential( + nn.Conv2d(3, 64, 3, 2, 1), + nn.BatchNorm2d(64), + nn.ReLU(inplace=True), + nn.Conv2d(64, 96, 3, 2, 1), + nn.BatchNorm2d(96), + nn.ReLU(inplace=True), + nn.Conv2d(96, 112, 3, 2, 1), + nn.BatchNorm2d(112), + nn.ReLU(inplace=True), + nn.Conv2d(112, out_channels, 3, 2, 1), + ) + + def forward(self, x): + return self.net(x) + + +class EfficientAD(nn.Module): + """EfficientAD-compatible teacher/student anomaly detector for AnomaVision.""" + + def __init__( + self, + device=torch.device("cpu"), + model_size="s", + lr=1e-4, + weight_decay=1e-5, + pretrained_teacher=True, + teacher_weights=None, + feature_weight=1.0, + reconstruction_weight=0.0, + threshold_quantile=0.995, + ): + super().__init__() + model_size = str(model_size).lower() + if model_size not in {"s", "m", "small", "medium"}: + raise ValueError("EfficientAD model_size must be one of: s, m") + if not 0.0 < float(threshold_quantile) < 1.0: + raise ValueError("threshold_quantile must be between 0 and 1") + self.device = torch.device(device) + self.model_size = "m" if model_size in {"m", "medium"} else "s" + self.lr = float(lr) + self.weight_decay = float(weight_decay) + self.feature_weight = float(feature_weight) + self.reconstruction_weight = float(reconstruction_weight) + self.threshold_quantile = float(threshold_quantile) + self.teacher = _FeatureTeacher(pretrained_teacher) + if teacher_weights: + self.teacher.load_state_dict( + torch.load(teacher_weights, map_location="cpu", weights_only=False), + strict=False, + ) + self.student = _Student(self.teacher.out_channels) + self.register_buffer("map_mean", torch.zeros(1, 224, 224)) + self.register_buffer("map_std", torch.ones(1, 224, 224)) + self.register_buffer("score_mean", torch.tensor(0.0)) + self.register_buffer("score_std", torch.tensor(1.0)) + self.register_buffer("threshold", torch.tensor(0.0)) + self.register_buffer("trained", torch.tensor(False, dtype=torch.bool)) + self.to(self.device) + + def _normalise(self, x): + return x + + @torch.no_grad() + def _raw_map(self, x, teacher=None): + teacher_features = ( + self.teacher(self._normalise(x)) if teacher is None else teacher + ) + student_features = self.student(self._normalise(x)) + raw = (student_features - teacher_features).pow(2).mean(1, keepdim=True) + return F.interpolate( + raw, size=x.shape[-2:], mode="bilinear", align_corners=False + ).squeeze(1) + + def forward(self, x, return_map=True, export=False): + del export + raw = self._raw_map(x) + normalized_map = (raw - self.map_mean.to(raw.device)) / self.map_std.to( + raw.device + ).clamp_min(1e-6) + scores = normalized_map.flatten(1).amax(1) + return scores, normalized_map if return_map else None + + def fit(self, dataloader, epochs=1): + cached = [] + self.teacher.eval() + with torch.no_grad(): + for item in dataloader: + images = item[0] if isinstance(item, (tuple, list)) else item + images = images.to(self.device, non_blocking=True).float() + teacher = self.teacher(self._normalise(images)).detach().cpu() + cached.append((images.cpu(), teacher)) + if not cached: + raise RuntimeError("EfficientAD training requires normal training images") + + optimizer = torch.optim.AdamW( + self.student.parameters(), lr=self.lr, weight_decay=self.weight_decay + ) + use_amp = self.device.type == "cuda" + scaler = torch.amp.GradScaler("cuda", enabled=use_amp) + self.student.train() + for _ in range(int(epochs)): + for images_cpu, teacher_cpu in cached: + images = images_cpu.to(self.device, non_blocking=True) + teacher = teacher_cpu.to(self.device, non_blocking=True) + optimizer.zero_grad(set_to_none=True) + with torch.autocast( + device_type="cuda", dtype=torch.float16, enabled=use_amp + ): + student = self.student(self._normalise(images)) + loss = F.mse_loss(student.float(), teacher.float()) + scaler.scale(loss).backward() + scaler.step(optimizer) + scaler.update() + + self.student.eval() + maps = [] + with torch.no_grad(): + for images_cpu, teacher_cpu in cached: + images = images_cpu.to(self.device, non_blocking=True) + teacher = teacher_cpu.to(self.device, non_blocking=True) + maps.append(self._raw_map(images, teacher).float()) + normal_maps = torch.cat(maps, 0) + mean = normal_maps.mean(0, keepdim=True) + std = normal_maps.std(0, unbiased=False, keepdim=True).clamp_min(1e-6) + normalized = (normal_maps - mean) / std + scores = normalized.flatten(1).amax(1) + self.map_mean.copy_(mean.cpu()) + self.map_std.copy_(std.cpu()) + self.score_mean.copy_(scores.mean().cpu()) + self.score_std.copy_(scores.std(unbiased=False).clamp_min(1e-6).cpu()) + self.threshold.copy_(torch.quantile(scores, self.threshold_quantile).cpu()) + self.trained.fill_(True) + self.to(self.device) + + def predict(self, batch, export=False): + if not export and not bool(self.trained.item()): + raise RuntimeError("EfficientAD model is not trained. Call fit() first.") + self.eval() + with torch.inference_mode(): + return self.forward( + batch.to(self.device, non_blocking=True).float(), export=export + ) + + def to_device(self, device): + self.device = torch.device(device) + self.to(self.device) + + def save_statistics(self, path: str, half: Optional[bool] = None): + if not bool(self.trained.item()): + raise RuntimeError("Model is not trained. Call fit() first.") + # Keep the calibrated threshold explicitly available to deployment tools. + state = {k: v.detach().cpu() for k, v in self.state_dict().items()} + torch.save( + { + "algorithm": "efficientad", + "model_state": state, + "model_size": self.model_size, + "threshold": float(self.threshold.detach().cpu().item()), + "threshold_quantile": self.threshold_quantile, + }, + path, + ) + + @staticmethod + def load_statistics(path: str, device: str = "cpu"): + obj = torch.load(path, map_location="cpu", weights_only=False) + if isinstance(obj, EfficientAD): + obj.to_device(torch.device(device)) + return obj + if isinstance(obj, dict) and obj.get("algorithm") == "efficientad": + model = EfficientAD( + device=torch.device(device), + model_size=obj.get("model_size", "s"), + pretrained_teacher=False, + threshold_quantile=obj.get("threshold_quantile", 0.995), + ) + model.load_state_dict(obj["model_state"]) + return model + raise ValueError("Not an EfficientAD artifact") + + +def build_efficientad_from_stats(stats, device="cpu"): + if isinstance(stats, EfficientAD): + stats.to_device(torch.device(device)) + return stats + if isinstance(stats, dict): + model = EfficientAD( + device=torch.device(device), + model_size=stats.get("model_size", "s"), + pretrained_teacher=False, + threshold_quantile=stats.get("threshold_quantile", 0.995), + ) + model.load_state_dict(stats["model_state"]) + return model + raise ValueError("Unsupported EfficientAD statistics artifact") diff --git a/anomavision/autopilot.py b/anomavision/autopilot.py index 9ac4ad2..940e68d 100644 --- a/anomavision/autopilot.py +++ b/anomavision/autopilot.py @@ -3,14 +3,14 @@ from __future__ import annotations import argparse +import html import json import platform import shutil import sys 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 @@ -29,31 +29,19 @@ 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, ) + parser.add_argument("--config", required=True) + parser.add_argument("--dataset_path", default=None) + parser.add_argument("--class_name", default=None) + parser.add_argument("--padim_model", default=None) + parser.add_argument("--patchcore_model", default=None) parser.add_argument( - "--config", type=str, required=True, help="Base AnomaVision config file." - ) - parser.add_argument( - "--dataset_path", type=str, default=None, help="MVTec-style dataset root." - ) - parser.add_argument( - "--class_name", type=str, default=None, help="Dataset class to evaluate." - ) - parser.add_argument( - "--padim_model", - type=str, + "--efficientad_model", default=None, - help="PaDiM model artifact (.pt/.pth/.onnx).", - ) - parser.add_argument( - "--patchcore_model", - type=str, - default=None, - help="PatchCore model artifact (.pt/.pth/.onnx).", + 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) @@ -61,13 +49,8 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: 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("--output_dir", type=str, default="./production_package") + parser.add_argument("--validation_split", type=float, default=1.0) + parser.add_argument("--output_dir", default="./production_package") parser.add_argument("--copy_config", action="store_true", default=True) return parser @@ -78,14 +61,32 @@ def _to_numpy(value: Any) -> np.ndarray: return np.asarray(value) -def _format_metric(value: Any) -> str: - return "N/A" if value is None else f"{float(value):.4f}" +def _fmt(value: Any, digits: int = 4) -> str: + return "N/A" if value is None else f"{float(value):.{digits}f}" + + +def _format_metric(value: Any, digits: int = 4) -> str: + """Backward-compatible public formatter used by the autopilot tests/API.""" + return _fmt(value, digits) -def _format_percent(value: Any) -> str: +def _pct(value: Any) -> str: return "N/A" if value is None else f"{float(value):.1%}" +def _json_default(value: Any) -> Any: + """Convert NumPy/PyTorch scalar values to JSON-safe Python values.""" + if isinstance(value, np.generic): + return value.item() + if isinstance(value, torch.Tensor): + if value.ndim == 0: + return value.item() + return value.detach().cpu().tolist() + if isinstance(value, Path): + return str(value) + raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") + + def _profile_model( model_path: str, dataloader: DataLoader, @@ -94,60 +95,53 @@ def _profile_model( timing_batches: int, ) -> Dict[str, Any]: wrapper = ModelWrapper(model_path, device) - iterator = iter(dataloader) - try: - first = next(iterator) - except StopIteration: - wrapper.close() - raise ValueError("The evaluation dataset is empty.") - first_batch = first[0].to(device) - for _ in range(max(0, warmup)): - 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: - batch, _, labels, masks = item - batch = batch.to(device) - measure = count < max(1, timing_batches) - if measure: - start = time.perf_counter() - scores, maps = wrapper.predict(batch) + scores_all, maps_all, labels_all, masks_all = [], [], [], [] + try: + first = next(iter(dataloader)) + first_batch = first[0].to(device) + for _ in range(max(0, warmup)): + wrapper.predict(first_batch) if device.startswith("cuda") and torch.cuda.is_available(): torch.cuda.synchronize() - if measure: - timings.append(time.perf_counter() - start) - all_scores.extend(_to_numpy(scores).reshape(-1).tolist()) - if maps is not None: - 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 = ( - np.asarray(all_maps, dtype=np.float32) - if all_maps + for index, (batch, _, labels, masks) in enumerate(dataloader): + batch = batch.to(device) + measure = index < max(1, timing_batches) + start = time.perf_counter() if measure else 0.0 + scores, maps = wrapper.predict(batch) + if device.startswith("cuda") and torch.cuda.is_available(): + torch.cuda.synchronize() + if measure: + timings.append(time.perf_counter() - start) + scores_all.extend(_to_numpy(scores).reshape(-1).tolist()) + if maps is not None: + maps_all.extend(_to_numpy(maps)) + labels_all.extend(_to_numpy(labels).reshape(-1).astype(int).tolist()) + masks_all.extend(_to_numpy(masks)) + finally: + wrapper.close() + + scores = np.asarray(scores_all, dtype=np.float32) + labels = np.asarray(labels_all, dtype=np.int64) + maps = ( + np.asarray(maps_all, dtype=np.float32) + if maps_all else np.empty((0, 0, 0), dtype=np.float32) ) - threshold, threshold_f1 = ( - find_optimal_threshold(labels_np, scores_np) - if len(np.unique(labels_np)) > 1 - else (float(np.median(scores_np)), 0.0) - ) - image_metrics = compute_metrics(labels_np, scores_np, thresh=threshold) - image_auroc = ( - image_metrics.get("auc_score") if len(np.unique(labels_np)) > 1 else None - ) + masks = np.asarray(masks_all, dtype=np.float32) + if masks.ndim == 4 and masks.shape[1] == 1: + masks = masks[:, 0] + + if len(np.unique(labels)) > 1: + threshold, threshold_f1 = find_optimal_threshold(labels, scores) + else: + threshold, threshold_f1 = ( + float(np.median(scores)) if len(scores) else 0.0 + ), 0.0 + metrics = compute_metrics(labels, scores, thresh=threshold) if len(labels) else {} 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] localization = { - "available": bool(len(maps_np) == len(labels_np) and maps_np.ndim == 3), + "available": bool(len(maps) == len(labels) and maps.ndim == 3), "non_empty_fraction": None, "mean_mask_area_fraction": None, "anomaly_non_empty_fraction": None, @@ -158,71 +152,57 @@ def _profile_model( } if ( localization["available"] - and masks_np.shape == maps_np.shape - and np.unique(masks_np).size > 1 + and masks.shape == maps.shape + and np.unique(masks).size > 1 ): try: pixel_auroc = float( - roc_auc_score(masks_np.reshape(-1) > 0.5, maps_np.reshape(-1)) + roc_auc_score(masks.reshape(-1) > 0.5, maps.reshape(-1)) ) except ValueError: - pixel_auroc = None - image_metrics["image_auroc"] = image_auroc - image_metrics["pixel_auroc"] = pixel_auroc - anomaly_labels = (scores_np >= threshold).astype(np.uint8) - if localization["available"]: - 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 + pass + loc_masks = make_localization_mask( + maps, (scores >= threshold).astype(np.uint8) + ).astype(bool) + flat = loc_masks.reshape(len(loc_masks), -1) + non_empty, area = flat.any(axis=1), flat.mean(axis=1) + anomaly, normal = labels == 1, labels == 0 localization["non_empty_fraction"] = float(non_empty.mean()) localization["mean_mask_area_fraction"] = float(area.mean()) - localization["anomaly_non_empty_fraction"] = ( - float(non_empty[anomaly_idx].mean()) if anomaly_idx.any() else None - ) - localization["normal_false_positive_fraction"] = ( - float(non_empty[normal_idx].mean()) if normal_idx.any() else None - ) - localization["anomaly_mean_mask_area_fraction"] = ( - float(area[anomaly_idx].mean()) if anomaly_idx.any() else None - ) - 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"] = ( - "healthy" - if (localization["normal_false_positive_fraction"] or 0.0) <= 0.10 - else "review false positives" + if anomaly.any(): + localization["anomaly_non_empty_fraction"] = float( + non_empty[anomaly].mean() ) - 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 + localization["anomaly_mean_mask_area_fraction"] = float( + area[anomaly].mean() + ) + if normal.any(): + localization["normal_false_positive_fraction"] = float( + non_empty[normal].mean() + ) + localization["normal_mean_mask_area_fraction"] = float(area[normal].mean()) + localization["verdict"] = ( + "healthy" + if (localization["normal_false_positive_fraction"] or 0.0) <= 0.10 + else "review false positives" + ) + metrics["image_auroc"] = ( + float(metrics["auc_score"]) if metrics.get("auc_score") is not None else None ) + metrics["pixel_auroc"] = pixel_auroc + bs = max(1, int(dataloader.batch_size or 1)) + median_ms = float(np.median(timings) * 1000 / bs) if timings else 0.0 + p95_ms = float(np.percentile(timings, 95) * 1000 / bs) 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) - for k, v in image_metrics.items() - }, + "metrics": metrics, "latency_ms": {"median": median_ms, "p95": p95_ms}, - "throughput_images_per_second": ( - float(1000.0 / median_ms) if median_ms > 0 else 0.0 - ), + "throughput_images_per_second": float(1000 / median_ms) if median_ms else 0.0, "localization": localization, - "samples": int(len(labels_np)), + "samples": int(len(labels)), } @@ -232,83 +212,56 @@ def _select( eligible = results if target_latency_ms is not None: eligible = { - name: result - for name, result in results.items() - if result["latency_ms"]["p95"] <= target_latency_ms + n: r + for n, r in results.items() + if r["latency_ms"]["p95"] <= target_latency_ms } if not eligible: eligible = results return max( eligible, - key=lambda name: ( - eligible[name]["metrics"].get("image_auroc") or 0.0, - -eligible[name]["latency_ms"]["p95"], + key=lambda n: ( + eligible[n]["metrics"].get("image_auroc") or 0.0, + -eligible[n]["latency_ms"]["p95"], ), ) 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 cards.append( - f'
{escape(name.upper())}{status}
' - f'
{_format_metric(metrics.get("image_auroc"))} image AUROC
' - f'
{_format_metric(metrics.get("pixel_auroc"))}pixel AUROC
{result["latency_ms"]["p95"]:.1f} msp95 latency
{_format_percent(loc.get("anomaly_non_empty_fraction"))}anomaly coverage
' + f"""
{html.escape(name.upper())}{"SELECTED" if active else "CANDIDATE"}
{_fmt(metrics.get("image_auroc"))}Image AUROC
{_fmt(metrics.get("pixel_auroc"))}Pixel AUROC
{result["latency_ms"]["p95"]:.1f} msP95 latency
{_pct(loc.get("anomaly_non_empty_fraction"))}Anomaly coverage
""" ) rows.append( - f'{escape(name)}{_format_metric(metrics.get("image_auroc"))}{_format_metric(metrics.get("pixel_auroc"))}{result["latency_ms"]["median"]:.2f}{result["latency_ms"]["p95"]:.2f}{_format_percent(loc.get("anomaly_non_empty_fraction"))}{_format_percent(loc.get("normal_false_positive_fraction"))}{result["threshold"]:.6f}' + f"""{html.escape(name)}{_fmt(metrics.get("image_auroc"))}{_fmt(metrics.get("pixel_auroc"))}{result["latency_ms"]["median"]:.2f}{result["latency_ms"]["p95"]:.2f}{_pct(loc.get("anomaly_non_empty_fraction"))}{_pct(loc.get("normal_false_positive_fraction"))}{result["threshold"]:.6f}""" ) target_text = ( f"under {target:.1f} ms p95" if target is not None else "with the strongest measured accuracy/latency balance" ) - environment_json = escape(json.dumps(manifest["environment"], indent=2)) - html = f""" - -AnomaVision Production Autopilot -
-
AnomaVision / Production Autopilot

Deployment confidence, before production.

Calibrated thresholds, hardware-aware profiling, and localization health checks in one reproducible report.

Selected: {escape(selected)}Class: {escape(str(manifest["dataset"]["class_name"]))}Samples: {manifest["dataset"]["samples"]}Device: {escape(str(manifest["environment"].get("device", "unknown")))}
-
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
{"".join(rows)}
ModelImage AUROCPixel AUROCMedian msP95 msAnomaly coverageNormal false positivesThreshold
-

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"))}
Localization verdict{escape(str(selected_result["localization"].get("verdict", "N/A")))}

Anomaly coverage measures detected defect images. Normal false positives should remain low.

Deployment artifact

Artifact{escape(str(manifest["selected_artifact"]))}
Format{escape(str(selected_result["model_format"]))}
Preprocessing{escape(str(manifest["preprocessing"].get("resize")))} px
Target latency{escape(str(target)) if target is not None else "not set"}
-

Reproducibility environment

{environment_json}
-
""" - (output_dir / "production_autopilot_report.html").write_text(html, encoding="utf-8") - - markdown = [ - "# AnomaVision Production Autopilot Report", - "", - f"**Selected model:** `{selected}`", - "", - "See `production_autopilot_report.html` for the full dashboard.", - "", - ] + env = html.escape( + json.dumps(manifest["environment"], indent=2, default=_json_default) + ) + document = f"""AnomaVision Production Autopilot
AnomaVision / Production Autopilot

Deployment confidence, before production.

Calibrated thresholds, hardware-aware profiling, and localization health checks in one reproducible report.

Selected: {html.escape(selected)}Class: {html.escape(str(manifest["dataset"]["class_name"]))}Samples: {manifest["dataset"]["samples"]}Device: {html.escape(str(manifest["environment"].get("device","unknown")))}
Recommendation
Deploy {html.escape(selected)} with threshold {selected_result["threshold"]:.6f}. It was selected {html.escape(target_text)}.

Candidate overview

{"".join(cards)}

Detailed comparison

{"".join(rows)}
ModelImage AUROCPixel AUROCMedian msP95 msAnomaly coverageNormal images with false-positive mapsThreshold

Localization health

Maps available{"PASS" if selected_result["localization"]["available"] else "CHECK"}
Anomaly localization{_pct(selected_result["localization"].get("anomaly_non_empty_fraction"))}
Normal false-positive maps{_pct(selected_result["localization"].get("normal_false_positive_fraction"))}
Mean anomaly mask area{_pct(selected_result["localization"].get("anomaly_mean_mask_area_fraction"))}
Verdict{html.escape(str(selected_result["localization"].get("verdict","N/A")))}

Deployment artifact

Artifact{html.escape(str(manifest["selected_artifact"]))}
Format{html.escape(str(selected_result["model_format"]))}
Preprocessing{manifest["preprocessing"].get("resize",224)} px
Target latency{target if target is not None else "not set"}

Reproducibility environment

{env}
""" + (output_dir / "production_autopilot_report.html").write_text( + document, encoding="utf-8" + ) (output_dir / "localization_report.md").write_text( - "\\n".join(markdown), encoding="utf-8" + f"# AnomaVision Production Autopilot Report\n\n**Selected model:** `{selected}`\n\nSee `production_autopilot_report.html` for the full dashboard.\n", + encoding="utf-8", ) def run(args: argparse.Namespace) -> Dict[str, Any]: - """Run Autopilot and create a deployment package.""" cfg = load_config(args.config) dataset_path = args.dataset_path or cfg.get("dataset_path") or cfg.get("img_path") class_name = args.class_name or cfg.get("class_name") @@ -329,32 +282,45 @@ def run(args: argparse.Namespace) -> Dict[str, Any]: ) dataloader = DataLoader( dataset, - batch_size=args.batch_size, + batch_size=max(1, args.batch_size), shuffle=False, - num_workers=args.num_workers, - pin_memory=False, + num_workers=max(0, args.num_workers), + pin_memory=device.startswith("cuda"), ) candidates = {} for name, model_path in ( ("padim", args.padim_model), ("patchcore", args.patchcore_model), + ("efficientad", args.efficientad_model), ): if model_path: candidates[name] = _profile_model( model_path, dataloader, device, args.warmup, args.timing_batches ) if not candidates: - raise ValueError("Provide at least one of --padim_model or --patchcore_model.") + raise ValueError( + "Provide at least one model: --padim_model, --patchcore_model, or --efficientad_model." + ) selected = _select(candidates, args.target_latency_ms) output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) - selected_source = Path(candidates[selected]["model_path"]) - packaged_model = output_dir / f"model{selected_source.suffix}" - shutil.copy2(selected_source, packaged_model) + source = Path(candidates[selected]["model_path"]) + packaged_model = output_dir / f"model{source.suffix}" + shutil.copy2(source, packaged_model) + sidecar = None + if selected == "efficientad": + for candidate in (source.with_suffix(".pth"), source.parent / "model.pth"): + if candidate.exists(): + sidecar = output_dir / candidate.name + shutil.copy2(candidate, sidecar) + break + if args.copy_config: + shutil.copy2(args.config, output_dir / Path(args.config).name) manifest = { - "schema_version": 2, + "schema_version": 5, "selected_model": selected, - "selected_artifact": str(packaged_model.name), + "selected_artifact": packaged_model.name, + "calibration_artifact": sidecar.name if sidecar else None, "dataset": { "path": str(Path(dataset_path).resolve()), "class_name": class_name, @@ -377,7 +343,7 @@ def run(args: argparse.Namespace) -> Dict[str, Any]: }, } (output_dir / "deployment_manifest.json").write_text( - json.dumps(manifest, indent=2), encoding="utf-8" + json.dumps(manifest, indent=2, default=_json_default), encoding="utf-8" ) _write_report(manifest, output_dir) return manifest @@ -391,6 +357,9 @@ def main(args: Optional[argparse.Namespace] = None) -> None: { "selected_model": manifest["selected_model"], "output_dir": str(Path(args.output_dir).resolve()), + "report": str( + Path(args.output_dir).resolve() / "production_autopilot_report.html" + ), }, indent=2, ) diff --git a/anomavision/cli.py b/anomavision/cli.py index dce4e75..68080e9 100644 --- a/anomavision/cli.py +++ b/anomavision/cli.py @@ -19,12 +19,6 @@ import argparse import sys -# Submodules are imported lazily inside _add_*_parser() and _dispatch_*(). -# CLI startup (including --help on the top-level parser) never touches torch/cv2. -# Note: --help on a subcommand (e.g. `anomavision train --help`) WILL import -# the submodule to build the parser — that is intentional and unavoidable if we -# want the submodule to own its argument definitions. - def create_parser() -> argparse.ArgumentParser: """Create the main argument parser with subcommands.""" @@ -55,7 +49,6 @@ def create_parser() -> argparse.ArgumentParser: version_str = "AnomaVision" parser.add_argument("--version", action="version", version=version_str) - subparsers = parser.add_subparsers( title="commands", description="Available AnomaVision operations", @@ -63,33 +56,14 @@ def create_parser() -> argparse.ArgumentParser: help="Operation to perform", required=True, ) - _add_train_parser(subparsers) _add_export_parser(subparsers) _add_detect_parser(subparsers) _add_eval_parser(subparsers) _add_autopilot_parser(subparsers) - return parser -# ============================================================ -# Subparser registration -# -# Each submodule owns its argument definitions in create_parser(). -# cli.py uses `parents=` to inherit all args — zero duplication. -# -# The key: call create_parser(add_help=False) so argparse doesn't -# register -h on the parent. The child subparser adds its own -h -# automatically. Setting add_help at *construction time* is the -# only reliable way — mutating .add_help after construction does -# not remove the already-registered -h action. -# -# Result: add --new-flag to detect.py and `anomavision detect --new-flag` -# works immediately with no changes needed here. -# ============================================================ - - def _add_train_parser(subparsers) -> None: from anomavision.train import create_parser as _cp @@ -145,12 +119,6 @@ def _add_autopilot_parser(subparsers) -> None: ).set_defaults(func=_dispatch_autopilot) -# ============================================================ -# Dispatch functions — one line each, Namespace passed directly. -# No sys.argv manipulation. No double-parsing. -# ============================================================ - - def _dispatch_train(args: argparse.Namespace) -> None: from anomavision import train @@ -164,13 +132,37 @@ def _dispatch_export(args: argparse.Namespace) -> None: def _dispatch_detect(args: argparse.Namespace) -> None: + from pathlib import Path + + from anomavision.config import load_config + from anomavision.efficientad_threshold import load_calibrated_threshold + + cfg = load_config(args.config) if getattr(args, "config", None) else {} + algorithm = str( + getattr(args, "algorithm", None) or cfg.get("algorithm", "") + ).lower() + + if algorithm == "efficientad" and getattr(args, "thresh", None) is None: + model_data_path = getattr(args, "model_data_path", None) or cfg.get( + "model_data_path", "./distributions" + ) + class_name = getattr(args, "class_name", None) or cfg.get("class_name") + run_name = getattr(args, "run_name", None) or cfg.get("run_name") + model_name = getattr(args, "model", None) or cfg.get("model") + + if class_name and run_name and model_name: + model_path = ( + Path(model_data_path) / algorithm / class_name / run_name / model_name + ) + args.thresh = load_calibrated_threshold(model_path) + from anomavision import detect detect.main(args) def _dispatch_eval(args: argparse.Namespace) -> None: - from anomavision import eval as eval_module # 'eval' shadows the Python builtin + from anomavision import eval as eval_module eval_module.main(args) @@ -181,11 +173,6 @@ def _dispatch_autopilot(args: argparse.Namespace) -> None: autopilot.main(args) -# ============================================================ -# Entry point -# ============================================================ - - def main() -> None: parser = create_parser() args = parser.parse_args() diff --git a/anomavision/config.py b/anomavision/config.py index 5965679..88f7b71 100644 --- a/anomavision/config.py +++ b/anomavision/config.py @@ -13,19 +13,30 @@ def load_config(path: str = None): if not p.exists(): raise FileNotFoundError(f"Config not found: {path}") if p.suffix.lower() in {".yml", ".yaml"}: - return yaml.safe_load(p.read_text()) - if p.suffix.lower() == ".json": - return json.loads(p.read_text()) - raise ValueError("Config must be .yml/.yaml or .json") + config = yaml.safe_load(p.read_text()) or {} + elif p.suffix.lower() == ".json": + config = json.loads(p.read_text()) or {} + else: + raise ValueError("Config must be .yml/.yaml or .json") + + # Native model selector: allow either historical ``algorithm: padim`` + # or ``model: {name: padim}`` configuration. + model_section = config.get("model") + if isinstance(model_section, dict): + if not config.get("algorithm") and model_section.get("name"): + config["algorithm"] = model_section["name"] + if model_section.get("file") and not config.get("model_path"): + config["model_path"] = model_section["file"] + # Existing detect/export code expects ``model`` to be the artifact name. + config["model"] = config.get("model_path") + return config def to_dict(ns: Namespace) -> dict: - # turn argparse Namespace into a dict (ignores None later) return {k: v for k, v in vars(ns).items()} def pick(*vals): - # first non-None for v in vals: if v is not None: return v diff --git a/anomavision/detect.py b/anomavision/detect.py index a9587a0..0a32809 100644 --- a/anomavision/detect.py +++ b/anomavision/detect.py @@ -1,12 +1,4 @@ -""" -Run Anomaly detection inference on images using various model formats. -Usage - formats: - $ python detect.py --model model.pt # PyTorch - model.torchscript # TorchScript - model.onnx # ONNX Runtime - model_openvino # OpenVINO - model.engine # TensorRT -""" +"""Run AnomaVision anomaly detection inference.""" import argparse import os @@ -36,496 +28,303 @@ setup_logging, ) -matplotlib.use("Agg") # non-interactive, faster PNG writing +matplotlib.use("Agg") -def create_parser(add_help: bool = True) -> argparse.ArgumentParser: +def create_parser(add_help: bool = True): parser = argparse.ArgumentParser( - description="Run anomaly detection inference using trained models.", - add_help=add_help, - ) - - # Config file - parser.add_argument( - "--config", type=str, default=None, help="Path to config.yml/.json" - ) - - # Dataset parameters - parser.add_argument( - "--img_path", - default=None, - type=str, - help="Path to the dataset folder containing test images.", - ) - - # Model parameters - parser.add_argument( - "--model_data_path", - type=str, - default="./distributions", - help="Directory containing model files.", - ) - parser.add_argument( - "--algorithm", - type=str, - default=None, - help="Algorithm name (e.g., padim, patchcore).", - ) - parser.add_argument( - "--model", - type=str, - default=None, - help="Model file (.pt for PyTorch, .onnx for ONNX, .engine for TensorRT)", - ) - parser.add_argument( - "--device", - type=str, - default=None, - choices=["auto", "cpu", "cuda"], - help="Device to run inference on (auto will choose cuda if available)", - ) - parser.add_argument( - "--batch_size", type=int, default=None, help="Batch size for inference" - ) - parser.add_argument( - "--thresh", - type=float, - default=None, - help="Threshold for anomaly classification", - ) - - # Data loading parameters - parser.add_argument( - "--num_workers", - type=int, - default=1, - help="Number of worker processes for data loading.", - ) - parser.add_argument( - "--pin_memory", - action="store_true", - help="Use pinned memory for faster GPU transfers.", - ) - - # Visualization parameters - parser.add_argument( - "--enable_visualization", - action="store_true", - default=None, - help="Enable visualization of results.", + description="Run anomaly detection inference.", add_help=add_help ) + parser.add_argument("--config", type=str, default=None) + parser.add_argument("--img_path", type=str, default=None) + parser.add_argument("--model_data_path", type=str, default="./distributions") + parser.add_argument("--algorithm", type=str, default=None) + parser.add_argument("--model", type=str, default=None) parser.add_argument( - "--save_visualizations", - action="store_true", - default=None, - help="Save visualization images to disk.", + "--device", type=str, default=None, choices=["auto", "cpu", "cuda"] ) - parser.add_argument( - "--viz_output_dir", - type=str, - default=None, - help="Directory to save visualization images.", - ) - parser.add_argument( - "--run_name", - default=None, - help="experiment name for this inference run", - ) - parser.add_argument( - "--overwrite", - action="store_true", - help="overwrite existing run directory without auto-incrementing", - ) - parser.add_argument( - "--viz_alpha", type=float, default=None, help="Alpha value for heatmap overlay." - ) - parser.add_argument( - "--viz_padding", - type=int, - default=None, - help="Padding for boundary visualization.", - ) - parser.add_argument( - "--viz_color", - type=str, - default=None, - help='RGB color for highlighting (comma-separated, e.g., "128,0,128").', - ) - - # Logging parameters + parser.add_argument("--batch_size", type=int, default=None) + parser.add_argument("--thresh", type=float, default=None) + parser.add_argument("--num_workers", type=int, default=1) + parser.add_argument("--pin_memory", action="store_true") + parser.add_argument("--enable_visualization", action="store_true", default=None) + parser.add_argument("--save_visualizations", action="store_true", default=None) + parser.add_argument("--viz_output_dir", type=str, default=None) + parser.add_argument("--run_name", type=str, default=None) + parser.add_argument("--overwrite", action="store_true") + parser.add_argument("--viz_alpha", type=float, default=None) + parser.add_argument("--viz_padding", type=int, default=None) + parser.add_argument("--viz_color", type=str, default=None) parser.add_argument( "--log_level", type=str, default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], - help="Logging level.", ) - parser.add_argument( - "--detailed_timing", - action="store_true", - help="Enable detailed timing measurements.", - ) - + parser.add_argument("--detailed_timing", action="store_true") return parser -def run_inference(args): - """ - Executes the inference pipeline. +def _load_efficientad_threshold(model_path: Path): + """Load the calibrated EfficientAD threshold from the training sidecar.""" + candidates = [model_path.with_suffix(".pth"), model_path.parent / "model.pth"] + for sidecar in candidates: + if not sidecar.exists(): + continue + try: + artifact = torch.load(sidecar, map_location="cpu", weights_only=False) + if ( + isinstance(artifact, dict) + and artifact.get("algorithm") == "efficientad" + ): + threshold = artifact.get("threshold") + if threshold is None and isinstance(artifact.get("model_state"), dict): + threshold = artifact["model_state"].get("threshold") + if isinstance(threshold, torch.Tensor): + threshold = threshold.item() + if threshold is not None: + return float(threshold), sidecar + except Exception: + continue + return None, None - Args: - args: Namespace object containing configuration. - Returns: - metrics (dict): Performance and timing metrics. - results (dict): Dictionary containing keys ['scores', 'classifications', 'images'] - (Only populated for offline mode to prevent OOM in streaming). - """ +def run_inference(args): + total_start_time = time.time() + if args.config is not None: cfg = load_config(str(args.config)) else: - # Fallback to model directory config - potential_paths = [] - if args.model_data_path: - base_path = Path(args.model_data_path) - potential_paths.append(base_path / "config.yml") - cfg = {} - for path in potential_paths: - if path.exists(): - cfg = load_config(str(path)) - break + base = Path(args.model_data_path) if args.model_data_path else None + if base is not None and (base / "config.yml").exists(): + cfg = load_config(str(base / "config.yml")) - if not cfg: - cfg = {} - - # Merge config with CLI args config = edict(merge_config(args, cfg)) - config.thresh = resolve_threshold(config) algorithm_name = str(config.get("algorithm", "")).lower() + config.thresh = resolve_threshold(config) - # Setup logging setup_logging(enabled=True, log_level=config.log_level, log_to_file=True) logger = get_logger("anomavision.detect") + stream_mode = bool(config.get("stream_mode", False)) - stream_mode = config.get("stream_mode", False) - logger.info(f"Streaming mode: {stream_mode}") - - # Parse visualization color - try: - viz_color = ( - tuple(map(int, config.viz_color.split(","))) - if config.viz_color - else (128, 0, 128) - ) - if len(viz_color) != 3: - raise ValueError - except (ValueError, AttributeError): - logger.warning( - f"Invalid color format '{getattr(config, 'viz_color', 'None')}'. Using default (128,0,128)" - ) - viz_color = (128, 0, 128) - - # Parse image processing arguments resize = _shape(config.resize) crop_size = _shape(config.crop_size) normalize = config.get("normalize", True) + if not config.get("img_path") and not stream_mode: + raise ValueError("img_path is required when stream_mode is False") + if not config.get("model"): + raise ValueError("model is required") + + device_str = determine_device(config.device) + model_path = ( + Path(config.model_data_path) + / config.algorithm + / config.class_name + / config.run_name + / config.model + ) + model_path = model_path.resolve() + if not model_path.exists(): + raise FileNotFoundError(f"Model file not found: {model_path}") + + if algorithm_name == "efficientad" and config.thresh is None: + threshold, sidecar = _load_efficientad_threshold(model_path) + if threshold is not None: + config.thresh = threshold + logger.info( + "EfficientAD calibrated threshold: %.6f (source=%s)", threshold, sidecar + ) + else: + raise RuntimeError( + "EfficientAD threshold is not configured and no calibrated .pth sidecar was found " + f"next to {model_path}. Train EfficientAD first so its calibration artifact is saved." + ) logger.info( - "Image processing: resize=%s, crop=%s, norm=%s", resize, crop_size, normalize + "algorithm=%s model=%s device=%s threshold=%s", + algorithm_name, + model_path, + device_str, + config.thresh, ) - # Validation - if not config.get("img_path") and not stream_mode: - raise ValueError( - "img_path is required (via --img_path or config) when stream_mode is False" - ) - - if not config.get("model"): - raise ValueError("model is required (via --model or config)") - - # Profilers profilers = { - "setup": Profiler(), - "model_loading": Profiler(), - "data_loading": Profiler(), - "inference": Profiler(), - "postprocessing": Profiler(), - "visualization": Profiler(), - } - - results_accumulator = { - "scores": [], - "classifications": [], - # We only store images/maps if needed for downstream tasks to avoid memory issues - "images": [] if not stream_mode else None, + "setup": __import__("anomavision.general", fromlist=["Profiler"]).Profiler(), + "model_loading": __import__( + "anomavision.general", fromlist=["Profiler"] + ).Profiler(), + "data_loading": __import__( + "anomavision.general", fromlist=["Profiler"] + ).Profiler(), + "inference": __import__( + "anomavision.general", fromlist=["Profiler"] + ).Profiler(), + "postprocessing": __import__( + "anomavision.general", fromlist=["Profiler"] + ).Profiler(), + "visualization": __import__( + "anomavision.general", fromlist=["Profiler"] + ).Profiler(), } - total_start_time = time.time() - - # --- Setup Phase --- - with profilers["setup"]: - if not stream_mode: - DATASET_PATH = os.path.realpath(config.img_path) - logger.info(f"Dataset path: {DATASET_PATH}") - else: - DATASET_PATH = None - src = config.get("stream_source", {}) - logger.info(f"Streaming source type: {src.get('type', 'unknown')}") - - MODEL_DATA_PATH = os.path.realpath(config.model_data_path) - device_str = determine_device(config.device) - logger.info(f"Device: {device_str}") - - if device_str == "cuda" and torch.cuda.is_available(): - torch.backends.cudnn.benchmark = True - - # --- Model Loading Phase --- with profilers["model_loading"]: - model_path = os.path.join( - MODEL_DATA_PATH, - config.algorithm, - config.class_name, - config.run_name, - config.model, - ) - logger.info(f"Loading model: {model_path}") + model = ModelWrapper(str(model_path), device_str) + model_type = ModelType.from_extension(str(model_path)) - if not os.path.exists(model_path): - raise FileNotFoundError(f"Model file not found: {model_path}") - - try: - model = ModelWrapper(model_path, device_str) - model_type = ModelType.from_extension(model_path) - logger.info(f"Model loaded: {model_type.value.upper()}") - except Exception as e: - logger.error(f"Failed to load model: {e}") - raise - - # --- Viz Directory Setup --- - RESULTS_PATH = None + viz_color = (128, 0, 128) + try: + if config.get("viz_color"): + values = tuple(map(int, str(config.viz_color).split(","))) + if len(values) == 3: + viz_color = values + except (ValueError, TypeError): + pass + + results_path = None if config.get("save_visualizations", False): - run_name = config.run_name - viz_output_dir = config.get("viz_output_dir", "./visualizations/") - RESULTS_PATH = increment_path( - Path(viz_output_dir) + results_path = increment_path( + Path(config.get("viz_output_dir", "./visualizations")) / config.algorithm / config.class_name / model_type.value.upper() - / run_name, + / config.run_name, exist_ok=config.get("overwrite", False), mkdir=True, ) - logger.info(f"Visualization output: {RESULTS_PATH}") - # --- Data Loading Phase --- with profilers["data_loading"]: - try: - if not stream_mode: - test_dataset = anomavision.AnodetDataset( - DATASET_PATH, - resize=resize, - crop_size=crop_size, - normalize=normalize, - mean=config.norm_mean, - std=config.norm_std, - ) - num_workers = int(config.get("num_workers", 0)) - pin_memory = bool(config.get("pin_memory", False)) - else: - source = StreamSourceFactory.create(config.stream_source) - source.connect() - test_dataset = StreamDataset( - source=source, - resize=resize, - crop_size=crop_size, - normalize=normalize, - mean=config.norm_mean, - std=config.norm_std, - max_frames=config.get("stream_max_frames"), - ) - num_workers = 0 - pin_memory = False - - test_dataloader = DataLoader( - test_dataset, - batch_size=config.batch_size, - num_workers=num_workers, - pin_memory=pin_memory, + if stream_mode: + source = StreamSourceFactory.create(config.stream_source) + source.connect() + dataset = StreamDataset( + source=source, + resize=resize, + crop_size=crop_size, + normalize=normalize, + mean=config.norm_mean, + std=config.norm_std, + max_frames=config.get("stream_max_frames"), ) + workers, pin_memory = 0, False + else: + dataset_path = os.path.realpath(config.img_path) + dataset = anomavision.AnodetDataset( + dataset_path, + resize=resize, + crop_size=crop_size, + normalize=normalize, + mean=config.norm_mean, + std=config.norm_std, + ) + workers = int(config.get("num_workers", 0)) + pin_memory = bool(config.get("pin_memory", False)) + dataloader = DataLoader( + dataset, + batch_size=int(config.batch_size), + num_workers=workers, + pin_memory=pin_memory, + ) + try: + total_images = len(dataset) + except TypeError: + total_images = None - # Log dataset stats - try: - total_images = len(test_dataset) - logger.info(f"Total images: {total_images}") - except TypeError: - total_images = None - logger.info("Streaming mode (infinite/unknown length)") - - except Exception as e: - logger.error(f"Failed to create dataloader: {e}") - raise - - # --- Warm-up --- - try: - first = next(iter(test_dataloader)) - first_batch = first[0] - if device_str == "cuda": - first_batch = first_batch.half() - first_batch = first_batch.to(device_str) - model.warmup(batch=first_batch, runs=2) - logger.info("Warm-up complete.") - except StopIteration: - logger.warning("Dataset empty; skipping warm-up.") - except Exception as e: - logger.warning(f"Warm-up skipped: {e}") - - # --- Inference Loop --- + results = { + "scores": [], + "classifications": [], + "images": [] if not stream_mode else None, + } batch_count = 0 image_counter = 0 try: - for batch_idx, (batch, images, _, _) in enumerate(test_dataloader): + try: + with profilers["inference"]: + first = next(iter(dataloader))[0] + model.warmup(first.to(device_str), runs=2) + except Exception as exc: + logger.warning("Warm-up skipped: %s", exc) + + for batch_idx, (batch, images, _, _) in enumerate(dataloader): batch_count += 1 image_counter += batch.shape[0] - - if device_str == "cuda": - batch = batch.half() - batch = batch.to(device_str) - - # 1. Inference with profilers["inference"]: - try: - image_scores, score_maps = model.predict(batch) - except Exception as e: - logger.error(f"Inference failed batch {batch_idx}: {e}") - continue + image_scores, score_maps = model.predict(batch.to(device_str)) - # 2. Post-processing with profilers["postprocessing"]: - try: - score_maps = adaptive_gaussian_blur( - score_maps, kernel_size=33, sigma=4 + score_maps = adaptive_gaussian_blur(score_maps, kernel_size=33, sigma=4) + is_anomaly = anomavision.classification(image_scores, config.thresh) + if algorithm_name == "patchcore": + masks = make_localization_mask( + score_maps, is_anomaly, quantile=0.90 ) + else: + masks = anomavision.classification(score_maps, config.thresh) - # Classify - if config.thresh is not None: - is_anomaly = anomavision.classification( - image_scores, config.thresh - ) - else: - is_anomaly = np.zeros_like(image_scores) - - if algorithm_name == "patchcore": - localization_masks = make_localization_mask( - score_maps, is_anomaly, quantile=0.90 - ) - else: - localization_masks = ( - anomavision.classification(score_maps, config.thresh) - if config.thresh is not None - else np.zeros_like(score_maps) - ) - - # Accumulate Results (Offline only) - if not stream_mode: - results_accumulator["scores"].extend(image_scores.tolist()) - results_accumulator["classifications"].extend( - is_anomaly.tolist() - ) - results_accumulator["images"].extend(images) - - except Exception as e: - logger.error(f"Postprocessing failed batch {batch_idx}: {e}") - continue - - # 3. Visualization - if config.enable_visualization: + if not stream_mode: + results["scores"].extend( + np.asarray(image_scores).reshape(-1).tolist() + ) + results["classifications"].extend( + np.asarray(is_anomaly).reshape(-1).tolist() + ) + results["images"].extend(images) + + if config.get("enable_visualization", False): with profilers["visualization"]: - try: - - boundary_images = ( - anomavision.visualization.framed_boundary_images( - images, - localization_masks, - is_anomaly, - padding=config.get("viz_padding", 40), - ) - ) - - heatmap_images = anomavision.visualization.heatmap_images( - images, - score_maps, - masks=localization_masks, - alpha=config.get("viz_alpha", 0.5), - ) - highlighted_images = ( - anomavision.visualization.highlighted_images( - [images[i] for i in range(len(images))], - localization_masks, - color=viz_color, + boundaries = anomavision.visualization.framed_boundary_images( + images, masks, is_anomaly, padding=config.get("viz_padding", 40) + ) + heatmaps = anomavision.visualization.heatmap_images( + images, + score_maps, + masks=masks, + alpha=config.get("viz_alpha", 0.5), + ) + highlighted = anomavision.visualization.highlighted_images( + [images[i] for i in range(len(images))], masks, color=viz_color + ) + if config.get("save_visualizations", False) and results_path: + for i in range(len(images)): + fig, axs = plt.subplots(1, 4, figsize=(16, 8)) + axs[0].imshow(images[i]) + axs[0].set_title("Original") + axs[0].axis("off") + axs[1].imshow(boundaries[i]) + axs[1].set_title("Boundary") + axs[1].axis("off") + axs[2].imshow(heatmaps[i]) + axs[2].set_title("Heatmap") + axs[2].axis("off") + axs[3].imshow(highlighted[i]) + axs[3].set_title("Highlighted") + axs[3].axis("off") + fig.savefig( + Path(results_path) / f"batch_{batch_idx}_img_{i}.png", + dpi=100, + bbox_inches="tight", ) - ) - - # Save/Show - for img_id in range(len(images)): - # Only save if explicitly requested - if config.save_visualizations and RESULTS_PATH: - try: - fig, axs = plt.subplots(1, 4, figsize=(16, 8)) - fig.suptitle( - f"Result - Batch {batch_idx} Img {img_id}", - fontsize=14, - ) - - axs[0].imshow(images[img_id]) - axs[0].set_title("Original") - axs[0].axis("off") - - axs[1].imshow(boundary_images[img_id]) - axs[1].set_title("Boundary") - axs[1].axis("off") - - axs[2].imshow(heatmap_images[img_id]) - axs[2].set_title("Heatmap") - axs[2].axis("off") - - axs[3].imshow(highlighted_images[img_id]) - axs[3].set_title("Highlighted") - axs[3].axis("off") - - save_path = os.path.join( - RESULTS_PATH, - f"batch_{batch_idx}_img_{img_id}.png", - ) - plt.savefig(save_path, dpi=100, bbox_inches="tight") - plt.close(fig) - except Exception as e: - logger.warning(f"Viz save failed: {e}") - - except Exception as e: - logger.error(f"Visualization failed batch {batch_idx}: {e}") - + plt.close(fig) finally: - logger.info("Closing model...") model.close() if stream_mode: - # Clean up stream source try: - test_dataset.close() + dataset.close() except Exception: - pass - # --- Metrics & Summary --- total_pipeline_time = time.time() - total_start_time + final_count = ( + total_images + if (not stream_mode and total_images is not None) + else image_counter + ) + inference_seconds = profilers["inference"].accumulated_time + fps = final_count / inference_seconds if inference_seconds > 0 else 0.0 + avg_ms = (inference_seconds / batch_count * 1000.0) if batch_count > 0 else 0.0 + throughput = final_count / inference_seconds if inference_seconds > 0 else 0.0 - # Calculate FPS - final_count = total_images if (not stream_mode and total_images) else image_counter - fps = profilers["inference"].get_fps(final_count) - avg_ms = profilers["inference"].get_avg_time_ms(batch_count) - - # 1. TIMING SUMMARY logger.info("=" * 60) logger.info("ANOMAVISION PERFORMANCE SUMMARY") logger.info("=" * 60) @@ -550,7 +349,6 @@ def run_inference(args): logger.info(f"Total pipeline time: {total_pipeline_time * 1000:.2f} ms") logger.info("=" * 60) - # 2. INFERENCE PERFORMANCE logger.info("=" * 60) logger.info("ANOMAVISION INFERENCE PERFORMANCE") logger.info("=" * 60) @@ -558,44 +356,31 @@ def run_inference(args): logger.info(f"Pure inference FPS: {fps:.2f} images/sec") if avg_ms > 0: logger.info(f"Average inference time: {avg_ms:.2f} ms/batch") - if batch_count > 0: - batch_size = config.get("batch_size", 1) or 1 - throughput = fps * (final_count / batch_count) if batch_count else 0 logger.info( - f"Throughput: {throughput:.1f} images/sec (batch size: {batch_size})" + f"Throughput: {throughput:.1f} images/sec (batch size: {config.get('batch_size', 1) or 1})" ) logger.info("=" * 60) - metrics = { + return { "fps": fps, "avg_inference_ms": avg_ms, "total_time_s": total_pipeline_time, "total_images": final_count, - } - - return metrics, results_accumulator + }, results def main(args=None): try: if args is None: args = create_parser().parse_args() - - metrics, results = run_inference(args) - - # If running as script, maybe we want to print summary or save results to file? - # For now, logging handles the output. - - exit(0) + run_inference(args) except KeyboardInterrupt: - logger = get_logger("anomavision.detect") - logger.info("Process interrupted by user") - exit(1) - except Exception as e: - logger = get_logger("anomavision.detect") - logger.error(f"Process failed: {e}", exc_info=True) - exit(1) + get_logger("anomavision.detect").info("Process interrupted") + raise SystemExit(1) + except Exception as exc: + get_logger("anomavision.detect").error("Process failed: %s", exc, exc_info=True) + raise SystemExit(1) if __name__ == "__main__": diff --git a/anomavision/efficientad_threshold.py b/anomavision/efficientad_threshold.py new file mode 100644 index 0000000..37a534b --- /dev/null +++ b/anomavision/efficientad_threshold.py @@ -0,0 +1,46 @@ +"""Helpers for loading model-calibrated EfficientAD thresholds.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import torch + + +def load_calibrated_threshold(model_path: str | Path) -> float: + """Load the threshold persisted by EfficientAD training. + + Training writes a compact ``.pth`` artifact next to every model. The + artifact contains the EfficientAD state dict, including its calibrated + threshold buffer. This keeps the threshold tied to the exact trained model + and also makes it available when the inference model is ONNX/TensorRT/etc. + """ + model_path = Path(model_path) + candidates = [model_path.with_suffix(".pth")] + if model_path.suffix == ".pth": + candidates.insert(0, model_path) + + for candidate in candidates: + if not candidate.is_file(): + continue + data: Any = torch.load(candidate, map_location="cpu", weights_only=False) + if not isinstance(data, dict) or data.get("algorithm") != "efficientad": + continue + state = data.get("model_state", {}) + threshold = state.get("threshold") if isinstance(state, dict) else None + if threshold is None: + raise ValueError( + f"EfficientAD artifact has no calibrated threshold: {candidate}" + ) + value = float(torch.as_tensor(threshold).reshape(-1)[0].item()) + if not torch.isfinite(torch.tensor(value)): + raise ValueError( + f"EfficientAD calibrated threshold is not finite: {candidate}" + ) + return value + + raise FileNotFoundError( + f"No calibrated EfficientAD threshold found next to model '{model_path}'. " + "Retrain the EfficientAD model to create the .pth calibration artifact." + ) diff --git a/anomavision/train.py b/anomavision/train.py index 7761310..972b3fe 100644 --- a/anomavision/train.py +++ b/anomavision/train.py @@ -19,19 +19,15 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Train PaDiM (args OR config).", add_help=add_help ) - # meta parser.add_argument( "--config", type=str, default="config.yml", help="Path to config.yml/.json" ) - # dataset parser.add_argument( "--dataset_path", type=str, default=None, help='Path to the dataset folder containing "train/good" images.', ) - - # preprocessing parser.add_argument( "--resize", type=int, @@ -76,8 +72,6 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: metavar=("R", "G", "B"), help="Per-channel RGB standard deviation used when normalization is enabled. Example: 0.229 0.224 0.225.", ) - - # train parser.add_argument( "--backbone", type=str, @@ -141,6 +135,43 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: default=None, help="Seed used for deterministic PatchCore coreset selection.", ) + parser.add_argument( + "--efficientad_model_size", + type=str, + choices=["s", "m"], + default=None, + help="EfficientAD model size.", + ) + parser.add_argument( + "--efficientad_lr", + type=float, + default=None, + help="EfficientAD learning rate.", + ) + parser.add_argument( + "--efficientad_weight_decay", + type=float, + default=None, + help="EfficientAD weight decay.", + ) + parser.add_argument( + "--efficientad_epochs", + type=int, + default=None, + help="EfficientAD training epochs.", + ) + parser.add_argument( + "--efficientad_pretrained_teacher", + action="store_true", + default=None, + help="Use ImageNet-pretrained EfficientNet teacher.", + ) + parser.add_argument( + "--efficientad_threshold_quantile", + type=float, + default=None, + help="Normal-training score quantile used to calibrate the EfficientAD anomaly threshold.", + ) parser.add_argument( "--output_model", type=str, @@ -163,7 +194,7 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: "--algorithm", type=str, default=None, - help="Algorithm name (e.g., padim, patchcore).", + help="Algorithm name (e.g., padim, patchcore, efficientad).", ) parser.add_argument( "--log_level", @@ -177,33 +208,29 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: def run_training(args): - """ - Executes the training pipeline. - - Args: - args: Namespace object containing command line arguments. - - Returns: - padim (AnomaVision.Padim): The trained model object. - config (edict): The final merged configuration. - run_dir (Path): The directory where artifacts were saved. - dataloaders (dict): Dictionary containing the 'train' DataLoader. - """ + """Execute the training pipeline.""" cfg = load_config(args.config) - - # Merge config with CLI args config = edict(merge_config(args, cfg)) setup_logging(enabled=True, log_level=config.log_level, log_to_file=True) - logger = get_logger("anomavision.train") # Force it into anomavision hierarchy + logger = get_logger("anomavision.train") if not config.dataset_path: error_msg = "dataset.path is required (via --dataset_path or config.common.dataset_path)" logger.error(error_msg) raise ValueError(error_msg) - t0 = time.perf_counter() + algorithm = str(config.algorithm).lower() + if algorithm not in {"padim", "patchcore", "efficientad"}: + raise ValueError( + f"Unsupported algorithm: {algorithm}. Available: padim, patchcore, efficientad" + ) + if algorithm == "efficientad" and not bool(config.get("normalize", True)): + raise ValueError( + "EfficientAD requires normalize: true because its teacher uses ImageNet preprocessing" + ) + t0 = time.perf_counter() logger.info( "Image processing: resize=%s, crop_size=%s, normalize=%s", config.resize, @@ -213,7 +240,6 @@ def run_training(args): if config.normalize: logger.info("Normalization: mean=%s, std=%s", config.norm_mean, config.norm_std) - # Resolve output run dir once run_dir = increment_path( Path(config.model_data_path) / config.algorithm @@ -223,20 +249,10 @@ def run_training(args): mkdir=True, ) - # === Dataset === - # Handle the 'class_name' logic safely. - # If dataset_path ends with the class name, use parent? - # Original logic assumes dataset_path is the container of class folders OR the class folder itself? - # Original code: os.path.join(realpath(dataset_path), config.class_name, "train", "good") - # This implies dataset_path is the root (e.g. MVTec root) and config.class_name is "bottle" - root = os.path.join( os.path.realpath(config.dataset_path), config.class_name, "train", "good" ) - if not os.path.isdir(root): - # Fallback check: maybe dataset_path ALREADY points to the class folder? - # This makes it more robust for different input styles potential_root = os.path.join( os.path.realpath(config.dataset_path), "train", "good" ) @@ -254,7 +270,6 @@ def run_training(args): mean=config.norm_mean, std=config.norm_std, ) - if len(ds) == 0: error_msg = f"No training images found in {root}" logger.error(error_msg) @@ -263,13 +278,11 @@ def run_training(args): dl = DataLoader(ds, batch_size=int(config.batch_size), shuffle=False) logger.info("dataset: %d images | batch_size=%d", len(ds), config.batch_size) - # === Device === device = torch.device("cuda" if torch.cuda.is_available() else "cpu") logger.info( "device: %s (cuda_available=%s)", device.type, torch.cuda.is_available() ) - # === Model & Train === logger.info( "cfg: algorithm=%s | backbone=%s | layers=%s", config.algorithm, @@ -277,7 +290,7 @@ def run_training(args): config.layer_indices, ) - if str(config.algorithm).lower() == "patchcore": + if algorithm == "patchcore": model = anomavision.PatchCore( backbone=config.backbone, device=device, @@ -289,6 +302,17 @@ def run_training(args): coreset_method=config.get("coreset_method", "kcenter"), coreset_seed=int(config.get("coreset_seed", 42)), ) + elif algorithm == "efficientad": + model = anomavision.EfficientAD( + device=device, + model_size=config.get("efficientad_model_size", "s"), + lr=float(config.get("efficientad_lr", 1e-4)), + weight_decay=float(config.get("efficientad_weight_decay", 1e-5)), + pretrained_teacher=bool(config.get("efficientad_pretrained_teacher", True)), + threshold_quantile=float( + config.get("efficientad_threshold_quantile", 0.995) + ), + ) else: model = anomavision.Padim( backbone=config.backbone, @@ -298,14 +322,22 @@ def run_training(args): ) t_fit = time.perf_counter() - model.fit(dl) + if algorithm == "efficientad": + model.fit(dl, epochs=int(config.get("efficientad_epochs", 1))) + calibrated_threshold = float(model.threshold.detach().cpu().item()) + config["thresh_efficientad"] = calibrated_threshold + logger.info( + "EfficientAD threshold calibrated from normal training scores: %.6f (quantile=%.6f)", + calibrated_threshold, + model.threshold_quantile, + ) + else: + model.fit(dl) logger.info("fit: completed in %.2fs", time.perf_counter() - t_fit) - # === Save === model_path = Path(run_dir) / config.output_model torch.save(model, str(model_path)) - # Save a compact statistics/memory-bank artifact for deployment. stats_path = model_path.with_suffix(".pth") try: model.save_statistics(str(stats_path), half=True) @@ -313,13 +345,10 @@ def run_training(args): except Exception as e: logger.warning("saving slim statistics failed: %s", e) - # snapshot the effective configuration save_args_to_yaml(config, str(Path(run_dir) / "config.yml")) - logger.info("saved: model=%s, config=%s", model_path, Path(run_dir) / "config.yml") logger.info("=== Training done in %.2fs ===", time.perf_counter() - t0) - # Return objects for external usage (e.g. MLOps pipeline) return model, config, run_dir, {"train": dl} @@ -328,13 +357,12 @@ def main(args=None): if args is None: args = create_parser().parse_args() - # Optional git check — silently skipped if not in a repo or no network try: checker = GitStatusChecker() if checker.is_repo(): checker.check_status() except Exception: - pass # Never block training over a git check + pass run_training(args) diff --git a/config.yml b/config.yml index 0af6c53..9f7960e 100644 --- a/config.yml +++ b/config.yml @@ -1,124 +1,130 @@ # ========================= # Dataset / preprocessing (shared by train, detect, eval, stream) # ========================= -dataset_path: "D:/01-DATA" # Root dataset folder (MVTec-style: contains train/test subfolders) -class_name: "bottle" # Class name for MVTec dataset -resize: [224, 224] # Resize dimensions before processing [width, height] -crop_size: # Final crop size [width, height] -normalize: true # Whether to normalize images -no_normalize: false # Inverse flag (used by CLI) – keep in sync with "normalize" -norm_mean: [0.485, 0.456, 0.406] # Mean values for normalization (ImageNet default) -norm_std: [0.229, 0.224, 0.225] # Standard deviation for normalization (ImageNet default) +dataset_path: "D:/01-DATA" +class_name: "bottle" +resize: [224, 224] +crop_size: +normalize: true +no_normalize: false +norm_mean: [0.485, 0.456, 0.406] +norm_std: [0.229, 0.224, 0.225] # ========================= # Model / training # ========================= -backbone: "resnet18" # Backbone CNN architecture (resnet18 | wide_resnet50) -algorithm: "padim" # Algorithm to use: padim or patchcore -coreset_ratio: 0.02 # Ultra-light PatchCore memory fraction -max_memory_patches: 2048 # Hard memory-bank cap for low latency -patch_grid: 14 # Pool feature maps to at most 14x14 patches -search_chunk_size: 1024 # Bound nearest-neighbor working memory -coreset_method: "kcenter" # PatchCore selection: kcenter or random -coreset_seed: 42 # Reproducible k-center initialization -feat_dim: 50 # Feature dimension size for embedding -layer_indices: [0] # Which backbone layers to extract features from (0,1,2,3) -model_data_path: "./distributions" # Path to store/load model-related data -model: "model.pt" # File name for saved model (used by detect/eval/export) -output_model: "model.pt" # File name for saving trained model (train.py expects this) -batch_size: 2 # Training/evaluation/inference batch size -device: "auto" # Device to run on: "cpu", "cuda", or "auto" +backbone: "resnet18" +algorithm: "efficientad" # padim | patchcore | efficientad +# Equivalent native selector: +# model: +# name: padim + +# PaDiM +feat_dim: 50 +layer_indices: [0] + +# PatchCore +coreset_ratio: 0.02 +max_memory_patches: 2048 +patch_grid: 14 +search_chunk_size: 1024 +coreset_method: "kcenter" +coreset_seed: 42 + +# EfficientAD +efficientad_model_size: "s" # s | m +efficientad_lr: 0.0001 +efficientad_weight_decay: 0.00001 +efficientad_epochs: 1 +efficientad_pretrained_teacher: true +# Calibrated from normal training scores; this is not an anomaly-score threshold. +efficientad_threshold_quantile: 0.995 + +model_data_path: "./distributions" +model: "model.pt" +output_model: "model.pt" +batch_size: 2 +device: "auto" # ========================= # Logging / run metadata # ========================= -log_level: "INFO" # Logging level: DEBUG, INFO, WARNING, ERROR -run_name: "anomav_exp" # Name of experiment run (used for organizing results) -detailed_timing: false # Enable detailed timing measurements +log_level: "INFO" +run_name: "anomav_exp" +detailed_timing: false # ========================= -# Visualization (shared by detect & eval) +# Visualization # ========================= -enable_visualization: true # Enable visualization during inference/evaluation -save_visualizations: true # Save visualization results to disk -viz_output_dir: "./visualizations/" # Directory to save visualization results -viz_alpha: 0.5 # Transparency factor for overlay heatmaps -viz_padding: 40 # Padding added around visualization -viz_color: "128,0,128" # RGB color for visualization overlays +enable_visualization: true +save_visualizations: true +viz_output_dir: "./visualizations/" +viz_alpha: 0.5 +viz_padding: 40 +viz_color: "128,0,128" # ========================= -# Inference (detect.py) -# ========================= -img_path: "D:/01-DATA/test" # Path to test images for inference -thresh: null # Legacy fallback; prefer algorithm-specific thresholds -thresh_padim: 13.0 # PaDiM score threshold; null lets eval auto-select -thresh_patchcore: 0.25 # PatchCore score threshold; null lets eval auto-select -num_workers: 1 # Number of workers for dataloader -pin_memory: false # Use pinned memory for faster GPU transfers -overwrite: false # Overwrite existing run directory without auto-incrementing +# Inference +# ========================= +img_path: "D:/01-DATA/test" +thresh: null +thresh_padim: 13.0 +thresh_patchcore: 0.25 +# EfficientAD threshold is calibrated during training and saved with the model. +# Do not hard-code thresh_efficientad here. +num_workers: 1 +pin_memory: false +overwrite: false # ========================= -# Evaluation (eval.py) +# Evaluation # ========================= -memory_efficient: true # Use memory efficient evaluation mode +memory_efficient: true # ========================= -# Export (export.py) -# ========================= -format: "all" # onnx, tensorrt, torchscript, openvino, all -opset: 18 # ONNX opset version -precision: "auto" # ONNX/TorchScript precision: auto, fp16, fp32 -tensorrt_precision: "fp16" # TensorRT precision: fp32, fp16, int8 -dynamic_batch: true # Allow dynamic batch size in exported model -static_batch: false # Disable dynamic batch size (if true) -min_batch: 1 # TensorRT dynamic profile minimum batch -opt_batch: 1 # TensorRT dynamic profile optimal batch -max_batch: 4 # TensorRT dynamic profile maximum batch -workspace_gb: 2.0 # TensorRT workspace limit in GiB -calib_dir: null # INT8 calibration image directory; auto-derived when null -calib_samples: 100 # Maximum real images used for INT8 calibration -quantize_dynamic: false # Also write dynamically quantized INT8 ONNX -quantize_static: false # Also write statically quantized INT8 ONNX -optimize: false # Enable mobile optimization for TorchScript -output_path: null # Optional explicit output filename -half: false # Legacy compatibility field -int8: false # Legacy compatibility field; use tensorrt_precision: int8 +# Export +# ========================= +format: "all" +opset: 18 +precision: "auto" +tensorrt_precision: "fp16" +dynamic_batch: true +static_batch: false +min_batch: 1 +opt_batch: 1 +max_batch: 4 +workspace_gb: 2.0 +calib_dir: null +calib_samples: 100 +quantize_dynamic: false +quantize_static: false +optimize: false +output_path: null +half: false +int8: false # ========================= -# Streaming Configuration +# Streaming # ========================= -stream_mode: false # true = real-time streaming, false = static dataset - +stream_mode: false stream_source: - type: "webcam" # webcam | video | mqtt | tcp - - # Webcam settings (type: webcam) - camera_id: 0 # Camera device index - - # Video file settings (type: video) - video_path: "path/to/video.mp4" # Path to video file - loop: false # Loop video when it ends - - # MQTT settings (type: mqtt) - broker: "localhost" # MQTT broker hostname/IP - port: 1883 # MQTT broker port - topic: "camera/frames" # Topic to subscribe to - client_id: null # Optional client ID - keepalive: 60 # Keepalive interval (seconds) - qos: 0 # QoS level (0, 1, or 2) - max_queue_size: 10 # Max buffered frames - read_timeout: 1.0 # Timeout for reading frames (seconds) - - # TCP settings (type: tcp) - host: "192.168.1.100" # TCP server hostname/IP - port: 8080 # TCP server port (matches code) - recv_timeout: 1.0 # Socket timeout for recv (seconds) - header_size: 4 # Length header size in bytes - max_message_size: 10485760 # Max payload size (10MB) - - -# Streaming processing settings -stream_max_frames: null # Max frames to process (null = infinite) -stream_display_fps: true # Show FPS during streaming -stream_save_detections: true # Save detected anomalies to disk -stream_detection_dir: "./stream_detections/" # Directory for saved detections + type: "webcam" + camera_id: 0 + video_path: "path/to/video.mp4" + loop: false + broker: "localhost" + port: 1883 + topic: "camera/frames" + client_id: null + keepalive: 60 + qos: 0 + max_queue_size: 10 + read_timeout: 1.0 + host: "192.168.1.100" + recv_timeout: 1.0 + header_size: 4 + max_message_size: 10485760 +stream_max_frames: null +stream_display_fps: true +stream_save_detections: true +stream_detection_dir: "./stream_detections/" diff --git a/docs/config.md b/docs/config.md index 0b5c9cd..929b679 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,167 +1,130 @@ +# ⚙️ Configuration Guide +AnomaVision scripts (`train.py`, `detect.py`, `eval.py`, `export.py`) all accept a **YAML/JSON config file**. You can override any field via CLI arguments. -# ⚙️ Configuration Guide +## 1. Dataset & Preprocessing + +| Key | Type | Default | Description | +|---|---|---|---| +| `dataset_path` | str | None | Root dataset folder containing MVTec-style structure. | +| `class_name` | str | None | Target class name. | +| `resize` | [int,int] | None | Resize before processing. | +| `crop_size` | [int,int] | None | Center crop size. | +| `normalize` | bool | True | Apply input normalization. | +| `norm_mean` | [float] | [0.485,0.456,0.406] | RGB mean. | +| `norm_std` | [float] | [0.229,0.224,0.225] | RGB standard deviation. | -AnomaVision scripts (`train.py`, `detect.py`, `eval.py`, `export.py`) all accept a **YAML/JSON config file**. -You can override any field via CLI arguments. +## 2. Algorithm selection -Example: +The historical selector remains supported: -```bash -python train.py --config config.yml +```yaml +algorithm: padim ``` ---- +Available values are `padim`, `patchcore`, and `efficientad`. -## 1. Dataset & Preprocessing +A native model selector is also supported: -| Key | Type | Default | Description | -| -------------- | ---------- | ---------------------- | -------------------------------------------------------------------------------------------- | -| `dataset_path` | str | None | Root dataset folder containing MVTec-style structure (`class/train/good`, `class/test/...`). | -| `class_name` | str | None | Target class name (e.g. `bottle`, `cable`). | -| `resize` | \[int,int] | None | Resize before processing (e.g. `[256,192]`). One value applies square resize. | -| `crop_size` | \[int,int] | None | Center crop size (e.g. `[224,224]`). One value = square crop. | -| `normalize` | bool | True | Apply input normalization. | -| `norm_mean` | \[float] | \[0.485, 0.456, 0.406] | Mean values (RGB) if normalize is enabled. | -| `norm_std` | \[float] | \[0.229, 0.224, 0.225] | Std values (RGB) if normalize is enabled. | - ---- - -## 2. Training - -| Key | Type | Default | Description | -| ----------------- | ---- | --------------- | ------------------------------------------------ | -| `backbone` | str | resnet18 | Feature extractor (`resnet18`, `wide_resnet50`). | -| `batch_size` | int | 16 | Training batch size. | -| `feat_dim` | int | 100 | Number of random feature dimensions kept. | -| `layer_indices` | list | \[0,1,2] | Backbone layers used for features. | -| `run_name` | str | exp | Name of training run. | -| `model_data_path` | str | ./distributions | Where trained models/configs are stored. | -| `output_model` | str | padim\_model.pt | Name of saved model. | - ---- - -## 3. Detection - -| Key | Type | Default | Description | -| ---------------------- | ----- | ----------- | ------------------------------------ | -| `img_path` | str | None | Path to test images or folder. | -| `model` | str | None | Model file (`.pt`, `.pth`, `.onnx`). | -| `device` | str | auto | Device (`cpu`, `cuda`, or `auto`). | -| `batch_size` | int | 1 | Batch size for inference. | -| `thresh` | float | None | Legacy global anomaly threshold fallback. | -| `thresh_padim` | float | None | PaDiM-specific threshold; takes precedence over `thresh`. | -| `thresh_patchcore` | float | None | PatchCore-specific threshold; takes precedence over `thresh`. | -| `enable_visualization` | bool | False | Enable heatmap overlays. | -| `save_visualizations` | bool | False | Save visualization images. | -| `viz_output_dir` | str | ./results/ | Directory to save images. | -| `viz_alpha` | float | 0.5 | Heatmap transparency. | -| `viz_padding` | int | 40 | Padding around bounding boxes. | -| `viz_color` | str | "128,0,128" | RGB highlight color. | - ---- - -## 4. PatchCore +```yaml +model: + name: efficientad +``` + +When `model.name` is used, `load_config()` maps it to the same internal `algorithm` value, so the existing CLI workflow does not change. + +## 3. Training | Key | Type | Default | Description | |---|---|---:|---| -| `coreset_ratio` | float | 0.02 | Fraction of normal patches retained. | -| `max_memory_patches` | int | 2048 | Hard memory-bank cap. | -| `patch_grid` | int | 14 | Spatial pooling grid for lightweight localization. | -| `search_chunk_size` | int | 1024 | Chunk size for bounded nearest-neighbor search. | -| `coreset_method` | str | kcenter | `kcenter` for deterministic diverse selection or `random`. | -| `coreset_seed` | int | 42 | Reproducibility seed for coreset selection. | - ---- +| `backbone` | str | resnet18 | PaDiM/PatchCore feature extractor. | +| `batch_size` | int | 16 | Training batch size. | +| `feat_dim` | int | 100 | PaDiM feature dimensions. | +| `layer_indices` | list | [0,1,2] | PaDiM/PatchCore feature layers. | +| `run_name` | str | exp | Training run name. | +| `model_data_path` | str | ./distributions | Model artifact root. | +| `output_model` | str | padim_model.pt | Saved model filename. | -## 5. Evaluation +### EfficientAD -| Key | Type | Default | Description | -| ------------------ | ---- | ------- | -------------------------------- | -| `memory_efficient` | bool | True | Use memory-efficient evaluation. | -| `detailed_timing` | bool | False | Log per-image timings. | +| Key | Type | Default | Description | +|---|---|---:|---| +| `efficientad_model_size` | str | s | EfficientAD size: `s` or `m`. | +| `efficientad_lr` | float | 0.0001 | Adam learning rate. | +| `efficientad_weight_decay` | float | 0.00001 | Adam weight decay. | +| `efficientad_epochs` | int | 1 | Number of normal-data training epochs. | +| `efficientad_pretrained_teacher` | bool | true | Use ImageNet-pretrained EfficientNet teacher. | -(Other keys mirror **Detection** and **Training**.) +EfficientAD requires `normalize: true` in the current integration because the teacher uses ImageNet preprocessing. ---- +## 4. Detection -## 6. Export +| Key | Type | Default | Description | +|---|---|---|---| +| `img_path` | str | None | Path to test images or folder. | +| `model` | str | None | Model file (`.pt`, `.pth`, `.onnx`). | +| `device` | str | auto | Device (`cpu`, `cuda`, or `auto`). | +| `batch_size` | int | 1 | Inference batch size. | +| `thresh` | float | None | Legacy global anomaly threshold. | +| `thresh_padim` | float | None | PaDiM-specific threshold. | +| `thresh_patchcore` | float | None | PatchCore-specific threshold. | +| `thresh_efficientad` | float | None | EfficientAD-specific threshold. | +| `enable_visualization` | bool | False | Enable heatmap overlays. | +| `save_visualizations` | bool | False | Save visualization images. | +| `viz_output_dir` | str | ./results/ | Visualization directory. | + +## 5. PatchCore -| Key | Type | Default | Description | -| ------------------ | ---- | ------- | --------------------------------------------------------- | -| `format` | str | onnx | Export target (`onnx`, `torchscript`, `openvino`, `all`). | -| `precision` | str | auto | Precision (`fp32`, `fp16`, or `auto`). | -| `opset` | int | 17 | ONNX opset version. | -| `static_batch` | bool | False | Disable dynamic batch. | -| `optimize` | bool | False | TorchScript mobile optimization. | -| `quantize_dynamic` | bool | False | Export dynamic INT8 ONNX. | -| `quantize_static` | bool | False | Export static INT8 ONNX (requires calibration). | -| `calib_samples` | int | 100 | Calibration samples for static quantization. | -| `tensorrt_precision` | str | fp16 | TensorRT precision (`fp32`, `fp16`, or `int8`). | -| `workspace_gb` | float | 2.0 | TensorRT builder workspace limit in GB. | -| `min_batch` | int | 1 | Minimum TensorRT dynamic batch size. | -| `opt_batch` | int | 1 | Optimized TensorRT dynamic batch size. | -| `max_batch` | int | 4 | Maximum TensorRT dynamic batch size. | -| `calib_dir` | str/null | null | Real-image directory for TensorRT INT8 calibration. | +| Key | Type | Default | Description | +|---|---|---:|---| +| `coreset_ratio` | float | 0.02 | Fraction of normal patches retained. | +| `max_memory_patches` | int | 2048 | Hard memory-bank cap. | +| `patch_grid` | int | 14 | Spatial pooling grid. | +| `search_chunk_size` | int | 1024 | Nearest-neighbor chunk size. | +| `coreset_method` | str | kcenter | `kcenter` or `random`. | +| `coreset_seed` | int | 42 | Coreset reproducibility seed. | ---- +## 6. Evaluation -## 7. Production Autopilot +| Key | Type | Default | Description | +|---|---|---|---| +| `memory_efficient` | bool | True | Use memory-efficient evaluation. | +| `detailed_timing` | bool | False | Log detailed timings. | -Autopilot reads `dataset_path` and `class_name`, uses the complete labeled `test` split by default, and writes `deployment_manifest.json`, `production_autopilot_report.html`, and a Markdown fallback. Its HTML report distinguishes image AUROC, pixel AUROC, anomaly localization coverage, normal-image false-positive localization, and anomaly mean mask area. +Other keys mirror **Detection** and **Training**. ---- +## 7. Export -## 8. Logging +| Key | Type | Default | Description | +|---|---|---:|---| +| `format` | str | onnx | `onnx`, `torchscript`, `openvino`, `all`. | +| `precision` | str | auto | `fp32`, `fp16`, or `auto`. | +| `opset` | int | 17 | ONNX opset version. | +| `static_batch` | bool | False | Disable dynamic batch. | +| `quantize_dynamic` | bool | False | Export dynamic INT8 ONNX. | +| `quantize_static` | bool | False | Export static INT8 ONNX. | +| `calib_samples` | int | 100 | Static quantization samples. | -| Key | Type | Default | Description | -| ----------- | ---- | ------- | ---------------------------------------------------- | -| `log_level` | str | INFO | Logging level (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | +## Example: switch PaDiM → EfficientAD ---- +```yaml +# Everything else in the existing config can remain unchanged. +algorithm: efficientad + +efficientad_model_size: s +efficientad_lr: 0.0001 +efficientad_weight_decay: 0.00001 +efficientad_epochs: 1 +efficientad_pretrained_teacher: true +``` -## Example Config +Then use the same commands: -```yaml -dataset_path: ./dataset -class_name: bottle -resize: [256, 192] -crop_size: [224, 224] -normalize: true -norm_mean: [0.485, 0.456, 0.406] -norm_std: [0.229, 0.224, 0.225] - -backbone: resnet18 -batch_size: 16 -feat_dim: 100 -layer_indices: [0, 1, 2] -output_model: model.pt -run_name: exp1 -model_data_path: ./distributions/padim/bottle/anomav_exp - -model: model.onnx -device: auto -enable_visualization: true -save_visualizations: true -viz_output_dir: ./results/ - -format: onnx -precision: fp16 -quantize_dynamic: true - -# Algorithm-specific thresholds -thresh: null -thresh_padim: null -thresh_patchcore: null - -# Ultra-light PatchCore -algorithm: patchcore -coreset_method: kcenter -coreset_seed: 42 -coreset_ratio: 0.02 -max_memory_patches: 2048 -patch_grid: 14 -search_chunk_size: 1024 +```bash +anomavision train --config config.yml +anomavision export --config config.yml --model model.pt --format onnx +anomavision detect --config config.yml --model model.onnx --img_path ./test_images +anomavision eval --config config.yml --model model.pt --class_name bottle ``` ---- +See [`docs/efficientad.md`](efficientad.md) for the complete EfficientAD guide. diff --git a/docs/efficientad.md b/docs/efficientad.md new file mode 100644 index 0000000..4e6cdc6 --- /dev/null +++ b/docs/efficientad.md @@ -0,0 +1,102 @@ +# EfficientAD + +EfficientAD is available as a native AnomaVision algorithm and uses the same training, model loading, detection, evaluation, and export workflow as PaDiM and PatchCore. + +The implementation follows the EfficientAD student/teacher design: a frozen EfficientNet teacher provides normal feature targets, a lightweight student learns those features, and a compact autoencoder adds a global reconstruction signal. The original EfficientAD paper combines local teacher/student discrepancy with global reconstruction discrepancy for fast anomaly detection. + +## Select the algorithm + +The existing top-level selector remains supported: + +```yaml +algorithm: efficientad +``` + +AnomaVision also accepts the native model selector: + +```yaml +model: + name: efficientad +``` + +For existing projects, changing only `algorithm` is the safest option because the rest of the current configuration remains unchanged. + +## Training + +```bash +anomavision train --config config.yml +``` + +Recommended starting values: + +```yaml +algorithm: efficientad +resize: [224, 224] +normalize: true +batch_size: 1 +efficientad_model_size: s +efficientad_lr: 0.0001 +efficientad_weight_decay: 0.00001 +efficientad_epochs: 1 +efficientad_pretrained_teacher: true +``` + +EfficientAD uses ImageNet preprocessing for the teacher, so `normalize: true` is required by the AnomaVision integration. + +## Detection and thresholding + +Use the same command as the other algorithms: + +```bash +anomavision detect --config config.yml --model model.onnx +``` + +EfficientAD and PaDiM do **not** produce scores on the same numerical scale. PaDiM's threshold (for example `13.0`) must not be reused for EfficientAD. EfficientAD normalizes its image score using the normal-training score mean and standard deviation, so its threshold is expressed in standard deviations from the normal score distribution. + +The default configuration uses an independent EfficientAD threshold: + +```yaml +thresh_padim: 13.0 +thresh_patchcore: 0.25 +thresh_efficientad: 3.0 +``` + +`3.0` is a conservative 3-sigma starting point for deployment. It is intentionally separate from PaDiM and should be calibrated on the validation set for the target MVTec class if you need the closest possible classification agreement with an existing PaDiM deployment. + +## Export + +The trained PyTorch model can be exported through the existing exporter: + +```bash +anomavision export --config config.yml --model model.pt --format onnx +``` + +The ONNX graph contains the EfficientAD inference path, including the teacher, student, autoencoder, score calculation, and anomaly map generation. + +## Evaluation + +```bash +anomavision eval --config config.yml --model model.pt --class_name bottle +``` + +EfficientAD has its own score distribution, so thresholds should be calibrated independently from PaDiM. The inference threshold is controlled by `thresh_efficientad`. + +## Model artifacts + +Training produces the same primary artifact layout used by the other AnomaVision algorithms: + +```text +distributions/ +└── efficientad/ + └── bottle/ + └── anomav_exp/ + ├── model.pt + ├── model.pth + └── config.yml +``` + +`model.pt` is the complete PyTorch model used by the normal AnomaVision inference backend. `model.pth` is a self-contained EfficientAD checkpoint artifact containing the model state and metadata. + +## Difference from PaDiM + +EfficientAD is not numerically interchangeable with PaDiM. PaDiM models feature distributions with Gaussian statistics, while EfficientAD learns a student/teacher representation and reconstruction model. Consequently, scores, thresholds, training time, and localization patterns will differ. What remains intentionally identical is the AnomaVision contract: dataset input, CLI commands, artifact layout, `fit`, `predict`, model loading, and ONNX export. diff --git a/examples/efficientad_cpu.yml b/examples/efficientad_cpu.yml new file mode 100644 index 0000000..7ec468b --- /dev/null +++ b/examples/efficientad_cpu.yml @@ -0,0 +1,38 @@ +# EfficientAD quick-start configuration. +# Keep normalize=true: EfficientAD's teacher uses ImageNet preprocessing. +dataset_path: "./dataset" +class_name: "bottle" +resize: [224, 224] +crop_size: null +normalize: true +norm_mean: [0.485, 0.456, 0.406] +norm_std: [0.229, 0.224, 0.225] + +algorithm: "efficientad" +efficientad_model_size: "s" +efficientad_lr: 0.0001 +efficientad_weight_decay: 0.00001 +efficientad_epochs: 1 +efficientad_pretrained_teacher: true +# The threshold is calibrated from normal training scores. +efficientad_threshold_quantile: 0.995 + +model_data_path: "./distributions" +output_model: "model.pt" +model: "model.pt" +batch_size: 1 +device: "cpu" +run_name: "efficientad_exp" +log_level: "INFO" + +img_path: "./dataset/bottle/test" +thresh: null + +# Visualization/evaluation/export defaults. +enable_visualization: true +save_visualizations: true +viz_output_dir: "./visualizations/" +format: "onnx" +opset: 18 +precision: "fp32" +dynamic_batch: true diff --git a/tests/test_autopilot.py b/tests/test_autopilot.py index a5c954c..1e82523 100644 --- a/tests/test_autopilot.py +++ b/tests/test_autopilot.py @@ -52,7 +52,7 @@ def test_autopilot_report_contains_manifest_summary(tmp_path): assert "0.9000" in html assert "Deployment confidence, before production." in html assert "= 0.0 + + +def test_efficientad_rejects_unknown_model_size(): + try: + EfficientAD(pretrained_teacher=False, model_size="large") + except ValueError as exc: + assert "model_size" in str(exc) + else: + raise AssertionError("Expected invalid EfficientAD model_size to fail") + + +def test_efficientad_rejects_invalid_threshold_quantile(): + try: + EfficientAD(pretrained_teacher=False, threshold_quantile=1.0) + except ValueError as exc: + assert "threshold_quantile" in str(exc) + else: + raise AssertionError("Expected invalid threshold_quantile to fail") diff --git a/tests/test_efficientad_config.py b/tests/test_efficientad_config.py new file mode 100644 index 0000000..095be78 --- /dev/null +++ b/tests/test_efficientad_config.py @@ -0,0 +1,19 @@ +from pathlib import Path + +from anomavision.config import load_config + + +def test_model_name_selects_algorithm(tmp_path: Path): + config_path = tmp_path / "config.yml" + config_path.write_text("model:\n name: efficientad\n") + config = load_config(str(config_path)) + assert config["algorithm"] == "efficientad" + assert config["model"] is None + + +def test_legacy_algorithm_config_is_unchanged(tmp_path: Path): + config_path = tmp_path / "config.yml" + config_path.write_text("algorithm: padim\nmodel: model.pt\n") + config = load_config(str(config_path)) + assert config["algorithm"] == "padim" + assert config["model"] == "model.pt" diff --git a/tests/test_efficientad_threshold.py b/tests/test_efficientad_threshold.py new file mode 100644 index 0000000..f95c5eb --- /dev/null +++ b/tests/test_efficientad_threshold.py @@ -0,0 +1,19 @@ +from pathlib import Path + +import torch + +from anomavision.efficientad_threshold import load_calibrated_threshold + + +def test_load_calibrated_efficientad_threshold(tmp_path: Path): + model_path = tmp_path / "model.onnx" + stats_path = model_path.with_suffix(".pth") + torch.save( + { + "algorithm": "efficientad", + "model_state": {"threshold": torch.tensor(2.75)}, + }, + stats_path, + ) + + assert load_calibrated_threshold(model_path) == 2.75