From d6aa4bb469de0f4fd23f4d8f98528fc0b454a3a5 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:52:43 +0200 Subject: [PATCH 01/47] feat: add EfficientAD algorithm package --- anomavision/algorithm/efficientad/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 anomavision/algorithm/efficientad/__init__.py 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"] From 8ca31222e17ac0eec6237d8ef575f6ac9c55e7a3 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:52:55 +0200 Subject: [PATCH 02/47] feat: implement EfficientAD with native AnomaVision interface --- .../algorithm/efficientad/efficientad.py | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 anomavision/algorithm/efficientad/efficientad.py diff --git a/anomavision/algorithm/efficientad/efficientad.py b/anomavision/algorithm/efficientad/efficientad.py new file mode 100644 index 0000000..adb88ec --- /dev/null +++ b/anomavision/algorithm/efficientad/efficientad.py @@ -0,0 +1,216 @@ +"""Native AnomaVision implementation of EfficientAD. + +The implementation follows the EfficientAD student/teacher idea while exposing +AnomaVision's common ``fit``/``predict``/``save_statistics`` interface. The +teacher is frozen, the student learns normal teacher features, and a compact +autoencoder provides a global reconstruction signal. +""" + +from __future__ import annotations + +from pathlib import Path +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) + # B0 stage 4 gives a compact spatial representation and is inexpensive. + self.features = nn.Sequential(*list(net.features[:6])) + self.out_channels = 112 + for p in self.parameters(): + p.requires_grad_(False) + self.eval() + + def forward(self, x: torch.Tensor) -> torch.Tensor: + with torch.no_grad(): + return self.features(x) + + +class _Student(nn.Module): + def __init__(self, out_channels: int = 112) -> None: + super().__init__() + self.net = nn.Sequential( + nn.Conv2d(3, 64, 3, stride=2, padding=1), + nn.BatchNorm2d(64), + nn.ReLU(inplace=True), + nn.Conv2d(64, 96, 3, stride=2, padding=1), + nn.BatchNorm2d(96), + nn.ReLU(inplace=True), + nn.Conv2d(96, out_channels, 3, stride=2, padding=1), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.net(x) + + +class _AutoEncoder(nn.Module): + def __init__(self) -> None: + super().__init__() + self.encoder = nn.Sequential( + nn.Conv2d(3, 32, 4, 2, 1), nn.ReLU(inplace=True), + nn.Conv2d(32, 64, 4, 2, 1), nn.ReLU(inplace=True), + nn.Conv2d(64, 96, 4, 2, 1), nn.ReLU(inplace=True), + ) + self.decoder = nn.Sequential( + nn.ConvTranspose2d(96, 64, 4, 2, 1), nn.ReLU(inplace=True), + nn.ConvTranspose2d(64, 32, 4, 2, 1), nn.ReLU(inplace=True), + nn.ConvTranspose2d(32, 3, 4, 2, 1), nn.Sigmoid(), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.decoder(self.encoder(x)) + + +class EfficientAD(nn.Module): + """EfficientAD-compatible anomaly detector for the AnomaVision pipeline. + + Args: + device: Torch device used for training/inference. + model_size: ``s`` or ``m``. ``m`` keeps the same interface but uses a + wider student/autoencoder in future-compatible checkpoints. + lr: Adam learning rate. + weight_decay: Adam weight decay. + pretrained_teacher: Load ImageNet EfficientNet-B0 weights. + teacher_weights: Optional local checkpoint for the teacher. + feature_weight: Weight of the student-teacher loss. + reconstruction_weight: Weight of the autoencoder loss. + """ + + def __init__( + self, + device: torch.device = torch.device("cpu"), + model_size: str = "s", + lr: float = 1e-4, + weight_decay: float = 1e-5, + pretrained_teacher: bool = True, + teacher_weights: Optional[str] = None, + feature_weight: float = 1.0, + reconstruction_weight: float = 0.1, + ) -> None: + 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 lr <= 0 or weight_decay < 0: + raise ValueError("lr must be > 0 and weight_decay must be >= 0") + + 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.teacher = _FeatureTeacher(pretrained=pretrained_teacher) + if teacher_weights: + state = torch.load(teacher_weights, map_location="cpu", weights_only=False) + self.teacher.load_state_dict(state, strict=False) + self.student = _Student(self.teacher.out_channels) + self.autoencoder = _AutoEncoder() + self.register_buffer("score_mean", torch.tensor(0.0)) + self.register_buffer("score_std", torch.tensor(1.0)) + self.register_buffer("trained", torch.tensor(False, dtype=torch.bool)) + self.to(self.device) + + def _normalise(self, x: torch.Tensor) -> torch.Tensor: + mean = x.new_tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1) + std = x.new_tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1) + return (x - mean) / std + + def _signals(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: + x01 = x + x_norm = self._normalise(x) + teacher = self.teacher(x_norm) + student = self.student(x_norm) + feature_map = (student - teacher).pow(2).mean(dim=1) + reconstruction = (self.autoencoder(x01) - x01).abs().mean(dim=1) + feature_map = F.interpolate(feature_map.unsqueeze(1), size=x.shape[-2:], mode="bilinear", align_corners=False).squeeze(1) + return feature_map, reconstruction + + def forward(self, x: torch.Tensor, return_map: bool = True, export: bool = False): + feature_map, reconstruction = self._signals(x) + score_map = feature_map + self.reconstruction_weight * reconstruction + scores = score_map.flatten(1).amax(1) + scores = (scores - self.score_mean) / self.score_std.clamp_min(1e-6) + return scores, score_map if return_map else None + + def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 1) -> None: + """Train student and autoencoder using only normal images.""" + self.train() + self.teacher.eval() + optimizer = torch.optim.Adam( + list(self.student.parameters()) + list(self.autoencoder.parameters()), + lr=self.lr, + weight_decay=self.weight_decay, + ) + for _ in range(int(epochs)): + for batch in dataloader: + if isinstance(batch, (tuple, list)): + batch = batch[0] + batch = batch.to(self.device, non_blocking=True).float() + with torch.no_grad(): + teacher = self.teacher(self._normalise(batch)) + student = self.student(self._normalise(batch)) + reconstructed = self.autoencoder(batch) + feature_loss = F.mse_loss(student, teacher) + reconstruction_loss = F.l1_loss(reconstructed, batch) + loss = self.feature_weight * feature_loss + self.reconstruction_weight * reconstruction_loss + optimizer.zero_grad(set_to_none=True) + loss.backward() + optimizer.step() + + self.eval() + # Calibrate score scale on the normal training set. + values = [] + with torch.no_grad(): + for batch in dataloader: + if isinstance(batch, (tuple, list)): + batch = batch[0] + batch = batch.to(self.device).float() + fmap, recon = self._signals(batch) + values.append((fmap + self.reconstruction_weight * recon).flatten(1).amax(1)) + if values: + scores = torch.cat(values) + self.score_mean.copy_(scores.mean()) + self.score_std.copy_(scores.std(unbiased=False).clamp_min(1e-6)) + self.trained.fill_(True) + + def predict(self, batch: torch.Tensor, export: bool = False): + if not bool(self.trained.item()): + raise RuntimeError("EfficientAD model is not trained. Call fit() first.") + self.eval() + with torch.no_grad(): + return self.forward(batch.to(self.device).float(), export=export) + + def to_device(self, device: torch.device) -> None: + self.device = torch.device(device) + self.to(self.device) + + def save_statistics(self, path: str, half: Optional[bool] = None) -> None: + """Save a self-contained EfficientAD checkpoint artifact.""" + if not bool(self.trained.item()): + raise RuntimeError("Model is not trained. Call fit() first.") + torch.save({ + "algorithm": "efficientad", + "model_state": self.state_dict(), + "model_size": self.model_size, + "lr": self.lr, + "weight_decay": self.weight_decay, + }, path) + + @staticmethod + def load_statistics(path: str, device: str = "cpu") -> "EfficientAD": + data = torch.load(path, map_location="cpu", weights_only=False) + if data.get("algorithm") != "efficientad": + raise ValueError("Not an EfficientAD statistics artifact") + model = EfficientAD(device=torch.device(device), model_size=data.get("model_size", "s"), pretrained_teacher=False) + model.load_state_dict(data["model_state"]) + return model From be5e7d1acbb6c39fd62c4f66fd098e4c0ed006ba Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:53:07 +0200 Subject: [PATCH 03/47] fix: align EfficientAD student and teacher feature strides --- .../algorithm/efficientad/efficientad.py | 39 ++++++++----------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/anomavision/algorithm/efficientad/efficientad.py b/anomavision/algorithm/efficientad/efficientad.py index adb88ec..5e5f024 100644 --- a/anomavision/algorithm/efficientad/efficientad.py +++ b/anomavision/algorithm/efficientad/efficientad.py @@ -1,14 +1,13 @@ """Native AnomaVision implementation of EfficientAD. The implementation follows the EfficientAD student/teacher idea while exposing -AnomaVision's common ``fit``/``predict``/``save_statistics`` interface. The +AnomaVision's common ``fit``/``predict``/``save_statistics`` interface. The teacher is frozen, the student learns normal teacher features, and a compact autoencoder provides a global reconstruction signal. """ from __future__ import annotations -from pathlib import Path from typing import Optional, Tuple import torch @@ -22,7 +21,7 @@ def __init__(self, pretrained: bool = True) -> None: super().__init__() weights = EfficientNet_B0_Weights.DEFAULT if pretrained else None net = efficientnet_b0(weights=weights) - # B0 stage 4 gives a compact spatial representation and is inexpensive. + # EfficientNet-B0 stage 5 produces a compact 112-channel feature map. self.features = nn.Sequential(*list(net.features[:6])) self.out_channels = 112 for p in self.parameters(): @@ -37,6 +36,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: class _Student(nn.Module): def __init__(self, out_channels: int = 112) -> None: super().__init__() + # Four downsampling stages match the teacher's 1/16 spatial stride. self.net = nn.Sequential( nn.Conv2d(3, 64, 3, stride=2, padding=1), nn.BatchNorm2d(64), @@ -44,7 +44,10 @@ def __init__(self, out_channels: int = 112) -> None: nn.Conv2d(64, 96, 3, stride=2, padding=1), nn.BatchNorm2d(96), nn.ReLU(inplace=True), - nn.Conv2d(96, out_channels, 3, stride=2, padding=1), + nn.Conv2d(96, 112, 3, stride=2, padding=1), + nn.BatchNorm2d(112), + nn.ReLU(inplace=True), + nn.Conv2d(112, out_channels, 3, stride=2, padding=1), ) def forward(self, x: torch.Tensor) -> torch.Tensor: @@ -70,19 +73,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: class EfficientAD(nn.Module): - """EfficientAD-compatible anomaly detector for the AnomaVision pipeline. - - Args: - device: Torch device used for training/inference. - model_size: ``s`` or ``m``. ``m`` keeps the same interface but uses a - wider student/autoencoder in future-compatible checkpoints. - lr: Adam learning rate. - weight_decay: Adam weight decay. - pretrained_teacher: Load ImageNet EfficientNet-B0 weights. - teacher_weights: Optional local checkpoint for the teacher. - feature_weight: Weight of the student-teacher loss. - reconstruction_weight: Weight of the autoencoder loss. - """ + """EfficientAD-compatible anomaly detector for the AnomaVision pipeline.""" def __init__( self, @@ -126,13 +117,14 @@ def _normalise(self, x: torch.Tensor) -> torch.Tensor: return (x - mean) / std def _signals(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: - x01 = x x_norm = self._normalise(x) teacher = self.teacher(x_norm) student = self.student(x_norm) feature_map = (student - teacher).pow(2).mean(dim=1) - reconstruction = (self.autoencoder(x01) - x01).abs().mean(dim=1) - feature_map = F.interpolate(feature_map.unsqueeze(1), size=x.shape[-2:], mode="bilinear", align_corners=False).squeeze(1) + reconstruction = (self.autoencoder(x) - x).abs().mean(dim=1) + feature_map = F.interpolate( + feature_map.unsqueeze(1), size=x.shape[-2:], mode="bilinear", align_corners=False + ).squeeze(1) return feature_map, reconstruction def forward(self, x: torch.Tensor, return_map: bool = True, export: bool = False): @@ -168,7 +160,6 @@ def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 1) -> None: optimizer.step() self.eval() - # Calibrate score scale on the normal training set. values = [] with torch.no_grad(): for batch in dataloader: @@ -211,6 +202,10 @@ def load_statistics(path: str, device: str = "cpu") -> "EfficientAD": data = torch.load(path, map_location="cpu", weights_only=False) if data.get("algorithm") != "efficientad": raise ValueError("Not an EfficientAD statistics artifact") - model = EfficientAD(device=torch.device(device), model_size=data.get("model_size", "s"), pretrained_teacher=False) + model = EfficientAD( + device=torch.device(device), + model_size=data.get("model_size", "s"), + pretrained_teacher=False, + ) model.load_state_dict(data["model_state"]) return model From b42cc12681196f54c7954b97f842e6ae08789610 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:53:18 +0200 Subject: [PATCH 04/47] fix: keep EfficientAD compatible with shared normalized dataset --- anomavision/algorithm/efficientad/efficientad.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/anomavision/algorithm/efficientad/efficientad.py b/anomavision/algorithm/efficientad/efficientad.py index 5e5f024..bc403cd 100644 --- a/anomavision/algorithm/efficientad/efficientad.py +++ b/anomavision/algorithm/efficientad/efficientad.py @@ -21,7 +21,6 @@ def __init__(self, pretrained: bool = True) -> None: super().__init__() weights = EfficientNet_B0_Weights.DEFAULT if pretrained else None net = efficientnet_b0(weights=weights) - # EfficientNet-B0 stage 5 produces a compact 112-channel feature map. self.features = nn.Sequential(*list(net.features[:6])) self.out_channels = 112 for p in self.parameters(): @@ -36,7 +35,6 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: class _Student(nn.Module): def __init__(self, out_channels: int = 112) -> None: super().__init__() - # Four downsampling stages match the teacher's 1/16 spatial stride. self.net = nn.Sequential( nn.Conv2d(3, 64, 3, stride=2, padding=1), nn.BatchNorm2d(64), @@ -65,7 +63,7 @@ def __init__(self) -> None: self.decoder = nn.Sequential( nn.ConvTranspose2d(96, 64, 4, 2, 1), nn.ReLU(inplace=True), nn.ConvTranspose2d(64, 32, 4, 2, 1), nn.ReLU(inplace=True), - nn.ConvTranspose2d(32, 3, 4, 2, 1), nn.Sigmoid(), + nn.ConvTranspose2d(32, 3, 4, 2, 1), ) def forward(self, x: torch.Tensor) -> torch.Tensor: @@ -112,9 +110,9 @@ def __init__( self.to(self.device) def _normalise(self, x: torch.Tensor) -> torch.Tensor: - mean = x.new_tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1) - std = x.new_tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1) - return (x - mean) / std + # Dataset tensors may already be ImageNet-normalized. This method is + # intentionally a no-op in that case; see ``normalize_input`` below. + return x def _signals(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: x_norm = self._normalise(x) From 99459b4050ff4f036afec869298ead77c1fe0850 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:53:30 +0200 Subject: [PATCH 05/47] feat: expose EfficientAD from AnomaVision API --- anomavision/__init__.py | 1 + 1 file changed, 1 insertion(+) 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 From 1f1b1209db1cafbde4082654a10c7311001d72de Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:53:40 +0200 Subject: [PATCH 06/47] feat: route training through configurable EfficientAD implementation --- anomavision/train.py | 312 ++++++++----------------------------------- 1 file changed, 59 insertions(+), 253 deletions(-) diff --git a/anomavision/train.py b/anomavision/train.py index 7761310..280a0f0 100644 --- a/anomavision/train.py +++ b/anomavision/train.py @@ -17,233 +17,66 @@ 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, - nargs="*", - default=None, - metavar=("W", "H"), - help="Resize before processing. Provide one value for a square resize (e.g., 256) or two values for width and height (e.g., 256 192). Omit to keep original size.", - ) - parser.add_argument( - "--crop_size", - type=int, - nargs="*", - default=None, - metavar=("W", "H"), - help="Apply a center (or configured) crop. One value for a square crop (e.g., 224) or two for width and height (e.g., 224 224). Omit to disable cropping.", - ) - parser.add_argument( - "--normalize", - action="store_true", - default=None, - help="Enable input normalization. If set, inputs are normalized using --norm_mean/--norm_std if provided (commonly ImageNet stats).", - ) - parser.add_argument( - "--no_normalize", - action="store_true", - default=None, - help="Disable input normalization explicitly. If both --normalize and --no_normalize are set, this flag should take precedence in your code.", - ) - parser.add_argument( - "--norm_mean", - type=float, - nargs=3, - default=None, - metavar=("R", "G", "B"), - help="Per-channel RGB mean used when normalization is enabled. Example: 0.485 0.456 0.406.", - ) - parser.add_argument( - "--norm_std", - type=float, - nargs=3, - default=None, - 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, - choices=["resnet18", "wide_resnet50"], - default=None, - help="Backbone network to use for feature extraction.", - ) - parser.add_argument( - "--batch_size", - type=int, - default=None, - help="Batch size used during training and inference.", - ) - parser.add_argument( - "--feat_dim", - type=int, - default=None, - help="Number of random feature dimensions to keep.", - ) - parser.add_argument( - "--layer_indices", - type=int, - nargs="+", - default=None, - help="List of layer indices to extract features from, e.g., 0 1 2.", - ) - parser.add_argument( - "--coreset_ratio", - type=float, - default=None, - help="PatchCore memory-bank fraction to retain (0, 1].", - ) - parser.add_argument( - "--max_memory_patches", - type=int, - default=None, - help="Maximum PatchCore memory-bank size; omit for no cap.", - ) - parser.add_argument( - "--patch_grid", - type=int, - default=None, - help="PatchCore pooled grid size; use a smaller value for lower latency.", - ) - parser.add_argument( - "--search_chunk_size", - type=int, - default=None, - help="PatchCore query chunk size used to bound nearest-neighbor memory.", - ) - parser.add_argument( - "--coreset_method", - type=str, - choices=["kcenter", "random"], - default=None, - help="PatchCore coreset selection strategy; kcenter is the diverse default.", - ) - parser.add_argument( - "--coreset_seed", - type=int, - default=None, - help="Seed used for deterministic PatchCore coreset selection.", - ) - parser.add_argument( - "--output_model", - type=str, - default=None, - help="Filename to save the PT model.", - ) - parser.add_argument( - "--run_name", - type=str, - default=None, - help="Experiment name for this training run.", - ) - parser.add_argument( - "--model_data_path", - type=str, - default=None, - help="Directory to save model distributions and PT file.", - ) - parser.add_argument( - "--algorithm", - type=str, - default=None, - help="Algorithm name (e.g., padim, patchcore).", - ) - parser.add_argument( - "--log_level", - type=str, - choices=["DEBUG", "INFO", "WARNING", "ERROR"], - default=None, - help="Logging level (default: INFO).", - ) - + description="Train AnomaVision anomaly detection models (args OR config).", add_help=add_help + ) + parser.add_argument("--config", type=str, default="config.yml", help="Path to config.yml/.json") + parser.add_argument("--dataset_path", type=str, default=None, help='Path to the dataset folder containing "train/good" images.') + parser.add_argument("--resize", type=int, nargs="*", default=None, metavar=("W", "H"), help="Resize before processing.") + parser.add_argument("--crop_size", type=int, nargs="*", default=None, metavar=("W", "H"), help="Apply a center crop.") + parser.add_argument("--normalize", action="store_true", default=None, help="Enable input normalization.") + parser.add_argument("--no_normalize", action="store_true", default=None, help="Disable input normalization explicitly.") + parser.add_argument("--norm_mean", type=float, nargs=3, default=None, metavar=("R", "G", "B"), help="RGB normalization mean.") + parser.add_argument("--norm_std", type=float, nargs=3, default=None, metavar=("R", "G", "B"), help="RGB normalization std.") + parser.add_argument("--backbone", type=str, choices=["resnet18", "wide_resnet50"], default=None, help="PaDiM/PatchCore backbone.") + parser.add_argument("--batch_size", type=int, default=None, help="Batch size used during training and inference.") + parser.add_argument("--feat_dim", type=int, default=None, help="PaDiM feature dimension.") + parser.add_argument("--layer_indices", type=int, nargs="+", default=None, help="PaDiM/PatchCore feature layers.") + parser.add_argument("--coreset_ratio", type=float, default=None, help="PatchCore memory-bank fraction.") + parser.add_argument("--max_memory_patches", type=int, default=None, help="PatchCore memory-bank cap.") + parser.add_argument("--patch_grid", type=int, default=None, help="PatchCore pooled grid size.") + parser.add_argument("--search_chunk_size", type=int, default=None, help="PatchCore query chunk size.") + parser.add_argument("--coreset_method", type=str, choices=["kcenter", "random"], default=None, help="PatchCore coreset strategy.") + parser.add_argument("--coreset_seed", type=int, default=None, help="PatchCore coreset seed.") + 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("--output_model", type=str, default=None, help="Filename to save the PT model.") + parser.add_argument("--run_name", type=str, default=None, help="Experiment name for this training run.") + parser.add_argument("--model_data_path", type=str, default=None, help="Directory to save model distributions and PT file.") + parser.add_argument("--algorithm", type=str, default=None, help="Algorithm name: padim, patchcore, or efficientad.") + parser.add_argument("--log_level", type=str, choices=["DEBUG", "INFO", "WARNING", "ERROR"], default=None, help="Logging level.") return parser 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. - """ 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") + algorithm = str(config.algorithm).lower() + if algorithm not in {"padim", "patchcore", "efficientad"}: + raise ValueError("Unsupported algorithm: %s. Available: padim, patchcore, efficientad" % algorithm) 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) + raise ValueError("dataset_path is required") + 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, - config.crop_size, - config.normalize, - ) - 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 - / config.class_name - / config.run_name, + Path(config.model_data_path) / algorithm / config.class_name / config.run_name, exist_ok=True, 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" - ) - + 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" - ) + potential_root = os.path.join(os.path.realpath(config.dataset_path), "train", "good") if os.path.isdir(potential_root): root = potential_root else: - logger.error('Expected folder "%s" does not exist.', root) raise FileNotFoundError(f"Dataset root not found: {root}") ds = anomavision.AnodetDataset( @@ -254,58 +87,39 @@ 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) - raise ValueError(error_msg) + raise ValueError(f"No training images found in {root}") 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, - config.backbone, - config.layer_indices, - ) + logger.info("algorithm=%s | device=%s | images=%d | batch_size=%d", algorithm, device, len(ds), config.batch_size) - if str(config.algorithm).lower() == "patchcore": + if algorithm == "patchcore": model = anomavision.PatchCore( - backbone=config.backbone, + backbone=config.backbone, device=device, layer_indices=config.layer_indices, + coreset_ratio=float(config.coreset_ratio), max_memory_patches=config.max_memory_patches, + patch_grid=config.patch_grid, search_chunk_size=config.search_chunk_size, + coreset_method=config.get("coreset_method", "kcenter"), coreset_seed=int(config.get("coreset_seed", 42)), + ) + model.fit(dl) + elif algorithm == "efficientad": + model = anomavision.EfficientAD( device=device, - layer_indices=config.layer_indices, - coreset_ratio=float(config.coreset_ratio), - max_memory_patches=config.max_memory_patches, - patch_grid=config.patch_grid, - search_chunk_size=config.search_chunk_size, - coreset_method=config.get("coreset_method", "kcenter"), - coreset_seed=int(config.get("coreset_seed", 42)), + 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)), ) + model.fit(dl, epochs=int(config.get("efficientad_epochs", 1))) else: model = anomavision.Padim( - backbone=config.backbone, - device=device, - layer_indices=config.layer_indices, - feat_dim=int(config.feat_dim), + backbone=config.backbone, device=device, layer_indices=config.layer_indices, feat_dim=int(config.feat_dim) ) + model.fit(dl) - t_fit = time.perf_counter() - 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 +127,9 @@ 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} @@ -327,17 +137,13 @@ def main(args=None): try: 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) - except Exception: get_logger(__name__).exception("Fatal error during training.") sys.exit(1) From 536ac364871196094fe84cbec4c62316b0988c80 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:53:50 +0200 Subject: [PATCH 07/47] refactor: preserve training workflow while adding EfficientAD --- anomavision/train.py | 64 +++++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/anomavision/train.py b/anomavision/train.py index 280a0f0..42e6175 100644 --- a/anomavision/train.py +++ b/anomavision/train.py @@ -16,27 +16,25 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Train AnomaVision anomaly detection models (args OR config).", add_help=add_help - ) + parser = argparse.ArgumentParser(description="Train AnomaVision anomaly detection models (args OR config).", add_help=add_help) parser.add_argument("--config", type=str, default="config.yml", help="Path to config.yml/.json") parser.add_argument("--dataset_path", type=str, default=None, help='Path to the dataset folder containing "train/good" images.') - parser.add_argument("--resize", type=int, nargs="*", default=None, metavar=("W", "H"), help="Resize before processing.") - parser.add_argument("--crop_size", type=int, nargs="*", default=None, metavar=("W", "H"), help="Apply a center crop.") + parser.add_argument("--resize", type=int, nargs="*", default=None, metavar=("W", "H"), help="Resize before processing. Provide one value for a square resize or two values for width and height.") + parser.add_argument("--crop_size", type=int, nargs="*", default=None, metavar=("W", "H"), help="Apply a center (or configured) crop.") parser.add_argument("--normalize", action="store_true", default=None, help="Enable input normalization.") parser.add_argument("--no_normalize", action="store_true", default=None, help="Disable input normalization explicitly.") - parser.add_argument("--norm_mean", type=float, nargs=3, default=None, metavar=("R", "G", "B"), help="RGB normalization mean.") - parser.add_argument("--norm_std", type=float, nargs=3, default=None, metavar=("R", "G", "B"), help="RGB normalization std.") - parser.add_argument("--backbone", type=str, choices=["resnet18", "wide_resnet50"], default=None, help="PaDiM/PatchCore backbone.") + parser.add_argument("--norm_mean", type=float, nargs=3, default=None, metavar=("R", "G", "B"), help="Per-channel RGB mean.") + parser.add_argument("--norm_std", type=float, nargs=3, default=None, metavar=("R", "G", "B"), help="Per-channel RGB standard deviation.") + parser.add_argument("--backbone", type=str, choices=["resnet18", "wide_resnet50"], default=None, help="Backbone network for PaDiM/PatchCore.") parser.add_argument("--batch_size", type=int, default=None, help="Batch size used during training and inference.") - parser.add_argument("--feat_dim", type=int, default=None, help="PaDiM feature dimension.") - parser.add_argument("--layer_indices", type=int, nargs="+", default=None, help="PaDiM/PatchCore feature layers.") - parser.add_argument("--coreset_ratio", type=float, default=None, help="PatchCore memory-bank fraction.") - parser.add_argument("--max_memory_patches", type=int, default=None, help="PatchCore memory-bank cap.") + parser.add_argument("--feat_dim", type=int, default=None, help="Number of random feature dimensions to keep.") + parser.add_argument("--layer_indices", type=int, nargs="+", default=None, help="List of feature layers to extract.") + parser.add_argument("--coreset_ratio", type=float, default=None, help="PatchCore memory-bank fraction to retain.") + parser.add_argument("--max_memory_patches", type=int, default=None, help="Maximum PatchCore memory-bank size.") parser.add_argument("--patch_grid", type=int, default=None, help="PatchCore pooled grid size.") parser.add_argument("--search_chunk_size", type=int, default=None, help="PatchCore query chunk size.") - parser.add_argument("--coreset_method", type=str, choices=["kcenter", "random"], default=None, help="PatchCore coreset strategy.") - parser.add_argument("--coreset_seed", type=int, default=None, help="PatchCore coreset seed.") + parser.add_argument("--coreset_method", type=str, choices=["kcenter", "random"], default=None, help="PatchCore coreset selection strategy.") + parser.add_argument("--coreset_seed", type=int, 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.") @@ -45,7 +43,7 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: parser.add_argument("--output_model", type=str, default=None, help="Filename to save the PT model.") parser.add_argument("--run_name", type=str, default=None, help="Experiment name for this training run.") parser.add_argument("--model_data_path", type=str, default=None, help="Directory to save model distributions and PT file.") - parser.add_argument("--algorithm", type=str, default=None, help="Algorithm name: padim, patchcore, or efficientad.") + parser.add_argument("--algorithm", type=str, default=None, help="Algorithm name (padim, patchcore, efficientad).") parser.add_argument("--log_level", type=str, choices=["DEBUG", "INFO", "WARNING", "ERROR"], default=None, help="Logging level.") return parser @@ -58,13 +56,17 @@ def run_training(args): algorithm = str(config.algorithm).lower() if algorithm not in {"padim", "patchcore", "efficientad"}: - raise ValueError("Unsupported algorithm: %s. Available: padim, patchcore, efficientad" % algorithm) + raise ValueError(f"Unsupported algorithm: {algorithm}. Available: padim, patchcore, efficientad") if not config.dataset_path: - raise ValueError("dataset_path is required") + raise ValueError("dataset_path is required (via --dataset_path or config)") if algorithm == "efficientad" and not bool(config.get("normalize", True)): - raise ValueError("EfficientAD requires normalize: true because its teacher uses ImageNet preprocessing") + raise ValueError("EfficientAD requires normalize: true because the teacher uses ImageNet preprocessing") t0 = time.perf_counter() + logger.info("Image processing: resize=%s, crop_size=%s, normalize=%s", config.resize, config.crop_size, config.normalize) + if config.normalize: + logger.info("Normalization: mean=%s, std=%s", config.norm_mean, config.norm_std) + run_dir = increment_path( Path(config.model_data_path) / algorithm / config.class_name / config.run_name, exist_ok=True, @@ -77,6 +79,7 @@ def run_training(args): if os.path.isdir(potential_root): root = potential_root else: + logger.error('Expected folder "%s" does not exist.', root) raise FileNotFoundError(f"Dataset root not found: {root}") ds = anomavision.AnodetDataset( @@ -92,14 +95,20 @@ def run_training(args): dl = DataLoader(ds, batch_size=int(config.batch_size), shuffle=False) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - logger.info("algorithm=%s | device=%s | images=%d | batch_size=%d", algorithm, device, len(ds), config.batch_size) + logger.info("device: %s (cuda_available=%s)", device.type, torch.cuda.is_available()) + logger.info("cfg: algorithm=%s | backbone=%s | layers=%s", algorithm, config.backbone, config.layer_indices) if algorithm == "patchcore": model = anomavision.PatchCore( - backbone=config.backbone, device=device, layer_indices=config.layer_indices, - coreset_ratio=float(config.coreset_ratio), max_memory_patches=config.max_memory_patches, - patch_grid=config.patch_grid, search_chunk_size=config.search_chunk_size, - coreset_method=config.get("coreset_method", "kcenter"), coreset_seed=int(config.get("coreset_seed", 42)), + backbone=config.backbone, + device=device, + layer_indices=config.layer_indices, + coreset_ratio=float(config.coreset_ratio), + max_memory_patches=config.max_memory_patches, + patch_grid=config.patch_grid, + search_chunk_size=config.search_chunk_size, + coreset_method=config.get("coreset_method", "kcenter"), + coreset_seed=int(config.get("coreset_seed", 42)), ) model.fit(dl) elif algorithm == "efficientad": @@ -110,12 +119,19 @@ def run_training(args): weight_decay=float(config.get("efficientad_weight_decay", 1e-5)), pretrained_teacher=bool(config.get("efficientad_pretrained_teacher", True)), ) + t_fit = time.perf_counter() model.fit(dl, epochs=int(config.get("efficientad_epochs", 1))) + logger.info("fit: completed in %.2fs", time.perf_counter() - t_fit) else: model = anomavision.Padim( - backbone=config.backbone, device=device, layer_indices=config.layer_indices, feat_dim=int(config.feat_dim) + backbone=config.backbone, + device=device, + layer_indices=config.layer_indices, + feat_dim=int(config.feat_dim), ) + t_fit = time.perf_counter() model.fit(dl) + logger.info("fit: completed in %.2fs", time.perf_counter() - t_fit) model_path = Path(run_dir) / config.output_model torch.save(model, str(model_path)) From 0b0b8ec98c3034947e556bc09066e090f2ed7461 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:54:11 +0200 Subject: [PATCH 08/47] feat: support model.name as a native algorithm selector --- anomavision/config.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) 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 From a29eeefe63e5f19dab65f99df45d69f218925953 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:54:19 +0200 Subject: [PATCH 09/47] docs: add EfficientAD configuration options and selector --- config.yml | 200 +++++++++++++++++++++++++++-------------------------- 1 file changed, 102 insertions(+), 98 deletions(-) diff --git a/config.yml b/config.yml index 0af6c53..20dda13 100644 --- a/config.yml +++ b/config.yml @@ -1,124 +1,128 @@ # ========================= # 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: "padim" # 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 uses ImageNet-normalized input and an EfficientNet-B0 teacher. +efficientad_model_size: "s" # s | m +efficientad_lr: 0.0001 +efficientad_weight_decay: 0.00001 +efficientad_epochs: 1 +efficientad_pretrained_teacher: true + +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) +# Inference # ========================= -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 +img_path: "D:/01-DATA/test" +thresh: null +thresh_padim: 13.0 +thresh_patchcore: 0.25 +thresh_efficientad: null +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/" From f66626a7bf9d7b649b201130fd8d326d174c955f Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:54:24 +0200 Subject: [PATCH 10/47] docs: add EfficientAD CPU example configuration --- examples/efficientad_cpu.yml | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 examples/efficientad_cpu.yml diff --git a/examples/efficientad_cpu.yml b/examples/efficientad_cpu.yml new file mode 100644 index 0000000..157b215 --- /dev/null +++ b/examples/efficientad_cpu.yml @@ -0,0 +1,37 @@ +# 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 + +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 +thresh_efficientad: null + +# Visualization/evaluation/export defaults. +enable_visualization: true +save_visualizations: true +viz_output_dir: "./visualizations/" +format: "onnx" +opset: 18 +precision: "fp32" +dynamic_batch: true From c110401fb3e795031e0d4c5d7485b25a8567cdfc Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:54:31 +0200 Subject: [PATCH 11/47] test: add EfficientAD fit and validation coverage --- tests/test_efficientad.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/test_efficientad.py diff --git a/tests/test_efficientad.py b/tests/test_efficientad.py new file mode 100644 index 0000000..0b300c4 --- /dev/null +++ b/tests/test_efficientad.py @@ -0,0 +1,31 @@ +import torch +from torch.utils.data import DataLoader, TensorDataset + +from anomavision.algorithm.efficientad import EfficientAD + + +def test_efficientad_fit_predict_contract(): + images = torch.rand(2, 3, 224, 224) + loader = DataLoader(TensorDataset(images), batch_size=1, shuffle=False) + + model = EfficientAD( + device=torch.device("cpu"), + pretrained_teacher=False, + model_size="s", + ) + model.fit(loader, epochs=1) + + scores, maps = model.predict(images[:1]) + assert scores.shape == (1,) + assert maps.shape == (1, 224, 224) + assert torch.isfinite(scores).all() + assert torch.isfinite(maps).all() + + +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") From fdfe45f4ee42e58fd68da4a3d718be0b31c85c97 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:54:37 +0200 Subject: [PATCH 12/47] docs: document native EfficientAD workflow --- docs/efficientad.md | 92 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/efficientad.md diff --git a/docs/efficientad.md b/docs/efficientad.md new file mode 100644 index 0000000..69c2c48 --- /dev/null +++ b/docs/efficientad.md @@ -0,0 +1,92 @@ +# 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 is designed for millisecond-level anomaly detection and combines local teacher/student discrepancy with global reconstruction discrepancy. citeturn0academia12 + +## 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 + +Use the same command as the other algorithms: + +```bash +anomavision detect --config config.yml --model model.pt --img_path ./test_images +``` + +The model returns an image-level anomaly score and a full-resolution anomaly map, so the existing visualization and post-processing pipeline can be reused. + +## 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. Keep `thresh_efficientad: null` to let evaluation determine an appropriate threshold where the existing evaluation workflow supports automatic threshold selection. + +## 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. From e915967d1eee8557a119de5367dce95050d96fb1 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:54:53 +0200 Subject: [PATCH 13/47] docs: clean EfficientAD documentation --- docs/efficientad.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/efficientad.md b/docs/efficientad.md index 69c2c48..4f44f53 100644 --- a/docs/efficientad.md +++ b/docs/efficientad.md @@ -2,7 +2,7 @@ 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 is designed for millisecond-level anomaly detection and combines local teacher/student discrepancy with global reconstruction discrepancy. citeturn0academia12 +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 From d4e3ef8ecc965e2bbe3a1987431c96ecd7ad267f Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:55:04 +0200 Subject: [PATCH 14/47] docs: document EfficientAD as a first-class algorithm --- README.md | 90 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 46 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 8e93371..c1ac7f0 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. @@ -166,8 +173,7 @@ anomavision autopilot \ --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 +182,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 +193,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) ``` From fb2d93be422fb070d3e2063168f19fe84d805b72 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:55:29 +0200 Subject: [PATCH 15/47] test: validate native model.name algorithm selection --- tests/test_efficientad_config.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/test_efficientad_config.py 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" From d8923dbc4dbd49ffb3bc91772c44a63ecdb96554 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:55:46 +0200 Subject: [PATCH 16/47] refactor: preserve existing training CLI and add EfficientAD routing --- anomavision/train.py | 313 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 267 insertions(+), 46 deletions(-) diff --git a/anomavision/train.py b/anomavision/train.py index 42e6175..90594c3 100644 --- a/anomavision/train.py +++ b/anomavision/train.py @@ -16,66 +16,263 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Train AnomaVision anomaly detection models (args OR config).", add_help=add_help) - parser.add_argument("--config", type=str, default="config.yml", help="Path to config.yml/.json") - parser.add_argument("--dataset_path", type=str, default=None, help='Path to the dataset folder containing "train/good" images.') - parser.add_argument("--resize", type=int, nargs="*", default=None, metavar=("W", "H"), help="Resize before processing. Provide one value for a square resize or two values for width and height.") - parser.add_argument("--crop_size", type=int, nargs="*", default=None, metavar=("W", "H"), help="Apply a center (or configured) crop.") - parser.add_argument("--normalize", action="store_true", default=None, help="Enable input normalization.") - parser.add_argument("--no_normalize", action="store_true", default=None, help="Disable input normalization explicitly.") - parser.add_argument("--norm_mean", type=float, nargs=3, default=None, metavar=("R", "G", "B"), help="Per-channel RGB mean.") - parser.add_argument("--norm_std", type=float, nargs=3, default=None, metavar=("R", "G", "B"), help="Per-channel RGB standard deviation.") - parser.add_argument("--backbone", type=str, choices=["resnet18", "wide_resnet50"], default=None, help="Backbone network for PaDiM/PatchCore.") - parser.add_argument("--batch_size", type=int, default=None, help="Batch size used during training and inference.") - parser.add_argument("--feat_dim", type=int, default=None, help="Number of random feature dimensions to keep.") - parser.add_argument("--layer_indices", type=int, nargs="+", default=None, help="List of feature layers to extract.") - parser.add_argument("--coreset_ratio", type=float, default=None, help="PatchCore memory-bank fraction to retain.") - parser.add_argument("--max_memory_patches", type=int, default=None, help="Maximum PatchCore memory-bank size.") - parser.add_argument("--patch_grid", type=int, default=None, help="PatchCore pooled grid size.") - parser.add_argument("--search_chunk_size", type=int, default=None, help="PatchCore query chunk size.") - parser.add_argument("--coreset_method", type=str, choices=["kcenter", "random"], default=None, help="PatchCore coreset selection strategy.") - parser.add_argument("--coreset_seed", type=int, 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("--output_model", type=str, default=None, help="Filename to save the PT model.") - parser.add_argument("--run_name", type=str, default=None, help="Experiment name for this training run.") - parser.add_argument("--model_data_path", type=str, default=None, help="Directory to save model distributions and PT file.") - parser.add_argument("--algorithm", type=str, default=None, help="Algorithm name (padim, patchcore, efficientad).") - parser.add_argument("--log_level", type=str, choices=["DEBUG", "INFO", "WARNING", "ERROR"], default=None, help="Logging level.") + 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, + nargs="*", + default=None, + metavar=("W", "H"), + help="Resize before processing. Provide one value for a square resize (e.g., 256) or two values for width and height (e.g., 256 192). Omit to keep original size.", + ) + parser.add_argument( + "--crop_size", + type=int, + nargs="*", + default=None, + metavar=("W", "H"), + help="Apply a center (or configured) crop. One value for a square crop (e.g., 224) or two for width and height (e.g., 224 224). Omit to disable cropping.", + ) + parser.add_argument( + "--normalize", + action="store_true", + default=None, + help="Enable input normalization. If set, inputs are normalized using --norm_mean/--norm_std if provided (commonly ImageNet stats).", + ) + parser.add_argument( + "--no_normalize", + action="store_true", + default=None, + help="Disable input normalization explicitly. If both --normalize and --no_normalize are set, this flag should take precedence in your code.", + ) + parser.add_argument( + "--norm_mean", + type=float, + nargs=3, + default=None, + metavar=("R", "G", "B"), + help="Per-channel RGB mean used when normalization is enabled. Example: 0.485 0.456 0.406.", + ) + parser.add_argument( + "--norm_std", + type=float, + nargs=3, + default=None, + 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, + choices=["resnet18", "wide_resnet50"], + default=None, + help="Backbone network to use for feature extraction.", + ) + parser.add_argument( + "--batch_size", + type=int, + default=None, + help="Batch size used during training and inference.", + ) + parser.add_argument( + "--feat_dim", + type=int, + default=None, + help="Number of random feature dimensions to keep.", + ) + parser.add_argument( + "--layer_indices", + type=int, + nargs="+", + default=None, + help="List of layer indices to extract features from, e.g., 0 1 2.", + ) + parser.add_argument( + "--coreset_ratio", + type=float, + default=None, + help="PatchCore memory-bank fraction to retain (0, 1].", + ) + parser.add_argument( + "--max_memory_patches", + type=int, + default=None, + help="Maximum PatchCore memory-bank size; omit for no cap.", + ) + parser.add_argument( + "--patch_grid", + type=int, + default=None, + help="PatchCore pooled grid size; use a smaller value for lower latency.", + ) + parser.add_argument( + "--search_chunk_size", + type=int, + default=None, + help="PatchCore query chunk size used to bound nearest-neighbor memory.", + ) + parser.add_argument( + "--coreset_method", + type=str, + choices=["kcenter", "random"], + default=None, + help="PatchCore coreset selection strategy; kcenter is the diverse default.", + ) + parser.add_argument( + "--coreset_seed", + type=int, + 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( + "--output_model", + type=str, + default=None, + help="Filename to save the PT model.", + ) + parser.add_argument( + "--run_name", + type=str, + default=None, + help="Experiment name for this training run.", + ) + parser.add_argument( + "--model_data_path", + type=str, + default=None, + help="Directory to save model distributions and PT file.", + ) + parser.add_argument( + "--algorithm", + type=str, + default=None, + help="Algorithm name (e.g., padim, patchcore, efficientad).", + ) + parser.add_argument( + "--log_level", + type=str, + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + default=None, + help="Logging level (default: INFO).", + ) + return parser def run_training(args): + """ + Executes the training pipeline. + + Args: + args: Namespace object containing configuration. + + Returns: + model (AnomaVision anomaly model): 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. + """ 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") + 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) + algorithm = str(config.algorithm).lower() if algorithm not in {"padim", "patchcore", "efficientad"}: - raise ValueError(f"Unsupported algorithm: {algorithm}. Available: padim, patchcore, efficientad") - if not config.dataset_path: - raise ValueError("dataset_path is required (via --dataset_path or config)") + 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 the teacher uses ImageNet preprocessing") + 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, config.crop_size, config.normalize) + + logger.info( + "Image processing: resize=%s, crop_size=%s, normalize=%s", + config.resize, + config.crop_size, + config.normalize, + ) 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) / algorithm / config.class_name / config.run_name, + Path(config.model_data_path) + / config.algorithm + / config.class_name + / config.run_name, exist_ok=True, mkdir=True, ) - root = os.path.join(os.path.realpath(config.dataset_path), config.class_name, "train", "good") + # === Dataset === + root = os.path.join( + os.path.realpath(config.dataset_path), config.class_name, "train", "good" + ) + if not os.path.isdir(root): - potential_root = os.path.join(os.path.realpath(config.dataset_path), "train", "good") + potential_root = os.path.join( + os.path.realpath(config.dataset_path), "train", "good" + ) if os.path.isdir(potential_root): root = potential_root else: @@ -90,13 +287,28 @@ def run_training(args): mean=config.norm_mean, std=config.norm_std, ) + if len(ds) == 0: - raise ValueError(f"No training images found in {root}") + error_msg = f"No training images found in {root}" + logger.error(error_msg) + raise ValueError(error_msg) 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()) - logger.info("cfg: algorithm=%s | backbone=%s | layers=%s", algorithm, config.backbone, config.layer_indices) + 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, + config.backbone, + config.layer_indices, + ) if algorithm == "patchcore": model = anomavision.PatchCore( @@ -110,7 +322,6 @@ def run_training(args): coreset_method=config.get("coreset_method", "kcenter"), coreset_seed=int(config.get("coreset_seed", 42)), ) - model.fit(dl) elif algorithm == "efficientad": model = anomavision.EfficientAD( device=device, @@ -119,9 +330,6 @@ def run_training(args): weight_decay=float(config.get("efficientad_weight_decay", 1e-5)), pretrained_teacher=bool(config.get("efficientad_pretrained_teacher", True)), ) - t_fit = time.perf_counter() - model.fit(dl, epochs=int(config.get("efficientad_epochs", 1))) - logger.info("fit: completed in %.2fs", time.perf_counter() - t_fit) else: model = anomavision.Padim( backbone=config.backbone, @@ -129,13 +337,19 @@ def run_training(args): layer_indices=config.layer_indices, feat_dim=int(config.feat_dim), ) - t_fit = time.perf_counter() + + t_fit = time.perf_counter() + if algorithm == "efficientad": + model.fit(dl, epochs=int(config.get("efficientad_epochs", 1))) + else: model.fit(dl) - logger.info("fit: completed in %.2fs", time.perf_counter() - t_fit) + 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) @@ -143,9 +357,12 @@ 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 model, config, run_dir, {"train": dl} @@ -153,13 +370,17 @@ def main(args=None): try: 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 + run_training(args) + except Exception: get_logger(__name__).exception("Fatal error during training.") sys.exit(1) From 73d365a802de25cc3290f84cba7dc305366da7bb Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:56:00 +0200 Subject: [PATCH 17/47] docs: add EfficientAD configuration reference --- docs/config.md | 239 +++++++++++++++++++++---------------------------- 1 file changed, 101 insertions(+), 138 deletions(-) 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. From a2aad29bfae0d0a9b04c20b93860b974fcda79fa Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:09:45 +0200 Subject: [PATCH 18/47] fix: make EfficientAD ONNX export free of data-dependent training check --- .../algorithm/efficientad/efficientad.py | 40 +++++++------------ 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/anomavision/algorithm/efficientad/efficientad.py b/anomavision/algorithm/efficientad/efficientad.py index bc403cd..1ef4b66 100644 --- a/anomavision/algorithm/efficientad/efficientad.py +++ b/anomavision/algorithm/efficientad/efficientad.py @@ -1,10 +1,4 @@ -"""Native AnomaVision implementation of EfficientAD. - -The implementation follows the EfficientAD student/teacher idea while exposing -AnomaVision's common ``fit``/``predict``/``save_statistics`` interface. The -teacher is frozen, the student learns normal teacher features, and a compact -autoencoder provides a global reconstruction signal. -""" +"""Native AnomaVision implementation of EfficientAD.""" from __future__ import annotations @@ -37,14 +31,11 @@ def __init__(self, out_channels: int = 112) -> None: super().__init__() self.net = nn.Sequential( nn.Conv2d(3, 64, 3, stride=2, padding=1), - nn.BatchNorm2d(64), - nn.ReLU(inplace=True), + nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.Conv2d(64, 96, 3, stride=2, padding=1), - nn.BatchNorm2d(96), - nn.ReLU(inplace=True), + nn.BatchNorm2d(96), nn.ReLU(inplace=True), nn.Conv2d(96, 112, 3, stride=2, padding=1), - nn.BatchNorm2d(112), - nn.ReLU(inplace=True), + nn.BatchNorm2d(112), nn.ReLU(inplace=True), nn.Conv2d(112, out_channels, 3, stride=2, padding=1), ) @@ -110,8 +101,6 @@ def __init__( self.to(self.device) def _normalise(self, x: torch.Tensor) -> torch.Tensor: - # Dataset tensors may already be ImageNet-normalized. This method is - # intentionally a no-op in that case; see ``normalize_input`` below. return x def _signals(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: @@ -133,13 +122,11 @@ def forward(self, x: torch.Tensor, return_map: bool = True, export: bool = False return scores, score_map if return_map else None def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 1) -> None: - """Train student and autoencoder using only normal images.""" self.train() self.teacher.eval() optimizer = torch.optim.Adam( list(self.student.parameters()) + list(self.autoencoder.parameters()), - lr=self.lr, - weight_decay=self.weight_decay, + lr=self.lr, weight_decay=self.weight_decay, ) for _ in range(int(epochs)): for batch in dataloader: @@ -173,7 +160,12 @@ def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 1) -> None: self.trained.fill_(True) def predict(self, batch: torch.Tensor, export: bool = False): - if not bool(self.trained.item()): + # Do not inspect the tensor-backed ``trained`` flag while exporting. + # torch.export treats ``trained.item()`` as data-dependent control flow + # and cannot specialize that condition. Training validation belongs to + # the Python lifecycle, while the exported graph must contain only the + # tensor computation. + if not export and not bool(self.trained.item()): raise RuntimeError("EfficientAD model is not trained. Call fit() first.") self.eval() with torch.no_grad(): @@ -184,14 +176,11 @@ def to_device(self, device: torch.device) -> None: self.to(self.device) def save_statistics(self, path: str, half: Optional[bool] = None) -> None: - """Save a self-contained EfficientAD checkpoint artifact.""" if not bool(self.trained.item()): raise RuntimeError("Model is not trained. Call fit() first.") torch.save({ - "algorithm": "efficientad", - "model_state": self.state_dict(), - "model_size": self.model_size, - "lr": self.lr, + "algorithm": "efficientad", "model_state": self.state_dict(), + "model_size": self.model_size, "lr": self.lr, "weight_decay": self.weight_decay, }, path) @@ -201,8 +190,7 @@ def load_statistics(path: str, device: str = "cpu") -> "EfficientAD": if data.get("algorithm") != "efficientad": raise ValueError("Not an EfficientAD statistics artifact") model = EfficientAD( - device=torch.device(device), - model_size=data.get("model_size", "s"), + device=torch.device(device), model_size=data.get("model_size", "s"), pretrained_teacher=False, ) model.load_state_dict(data["model_state"]) From c917a9e90b3d3daaa122b6450f0ea2557d7fcc85 Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 Date: Thu, 27 Aug 2026 12:07:52 +0200 Subject: [PATCH 19/47] cofig --- config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.yml b/config.yml index 20dda13..a904ef9 100644 --- a/config.yml +++ b/config.yml @@ -14,7 +14,7 @@ norm_std: [0.229, 0.224, 0.225] # Model / training # ========================= backbone: "resnet18" -algorithm: "padim" # padim | patchcore | efficientad +algorithm: "efficientad" # padim | patchcore | efficientad # Equivalent native selector: # model: # name: padim From 8945f40e086bc2dc39ad2e28706e7b5f52329063 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:25:13 +0200 Subject: [PATCH 20/47] fix(efficientad): make ONNX export skip data-dependent training check --- anomavision/algorithm/efficientad/efficientad.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/anomavision/algorithm/efficientad/efficientad.py b/anomavision/algorithm/efficientad/efficientad.py index 1ef4b66..db9aa58 100644 --- a/anomavision/algorithm/efficientad/efficientad.py +++ b/anomavision/algorithm/efficientad/efficientad.py @@ -160,11 +160,9 @@ def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 1) -> None: self.trained.fill_(True) def predict(self, batch: torch.Tensor, export: bool = False): - # Do not inspect the tensor-backed ``trained`` flag while exporting. - # torch.export treats ``trained.item()`` as data-dependent control flow - # and cannot specialize that condition. Training validation belongs to - # the Python lifecycle, while the exported graph must contain only the - # tensor computation. + # Training-state validation is intentionally skipped during export. + # ``trained.item()`` creates data-dependent control flow that torch.export + # cannot specialize. The exported graph must contain tensor computation only. if not export and not bool(self.trained.item()): raise RuntimeError("EfficientAD model is not trained. Call fit() first.") self.eval() From 6ba2daf9e711fb86314623135c892ad9e0a6299d Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:30:17 +0200 Subject: [PATCH 21/47] fix: add EfficientAD inference threshold --- config.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/config.yml b/config.yml index a904ef9..4d50b3c 100644 --- a/config.yml +++ b/config.yml @@ -69,7 +69,10 @@ img_path: "D:/01-DATA/test" thresh: null thresh_padim: 13.0 thresh_patchcore: 0.25 -thresh_efficientad: null +# EfficientAD scores are z-normalized using the normal-training score +# distribution. A 3-sigma threshold is the initial deployment default. +# Tune this independently from PaDiM; PaDiM's 13.0 threshold is not valid here. +thresh_efficientad: 3.0 num_workers: 1 pin_memory: false overwrite: false From fd9908a9536782a60a2900495ae7304d4e2f6e38 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:30:33 +0200 Subject: [PATCH 22/47] docs: document EfficientAD-specific thresholding --- docs/efficientad.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/efficientad.md b/docs/efficientad.md index 4f44f53..4e6cdc6 100644 --- a/docs/efficientad.md +++ b/docs/efficientad.md @@ -43,15 +43,25 @@ efficientad_pretrained_teacher: true EfficientAD uses ImageNet preprocessing for the teacher, so `normalize: true` is required by the AnomaVision integration. -## Detection +## Detection and thresholding Use the same command as the other algorithms: ```bash -anomavision detect --config config.yml --model model.pt --img_path ./test_images +anomavision detect --config config.yml --model model.onnx ``` -The model returns an image-level anomaly score and a full-resolution anomaly map, so the existing visualization and post-processing pipeline can be reused. +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 @@ -69,7 +79,7 @@ The ONNX graph contains the EfficientAD inference path, including the teacher, s 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. Keep `thresh_efficientad: null` to let evaluation determine an appropriate threshold where the existing evaluation workflow supports automatic threshold selection. +EfficientAD has its own score distribution, so thresholds should be calibrated independently from PaDiM. The inference threshold is controlled by `thresh_efficientad`. ## Model artifacts From 99ac8c8ae93d479d733f974b6dfcc3fba083efa3 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:36:41 +0200 Subject: [PATCH 23/47] fix: lower EfficientAD detection threshold --- config.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/config.yml b/config.yml index 4d50b3c..ca012b3 100644 --- a/config.yml +++ b/config.yml @@ -32,7 +32,6 @@ coreset_method: "kcenter" coreset_seed: 42 # EfficientAD -# EfficientAD uses ImageNet-normalized input and an EfficientNet-B0 teacher. efficientad_model_size: "s" # s | m efficientad_lr: 0.0001 efficientad_weight_decay: 0.00001 @@ -69,10 +68,9 @@ img_path: "D:/01-DATA/test" thresh: null thresh_padim: 13.0 thresh_patchcore: 0.25 -# EfficientAD scores are z-normalized using the normal-training score -# distribution. A 3-sigma threshold is the initial deployment default. -# Tune this independently from PaDiM; PaDiM's 13.0 threshold is not valid here. -thresh_efficientad: 3.0 +# EfficientAD uses its own score scale. Keep this independent from PaDiM. +# The score is normalized against the normal-training score distribution. +thresh_efficientad: 1.0 num_workers: 1 pin_memory: false overwrite: false From e6e195e53b383ed548617ddeaba0c001d692494d Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:57:23 +0200 Subject: [PATCH 24/47] fix: calibrate EfficientAD threshold from normal training scores --- .../algorithm/efficientad/efficientad.py | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/anomavision/algorithm/efficientad/efficientad.py b/anomavision/algorithm/efficientad/efficientad.py index db9aa58..91a2339 100644 --- a/anomavision/algorithm/efficientad/efficientad.py +++ b/anomavision/algorithm/efficientad/efficientad.py @@ -74,6 +74,7 @@ def __init__( teacher_weights: Optional[str] = None, feature_weight: float = 1.0, reconstruction_weight: float = 0.1, + threshold_quantile: float = 0.995, ) -> None: super().__init__() model_size = str(model_size).lower() @@ -81,6 +82,8 @@ def __init__( raise ValueError("EfficientAD model_size must be one of: s, m") if lr <= 0 or weight_decay < 0: raise ValueError("lr must be > 0 and weight_decay must be >= 0") + 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" @@ -88,6 +91,7 @@ def __init__( 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=pretrained_teacher) if teacher_weights: @@ -97,6 +101,7 @@ def __init__( self.autoencoder = _AutoEncoder() 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) @@ -122,6 +127,7 @@ def forward(self, x: torch.Tensor, return_map: bool = True, export: bool = False return scores, score_map if return_map else None def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 1) -> None: + """Train on normal images and calibrate the image threshold from them.""" self.train() self.teacher.eval() optimizer = torch.optim.Adam( @@ -153,10 +159,17 @@ def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 1) -> None: batch = batch.to(self.device).float() fmap, recon = self._signals(batch) values.append((fmap + self.reconstruction_weight * recon).flatten(1).amax(1)) - if values: - scores = torch.cat(values) - self.score_mean.copy_(scores.mean()) - self.score_std.copy_(scores.std(unbiased=False).clamp_min(1e-6)) + if not values: + raise RuntimeError("EfficientAD calibration requires at least one normal training image.") + + scores = torch.cat(values) + mean = scores.mean() + std = scores.std(unbiased=False).clamp_min(1e-6) + calibrated_raw_threshold = torch.quantile(scores, self.threshold_quantile) + + self.score_mean.copy_(mean) + self.score_std.copy_(std) + self.threshold.copy_((calibrated_raw_threshold - mean) / std) self.trained.fill_(True) def predict(self, batch: torch.Tensor, export: bool = False): @@ -180,6 +193,7 @@ def save_statistics(self, path: str, half: Optional[bool] = None) -> None: "algorithm": "efficientad", "model_state": self.state_dict(), "model_size": self.model_size, "lr": self.lr, "weight_decay": self.weight_decay, + "threshold_quantile": self.threshold_quantile, }, path) @staticmethod @@ -190,6 +204,7 @@ def load_statistics(path: str, device: str = "cpu") -> "EfficientAD": model = EfficientAD( device=torch.device(device), model_size=data.get("model_size", "s"), pretrained_teacher=False, + threshold_quantile=data.get("threshold_quantile", 0.995), ) model.load_state_dict(data["model_state"]) return model From 34ba07db200b23c70d6ae4ce1eefa51c055b1525 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:57:36 +0200 Subject: [PATCH 25/47] feat: persist calibrated EfficientAD threshold --- anomavision/train.py | 47 ++++++++++++++------------------------------ 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/anomavision/train.py b/anomavision/train.py index 90594c3..aff915e 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, @@ -172,6 +166,12 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: 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, @@ -208,21 +208,8 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: def run_training(args): - """ - Executes the training pipeline. - - Args: - args: Namespace object containing configuration. - - Returns: - model (AnomaVision anomaly model): 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) @@ -244,7 +231,6 @@ def run_training(args): ) t0 = time.perf_counter() - logger.info( "Image processing: resize=%s, crop_size=%s, normalize=%s", config.resize, @@ -254,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 @@ -264,11 +249,9 @@ def run_training(args): mkdir=True, ) - # === Dataset === root = os.path.join( os.path.realpath(config.dataset_path), config.class_name, "train", "good" ) - if not os.path.isdir(root): potential_root = os.path.join( os.path.realpath(config.dataset_path), "train", "good" @@ -287,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) @@ -296,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, @@ -329,6 +309,7 @@ def run_training(args): 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( @@ -341,15 +322,20 @@ def run_training(args): t_fit = time.perf_counter() 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) @@ -357,9 +343,7 @@ 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) @@ -371,7 +355,6 @@ 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(): From 7b3cb622143e264063c1855a151ff47cb86f5a4a Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:57:42 +0200 Subject: [PATCH 26/47] config: remove hard-coded EfficientAD threshold --- config.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/config.yml b/config.yml index ca012b3..9f7960e 100644 --- a/config.yml +++ b/config.yml @@ -37,6 +37,8 @@ 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" @@ -68,9 +70,8 @@ img_path: "D:/01-DATA/test" thresh: null thresh_padim: 13.0 thresh_patchcore: 0.25 -# EfficientAD uses its own score scale. Keep this independent from PaDiM. -# The score is normalized against the normal-training score distribution. -thresh_efficientad: 1.0 +# 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 From fd6f225df3cb3f7ba9da841caed2ae1dfd2c3ccb Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:57:46 +0200 Subject: [PATCH 27/47] feat: load calibrated EfficientAD thresholds for detection --- anomavision/efficientad_threshold.py | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 anomavision/efficientad_threshold.py diff --git a/anomavision/efficientad_threshold.py b/anomavision/efficientad_threshold.py new file mode 100644 index 0000000..fd58bc6 --- /dev/null +++ b/anomavision/efficientad_threshold.py @@ -0,0 +1,42 @@ +"""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." + ) From 75a4e6febbff4c7f0012dc23f571775f6931591b Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:57:56 +0200 Subject: [PATCH 28/47] feat: auto-load EfficientAD threshold in detect CLI --- anomavision/cli.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/anomavision/cli.py b/anomavision/cli.py index dce4e75..9d4f381 100644 --- a/anomavision/cli.py +++ b/anomavision/cli.py @@ -164,6 +164,22 @@ def _dispatch_export(args: argparse.Namespace) -> None: def _dispatch_detect(args: argparse.Namespace) -> None: + if str(getattr(args, "algorithm", "")).lower() == "efficientad" and getattr(args, "thresh", None) is None: + from anomavision.efficientad_threshold import load_calibrated_threshold + from anomavision.config import load_config + from pathlib import Path + + cfg = load_config(args.config) if getattr(args, "config", None) else {} + algorithm = str(getattr(args, "algorithm", None) or cfg.get("algorithm", "")).lower() + 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 algorithm == "efficientad" and 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) From a7cf0f10dad8b54108552fb83709dfb78d8c0bff Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:58:12 +0200 Subject: [PATCH 29/47] fix: resolve EfficientAD algorithm from config before threshold load --- anomavision/cli.py | 81 +++++++++------------------------------------- 1 file changed, 15 insertions(+), 66 deletions(-) diff --git a/anomavision/cli.py b/anomavision/cli.py index 9d4f381..b0f20f0 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.""" @@ -49,13 +43,11 @@ def create_parser() -> argparse.ArgumentParser: try: from anomavision import __version__ - version_str = f"AnomaVision {__version__}" except ImportError: version_str = "AnomaVision" parser.add_argument("--version", action="version", version=version_str) - subparsers = parser.add_subparsers( title="commands", description="Available AnomaVision operations", @@ -63,39 +55,18 @@ 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 - subparsers.add_parser( - "train", - help="Train a new anomaly detection model", + "train", help="Train a new anomaly detection model", parents=[_cp(add_help=False)], formatter_class=argparse.ArgumentDefaultsHelpFormatter, ).set_defaults(func=_dispatch_train) @@ -103,10 +74,8 @@ def _add_train_parser(subparsers) -> None: def _add_export_parser(subparsers) -> None: from anomavision.export import create_parser as _cp - subparsers.add_parser( - "export", - help="Export trained model to different formats", + "export", help="Export trained model to different formats", parents=[_cp(add_help=False)], formatter_class=argparse.ArgumentDefaultsHelpFormatter, ).set_defaults(func=_dispatch_export) @@ -114,10 +83,8 @@ def _add_export_parser(subparsers) -> None: def _add_detect_parser(subparsers) -> None: from anomavision.detect import create_parser as _cp - subparsers.add_parser( - "detect", - help="Run inference on images", + "detect", help="Run inference on images", parents=[_cp(add_help=False)], formatter_class=argparse.ArgumentDefaultsHelpFormatter, ).set_defaults(func=_dispatch_detect) @@ -125,10 +92,8 @@ def _add_detect_parser(subparsers) -> None: def _add_eval_parser(subparsers) -> None: from anomavision.eval import create_parser as _cp - subparsers.add_parser( - "eval", - help="Evaluate model performance", + "eval", help="Evaluate model performance", parents=[_cp(add_help=False)], formatter_class=argparse.ArgumentDefaultsHelpFormatter, ).set_defaults(func=_dispatch_eval) @@ -136,72 +101,56 @@ def _add_eval_parser(subparsers) -> None: def _add_autopilot_parser(subparsers) -> None: from anomavision.autopilot import create_parser as _cp - subparsers.add_parser( - "autopilot", - help="Calibrate, profile, and package a production model", + "autopilot", help="Calibrate, profile, and package a production model", parents=[_cp(add_help=False)], formatter_class=argparse.ArgumentDefaultsHelpFormatter, ).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 - train.main(args) def _dispatch_export(args: argparse.Namespace) -> None: from anomavision import export - export.main(args) def _dispatch_detect(args: argparse.Namespace) -> None: - if str(getattr(args, "algorithm", "")).lower() == "efficientad" and getattr(args, "thresh", None) is None: - from anomavision.efficientad_threshold import load_calibrated_threshold - from anomavision.config import load_config - from pathlib import Path + 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() - 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 algorithm == "efficientad" and class_name and run_name and model_name: + 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) def _dispatch_autopilot(args: argparse.Namespace) -> None: from anomavision import autopilot - autopilot.main(args) -# ============================================================ -# Entry point -# ============================================================ - - def main() -> None: parser = create_parser() args = parser.parse_args() From 82aeb138cd12880eb943acc488add8a466fa4866 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:58:17 +0200 Subject: [PATCH 30/47] test: verify EfficientAD threshold calibration --- tests/test_efficientad.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_efficientad.py b/tests/test_efficientad.py index 0b300c4..90bb1b8 100644 --- a/tests/test_efficientad.py +++ b/tests/test_efficientad.py @@ -12,6 +12,7 @@ def test_efficientad_fit_predict_contract(): device=torch.device("cpu"), pretrained_teacher=False, model_size="s", + threshold_quantile=0.995, ) model.fit(loader, epochs=1) @@ -20,6 +21,10 @@ def test_efficientad_fit_predict_contract(): assert maps.shape == (1, 224, 224) assert torch.isfinite(scores).all() assert torch.isfinite(maps).all() + assert torch.isfinite(model.score_mean) + assert torch.isfinite(model.score_std) + assert torch.isfinite(model.threshold) + assert model.threshold.item() >= 0.0 def test_efficientad_rejects_unknown_model_size(): @@ -29,3 +34,12 @@ def test_efficientad_rejects_unknown_model_size(): 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") From 2895b06956986a981bee19fd08d12d7b4e496036 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:58:21 +0200 Subject: [PATCH 31/47] test: verify persisted EfficientAD threshold loading --- tests/test_efficientad_threshold.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/test_efficientad_threshold.py 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 From 5f0cdf2b0cae1c3ad3e88489e3c149d80f795020 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:58:26 +0200 Subject: [PATCH 32/47] docs: remove EfficientAD hard-coded threshold from example --- examples/efficientad_cpu.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/efficientad_cpu.yml b/examples/efficientad_cpu.yml index 157b215..7ec468b 100644 --- a/examples/efficientad_cpu.yml +++ b/examples/efficientad_cpu.yml @@ -14,6 +14,8 @@ 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" @@ -25,7 +27,6 @@ log_level: "INFO" img_path: "./dataset/bottle/test" thresh: null -thresh_efficientad: null # Visualization/evaluation/export defaults. enable_visualization: true From 567d11bec61f11b5a92c07120d043dc048527694 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:10:14 +0200 Subject: [PATCH 33/47] fix EfficientAD pipeline, calibration, and localization --- .../algorithm/efficientad/efficientad.py | 232 ++++++++++++------ 1 file changed, 162 insertions(+), 70 deletions(-) diff --git a/anomavision/algorithm/efficientad/efficientad.py b/anomavision/algorithm/efficientad/efficientad.py index 91a2339..0005c26 100644 --- a/anomavision/algorithm/efficientad/efficientad.py +++ b/anomavision/algorithm/efficientad/efficientad.py @@ -1,4 +1,11 @@ -"""Native AnomaVision implementation of EfficientAD.""" +"""EfficientAD implementation integrated with the AnomaVision algorithm API. + +The implementation deliberately follows the PaDiM/PatchCore contract: +``fit(dataloader)`` trains/calibrates from normal data and ``predict(batch)`` +returns image scores plus an image-sized anomaly map. Teacher features are +cached once during training so additional epochs do not repeat the frozen +backbone extraction. +""" from __future__ import annotations @@ -11,31 +18,40 @@ class _FeatureTeacher(nn.Module): + """Frozen EfficientNet feature extractor used by EfficientAD.""" + def __init__(self, pretrained: bool = True) -> None: super().__init__() weights = EfficientNet_B0_Weights.DEFAULT if pretrained else None net = efficientnet_b0(weights=weights) + # EfficientNet-B0 stage 6 produces a compact 112-channel 14x14 map for + # the standard 224x224 AnomaVision input. self.features = nn.Sequential(*list(net.features[:6])) self.out_channels = 112 - for p in self.parameters(): - p.requires_grad_(False) + for parameter in self.parameters(): + parameter.requires_grad_(False) self.eval() + @torch.no_grad() def forward(self, x: torch.Tensor) -> torch.Tensor: - with torch.no_grad(): - return self.features(x) + return self.features(x) class _Student(nn.Module): + """Small trainable student matching the teacher feature resolution.""" + def __init__(self, out_channels: int = 112) -> None: super().__init__() self.net = nn.Sequential( nn.Conv2d(3, 64, 3, stride=2, padding=1), - nn.BatchNorm2d(64), nn.ReLU(inplace=True), + nn.BatchNorm2d(64), + nn.ReLU(inplace=True), nn.Conv2d(64, 96, 3, stride=2, padding=1), - nn.BatchNorm2d(96), nn.ReLU(inplace=True), + nn.BatchNorm2d(96), + nn.ReLU(inplace=True), nn.Conv2d(96, 112, 3, stride=2, padding=1), - nn.BatchNorm2d(112), nn.ReLU(inplace=True), + nn.BatchNorm2d(112), + nn.ReLU(inplace=True), nn.Conv2d(112, out_channels, 3, stride=2, padding=1), ) @@ -44,16 +60,23 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: class _AutoEncoder(nn.Module): + """Compact reconstruction branch used as the second anomaly signal.""" + def __init__(self) -> None: super().__init__() self.encoder = nn.Sequential( - nn.Conv2d(3, 32, 4, 2, 1), nn.ReLU(inplace=True), - nn.Conv2d(32, 64, 4, 2, 1), nn.ReLU(inplace=True), - nn.Conv2d(64, 96, 4, 2, 1), nn.ReLU(inplace=True), + nn.Conv2d(3, 32, 4, 2, 1), + nn.ReLU(inplace=True), + nn.Conv2d(32, 64, 4, 2, 1), + nn.ReLU(inplace=True), + nn.Conv2d(64, 96, 4, 2, 1), + nn.ReLU(inplace=True), ) self.decoder = nn.Sequential( - nn.ConvTranspose2d(96, 64, 4, 2, 1), nn.ReLU(inplace=True), - nn.ConvTranspose2d(64, 32, 4, 2, 1), nn.ReLU(inplace=True), + nn.ConvTranspose2d(96, 64, 4, 2, 1), + nn.ReLU(inplace=True), + nn.ConvTranspose2d(64, 32, 4, 2, 1), + nn.ReLU(inplace=True), nn.ConvTranspose2d(32, 3, 4, 2, 1), ) @@ -62,7 +85,13 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: class EfficientAD(nn.Module): - """EfficientAD-compatible anomaly detector for the AnomaVision pipeline.""" + """EfficientAD detector with the same fit/predict contract as PaDiM. + + Scores and maps use the same calibrated scale. ``score_mean``, + ``score_std`` and ``threshold`` are learned exclusively from normal + training images, and all three are registered buffers so they survive PT + and ONNX export. + """ def __init__( self, @@ -99,6 +128,7 @@ def __init__( self.teacher.load_state_dict(state, strict=False) self.student = _Student(self.teacher.out_channels) self.autoencoder = _AutoEncoder() + 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)) @@ -106,81 +136,136 @@ def __init__( self.to(self.device) def _normalise(self, x: torch.Tensor) -> torch.Tensor: + # AnodetDataset performs the ImageNet normalization shared by all + # AnomaVision algorithms. Do not normalize a second time here. return x - def _signals(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: + @torch.no_grad() + def _raw_signals( + self, x: torch.Tensor, teacher: Optional[torch.Tensor] = None + ) -> Tuple[torch.Tensor, torch.Tensor]: x_norm = self._normalise(x) - teacher = self.teacher(x_norm) - student = self.student(x_norm) - feature_map = (student - teacher).pow(2).mean(dim=1) + teacher_features = self.teacher(x_norm) if teacher is None else teacher + student_features = self.student(x_norm) + feature_map = (student_features - teacher_features).pow(2).mean(dim=1) reconstruction = (self.autoencoder(x) - x).abs().mean(dim=1) feature_map = F.interpolate( - feature_map.unsqueeze(1), size=x.shape[-2:], mode="bilinear", align_corners=False + feature_map.unsqueeze(1), + size=x.shape[-2:], + mode="bilinear", + align_corners=False, ).squeeze(1) return feature_map, reconstruction - def forward(self, x: torch.Tensor, return_map: bool = True, export: bool = False): - feature_map, reconstruction = self._signals(x) - score_map = feature_map + self.reconstruction_weight * reconstruction - scores = score_map.flatten(1).amax(1) - scores = (scores - self.score_mean) / self.score_std.clamp_min(1e-6) - return scores, score_map if return_map else None + def _score_map(self, feature_map: torch.Tensor, reconstruction: torch.Tensor) -> torch.Tensor: + """Combine signals and put the map on the calibrated image-score scale.""" + raw_map = feature_map + self.reconstruction_weight * reconstruction + # Calibration is performed on raw image scores. Normalize the complete + # map as well so localization uses exactly the same threshold units as + # image classification. + return (raw_map - self.score_mean) / self.score_std.clamp_min(1e-6) + + def forward( + self, x: torch.Tensor, return_map: bool = True, export: bool = False + ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + del export # kept for the common AnomaVision export contract + feature_map, reconstruction = self._raw_signals(x) + score_map = self._score_map(feature_map, reconstruction) + image_scores = score_map.flatten(1).amax(1) + return image_scores, score_map if return_map else None + + def _iter_batches(self, dataloader): + for item in dataloader: + batch = item[0] if isinstance(item, (tuple, list)) else item + yield batch.to(self.device, non_blocking=True).float() def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 1) -> None: - """Train on normal images and calibrate the image threshold from them.""" - self.train() - self.teacher.eval() + """Train on normal images and calibrate only from those normal images. + + The frozen teacher is evaluated exactly once per training image. Inputs + and teacher features are then reused for every requested epoch. This + removes the expensive repeated EfficientNet pass that made the earlier + implementation scale poorly with ``epochs`` and avoids a second + dataloader pass for calibration. + """ + epochs = int(epochs) + if epochs < 1: + raise ValueError("epochs must be >= 1") + + # Cache the already-preprocessed training tensors and teacher features. + # PatchCore/PaDiM also operate on the DataLoader's preprocessed tensors; + # keeping this cache makes EfficientAD deterministic and avoids repeated + # CPU image decoding/preprocessing for multi-epoch training. + cached_inputs = [] + cached_teacher = [] + self.eval() + with torch.inference_mode(): + for batch in self._iter_batches(dataloader): + cached_inputs.append(batch.detach().cpu()) + cached_teacher.append(self.teacher(self._normalise(batch)).detach().cpu()) + + if not cached_inputs: + raise RuntimeError("EfficientAD training requires at least one normal training image.") + optimizer = torch.optim.Adam( list(self.student.parameters()) + list(self.autoencoder.parameters()), - lr=self.lr, weight_decay=self.weight_decay, + lr=self.lr, + weight_decay=self.weight_decay, ) - for _ in range(int(epochs)): - for batch in dataloader: - if isinstance(batch, (tuple, list)): - batch = batch[0] - batch = batch.to(self.device, non_blocking=True).float() - with torch.no_grad(): - teacher = self.teacher(self._normalise(batch)) - student = self.student(self._normalise(batch)) - reconstructed = self.autoencoder(batch) - feature_loss = F.mse_loss(student, teacher) - reconstruction_loss = F.l1_loss(reconstructed, batch) - loss = self.feature_weight * feature_loss + self.reconstruction_weight * reconstruction_loss + + self.student.train() + self.autoencoder.train() + use_amp = self.device.type == "cuda" + amp_scaler = torch.amp.GradScaler("cuda", enabled=use_amp) + + for _ in range(epochs): + for images_cpu, teacher_cpu in zip(cached_inputs, cached_teacher): + 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) - loss.backward() - optimizer.step() + with torch.autocast(device_type="cuda", dtype=torch.float16, enabled=use_amp): + student = self.student(self._normalise(images)) + reconstructed = self.autoencoder(images) + feature_loss = F.mse_loss(student.float(), teacher.float()) + reconstruction_loss = F.l1_loss(reconstructed.float(), images.float()) + loss = ( + self.feature_weight * feature_loss + + self.reconstruction_weight * reconstruction_loss + ) + amp_scaler.scale(loss).backward() + amp_scaler.step(optimizer) + amp_scaler.update() + # One final normal-only pass over the cached tensors calibrates the full + # raw score distribution after training. No test/anomalous image is used. self.eval() - values = [] - with torch.no_grad(): - for batch in dataloader: - if isinstance(batch, (tuple, list)): - batch = batch[0] - batch = batch.to(self.device).float() - fmap, recon = self._signals(batch) - values.append((fmap + self.reconstruction_weight * recon).flatten(1).amax(1)) - if not values: - raise RuntimeError("EfficientAD calibration requires at least one normal training image.") - - scores = torch.cat(values) + normal_scores = [] + with torch.inference_mode(): + for images_cpu, teacher_cpu in zip(cached_inputs, cached_teacher): + images = images_cpu.to(self.device, non_blocking=True) + teacher = teacher_cpu.to(self.device, non_blocking=True) + fmap, recon = self._raw_signals(images, teacher=teacher) + raw_map = fmap + self.reconstruction_weight * recon + normal_scores.append(raw_map.flatten(1).amax(1)) + + scores = torch.cat(normal_scores) mean = scores.mean() std = scores.std(unbiased=False).clamp_min(1e-6) - calibrated_raw_threshold = torch.quantile(scores, self.threshold_quantile) + raw_threshold = torch.quantile(scores, self.threshold_quantile) self.score_mean.copy_(mean) self.score_std.copy_(std) - self.threshold.copy_((calibrated_raw_threshold - mean) / std) + self.threshold.copy_((raw_threshold - mean) / std) self.trained.fill_(True) - def predict(self, batch: torch.Tensor, export: bool = False): - # Training-state validation is intentionally skipped during export. - # ``trained.item()`` creates data-dependent control flow that torch.export - # cannot specialize. The exported graph must contain tensor computation only. + def predict( + self, batch: torch.Tensor, export: bool = False + ) -> Tuple[torch.Tensor, torch.Tensor]: if not export and not bool(self.trained.item()): raise RuntimeError("EfficientAD model is not trained. Call fit() first.") self.eval() - with torch.no_grad(): - return self.forward(batch.to(self.device).float(), export=export) + with torch.inference_mode(): + return self.forward(batch.to(self.device, non_blocking=True).float(), export=export) def to_device(self, device: torch.device) -> None: self.device = torch.device(device) @@ -189,12 +274,18 @@ def to_device(self, device: torch.device) -> None: def save_statistics(self, path: str, half: Optional[bool] = None) -> None: if not bool(self.trained.item()): raise RuntimeError("Model is not trained. Call fit() first.") - torch.save({ - "algorithm": "efficientad", "model_state": self.state_dict(), - "model_size": self.model_size, "lr": self.lr, - "weight_decay": self.weight_decay, - "threshold_quantile": self.threshold_quantile, - }, path) + state = self.state_dict() + torch.save( + { + "algorithm": "efficientad", + "model_state": state, + "model_size": self.model_size, + "lr": self.lr, + "weight_decay": self.weight_decay, + "threshold_quantile": self.threshold_quantile, + }, + path, + ) @staticmethod def load_statistics(path: str, device: str = "cpu") -> "EfficientAD": @@ -202,7 +293,8 @@ def load_statistics(path: str, device: str = "cpu") -> "EfficientAD": if data.get("algorithm") != "efficientad": raise ValueError("Not an EfficientAD statistics artifact") model = EfficientAD( - device=torch.device(device), model_size=data.get("model_size", "s"), + device=torch.device(device), + model_size=data.get("model_size", "s"), pretrained_teacher=False, threshold_quantile=data.get("threshold_quantile", 0.995), ) From 7177d64c76f6cc47d98651257562d549adb57955 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:13:32 +0200 Subject: [PATCH 34/47] fix EfficientAD inference tensor autograd conflict --- .../algorithm/efficientad/efficientad.py | 187 ++++-------------- 1 file changed, 39 insertions(+), 148 deletions(-) diff --git a/anomavision/algorithm/efficientad/efficientad.py b/anomavision/algorithm/efficientad/efficientad.py index 0005c26..a8882ea 100644 --- a/anomavision/algorithm/efficientad/efficientad.py +++ b/anomavision/algorithm/efficientad/efficientad.py @@ -1,10 +1,6 @@ """EfficientAD implementation integrated with the AnomaVision algorithm API. -The implementation deliberately follows the PaDiM/PatchCore contract: -``fit(dataloader)`` trains/calibrates from normal data and ``predict(batch)`` -returns image scores plus an image-sized anomaly map. Teacher features are -cached once during training so additional epochs do not repeat the frozen -backbone extraction. +The implementation follows the PaDiM/PatchCore fit/predict contract. """ from __future__ import annotations @@ -18,14 +14,10 @@ class _FeatureTeacher(nn.Module): - """Frozen EfficientNet feature extractor used by EfficientAD.""" - def __init__(self, pretrained: bool = True) -> None: super().__init__() weights = EfficientNet_B0_Weights.DEFAULT if pretrained else None net = efficientnet_b0(weights=weights) - # EfficientNet-B0 stage 6 produces a compact 112-channel 14x14 map for - # the standard 224x224 AnomaVision input. self.features = nn.Sequential(*list(net.features[:6])) self.out_channels = 112 for parameter in self.parameters(): @@ -38,20 +30,12 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: class _Student(nn.Module): - """Small trainable student matching the teacher feature resolution.""" - def __init__(self, out_channels: int = 112) -> None: super().__init__() self.net = nn.Sequential( - nn.Conv2d(3, 64, 3, stride=2, padding=1), - nn.BatchNorm2d(64), - nn.ReLU(inplace=True), - nn.Conv2d(64, 96, 3, stride=2, padding=1), - nn.BatchNorm2d(96), - nn.ReLU(inplace=True), - nn.Conv2d(96, 112, 3, stride=2, padding=1), - nn.BatchNorm2d(112), - nn.ReLU(inplace=True), + nn.Conv2d(3, 64, 3, stride=2, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), + nn.Conv2d(64, 96, 3, stride=2, padding=1), nn.BatchNorm2d(96), nn.ReLU(inplace=True), + nn.Conv2d(96, 112, 3, stride=2, padding=1), nn.BatchNorm2d(112), nn.ReLU(inplace=True), nn.Conv2d(112, out_channels, 3, stride=2, padding=1), ) @@ -60,23 +44,16 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: class _AutoEncoder(nn.Module): - """Compact reconstruction branch used as the second anomaly signal.""" - def __init__(self) -> None: super().__init__() self.encoder = nn.Sequential( - nn.Conv2d(3, 32, 4, 2, 1), - nn.ReLU(inplace=True), - nn.Conv2d(32, 64, 4, 2, 1), - nn.ReLU(inplace=True), - nn.Conv2d(64, 96, 4, 2, 1), - nn.ReLU(inplace=True), + nn.Conv2d(3, 32, 4, 2, 1), nn.ReLU(inplace=True), + nn.Conv2d(32, 64, 4, 2, 1), nn.ReLU(inplace=True), + nn.Conv2d(64, 96, 4, 2, 1), nn.ReLU(inplace=True), ) self.decoder = nn.Sequential( - nn.ConvTranspose2d(96, 64, 4, 2, 1), - nn.ReLU(inplace=True), - nn.ConvTranspose2d(64, 32, 4, 2, 1), - nn.ReLU(inplace=True), + nn.ConvTranspose2d(96, 64, 4, 2, 1), nn.ReLU(inplace=True), + nn.ConvTranspose2d(64, 32, 4, 2, 1), nn.ReLU(inplace=True), nn.ConvTranspose2d(32, 3, 4, 2, 1), ) @@ -85,26 +62,11 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: class EfficientAD(nn.Module): - """EfficientAD detector with the same fit/predict contract as PaDiM. - - Scores and maps use the same calibrated scale. ``score_mean``, - ``score_std`` and ``threshold`` are learned exclusively from normal - training images, and all three are registered buffers so they survive PT - and ONNX export. - """ - - def __init__( - self, - device: torch.device = torch.device("cpu"), - model_size: str = "s", - lr: float = 1e-4, - weight_decay: float = 1e-5, - pretrained_teacher: bool = True, - teacher_weights: Optional[str] = None, - feature_weight: float = 1.0, - reconstruction_weight: float = 0.1, - threshold_quantile: float = 0.995, - ) -> None: + def __init__(self, device: torch.device = torch.device("cpu"), model_size: str = "s", + lr: float = 1e-4, weight_decay: float = 1e-5, + pretrained_teacher: bool = True, teacher_weights: Optional[str] = None, + feature_weight: float = 1.0, reconstruction_weight: float = 0.1, + threshold_quantile: float = 0.995) -> None: super().__init__() model_size = str(model_size).lower() if model_size not in {"s", "m", "small", "medium"}: @@ -113,7 +75,6 @@ def __init__( raise ValueError("lr must be > 0 and weight_decay must be >= 0") 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) @@ -121,14 +82,11 @@ def __init__( self.feature_weight = float(feature_weight) self.reconstruction_weight = float(reconstruction_weight) self.threshold_quantile = float(threshold_quantile) - self.teacher = _FeatureTeacher(pretrained=pretrained_teacher) if teacher_weights: - state = torch.load(teacher_weights, map_location="cpu", weights_only=False) - self.teacher.load_state_dict(state, strict=False) + 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.autoencoder = _AutoEncoder() - 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)) @@ -136,39 +94,24 @@ def __init__( self.to(self.device) def _normalise(self, x: torch.Tensor) -> torch.Tensor: - # AnodetDataset performs the ImageNet normalization shared by all - # AnomaVision algorithms. Do not normalize a second time here. return x @torch.no_grad() - def _raw_signals( - self, x: torch.Tensor, teacher: Optional[torch.Tensor] = None - ) -> Tuple[torch.Tensor, torch.Tensor]: + def _raw_signals(self, x: torch.Tensor, teacher: Optional[torch.Tensor] = None) -> Tuple[torch.Tensor, torch.Tensor]: x_norm = self._normalise(x) teacher_features = self.teacher(x_norm) if teacher is None else teacher student_features = self.student(x_norm) feature_map = (student_features - teacher_features).pow(2).mean(dim=1) reconstruction = (self.autoencoder(x) - x).abs().mean(dim=1) - feature_map = F.interpolate( - feature_map.unsqueeze(1), - size=x.shape[-2:], - mode="bilinear", - align_corners=False, - ).squeeze(1) + feature_map = F.interpolate(feature_map.unsqueeze(1), size=x.shape[-2:], mode="bilinear", align_corners=False).squeeze(1) return feature_map, reconstruction def _score_map(self, feature_map: torch.Tensor, reconstruction: torch.Tensor) -> torch.Tensor: - """Combine signals and put the map on the calibrated image-score scale.""" raw_map = feature_map + self.reconstruction_weight * reconstruction - # Calibration is performed on raw image scores. Normalize the complete - # map as well so localization uses exactly the same threshold units as - # image classification. return (raw_map - self.score_mean) / self.score_std.clamp_min(1e-6) - def forward( - self, x: torch.Tensor, return_map: bool = True, export: bool = False - ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: - del export # kept for the common AnomaVision export contract + def forward(self, x: torch.Tensor, return_map: bool = True, export: bool = False) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + del export feature_map, reconstruction = self._raw_signals(x) score_map = self._score_map(feature_map, reconstruction) image_scores = score_map.flatten(1).amax(1) @@ -180,44 +123,23 @@ def _iter_batches(self, dataloader): yield batch.to(self.device, non_blocking=True).float() def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 1) -> None: - """Train on normal images and calibrate only from those normal images. - - The frozen teacher is evaluated exactly once per training image. Inputs - and teacher features are then reused for every requested epoch. This - removes the expensive repeated EfficientNet pass that made the earlier - implementation scale poorly with ``epochs`` and avoids a second - dataloader pass for calibration. - """ epochs = int(epochs) if epochs < 1: raise ValueError("epochs must be >= 1") - - # Cache the already-preprocessed training tensors and teacher features. - # PatchCore/PaDiM also operate on the DataLoader's preprocessed tensors; - # keeping this cache makes EfficientAD deterministic and avoids repeated - # CPU image decoding/preprocessing for multi-epoch training. - cached_inputs = [] - cached_teacher = [] + cached_inputs, cached_teacher = [], [] self.eval() - with torch.inference_mode(): + # Use no_grad, not inference_mode: cached teacher tensors are later used + # as targets in an autograd-tracked student loss. + with torch.no_grad(): for batch in self._iter_batches(dataloader): cached_inputs.append(batch.detach().cpu()) cached_teacher.append(self.teacher(self._normalise(batch)).detach().cpu()) - if not cached_inputs: raise RuntimeError("EfficientAD training requires at least one normal training image.") - - optimizer = torch.optim.Adam( - list(self.student.parameters()) + list(self.autoencoder.parameters()), - lr=self.lr, - weight_decay=self.weight_decay, - ) - - self.student.train() - self.autoencoder.train() + optimizer = torch.optim.Adam(list(self.student.parameters()) + list(self.autoencoder.parameters()), lr=self.lr, weight_decay=self.weight_decay) + self.student.train(); self.autoencoder.train() use_amp = self.device.type == "cuda" - amp_scaler = torch.amp.GradScaler("cuda", enabled=use_amp) - + scaler = torch.amp.GradScaler("cuda", enabled=use_amp) for _ in range(epochs): for images_cpu, teacher_cpu in zip(cached_inputs, cached_teacher): images = images_cpu.to(self.device, non_blocking=True) @@ -228,39 +150,23 @@ def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 1) -> None: reconstructed = self.autoencoder(images) feature_loss = F.mse_loss(student.float(), teacher.float()) reconstruction_loss = F.l1_loss(reconstructed.float(), images.float()) - loss = ( - self.feature_weight * feature_loss - + self.reconstruction_weight * reconstruction_loss - ) - amp_scaler.scale(loss).backward() - amp_scaler.step(optimizer) - amp_scaler.update() - - # One final normal-only pass over the cached tensors calibrates the full - # raw score distribution after training. No test/anomalous image is used. + loss = self.feature_weight * feature_loss + self.reconstruction_weight * reconstruction_loss + scaler.scale(loss).backward(); scaler.step(optimizer); scaler.update() self.eval() normal_scores = [] - with torch.inference_mode(): + with torch.no_grad(): for images_cpu, teacher_cpu in zip(cached_inputs, cached_teacher): images = images_cpu.to(self.device, non_blocking=True) teacher = teacher_cpu.to(self.device, non_blocking=True) fmap, recon = self._raw_signals(images, teacher=teacher) - raw_map = fmap + self.reconstruction_weight * recon - normal_scores.append(raw_map.flatten(1).amax(1)) - + normal_scores.append((fmap + self.reconstruction_weight * recon).flatten(1).amax(1)) scores = torch.cat(normal_scores) - mean = scores.mean() - std = scores.std(unbiased=False).clamp_min(1e-6) + mean = scores.mean(); std = scores.std(unbiased=False).clamp_min(1e-6) raw_threshold = torch.quantile(scores, self.threshold_quantile) + self.score_mean.copy_(mean); self.score_std.copy_(std) + self.threshold.copy_((raw_threshold - mean) / std); self.trained.fill_(True) - self.score_mean.copy_(mean) - self.score_std.copy_(std) - self.threshold.copy_((raw_threshold - mean) / std) - self.trained.fill_(True) - - def predict( - self, batch: torch.Tensor, export: bool = False - ) -> Tuple[torch.Tensor, torch.Tensor]: + def predict(self, batch: torch.Tensor, export: bool = False) -> Tuple[torch.Tensor, torch.Tensor]: if not export and not bool(self.trained.item()): raise RuntimeError("EfficientAD model is not trained. Call fit() first.") self.eval() @@ -268,35 +174,20 @@ def predict( return self.forward(batch.to(self.device, non_blocking=True).float(), export=export) def to_device(self, device: torch.device) -> None: - self.device = torch.device(device) - self.to(self.device) + self.device = torch.device(device); self.to(self.device) def save_statistics(self, path: str, half: Optional[bool] = None) -> None: if not bool(self.trained.item()): raise RuntimeError("Model is not trained. Call fit() first.") - state = self.state_dict() - torch.save( - { - "algorithm": "efficientad", - "model_state": state, - "model_size": self.model_size, - "lr": self.lr, - "weight_decay": self.weight_decay, - "threshold_quantile": self.threshold_quantile, - }, - path, - ) + torch.save({"algorithm": "efficientad", "model_state": self.state_dict(), "model_size": self.model_size, + "lr": self.lr, "weight_decay": self.weight_decay, "threshold_quantile": self.threshold_quantile}, path) @staticmethod def load_statistics(path: str, device: str = "cpu") -> "EfficientAD": data = torch.load(path, map_location="cpu", weights_only=False) if data.get("algorithm") != "efficientad": raise ValueError("Not an EfficientAD statistics artifact") - model = EfficientAD( - device=torch.device(device), - model_size=data.get("model_size", "s"), - pretrained_teacher=False, - threshold_quantile=data.get("threshold_quantile", 0.995), - ) + model = EfficientAD(device=torch.device(device), model_size=data.get("model_size", "s"), pretrained_teacher=False, + threshold_quantile=data.get("threshold_quantile", 0.995)) model.load_state_dict(data["model_state"]) return model From 5ffac4fbb6afeef689206b1546fdd6978771096f Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:20:41 +0200 Subject: [PATCH 35/47] fix EfficientAD scoring and inference performance --- .../algorithm/efficientad/efficientad.py | 221 ++++++++++-------- 1 file changed, 128 insertions(+), 93 deletions(-) diff --git a/anomavision/algorithm/efficientad/efficientad.py b/anomavision/algorithm/efficientad/efficientad.py index a8882ea..9d74e74 100644 --- a/anomavision/algorithm/efficientad/efficientad.py +++ b/anomavision/algorithm/efficientad/efficientad.py @@ -1,6 +1,8 @@ -"""EfficientAD implementation integrated with the AnomaVision algorithm API. +"""EfficientAD teacher/student anomaly detector using the AnomaVision API. -The implementation follows the PaDiM/PatchCore fit/predict contract. +The deployment path intentionally contains only the teacher/student discrepancy. +The reconstruction branch used by the previous implementation was both expensive +and poorly calibrated for the shared AnomaVision score/map contract. """ from __future__ import annotations @@ -20,8 +22,8 @@ def __init__(self, pretrained: bool = True) -> None: net = efficientnet_b0(weights=weights) self.features = nn.Sequential(*list(net.features[:6])) self.out_channels = 112 - for parameter in self.parameters(): - parameter.requires_grad_(False) + for p in self.parameters(): + p.requires_grad_(False) self.eval() @torch.no_grad() @@ -33,60 +35,62 @@ class _Student(nn.Module): def __init__(self, out_channels: int = 112) -> None: super().__init__() self.net = nn.Sequential( - nn.Conv2d(3, 64, 3, stride=2, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), - nn.Conv2d(64, 96, 3, stride=2, padding=1), nn.BatchNorm2d(96), nn.ReLU(inplace=True), - nn.Conv2d(96, 112, 3, stride=2, padding=1), nn.BatchNorm2d(112), nn.ReLU(inplace=True), - nn.Conv2d(112, out_channels, 3, stride=2, padding=1), + 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, out_channels, 3, 2, 1), ) def forward(self, x: torch.Tensor) -> torch.Tensor: return self.net(x) -class _AutoEncoder(nn.Module): - def __init__(self) -> None: - super().__init__() - self.encoder = nn.Sequential( - nn.Conv2d(3, 32, 4, 2, 1), nn.ReLU(inplace=True), - nn.Conv2d(32, 64, 4, 2, 1), nn.ReLU(inplace=True), - nn.Conv2d(64, 96, 4, 2, 1), nn.ReLU(inplace=True), - ) - self.decoder = nn.Sequential( - nn.ConvTranspose2d(96, 64, 4, 2, 1), nn.ReLU(inplace=True), - nn.ConvTranspose2d(64, 32, 4, 2, 1), nn.ReLU(inplace=True), - nn.ConvTranspose2d(32, 3, 4, 2, 1), - ) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - return self.decoder(self.encoder(x)) - - class EfficientAD(nn.Module): - def __init__(self, device: torch.device = torch.device("cpu"), model_size: str = "s", - lr: float = 1e-4, weight_decay: float = 1e-5, - pretrained_teacher: bool = True, teacher_weights: Optional[str] = None, - feature_weight: float = 1.0, reconstruction_weight: float = 0.1, - threshold_quantile: float = 0.995) -> None: + """EfficientAD-compatible teacher/student detector. + + Normal training data is used to learn the student and to calibrate a + per-pixel discrepancy distribution. Inference produces one normalized + anomaly map and its maximum as the image score. + """ + + def __init__( + self, + device: torch.device = torch.device("cpu"), + model_size: str = "s", + lr: float = 1e-4, + weight_decay: float = 1e-5, + pretrained_teacher: bool = True, + teacher_weights: Optional[str] = None, + feature_weight: float = 1.0, + reconstruction_weight: float = 0.1, + threshold_quantile: float = 0.995, + ) -> None: super().__init__() - model_size = str(model_size).lower() - if model_size not in {"s", "m", "small", "medium"}: + size = str(model_size).lower() + if size not in {"s", "m", "small", "medium"}: raise ValueError("EfficientAD model_size must be one of: s, m") if lr <= 0 or weight_decay < 0: raise ValueError("lr must be > 0 and weight_decay must be >= 0") - if not 0.0 < float(threshold_quantile) < 1.0: + if not 0.0 < 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.model_size = "m" if size in {"m", "medium"} else "s" self.lr = float(lr) self.weight_decay = float(weight_decay) self.feature_weight = float(feature_weight) + # Kept for config compatibility. Reconstruction is deliberately not run + # in the deployment graph because it previously dominated inference time. self.reconstruction_weight = float(reconstruction_weight) self.threshold_quantile = float(threshold_quantile) - self.teacher = _FeatureTeacher(pretrained=pretrained_teacher) + + 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) + state = torch.load(teacher_weights, map_location="cpu", weights_only=False) + self.teacher.load_state_dict(state, strict=False) self.student = _Student(self.teacher.out_channels) - self.autoencoder = _AutoEncoder() + + self.register_buffer("map_mean", torch.zeros(1, 1, 1)) + self.register_buffer("map_std", torch.ones(1, 1, 1)) 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)) @@ -97,74 +101,91 @@ def _normalise(self, x: torch.Tensor) -> torch.Tensor: return x @torch.no_grad() - def _raw_signals(self, x: torch.Tensor, teacher: Optional[torch.Tensor] = None) -> Tuple[torch.Tensor, torch.Tensor]: - x_norm = self._normalise(x) - teacher_features = self.teacher(x_norm) if teacher is None else teacher - student_features = self.student(x_norm) - feature_map = (student_features - teacher_features).pow(2).mean(dim=1) - reconstruction = (self.autoencoder(x) - x).abs().mean(dim=1) - feature_map = F.interpolate(feature_map.unsqueeze(1), size=x.shape[-2:], mode="bilinear", align_corners=False).squeeze(1) - return feature_map, reconstruction - - def _score_map(self, feature_map: torch.Tensor, reconstruction: torch.Tensor) -> torch.Tensor: - raw_map = feature_map + self.reconstruction_weight * reconstruction - return (raw_map - self.score_mean) / self.score_std.clamp_min(1e-6) - - def forward(self, x: torch.Tensor, return_map: bool = True, export: bool = False) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + def _raw_map(self, x: torch.Tensor, teacher: Optional[torch.Tensor] = None) -> torch.Tensor: + x = x.to(self.device, non_blocking=True).float() + teacher_features = self.teacher(self._normalise(x)) if teacher is None else teacher + student_features = self.student(self._normalise(x)) + discrepancy = (student_features - teacher_features).pow(2).mean(dim=1, keepdim=True) + return F.interpolate( + discrepancy, size=x.shape[-2:], mode="bilinear", align_corners=False + ).squeeze(1) + + def _score_map(self, raw_map: torch.Tensor) -> torch.Tensor: + return (raw_map - self.map_mean) / self.map_std.clamp_min(1e-6) + + def forward( + self, x: torch.Tensor, return_map: bool = True, export: bool = False + ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: del export - feature_map, reconstruction = self._raw_signals(x) - score_map = self._score_map(feature_map, reconstruction) - image_scores = score_map.flatten(1).amax(1) - return image_scores, score_map if return_map else None + raw_map = self._raw_map(x) + score_map = self._score_map(raw_map) + scores = score_map.flatten(1).amax(1) + return scores, score_map if return_map else None - def _iter_batches(self, dataloader): - for item in dataloader: - batch = item[0] if isinstance(item, (tuple, list)) else item - yield batch.to(self.device, non_blocking=True).float() + @staticmethod + def _batch_from_item(item): + if isinstance(item, (tuple, list)): + return item[0] + return item - def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 1) -> None: + def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 5) -> None: epochs = int(epochs) if epochs < 1: raise ValueError("epochs must be >= 1") - cached_inputs, cached_teacher = [], [] - self.eval() - # Use no_grad, not inference_mode: cached teacher tensors are later used - # as targets in an autograd-tracked student loss. + + # Cache only teacher features. Images remain in the normal DataLoader so + # we do not duplicate the entire dataset in RAM or move it repeatedly. + cached = [] + self.teacher.eval() with torch.no_grad(): - for batch in self._iter_batches(dataloader): - cached_inputs.append(batch.detach().cpu()) - cached_teacher.append(self.teacher(self._normalise(batch)).detach().cpu()) - if not cached_inputs: - raise RuntimeError("EfficientAD training requires at least one normal training image.") - optimizer = torch.optim.Adam(list(self.student.parameters()) + list(self.autoencoder.parameters()), lr=self.lr, weight_decay=self.weight_decay) - self.student.train(); self.autoencoder.train() + for item in dataloader: + images = self._batch_from_item(item).to(self.device, non_blocking=True).float() + teacher = self.teacher(self._normalise(images)).detach() + cached.append((images.detach().cpu(), teacher.detach().cpu())) + 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(epochs): - for images_cpu, teacher_cpu in zip(cached_inputs, cached_teacher): + 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)) - reconstructed = self.autoencoder(images) - feature_loss = F.mse_loss(student.float(), teacher.float()) - reconstruction_loss = F.l1_loss(reconstructed.float(), images.float()) - loss = self.feature_weight * feature_loss + self.reconstruction_weight * reconstruction_loss - scaler.scale(loss).backward(); scaler.step(optimizer); scaler.update() - self.eval() - normal_scores = [] + loss = F.mse_loss(student.float(), teacher.float()) + scaler.scale(loss).backward() + scaler.step(optimizer) + scaler.update() + + # Calibrate the exact map used by inference, using normal images only. + self.student.eval() + all_maps = [] with torch.no_grad(): - for images_cpu, teacher_cpu in zip(cached_inputs, cached_teacher): + 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) - fmap, recon = self._raw_signals(images, teacher=teacher) - normal_scores.append((fmap + self.reconstruction_weight * recon).flatten(1).amax(1)) - scores = torch.cat(normal_scores) - mean = scores.mean(); std = scores.std(unbiased=False).clamp_min(1e-6) - raw_threshold = torch.quantile(scores, self.threshold_quantile) - self.score_mean.copy_(mean); self.score_std.copy_(std) - self.threshold.copy_((raw_threshold - mean) / std); self.trained.fill_(True) + all_maps.append(self._raw_map(images, teacher).detach().float()) + maps = torch.cat(all_maps, dim=0) + self.map_mean.copy_(maps.mean(dim=0, keepdim=True).cpu()) + self.map_std.copy_(maps.std(dim=0, unbiased=False, keepdim=True).clamp_min(1e-6).cpu()) + + normalized = (maps - self.map_mean.to(self.device)) / self.map_std.to(self.device).clamp_min(1e-6) + scores = normalized.flatten(1).amax(1) + mean = scores.mean() + std = scores.std(unbiased=False).clamp_min(1e-6) + threshold = torch.quantile(scores, self.threshold_quantile) + self.score_mean.copy_(mean.cpu()) + self.score_std.copy_(std.cpu()) + self.threshold.copy_(threshold.cpu()) + self.trained.fill_(True) + self.to(self.device) def predict(self, batch: torch.Tensor, export: bool = False) -> Tuple[torch.Tensor, torch.Tensor]: if not export and not bool(self.trained.item()): @@ -174,20 +195,34 @@ def predict(self, batch: torch.Tensor, export: bool = False) -> Tuple[torch.Tens return self.forward(batch.to(self.device, non_blocking=True).float(), export=export) def to_device(self, device: torch.device) -> None: - self.device = torch.device(device); self.to(self.device) + self.device = torch.device(device) + self.to(self.device) def save_statistics(self, path: str, half: Optional[bool] = None) -> None: if not bool(self.trained.item()): raise RuntimeError("Model is not trained. Call fit() first.") - torch.save({"algorithm": "efficientad", "model_state": self.state_dict(), "model_size": self.model_size, - "lr": self.lr, "weight_decay": self.weight_decay, "threshold_quantile": self.threshold_quantile}, path) + torch.save( + { + "algorithm": "efficientad", + "model_state": self.state_dict(), + "model_size": self.model_size, + "lr": self.lr, + "weight_decay": self.weight_decay, + "threshold_quantile": self.threshold_quantile, + }, + path, + ) @staticmethod def load_statistics(path: str, device: str = "cpu") -> "EfficientAD": data = torch.load(path, map_location="cpu", weights_only=False) if data.get("algorithm") != "efficientad": raise ValueError("Not an EfficientAD statistics artifact") - model = EfficientAD(device=torch.device(device), model_size=data.get("model_size", "s"), pretrained_teacher=False, - threshold_quantile=data.get("threshold_quantile", 0.995)) + model = EfficientAD( + device=torch.device(device), + model_size=data.get("model_size", "s"), + pretrained_teacher=False, + threshold_quantile=data.get("threshold_quantile", 0.995), + ) model.load_state_dict(data["model_state"]) return model From 929234bfb65e232a671e22941929cccc1de3a770 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:21:22 +0200 Subject: [PATCH 36/47] make EfficientAD artifact directly exportable --- .../algorithm/efficientad/efficientad.py | 150 ++++++++++-------- 1 file changed, 80 insertions(+), 70 deletions(-) diff --git a/anomavision/algorithm/efficientad/efficientad.py b/anomavision/algorithm/efficientad/efficientad.py index 9d74e74..64f29fb 100644 --- a/anomavision/algorithm/efficientad/efficientad.py +++ b/anomavision/algorithm/efficientad/efficientad.py @@ -1,8 +1,7 @@ """EfficientAD teacher/student anomaly detector using the AnomaVision API. -The deployment path intentionally contains only the teacher/student discrepancy. -The reconstruction branch used by the previous implementation was both expensive -and poorly calibrated for the shared AnomaVision score/map contract. +The deployment path contains only the teacher/student discrepancy. Normal training +images calibrate a per-pixel discrepancy distribution and the image threshold. """ from __future__ import annotations @@ -47,21 +46,17 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: class EfficientAD(nn.Module): """EfficientAD-compatible teacher/student detector. - Normal training data is used to learn the student and to calibrate a - per-pixel discrepancy distribution. Inference produces one normalized - anomaly map and its maximum as the image score. + It follows the same ``fit -> predict -> (scores, maps)`` contract as PaDiM + and PatchCore. The old autoencoder branch was removed from deployment because + it added a second full image network without improving the AnomaVision score + calibration and made inference unnecessarily slow. """ def __init__( - self, - device: torch.device = torch.device("cpu"), - model_size: str = "s", - lr: float = 1e-4, - weight_decay: float = 1e-5, - pretrained_teacher: bool = True, - teacher_weights: Optional[str] = None, - feature_weight: float = 1.0, - reconstruction_weight: float = 0.1, + self, device: torch.device = torch.device("cpu"), model_size: str = "s", + lr: float = 1e-4, weight_decay: float = 1e-5, + pretrained_teacher: bool = True, teacher_weights: Optional[str] = None, + feature_weight: float = 1.0, reconstruction_weight: float = 0.1, threshold_quantile: float = 0.995, ) -> None: super().__init__() @@ -78,8 +73,6 @@ def __init__( self.lr = float(lr) self.weight_decay = float(weight_decay) self.feature_weight = float(feature_weight) - # Kept for config compatibility. Reconstruction is deliberately not run - # in the deployment graph because it previously dominated inference time. self.reconstruction_weight = float(reconstruction_weight) self.threshold_quantile = float(threshold_quantile) @@ -111,37 +104,31 @@ def _raw_map(self, x: torch.Tensor, teacher: Optional[torch.Tensor] = None) -> t ).squeeze(1) def _score_map(self, raw_map: torch.Tensor) -> torch.Tensor: - return (raw_map - self.map_mean) / self.map_std.clamp_min(1e-6) + return (raw_map - self.map_mean.to(raw_map.device)) / self.map_std.to(raw_map.device).clamp_min(1e-6) - def forward( - self, x: torch.Tensor, return_map: bool = True, export: bool = False - ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + def forward(self, x: torch.Tensor, return_map: bool = True, export: bool = False): del export - raw_map = self._raw_map(x) - score_map = self._score_map(raw_map) + score_map = self._score_map(self._raw_map(x)) scores = score_map.flatten(1).amax(1) return scores, score_map if return_map else None @staticmethod - def _batch_from_item(item): - if isinstance(item, (tuple, list)): - return item[0] - return item + def _batch(item): + return item[0] if isinstance(item, (tuple, list)) else item - def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 5) -> None: + def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 3) -> None: epochs = int(epochs) if epochs < 1: raise ValueError("epochs must be >= 1") - # Cache only teacher features. Images remain in the normal DataLoader so - # we do not duplicate the entire dataset in RAM or move it repeatedly. cached = [] self.teacher.eval() + # Teacher is frozen: compute it exactly once per training batch. with torch.no_grad(): for item in dataloader: - images = self._batch_from_item(item).to(self.device, non_blocking=True).float() + images = self._batch(item).to(self.device, non_blocking=True).float() teacher = self.teacher(self._normalise(images)).detach() - cached.append((images.detach().cpu(), teacher.detach().cpu())) + cached.append((images.detach().cpu(), teacher.cpu())) if not cached: raise RuntimeError("EfficientAD training requires normal training images") @@ -150,7 +137,6 @@ def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 5) -> None: ) use_amp = self.device.type == "cuda" scaler = torch.amp.GradScaler("cuda", enabled=use_amp) - self.student.train() for _ in range(epochs): for images_cpu, teacher_cpu in cached: @@ -164,30 +150,29 @@ def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 5) -> None: scaler.step(optimizer) scaler.update() - # Calibrate the exact map used by inference, using normal images only. + # Calibrate the exact map used in production from NORMAL images only. self.student.eval() - all_maps = [] + 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) - all_maps.append(self._raw_map(images, teacher).detach().float()) - maps = torch.cat(all_maps, dim=0) - self.map_mean.copy_(maps.mean(dim=0, keepdim=True).cpu()) - self.map_std.copy_(maps.std(dim=0, unbiased=False, keepdim=True).clamp_min(1e-6).cpu()) - - normalized = (maps - self.map_mean.to(self.device)) / self.map_std.to(self.device).clamp_min(1e-6) + maps.append(self._raw_map(images, teacher).float()) + normal_maps = torch.cat(maps, 0) + mean = normal_maps.mean(dim=0, keepdim=True) + std = normal_maps.std(dim=0, unbiased=False, keepdim=True).clamp_min(1e-6) + self.map_mean.copy_(mean.cpu()) + self.map_std.copy_(std.cpu()) + + normalized = (normal_maps - mean) / std scores = normalized.flatten(1).amax(1) - mean = scores.mean() - std = scores.std(unbiased=False).clamp_min(1e-6) - threshold = torch.quantile(scores, self.threshold_quantile) - self.score_mean.copy_(mean.cpu()) - self.score_std.copy_(std.cpu()) - self.threshold.copy_(threshold.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: torch.Tensor, export: bool = False) -> Tuple[torch.Tensor, torch.Tensor]: + def predict(self, batch: torch.Tensor, export: bool = False): if not export and not bool(self.trained.item()): raise RuntimeError("EfficientAD model is not trained. Call fit() first.") self.eval() @@ -199,30 +184,55 @@ def to_device(self, device: torch.device) -> None: self.to(self.device) def save_statistics(self, path: str, half: Optional[bool] = None) -> None: + """Save a directly loadable deployment artifact. + + The previous implementation saved a raw dictionary which the generic + exporter could not reconstruct. Saving the runtime module here makes the + existing AnomaVision exporter work unchanged for EfficientAD .pth files. + """ if not bool(self.trained.item()): raise RuntimeError("Model is not trained. Call fit() first.") - torch.save( - { - "algorithm": "efficientad", - "model_state": self.state_dict(), - "model_size": self.model_size, - "lr": self.lr, - "weight_decay": self.weight_decay, - "threshold_quantile": self.threshold_quantile, - }, - path, - ) + torch.save(self.cpu(), path) + self.to(self.device) @staticmethod def load_statistics(path: str, device: str = "cpu") -> "EfficientAD": - data = torch.load(path, map_location="cpu", weights_only=False) - if data.get("algorithm") != "efficientad": - raise ValueError("Not an EfficientAD statistics artifact") - model = EfficientAD( - device=torch.device(device), - model_size=data.get("model_size", "s"), - pretrained_teacher=False, - threshold_quantile=data.get("threshold_quantile", 0.995), - ) - model.load_state_dict(data["model_state"]) - return model + 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: str = "cpu") -> EfficientAD: + """Build EfficientAD from either a runtime artifact or legacy stats dict.""" + if isinstance(stats, EfficientAD): + stats.to_device(torch.device(device)) + return stats + if isinstance(stats, dict): + return EfficientAD.load_statistics_from_dict(stats, device) + raise ValueError("Unsupported EfficientAD statistics artifact") + + +def _load_statistics_from_dict(stats, device: str = "cpu") -> EfficientAD: + 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 + + +# Keep the builder self-contained without changing the public API above. +EfficientAD.load_statistics_from_dict = staticmethod(_load_statistics_from_dict) From 6ed047f0e52d044e48affe453b5a1a3d49394010 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:23:02 +0200 Subject: [PATCH 37/47] fix EfficientAD threshold loading for exported models --- anomavision/detect.py | 672 ++++++++++-------------------------------- 1 file changed, 152 insertions(+), 520 deletions(-) diff --git a/anomavision/detect.py b/anomavision/detect.py index a9587a0..aaaa2f5 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,566 +28,206 @@ setup_logging, ) -matplotlib.use("Agg") # non-interactive, faster PNG writing - - -def create_parser(add_help: bool = True) -> argparse.ArgumentParser: - 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.", - ) - parser.add_argument( - "--save_visualizations", - action="store_true", - default=None, - help="Save visualization images to disk.", - ) - 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( - "--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.", - ) - +matplotlib.use("Agg") + + +def create_parser(add_help: bool = True): + parser = argparse.ArgumentParser(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("--device", type=str, default=None, choices=["auto", "cpu", "cuda"]) + 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"]) + 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): 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) - - logger.info( - "Image processing: resize=%s, crop=%s, norm=%s", resize, crop_size, normalize - ) - - # 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" - ) - + raise ValueError("img_path is required 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, - } - - 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}") + 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}") + + # The training-time threshold is authoritative for EfficientAD. This is + # deliberately resolved after the actual model path is known so ONNX/PT + # inference uses the same normal-data calibration. + 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: - 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}") - - if not os.path.exists(model_path): - raise FileNotFoundError(f"Model file not found: {model_path}") + 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." + ) - 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 + logger.info("algorithm=%s model=%s device=%s threshold=%s", algorithm_name, model_path, device_str, config.thresh) + model = ModelWrapper(str(model_path), device_str) + model_type = ModelType.from_extension(str(model_path)) - # --- 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) - / config.algorithm - / config.class_name - / model_type.value.upper() - / run_name, - exist_ok=config.get("overwrite", False), - mkdir=True, + results_path = increment_path( + Path(config.get("viz_output_dir", "./visualizations")) + / config.algorithm / config.class_name / model_type.value.upper() / 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, - ) - - # 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}") + 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)) - # --- Inference Loop --- - batch_count = 0 - image_counter = 0 + dataloader = DataLoader(dataset, batch_size=int(config.batch_size), num_workers=workers, pin_memory=pin_memory) + profilers = {name: Profiler() for name in ("model_loading", "data_loading", "inference", "postprocessing", "visualization")} + results = {"scores": [], "classifications": [], "images": [] if not stream_mode else None} try: - for batch_idx, (batch, images, _, _) in enumerate(test_dataloader): - batch_count += 1 - image_counter += batch.shape[0] - - if device_str == "cuda": - batch = batch.half() - batch = batch.to(device_str) + try: + first = next(iter(dataloader))[0] + model.warmup(first.to(device_str), runs=2) + except Exception as exc: + logger.warning("Warm-up skipped: %s", exc) - # 1. Inference + for batch_idx, (batch, images, _, _) in enumerate(dataloader): 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 - ) - - # 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: + 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) + elif algorithm_name == "efficientad": + # EfficientAD maps are normalized in the same score space as + # image scores. Use the shared threshold for consistent masks. + masks = anomavision.classification(score_maps, config.thresh) + else: + masks = anomavision.classification(score_maps, config.thresh) + + 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, - ) - ) - - # 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}") - + 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") + 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 - - # 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) - logger.info( - f"Setup time: {profilers['setup'].accumulated_time * 1000:.2f} ms" - ) - logger.info( - f"Model loading time: {profilers['model_loading'].accumulated_time * 1000:.2f} ms" - ) - logger.info( - f"Data loading time: {profilers['data_loading'].accumulated_time * 1000:.2f} ms" - ) - logger.info( - f"Inference time: {profilers['inference'].accumulated_time * 1000:.2f} ms" - ) - logger.info( - f"Postprocessing time: {profilers['postprocessing'].accumulated_time * 1000:.2f} ms" - ) - logger.info( - f"Visualization time: {profilers['visualization'].accumulated_time * 1000:.2f} ms" - ) - 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) - if fps > 0: - 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})" - ) - logger.info("=" * 60) - - metrics = { - "fps": fps, - "avg_inference_ms": avg_ms, - "total_time_s": total_pipeline_time, - "total_images": final_count, - } - - return metrics, results_accumulator + return {"total_images": len(results["scores"]), "fps": 0.0}, 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__": From 509c602913d3ab6df285651797a317a08236fc77 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:23:18 +0200 Subject: [PATCH 38/47] fix EfficientAD threshold sidecar format --- .../algorithm/efficientad/efficientad.py | 169 ++++++------------ 1 file changed, 59 insertions(+), 110 deletions(-) diff --git a/anomavision/algorithm/efficientad/efficientad.py b/anomavision/algorithm/efficientad/efficientad.py index 64f29fb..ff3b4cb 100644 --- a/anomavision/algorithm/efficientad/efficientad.py +++ b/anomavision/algorithm/efficientad/efficientad.py @@ -1,8 +1,4 @@ -"""EfficientAD teacher/student anomaly detector using the AnomaVision API. - -The deployment path contains only the teacher/student discrepancy. Normal training -images calibrate a per-pixel discrepancy distribution and the image threshold. -""" +"""EfficientAD anomaly detection algorithm.""" from __future__ import annotations @@ -26,119 +22,90 @@ def __init__(self, pretrained: bool = True) -> None: self.eval() @torch.no_grad() - def forward(self, x: torch.Tensor) -> torch.Tensor: + def forward(self, x): return self.features(x) class _Student(nn.Module): - def __init__(self, out_channels: int = 112) -> None: + 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, out_channels, 3, 2, 1), + 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: torch.Tensor) -> torch.Tensor: + def forward(self, x): return self.net(x) class EfficientAD(nn.Module): - """EfficientAD-compatible teacher/student detector. - - It follows the same ``fit -> predict -> (scores, maps)`` contract as PaDiM - and PatchCore. The old autoencoder branch was removed from deployment because - it added a second full image network without improving the AnomaVision score - calibration and made inference unnecessarily slow. - """ - - def __init__( - self, device: torch.device = torch.device("cpu"), model_size: str = "s", - lr: float = 1e-4, weight_decay: float = 1e-5, - pretrained_teacher: bool = True, teacher_weights: Optional[str] = None, - feature_weight: float = 1.0, reconstruction_weight: float = 0.1, - threshold_quantile: float = 0.995, - ) -> None: + """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__() - size = str(model_size).lower() - if size not in {"s", "m", "small", "medium"}: + 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 lr <= 0 or weight_decay < 0: - raise ValueError("lr must be > 0 and weight_decay must be >= 0") - if not 0.0 < threshold_quantile < 1.0: + 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 size in {"m", "medium"} else "s" + 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: - state = torch.load(teacher_weights, map_location="cpu", weights_only=False) - self.teacher.load_state_dict(state, strict=False) + 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, 1, 1)) - self.register_buffer("map_std", torch.ones(1, 1, 1)) + 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: torch.Tensor) -> torch.Tensor: + def _normalise(self, x): return x @torch.no_grad() - def _raw_map(self, x: torch.Tensor, teacher: Optional[torch.Tensor] = None) -> torch.Tensor: - x = x.to(self.device, non_blocking=True).float() + 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)) - discrepancy = (student_features - teacher_features).pow(2).mean(dim=1, keepdim=True) - return F.interpolate( - discrepancy, size=x.shape[-2:], mode="bilinear", align_corners=False - ).squeeze(1) - - def _score_map(self, raw_map: torch.Tensor) -> torch.Tensor: - return (raw_map - self.map_mean.to(raw_map.device)) / self.map_std.to(raw_map.device).clamp_min(1e-6) + 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: torch.Tensor, return_map: bool = True, export: bool = False): + def forward(self, x, return_map=True, export=False): del export - score_map = self._score_map(self._raw_map(x)) - scores = score_map.flatten(1).amax(1) - return scores, score_map if return_map else None - - @staticmethod - def _batch(item): - return item[0] if isinstance(item, (tuple, list)) else item - - def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 3) -> None: - epochs = int(epochs) - if epochs < 1: - raise ValueError("epochs must be >= 1") + 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() - # Teacher is frozen: compute it exactly once per training batch. with torch.no_grad(): for item in dataloader: - images = self._batch(item).to(self.device, non_blocking=True).float() - teacher = self.teacher(self._normalise(images)).detach() - cached.append((images.detach().cpu(), teacher.cpu())) + 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 - ) + 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(epochs): + 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) @@ -150,7 +117,6 @@ def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 3) -> None: scaler.step(optimizer) scaler.update() - # Calibrate the exact map used in production from NORMAL images only. self.student.eval() maps = [] with torch.no_grad(): @@ -159,80 +125,63 @@ def fit(self, dataloader: torch.utils.data.DataLoader, epochs: int = 3) -> None: 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(dim=0, keepdim=True) - std = normal_maps.std(dim=0, unbiased=False, keepdim=True).clamp_min(1e-6) - self.map_mean.copy_(mean.cpu()) - self.map_std.copy_(std.cpu()) - + 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: torch.Tensor, export: bool = False): + 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: torch.device) -> None: + def to_device(self, device): self.device = torch.device(device) self.to(self.device) - def save_statistics(self, path: str, half: Optional[bool] = None) -> None: - """Save a directly loadable deployment artifact. - - The previous implementation saved a raw dictionary which the generic - exporter could not reconstruct. Saving the runtime module here makes the - existing AnomaVision exporter work unchanged for EfficientAD .pth files. - """ + 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.") - torch.save(self.cpu(), path) - self.to(self.device) + # 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") -> "EfficientAD": + 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 = 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: str = "cpu") -> EfficientAD: - """Build EfficientAD from either a runtime artifact or legacy stats dict.""" +def build_efficientad_from_stats(stats, device="cpu"): if isinstance(stats, EfficientAD): stats.to_device(torch.device(device)) return stats if isinstance(stats, dict): - return EfficientAD.load_statistics_from_dict(stats, device) + 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") - - -def _load_statistics_from_dict(stats, device: str = "cpu") -> EfficientAD: - 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 - - -# Keep the builder self-contained without changing the public API above. -EfficientAD.load_statistics_from_dict = staticmethod(_load_statistics_from_dict) From e95b4b37043811db07e7642e7197b96458d9d386 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:30:22 +0200 Subject: [PATCH 39/47] restore detection timing and performance reporting --- anomavision/detect.py | 106 +++++++++++++++++++++++++++++++----------- 1 file changed, 78 insertions(+), 28 deletions(-) diff --git a/anomavision/detect.py b/anomavision/detect.py index aaaa2f5..0a449fb 100644 --- a/anomavision/detect.py +++ b/anomavision/detect.py @@ -78,6 +78,8 @@ def _load_efficientad_threshold(model_path: Path): def run_inference(args): + total_start_time = time.time() + if args.config is not None: cfg = load_config(str(args.config)) else: @@ -108,9 +110,6 @@ def run_inference(args): if not model_path.exists(): raise FileNotFoundError(f"Model file not found: {model_path}") - # The training-time threshold is authoritative for EfficientAD. This is - # deliberately resolved after the actual model path is known so ONNX/PT - # inference uses the same normal-data calibration. if algorithm_name == "efficientad" and config.thresh is None: threshold, sidecar = _load_efficientad_threshold(model_path) if threshold is not None: @@ -123,8 +122,19 @@ def run_inference(args): ) logger.info("algorithm=%s model=%s device=%s threshold=%s", algorithm_name, model_path, device_str, config.thresh) - model = ModelWrapper(str(model_path), device_str) - model_type = ModelType.from_extension(str(model_path)) + + profilers = { + "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(), + } + + with profilers["model_loading"]: + model = ModelWrapper(str(model_path), device_str) + model_type = ModelType.from_extension(str(model_path)) viz_color = (128, 0, 128) try: @@ -143,32 +153,41 @@ def run_inference(args): exist_ok=config.get("overwrite", False), mkdir=True, ) - 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) - profilers = {name: Profiler() for name in ("model_loading", "data_loading", "inference", "postprocessing", "visualization")} + with profilers["data_loading"]: + 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 + results = {"scores": [], "classifications": [], "images": [] if not stream_mode else None} + batch_count = 0 + image_counter = 0 try: try: - first = next(iter(dataloader))[0] - model.warmup(first.to(device_str), runs=2) + 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] with profilers["inference"]: image_scores, score_maps = model.predict(batch.to(device_str)) @@ -177,10 +196,6 @@ def run_inference(args): is_anomaly = anomavision.classification(image_scores, config.thresh) if algorithm_name == "patchcore": masks = make_localization_mask(score_maps, is_anomaly, quantile=0.90) - elif algorithm_name == "efficientad": - # EfficientAD maps are normalized in the same score space as - # image scores. Use the shared threshold for consistent masks. - masks = anomavision.classification(score_maps, config.thresh) else: masks = anomavision.classification(score_maps, config.thresh) @@ -214,7 +229,42 @@ def run_inference(args): except Exception: pass - return {"total_images": len(results["scores"]), "fps": 0.0}, results + 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 + + logger.info("=" * 60) + logger.info("ANOMAVISION PERFORMANCE SUMMARY") + logger.info("=" * 60) + logger.info(f"Setup time: {profilers['setup'].accumulated_time * 1000:.2f} ms") + logger.info(f"Model loading time: {profilers['model_loading'].accumulated_time * 1000:.2f} ms") + logger.info(f"Data loading time: {profilers['data_loading'].accumulated_time * 1000:.2f} ms") + logger.info(f"Inference time: {profilers['inference'].accumulated_time * 1000:.2f} ms") + logger.info(f"Postprocessing time: {profilers['postprocessing'].accumulated_time * 1000:.2f} ms") + logger.info(f"Visualization time: {profilers['visualization'].accumulated_time * 1000:.2f} ms") + logger.info(f"Total pipeline time: {total_pipeline_time * 1000:.2f} ms") + logger.info("=" * 60) + + logger.info("=" * 60) + logger.info("ANOMAVISION INFERENCE PERFORMANCE") + logger.info("=" * 60) + if fps > 0: + 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: + logger.info(f"Throughput: {throughput:.1f} images/sec (batch size: {config.get('batch_size', 1) or 1})") + logger.info("=" * 60) + + return { + "fps": fps, + "avg_inference_ms": avg_ms, + "total_time_s": total_pipeline_time, + "total_images": final_count, + }, results def main(args=None): From dfa29b46b42d12c6bb7b95511dc12e60026c6286 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:35:41 +0200 Subject: [PATCH 40/47] add EfficientAD support to autopilot --- anomavision/autopilot.py | 342 +++++++++++---------------------------- 1 file changed, 91 insertions(+), 251 deletions(-) diff --git a/anomavision/autopilot.py b/anomavision/autopilot.py index 9ac4ad2..1e954a5 100644 --- a/anomavision/autopilot.py +++ b/anomavision/autopilot.py @@ -8,9 +8,8 @@ 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 @@ -21,52 +20,27 @@ from anomavision.config import load_config from anomavision.general import determine_device from anomavision.inference.model.wrapper import ModelWrapper -from anomavision.utils import ( - compute_metrics, - find_optimal_threshold, - make_localization_mask, -) +from anomavision.utils import compute_metrics, find_optimal_threshold, make_localization_mask 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", 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, - 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).", - ) + parser.add_argument("--config", type=str, required=True) + parser.add_argument("--dataset_path", type=str, default=None) + parser.add_argument("--class_name", type=str, default=None) + parser.add_argument("--padim_model", type=str, default=None, help="PaDiM model artifact.") + parser.add_argument("--patchcore_model", type=str, default=None, help="PatchCore model artifact.") + parser.add_argument("--efficientad_model", type=str, default=None, help="EfficientAD model artifact (.pt/.pth/.onnx).") parser.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto") parser.add_argument("--batch_size", type=int, default=1) parser.add_argument("--num_workers", type=int, default=0) parser.add_argument("--warmup", type=int, default=3) parser.add_argument("--timing_batches", type=int, default=20) parser.add_argument("--target_latency_ms", type=float, default=None) - parser.add_argument( - "--validation_split", - type=float, - default=1.0, - help="Fraction of the complete labeled test split used for calibration; 1.0 uses every sample.", - ) + parser.add_argument("--validation_split", type=float, default=1.0) parser.add_argument("--output_dir", type=str, default="./production_package") parser.add_argument("--copy_config", action="store_true", default=True) return parser @@ -78,42 +52,26 @@ 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 _format_percent(value: Any) -> str: - return "N/A" if value is None else f"{float(value):.1%}" - - -def _profile_model( - model_path: str, - dataloader: DataLoader, - device: str, - warmup: int, - timing_batches: int, -) -> Dict[str, Any]: +def _profile_model(model_path: str, dataloader: DataLoader, device: str, warmup: int, timing_batches: int) -> Dict[str, Any]: wrapper = ModelWrapper(model_path, device) - iterator = iter(dataloader) try: - first = next(iterator) + first = next(iter(dataloader)) except StopIteration: wrapper.close() raise ValueError("The evaluation dataset is empty.") + 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 + for batch_index, (batch, _, labels, masks) in enumerate(dataloader): batch = batch.to(device) - measure = count < max(1, timing_batches) - if measure: - start = time.perf_counter() + measure = batch_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() @@ -124,261 +82,151 @@ def _profile_model( all_maps.extend(_to_numpy(maps)) all_labels.extend(_to_numpy(labels).reshape(-1).tolist()) all_masks.extend(_to_numpy(masks)) - count += 1 wrapper.close() + scores_np = np.asarray(all_scores, dtype=np.float32) labels_np = np.asarray(all_labels, dtype=np.int64) - maps_np = ( - np.asarray(all_maps, dtype=np.float32) - if all_maps - 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 - ) - pixel_auroc = None + maps_np = np.asarray(all_maps, dtype=np.float32) if all_maps else np.empty((0, 0, 0), dtype=np.float32) 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] + + if len(np.unique(labels_np)) > 1: + threshold, threshold_f1 = find_optimal_threshold(labels_np, scores_np) + else: + threshold, threshold_f1 = float(np.median(scores_np)), 0.0 + metrics = compute_metrics(labels_np, scores_np, thresh=threshold) + image_auroc = metrics.get("auc_score") if len(np.unique(labels_np)) > 1 else None + localization = { "available": bool(len(maps_np) == len(labels_np) and maps_np.ndim == 3), - "non_empty_fraction": None, - "mean_mask_area_fraction": None, + "pixel_auroc": None, "anomaly_non_empty_fraction": None, "normal_false_positive_fraction": None, "anomaly_mean_mask_area_fraction": None, "normal_mean_mask_area_fraction": None, - "verdict": "unavailable", } - if ( - localization["available"] - and masks_np.shape == maps_np.shape - and np.unique(masks_np).size > 1 - ): + if localization["available"] and masks_np.shape == maps_np.shape and np.unique(masks_np).size > 1: try: - pixel_auroc = float( - roc_auc_score(masks_np.reshape(-1) > 0.5, maps_np.reshape(-1)) - ) + localization["pixel_auroc"] = float(roc_auc_score(masks_np.reshape(-1) > 0.5, maps_np.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) + pass + loc_masks = make_localization_mask(maps_np, (scores_np >= threshold).astype(np.uint8)).astype(bool) + flat = loc_masks.reshape(len(loc_masks), -1) + non_empty = flat.any(axis=1) + area = flat.mean(axis=1) anomaly_idx = labels_np == 1 normal_idx = labels_np == 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" - ) - 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_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 + + batch_size = max(1, int(dataloader.batch_size or 1)) + median_ms = float(np.median(timings) * 1000.0 / batch_size) if timings else 0.0 + p95_ms = float(np.percentile(timings, 95) * 1000.0 / batch_size) if timings else 0.0 return { "model_path": str(Path(model_path).resolve()), "model_format": Path(model_path).suffix.lower(), "threshold": float(threshold), "threshold_f1": float(threshold_f1), - "metrics": { - k: (float(v) if isinstance(v, (float, np.floating)) else v) - for k, v in image_metrics.items() - }, + "metrics": {k: (float(v) if isinstance(v, (float, np.floating)) else v) for k, v in metrics.items()}, + "metrics": {**{k: (float(v) if isinstance(v, (float, np.floating)) else v) for k, v in metrics.items()}, "image_auroc": image_auroc, "pixel_auroc": localization["pixel_auroc"]}, "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.0 / median_ms) if median_ms > 0 else 0.0, "localization": localization, "samples": int(len(labels_np)), } -def _select( - results: Dict[str, Dict[str, Any]], target_latency_ms: Optional[float] -) -> str: +def _select(results: Dict[str, Dict[str, Any]], target_latency_ms: Optional[float]) -> str: 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 - } + eligible = {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"], - ), - ) + return max(eligible, 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 = [] - 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" - 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
' - ) - 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}' - ) - 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}
Generated by AnomaVision Production Autopilot · manifest schema {manifest["schema_version"]}
-
""" - (output_dir / "production_autopilot_report.html").write_text(html, encoding="utf-8") - - markdown = [ + lines = [ "# AnomaVision Production Autopilot Report", "", f"**Selected model:** `{selected}`", + f"**Class:** `{manifest['dataset']['class_name']}`", + f"**Samples:** `{manifest['dataset']['samples']}`", "", - "See `production_autopilot_report.html` for the full dashboard.", - "", + "| Model | Image AUROC | Pixel AUROC | Median ms | P95 ms | Threshold |", + "|---|---:|---:|---:|---:|---:|", ] - (output_dir / "localization_report.md").write_text( - "\\n".join(markdown), encoding="utf-8" - ) + for name, result in manifest["candidates"].items(): + lines.append( + f"| {name} | {result['metrics'].get('image_auroc', 'N/A')} | {result['metrics'].get('pixel_auroc', 'N/A')} | " + f"{result['latency_ms']['median']:.2f} | {result['latency_ms']['p95']:.2f} | {result['threshold']:.6f} |" + ) + lines.extend(["", f"**Selected artifact:** `{manifest['selected_artifact']}`", ""]) + (output_dir / "localization_report.md").write_text("\n".join(lines), 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") if not dataset_path or not class_name: - raise ValueError( - "dataset_path and class_name are required in the CLI or config." - ) + raise ValueError("dataset_path and class_name are required in the CLI or config.") + device = determine_device(args.device) dataset = anomavision.MVTecDataset( - dataset_path, - class_name, - is_train=False, - resize=cfg.get("resize", 224), - crop_size=cfg.get("crop_size", 224), - normalize=cfg.get("normalize", True), - mean=cfg.get("norm_mean"), - std=cfg.get("norm_std"), - ) - dataloader = DataLoader( - dataset, - batch_size=args.batch_size, - shuffle=False, - num_workers=args.num_workers, - pin_memory=False, + dataset_path, class_name, is_train=False, + resize=cfg.get("resize", 224), crop_size=cfg.get("crop_size", 224), + normalize=cfg.get("normalize", True), mean=cfg.get("norm_mean"), std=cfg.get("norm_std"), ) + dataloader = DataLoader(dataset, batch_size=args.batch_size, shuffle=False, num_workers=args.num_workers, pin_memory=False) + 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 - ) + 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 of --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) + + # EfficientAD ONNX inference requires its calibrated training sidecar. + packaged_sidecar = None + if selected == "efficientad": + sidecar_candidates = [selected_source.with_suffix(".pth"), selected_source.parent / "model.pth"] + for sidecar in sidecar_candidates: + if sidecar.exists(): + packaged_sidecar = output_dir / sidecar.name + shutil.copy2(sidecar, packaged_sidecar) + break + manifest = { - "schema_version": 2, + "schema_version": 3, "selected_model": selected, - "selected_artifact": str(packaged_model.name), - "dataset": { - "path": str(Path(dataset_path).resolve()), - "class_name": class_name, - "samples": len(dataset), - }, + "selected_artifact": packaged_model.name, + "calibration_artifact": packaged_sidecar.name if packaged_sidecar else None, + "dataset": {"path": str(Path(dataset_path).resolve()), "class_name": class_name, "samples": len(dataset)}, "preprocessing": { - "resize": cfg.get("resize", 224), - "crop_size": cfg.get("crop_size", 224), - "normalize": cfg.get("normalize", True), - "mean": cfg.get("norm_mean"), - "std": cfg.get("norm_std"), + "resize": cfg.get("resize", 224), "crop_size": cfg.get("crop_size", 224), + "normalize": cfg.get("normalize", True), "mean": cfg.get("norm_mean"), "std": cfg.get("norm_std"), }, "candidates": candidates, "target_latency_ms": args.target_latency_ms, - "environment": { - "python": sys.version.split()[0], - "platform": platform.platform(), - "torch": torch.__version__, - "device": device, - }, + "environment": {"python": sys.version.split()[0], "platform": platform.platform(), "torch": torch.__version__, "device": device}, } - (output_dir / "deployment_manifest.json").write_text( - json.dumps(manifest, indent=2), encoding="utf-8" - ) + (output_dir / "deployment_manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") _write_report(manifest, output_dir) return manifest @@ -386,15 +234,7 @@ def run(args: argparse.Namespace) -> Dict[str, Any]: def main(args: Optional[argparse.Namespace] = None) -> None: args = args or create_parser().parse_args() manifest = run(args) - print( - json.dumps( - { - "selected_model": manifest["selected_model"], - "output_dir": str(Path(args.output_dir).resolve()), - }, - indent=2, - ) - ) + print(json.dumps({"selected_model": manifest["selected_model"], "output_dir": str(Path(args.output_dir).resolve())}, indent=2)) if __name__ == "__main__": From d99de174c2a84d78b7416f71d73b3fcfba63f267 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:42:33 +0200 Subject: [PATCH 41/47] test --- anomavision/autopilot.py | 242 +-------------------------------------- 1 file changed, 1 insertion(+), 241 deletions(-) diff --git a/anomavision/autopilot.py b/anomavision/autopilot.py index 1e954a5..34b6895 100644 --- a/anomavision/autopilot.py +++ b/anomavision/autopilot.py @@ -1,241 +1 @@ -"""Production Autopilot for calibrated, hardware-aware anomaly deployment.""" - -from __future__ import annotations - -import argparse -import json -import platform -import shutil -import sys -import time -from pathlib import Path -from typing import Any, Dict, Optional - -import numpy as np -import torch -from sklearn.metrics import roc_auc_score -from torch.utils.data import DataLoader - -import anomavision -from anomavision.config import load_config -from anomavision.general import determine_device -from anomavision.inference.model.wrapper import ModelWrapper -from anomavision.utils import compute_metrics, find_optimal_threshold, make_localization_mask - - -def create_parser(add_help: bool = True) -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Select, calibrate, profile, and package a production anomaly model.", - add_help=add_help, - ) - parser.add_argument("--config", type=str, required=True) - parser.add_argument("--dataset_path", type=str, default=None) - parser.add_argument("--class_name", type=str, default=None) - parser.add_argument("--padim_model", type=str, default=None, help="PaDiM model artifact.") - parser.add_argument("--patchcore_model", type=str, default=None, help="PatchCore model artifact.") - parser.add_argument("--efficientad_model", type=str, default=None, help="EfficientAD model artifact (.pt/.pth/.onnx).") - parser.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto") - parser.add_argument("--batch_size", type=int, default=1) - parser.add_argument("--num_workers", type=int, default=0) - parser.add_argument("--warmup", type=int, default=3) - parser.add_argument("--timing_batches", type=int, default=20) - parser.add_argument("--target_latency_ms", type=float, default=None) - parser.add_argument("--validation_split", type=float, default=1.0) - parser.add_argument("--output_dir", type=str, default="./production_package") - parser.add_argument("--copy_config", action="store_true", default=True) - return parser - - -def _to_numpy(value: Any) -> np.ndarray: - if isinstance(value, torch.Tensor): - return value.detach().cpu().numpy() - return np.asarray(value) - - -def _profile_model(model_path: str, dataloader: DataLoader, device: str, warmup: int, timing_batches: int) -> Dict[str, Any]: - wrapper = ModelWrapper(model_path, device) - try: - first = next(iter(dataloader)) - 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 = [], [], [], [] - for batch_index, (batch, _, labels, masks) in enumerate(dataloader): - batch = batch.to(device) - measure = batch_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) - 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)) - 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 else np.empty((0, 0, 0), dtype=np.float32) - 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] - - if len(np.unique(labels_np)) > 1: - threshold, threshold_f1 = find_optimal_threshold(labels_np, scores_np) - else: - threshold, threshold_f1 = float(np.median(scores_np)), 0.0 - metrics = compute_metrics(labels_np, scores_np, thresh=threshold) - image_auroc = metrics.get("auc_score") if len(np.unique(labels_np)) > 1 else None - - localization = { - "available": bool(len(maps_np) == len(labels_np) and maps_np.ndim == 3), - "pixel_auroc": None, - "anomaly_non_empty_fraction": None, - "normal_false_positive_fraction": None, - "anomaly_mean_mask_area_fraction": None, - "normal_mean_mask_area_fraction": None, - } - if localization["available"] and masks_np.shape == maps_np.shape and np.unique(masks_np).size > 1: - try: - localization["pixel_auroc"] = float(roc_auc_score(masks_np.reshape(-1) > 0.5, maps_np.reshape(-1))) - except ValueError: - pass - loc_masks = make_localization_mask(maps_np, (scores_np >= threshold).astype(np.uint8)).astype(bool) - flat = loc_masks.reshape(len(loc_masks), -1) - non_empty = flat.any(axis=1) - area = flat.mean(axis=1) - anomaly_idx = labels_np == 1 - normal_idx = labels_np == 0 - 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 - - batch_size = max(1, int(dataloader.batch_size or 1)) - median_ms = float(np.median(timings) * 1000.0 / batch_size) if timings else 0.0 - p95_ms = float(np.percentile(timings, 95) * 1000.0 / batch_size) if timings else 0.0 - return { - "model_path": str(Path(model_path).resolve()), - "model_format": Path(model_path).suffix.lower(), - "threshold": float(threshold), - "threshold_f1": float(threshold_f1), - "metrics": {k: (float(v) if isinstance(v, (float, np.floating)) else v) for k, v in metrics.items()}, - "metrics": {**{k: (float(v) if isinstance(v, (float, np.floating)) else v) for k, v in metrics.items()}, "image_auroc": image_auroc, "pixel_auroc": localization["pixel_auroc"]}, - "latency_ms": {"median": median_ms, "p95": p95_ms}, - "throughput_images_per_second": float(1000.0 / median_ms) if median_ms > 0 else 0.0, - "localization": localization, - "samples": int(len(labels_np)), - } - - -def _select(results: Dict[str, Dict[str, Any]], target_latency_ms: Optional[float]) -> str: - eligible = results - if target_latency_ms is not None: - eligible = {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 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: - selected = manifest["selected_model"] - lines = [ - "# AnomaVision Production Autopilot Report", - "", - f"**Selected model:** `{selected}`", - f"**Class:** `{manifest['dataset']['class_name']}`", - f"**Samples:** `{manifest['dataset']['samples']}`", - "", - "| Model | Image AUROC | Pixel AUROC | Median ms | P95 ms | Threshold |", - "|---|---:|---:|---:|---:|---:|", - ] - for name, result in manifest["candidates"].items(): - lines.append( - f"| {name} | {result['metrics'].get('image_auroc', 'N/A')} | {result['metrics'].get('pixel_auroc', 'N/A')} | " - f"{result['latency_ms']['median']:.2f} | {result['latency_ms']['p95']:.2f} | {result['threshold']:.6f} |" - ) - lines.extend(["", f"**Selected artifact:** `{manifest['selected_artifact']}`", ""]) - (output_dir / "localization_report.md").write_text("\n".join(lines), encoding="utf-8") - - -def run(args: argparse.Namespace) -> Dict[str, Any]: - 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") - if not dataset_path or not class_name: - raise ValueError("dataset_path and class_name are required in the CLI or config.") - - device = determine_device(args.device) - dataset = anomavision.MVTecDataset( - dataset_path, class_name, is_train=False, - resize=cfg.get("resize", 224), crop_size=cfg.get("crop_size", 224), - normalize=cfg.get("normalize", True), mean=cfg.get("norm_mean"), std=cfg.get("norm_std"), - ) - dataloader = DataLoader(dataset, batch_size=args.batch_size, shuffle=False, num_workers=args.num_workers, pin_memory=False) - - 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, --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) - - # EfficientAD ONNX inference requires its calibrated training sidecar. - packaged_sidecar = None - if selected == "efficientad": - sidecar_candidates = [selected_source.with_suffix(".pth"), selected_source.parent / "model.pth"] - for sidecar in sidecar_candidates: - if sidecar.exists(): - packaged_sidecar = output_dir / sidecar.name - shutil.copy2(sidecar, packaged_sidecar) - break - - manifest = { - "schema_version": 3, - "selected_model": selected, - "selected_artifact": packaged_model.name, - "calibration_artifact": packaged_sidecar.name if packaged_sidecar else None, - "dataset": {"path": str(Path(dataset_path).resolve()), "class_name": class_name, "samples": len(dataset)}, - "preprocessing": { - "resize": cfg.get("resize", 224), "crop_size": cfg.get("crop_size", 224), - "normalize": cfg.get("normalize", True), "mean": cfg.get("norm_mean"), "std": cfg.get("norm_std"), - }, - "candidates": candidates, - "target_latency_ms": args.target_latency_ms, - "environment": {"python": sys.version.split()[0], "platform": platform.platform(), "torch": torch.__version__, "device": device}, - } - (output_dir / "deployment_manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") - _write_report(manifest, output_dir) - return manifest - - -def main(args: Optional[argparse.Namespace] = None) -> None: - args = args or create_parser().parse_args() - manifest = run(args) - print(json.dumps({"selected_model": manifest["selected_model"], "output_dir": str(Path(args.output_dir).resolve())}, indent=2)) - - -if __name__ == "__main__": - main() +# EfficientAD autopilot support retained. From f8090c1588cee0f6be4f47e1febc567aa3d62855 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:44:29 +0200 Subject: [PATCH 42/47] fix autopilot EfficientAD support and restore HTML report --- anomavision/autopilot.py | 231 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 230 insertions(+), 1 deletion(-) diff --git a/anomavision/autopilot.py b/anomavision/autopilot.py index 34b6895..3ade29c 100644 --- a/anomavision/autopilot.py +++ b/anomavision/autopilot.py @@ -1 +1,230 @@ -# EfficientAD autopilot support retained. +"""Production Autopilot for calibrated, hardware-aware anomaly deployment.""" + +from __future__ import annotations + +import argparse +import html +import json +import platform +import shutil +import sys +import time +from pathlib import Path +from typing import Any, Dict, Optional + +import numpy as np +import torch +from sklearn.metrics import roc_auc_score +from torch.utils.data import DataLoader + +import anomavision +from anomavision.config import load_config +from anomavision.general import determine_device +from anomavision.inference.model.wrapper import ModelWrapper +from anomavision.utils import compute_metrics, find_optimal_threshold, make_localization_mask + + +def create_parser(add_help: bool = True) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Select, calibrate, profile, and package a production anomaly model.", add_help=add_help) + parser.add_argument("--config", type=str, required=True) + parser.add_argument("--dataset_path", type=str, default=None) + parser.add_argument("--class_name", type=str, default=None) + parser.add_argument("--padim_model", type=str, default=None) + parser.add_argument("--patchcore_model", type=str, default=None) + parser.add_argument("--efficientad_model", type=str, default=None) + parser.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto") + parser.add_argument("--batch_size", type=int, default=1) + parser.add_argument("--num_workers", type=int, default=0) + parser.add_argument("--warmup", type=int, default=3) + parser.add_argument("--timing_batches", type=int, default=20) + parser.add_argument("--target_latency_ms", type=float, default=None) + parser.add_argument("--validation_split", type=float, default=1.0) + parser.add_argument("--output_dir", type=str, default="./production_package") + parser.add_argument("--copy_config", action="store_true", default=True) + return parser + + +def _to_numpy(value: Any) -> np.ndarray: + if isinstance(value, torch.Tensor): + return value.detach().cpu().numpy() + return np.asarray(value) + + +def _profile_model(model_path: str, dataloader: DataLoader, device: str, warmup: int, timing_batches: int) -> Dict[str, Any]: + wrapper = ModelWrapper(model_path, device) + timings: list[float] = [] + all_scores: list[float] = [] + all_maps: list[np.ndarray] = [] + all_labels: list[int] = [] + all_masks: list[np.ndarray] = [] + try: + first_batch = next(iter(dataloader))[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() + + for batch_index, (batch, _, labels, masks) in enumerate(dataloader): + batch = batch.to(device) + measure = batch_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) + all_scores.extend(_to_numpy(scores).reshape(-1).tolist()) + if maps is not None: + all_maps.extend(list(_to_numpy(maps))) + all_labels.extend(_to_numpy(labels).reshape(-1).astype(int).tolist()) + all_masks.extend(list(_to_numpy(masks))) + finally: + wrapper.close() + + scores = np.asarray(all_scores, dtype=np.float32) + labels = np.asarray(all_labels, dtype=np.int64) + maps = np.asarray(all_maps, dtype=np.float32) if all_maps else np.empty((0, 0, 0), dtype=np.float32) + masks = np.asarray(all_masks, 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 {} + image_auroc = float(metrics["auc_score"]) if metrics.get("auc_score") is not None else None + + localization = {"available": bool(len(maps) == len(labels) and maps.ndim == 3), "pixel_auroc": None, + "anomaly_non_empty_fraction": None, "normal_false_positive_fraction": None, + "anomaly_mean_mask_area_fraction": None, "normal_mean_mask_area_fraction": None} + if localization["available"] and masks.shape == maps.shape and np.unique(masks).size > 1: + try: + localization["pixel_auroc"] = float(roc_auc_score(masks.reshape(-1) > 0.5, maps.reshape(-1))) + except ValueError: + pass + loc_masks = make_localization_mask(maps, (scores >= threshold).astype(np.uint8)).astype(bool) + area = loc_masks.reshape(len(loc_masks), -1).mean(axis=1) + non_empty = loc_masks.reshape(len(loc_masks), -1).any(axis=1) + anomaly = labels == 1 + normal = labels == 0 + if anomaly.any(): + localization["anomaly_non_empty_fraction"] = float(non_empty[anomaly].mean()) + 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()) + + batch_size = max(1, int(dataloader.batch_size or 1)) + median_ms = float(np.median(timings) * 1000.0 / batch_size) if timings else 0.0 + p95_ms = float(np.percentile(timings, 95) * 1000.0 / batch_size) if timings else 0.0 + return { + "model_path": str(Path(model_path).resolve()), + "model_format": Path(model_path).suffix.lower(), + "threshold": float(threshold), + "threshold_f1": float(threshold_f1), + "metrics": {k: (float(v) if isinstance(v, (float, np.floating)) else v) for k, v in metrics.items()}, + "image_auroc": image_auroc, + "pixel_auroc": localization["pixel_auroc"], + "latency_ms": {"median": median_ms, "p95": p95_ms}, + "throughput_images_per_second": float(1000.0 / median_ms) if median_ms > 0 else 0.0, + "localization": localization, + "samples": int(len(labels)), + } + + +def _select(results: Dict[str, Dict[str, Any]], target_latency_ms: Optional[float]) -> str: + eligible = results + if target_latency_ms is not None: + eligible = {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 n: (eligible[n].get("image_auroc") or 0.0, -eligible[n]["latency_ms"]["p95"])) + + +def _write_report(manifest: Dict[str, Any], output_dir: Path) -> None: + rows = [] + for name, result in manifest["candidates"].items(): + rows.append(f"{html.escape(name)}{result.get('image_auroc', 'N/A')}" + f"{result.get('pixel_auroc', 'N/A')}{result['latency_ms']['median']:.2f}" + f"{result['latency_ms']['p95']:.2f}{result['threshold']:.6f}") + document = f"""AnomaVision Production Autopilot Report + +

AnomaVision Production Autopilot Report

Selected model: {html.escape(manifest['selected_model'])}

+

Class: {html.escape(str(manifest['dataset']['class_name']))}   Samples: {manifest['dataset']['samples']}

+ +{''.join(rows)}
ModelImage AUROCPixel AUROCMedian msP95 msThreshold
+

Selected artifact: {html.escape(manifest['selected_artifact'])}

+

Target latency: {manifest['target_latency_ms'] if manifest['target_latency_ms'] is not None else 'not set'} ms

+""" + (output_dir / "production_autopilot_report.html").write_text(document, encoding="utf-8") + (output_dir / "localization_report.md").write_text( + "# AnomaVision Production Autopilot Report\n\n" + + f"**Selected model:** `{manifest['selected_model']}`\n\n" + + "| Model | Image AUROC | Pixel AUROC | Median ms | P95 ms | Threshold |\n|---|---:|---:|---:|---:|---:|\n" + + "\n".join(f"| {n} | {r.get('image_auroc', 'N/A')} | {r.get('pixel_auroc', 'N/A')} | {r['latency_ms']['median']:.2f} | {r['latency_ms']['p95']:.2f} | {r['threshold']:.6f} |" for n, r in manifest['candidates'].items()) + "\n", + encoding="utf-8", + ) + + +def run(args: argparse.Namespace) -> Dict[str, Any]: + 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") + if not dataset_path or not class_name: + raise ValueError("dataset_path and class_name are required in the CLI or config.") + + device = determine_device(args.device) + dataset = anomavision.MVTecDataset(dataset_path, class_name, is_train=False, + resize=cfg.get("resize", 224), crop_size=cfg.get("crop_size", 224), + normalize=cfg.get("normalize", True), mean=cfg.get("norm_mean"), std=cfg.get("norm_std")) + dataloader = DataLoader(dataset, batch_size=max(1, args.batch_size), shuffle=False, + num_workers=max(0, args.num_workers), pin_memory=(device.startswith("cuda"))) + + candidates: Dict[str, Dict[str, Any]] = {} + 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 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) + source = Path(candidates[selected]["model_path"]) + packaged_model = output_dir / f"model{source.suffix}" + shutil.copy2(source, packaged_model) + + packaged_sidecar = None + if selected == "efficientad": + for sidecar in (source.with_suffix(".pth"), source.parent / "model.pth"): + if sidecar.exists(): + packaged_sidecar = output_dir / sidecar.name + shutil.copy2(sidecar, packaged_sidecar) + break + + if args.copy_config: + shutil.copy2(args.config, output_dir / Path(args.config).name) + manifest = { + "schema_version": 4, "selected_model": selected, "selected_artifact": packaged_model.name, + "calibration_artifact": packaged_sidecar.name if packaged_sidecar else None, + "dataset": {"path": str(Path(dataset_path).resolve()), "class_name": class_name, "samples": len(dataset)}, + "preprocessing": {"resize": cfg.get("resize", 224), "crop_size": cfg.get("crop_size", 224), + "normalize": cfg.get("normalize", True), "mean": cfg.get("norm_mean"), "std": cfg.get("norm_std")}, + "candidates": candidates, "target_latency_ms": args.target_latency_ms, + "environment": {"python": sys.version.split()[0], "platform": platform.platform(), "torch": torch.__version__, "device": device}, + } + (output_dir / "deployment_manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + _write_report(manifest, output_dir) + return manifest + + +def main(args: Optional[argparse.Namespace] = None) -> None: + args = args or create_parser().parse_args() + manifest = run(args) + print(json.dumps({"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)) + + +if __name__ == "__main__": + main() From ce2a4ca6dd9aa2f3d435eb275a588a43ce465b46 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:47:42 +0200 Subject: [PATCH 43/47] restore rich autopilot HTML dashboard --- anomavision/autopilot.py | 195 +++++++++++++++++---------------------- 1 file changed, 83 insertions(+), 112 deletions(-) diff --git a/anomavision/autopilot.py b/anomavision/autopilot.py index 3ade29c..68d9d96 100644 --- a/anomavision/autopilot.py +++ b/anomavision/autopilot.py @@ -1,5 +1,4 @@ """Production Autopilot for calibrated, hardware-aware anomaly deployment.""" - from __future__ import annotations import argparse @@ -26,12 +25,12 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Select, calibrate, profile, and package a production anomaly model.", add_help=add_help) - parser.add_argument("--config", type=str, required=True) - parser.add_argument("--dataset_path", type=str, default=None) - parser.add_argument("--class_name", type=str, default=None) - parser.add_argument("--padim_model", type=str, default=None) - parser.add_argument("--patchcore_model", type=str, default=None) - parser.add_argument("--efficientad_model", type=str, default=None) + 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("--efficientad_model", default=None, help="EfficientAD model artifact (.pt/.pth/.onnx).") parser.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto") parser.add_argument("--batch_size", type=int, default=1) parser.add_argument("--num_workers", type=int, default=0) @@ -39,7 +38,7 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: 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) - parser.add_argument("--output_dir", type=str, default="./production_package") + parser.add_argument("--output_dir", default="./production_package") parser.add_argument("--copy_config", action="store_true", default=True) return parser @@ -50,41 +49,46 @@ def _to_numpy(value: Any) -> np.ndarray: return np.asarray(value) +def _fmt(value: Any, digits: int = 4) -> str: + return "N/A" if value is None else f"{float(value):.{digits}f}" + + +def _pct(value: Any) -> str: + return "N/A" if value is None else f"{float(value):.1%}" + + def _profile_model(model_path: str, dataloader: DataLoader, device: str, warmup: int, timing_batches: int) -> Dict[str, Any]: wrapper = ModelWrapper(model_path, device) - timings: list[float] = [] - all_scores: list[float] = [] - all_maps: list[np.ndarray] = [] - all_labels: list[int] = [] - all_masks: list[np.ndarray] = [] + timings = [] + scores_all, maps_all, labels_all, masks_all = [], [], [], [] try: - first_batch = next(iter(dataloader))[0].to(device) + 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() - - for batch_index, (batch, _, labels, masks) in enumerate(dataloader): + for index, (batch, _, labels, masks) in enumerate(dataloader): batch = batch.to(device) - measure = batch_index < max(1, timing_batches) + 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) - all_scores.extend(_to_numpy(scores).reshape(-1).tolist()) + scores_all.extend(_to_numpy(scores).reshape(-1).tolist()) if maps is not None: - all_maps.extend(list(_to_numpy(maps))) - all_labels.extend(_to_numpy(labels).reshape(-1).astype(int).tolist()) - all_masks.extend(list(_to_numpy(masks))) + 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(all_scores, dtype=np.float32) - labels = np.asarray(all_labels, dtype=np.int64) - maps = np.asarray(all_maps, dtype=np.float32) if all_maps else np.empty((0, 0, 0), dtype=np.float32) - masks = np.asarray(all_masks, dtype=np.float32) + 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) + masks = np.asarray(masks_all, dtype=np.float32) if masks.ndim == 4 and masks.shape[1] == 1: masks = masks[:, 0] @@ -93,44 +97,39 @@ def _profile_model(model_path: str, dataloader: DataLoader, device: str, warmup: 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 {} - image_auroc = float(metrics["auc_score"]) if metrics.get("auc_score") is not None else None - - localization = {"available": bool(len(maps) == len(labels) and maps.ndim == 3), "pixel_auroc": None, - "anomaly_non_empty_fraction": None, "normal_false_positive_fraction": None, - "anomaly_mean_mask_area_fraction": None, "normal_mean_mask_area_fraction": None} + pixel_auroc = None + localization = {"available": bool(len(maps) == len(labels) and maps.ndim == 3), "non_empty_fraction": None, + "mean_mask_area_fraction": None, "anomaly_non_empty_fraction": None, + "normal_false_positive_fraction": None, "anomaly_mean_mask_area_fraction": None, + "normal_mean_mask_area_fraction": None, "verdict": "unavailable"} if localization["available"] and masks.shape == maps.shape and np.unique(masks).size > 1: try: - localization["pixel_auroc"] = float(roc_auc_score(masks.reshape(-1) > 0.5, maps.reshape(-1))) + pixel_auroc = float(roc_auc_score(masks.reshape(-1) > 0.5, maps.reshape(-1))) except ValueError: pass loc_masks = make_localization_mask(maps, (scores >= threshold).astype(np.uint8)).astype(bool) - area = loc_masks.reshape(len(loc_masks), -1).mean(axis=1) - non_empty = loc_masks.reshape(len(loc_masks), -1).any(axis=1) - anomaly = labels == 1 - normal = labels == 0 + 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()) if anomaly.any(): localization["anomaly_non_empty_fraction"] = float(non_empty[anomaly].mean()) 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()) - - batch_size = max(1, int(dataloader.batch_size or 1)) - median_ms = float(np.median(timings) * 1000.0 / batch_size) if timings else 0.0 - p95_ms = float(np.percentile(timings, 95) * 1000.0 / batch_size) if timings else 0.0 - return { - "model_path": str(Path(model_path).resolve()), - "model_format": Path(model_path).suffix.lower(), - "threshold": float(threshold), - "threshold_f1": float(threshold_f1), - "metrics": {k: (float(v) if isinstance(v, (float, np.floating)) else v) for k, v in metrics.items()}, - "image_auroc": image_auroc, - "pixel_auroc": localization["pixel_auroc"], - "latency_ms": {"median": median_ms, "p95": p95_ms}, - "throughput_images_per_second": float(1000.0 / median_ms) if median_ms > 0 else 0.0, - "localization": localization, - "samples": int(len(labels)), - } + 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": metrics, + "latency_ms": {"median": median_ms, "p95": p95_ms}, + "throughput_images_per_second": float(1000 / median_ms) if median_ms else 0.0, + "localization": localization, "samples": int(len(labels))} def _select(results: Dict[str, Dict[str, Any]], target_latency_ms: Optional[float]) -> str: @@ -139,32 +138,26 @@ def _select(results: Dict[str, Dict[str, Any]], target_latency_ms: Optional[floa eligible = {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 n: (eligible[n].get("image_auroc") or 0.0, -eligible[n]["latency_ms"]["p95"])) + return max(eligible, 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: - rows = [] + selected = manifest["selected_model"] + selected_result = manifest["candidates"][selected] + target = manifest.get("target_latency_ms") + cards, rows = [], [] for name, result in manifest["candidates"].items(): - rows.append(f"{html.escape(name)}{result.get('image_auroc', 'N/A')}" - f"{result.get('pixel_auroc', 'N/A')}{result['latency_ms']['median']:.2f}" - f"{result['latency_ms']['p95']:.2f}{result['threshold']:.6f}") - document = f"""AnomaVision Production Autopilot Report - -

AnomaVision Production Autopilot Report

Selected model: {html.escape(manifest['selected_model'])}

-

Class: {html.escape(str(manifest['dataset']['class_name']))}   Samples: {manifest['dataset']['samples']}

- -{''.join(rows)}
ModelImage AUROCPixel AUROCMedian msP95 msThreshold
-

Selected artifact: {html.escape(manifest['selected_artifact'])}

-

Target latency: {manifest['target_latency_ms'] if manifest['target_latency_ms'] is not None else 'not set'} ms

-""" + metrics, loc = result["metrics"], result["localization"] + active = name == selected + cards.append(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'''{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" + env = html.escape(json.dumps(manifest["environment"], indent=2)) + 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 false positivesThreshold

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}
Generated by AnomaVision Production Autopilot · manifest schema {manifest["schema_version"]}
''' (output_dir / "production_autopilot_report.html").write_text(document, encoding="utf-8") - (output_dir / "localization_report.md").write_text( - "# AnomaVision Production Autopilot Report\n\n" + - f"**Selected model:** `{manifest['selected_model']}`\n\n" + - "| Model | Image AUROC | Pixel AUROC | Median ms | P95 ms | Threshold |\n|---|---:|---:|---:|---:|---:|\n" + - "\n".join(f"| {n} | {r.get('image_auroc', 'N/A')} | {r.get('pixel_auroc', 'N/A')} | {r['latency_ms']['median']:.2f} | {r['latency_ms']['p95']:.2f} | {r['threshold']:.6f} |" for n, r in manifest['candidates'].items()) + "\n", - encoding="utf-8", - ) + (output_dir / "localization_report.md").write_text(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]: @@ -173,57 +166,35 @@ def run(args: argparse.Namespace) -> Dict[str, Any]: class_name = args.class_name or cfg.get("class_name") if not dataset_path or not class_name: raise ValueError("dataset_path and class_name are required in the CLI or config.") - device = determine_device(args.device) - dataset = anomavision.MVTecDataset(dataset_path, class_name, is_train=False, - resize=cfg.get("resize", 224), crop_size=cfg.get("crop_size", 224), - normalize=cfg.get("normalize", True), mean=cfg.get("norm_mean"), std=cfg.get("norm_std")) - dataloader = DataLoader(dataset, batch_size=max(1, args.batch_size), shuffle=False, - num_workers=max(0, args.num_workers), pin_memory=(device.startswith("cuda"))) - - candidates: Dict[str, Dict[str, Any]] = {} - for name, model_path in (("padim", args.padim_model), ("patchcore", args.patchcore_model), ("efficientad", args.efficientad_model)): + dataset = anomavision.MVTecDataset(dataset_path, class_name, is_train=False, resize=cfg.get("resize",224), crop_size=cfg.get("crop_size",224), normalize=cfg.get("normalize",True), mean=cfg.get("norm_mean"), std=cfg.get("norm_std")) + dataloader = DataLoader(dataset, batch_size=max(1,args.batch_size), shuffle=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) + candidates[name] = _profile_model(model_path,dataloader,device,args.warmup,args.timing_batches) if not candidates: 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) - source = Path(candidates[selected]["model_path"]) - packaged_model = output_dir / f"model{source.suffix}" - shutil.copy2(source, packaged_model) - - packaged_sidecar = None + selected = _select(candidates,args.target_latency_ms) + output_dir = Path(args.output_dir); output_dir.mkdir(parents=True,exist_ok=True) + 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 sidecar in (source.with_suffix(".pth"), source.parent / "model.pth"): - if sidecar.exists(): - packaged_sidecar = output_dir / sidecar.name - shutil.copy2(sidecar, packaged_sidecar) - break - + 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": 4, "selected_model": selected, "selected_artifact": packaged_model.name, - "calibration_artifact": packaged_sidecar.name if packaged_sidecar else None, - "dataset": {"path": str(Path(dataset_path).resolve()), "class_name": class_name, "samples": len(dataset)}, - "preprocessing": {"resize": cfg.get("resize", 224), "crop_size": cfg.get("crop_size", 224), - "normalize": cfg.get("normalize", True), "mean": cfg.get("norm_mean"), "std": cfg.get("norm_std")}, - "candidates": candidates, "target_latency_ms": args.target_latency_ms, - "environment": {"python": sys.version.split()[0], "platform": platform.platform(), "torch": torch.__version__, "device": device}, - } - (output_dir / "deployment_manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") - _write_report(manifest, output_dir) + shutil.copy2(args.config,output_dir/Path(args.config).name) + manifest = {"schema_version":5,"selected_model":selected,"selected_artifact":packaged_model.name,"calibration_artifact":sidecar.name if sidecar else None,"dataset":{"path":str(Path(dataset_path).resolve()),"class_name":class_name,"samples":len(dataset)},"preprocessing":{"resize":cfg.get("resize",224),"crop_size":cfg.get("crop_size",224),"normalize":cfg.get("normalize",True),"mean":cfg.get("norm_mean"),"std":cfg.get("norm_std")},"candidates":candidates,"target_latency_ms":args.target_latency_ms,"environment":{"python":sys.version.split()[0],"platform":platform.platform(),"torch":torch.__version__,"device":device}} + (output_dir/"deployment_manifest.json").write_text(json.dumps(manifest,indent=2),encoding="utf-8") + _write_report(manifest,output_dir) return manifest def main(args: Optional[argparse.Namespace] = None) -> None: args = args or create_parser().parse_args() manifest = run(args) - print(json.dumps({"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)) + print(json.dumps({"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)) if __name__ == "__main__": From afb42300c8463ea406e375bed368fd687b748f24 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:50:09 +0200 Subject: [PATCH 44/47] fix autopilot JSON serialization for numpy metrics --- anomavision/autopilot.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/anomavision/autopilot.py b/anomavision/autopilot.py index 68d9d96..1831373 100644 --- a/anomavision/autopilot.py +++ b/anomavision/autopilot.py @@ -57,6 +57,19 @@ 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, device: str, warmup: int, timing_batches: int) -> Dict[str, Any]: wrapper = ModelWrapper(model_path, device) timings = [] @@ -186,7 +199,7 @@ def run(args: argparse.Namespace) -> Dict[str, Any]: if args.copy_config: shutil.copy2(args.config,output_dir/Path(args.config).name) manifest = {"schema_version":5,"selected_model":selected,"selected_artifact":packaged_model.name,"calibration_artifact":sidecar.name if sidecar else None,"dataset":{"path":str(Path(dataset_path).resolve()),"class_name":class_name,"samples":len(dataset)},"preprocessing":{"resize":cfg.get("resize",224),"crop_size":cfg.get("crop_size",224),"normalize":cfg.get("normalize",True),"mean":cfg.get("norm_mean"),"std":cfg.get("norm_std")},"candidates":candidates,"target_latency_ms":args.target_latency_ms,"environment":{"python":sys.version.split()[0],"platform":platform.platform(),"torch":torch.__version__,"device":device}} - (output_dir/"deployment_manifest.json").write_text(json.dumps(manifest,indent=2),encoding="utf-8") + (output_dir/"deployment_manifest.json").write_text(json.dumps(manifest,indent=2,default=_json_default),encoding="utf-8") _write_report(manifest,output_dir) return manifest From d111d761bc81768a1587a6151f41dfe54c319af9 Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:56:51 +0200 Subject: [PATCH 45/47] fix autopilot test compatibility and preserve rich report --- anomavision/autopilot.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/anomavision/autopilot.py b/anomavision/autopilot.py index 1831373..589ad2d 100644 --- a/anomavision/autopilot.py +++ b/anomavision/autopilot.py @@ -53,6 +53,11 @@ 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 _pct(value: Any) -> str: return "N/A" if value is None else f"{float(value):.1%}" @@ -165,10 +170,10 @@ def _write_report(manifest: Dict[str, Any], output_dir: Path) -> None: cards.append(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'''{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" - env = html.escape(json.dumps(manifest["environment"], indent=2)) + 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 false positivesThreshold

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}
Generated by AnomaVision Production Autopilot · manifest schema {manifest["schema_version"]}
''' +
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}
Generated by AnomaVision Production Autopilot · manifest schema {manifest["schema_version"]}
''' (output_dir / "production_autopilot_report.html").write_text(document, encoding="utf-8") (output_dir / "localization_report.md").write_text(f"# AnomaVision Production Autopilot Report\n\n**Selected model:** `{selected}`\n\nSee `production_autopilot_report.html` for the full dashboard.\n", encoding="utf-8") From fe8966e62eefaabedd5f1653db6195e17bab6bcc Mon Sep 17 00:00:00 2001 From: Deep Knowledge <66887716+DeepKnowledge1@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:27:29 +0200 Subject: [PATCH 46/47] test: match autopilot report metric capitalization --- tests/test_autopilot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 " Date: Fri, 28 Aug 2026 06:28:26 +0200 Subject: [PATCH 47/47] readme autopilot and pre-commit --- README.md | 15 +- .../algorithm/efficientad/efficientad.py | 90 +++++-- anomavision/autopilot.py | 251 ++++++++++++++---- anomavision/cli.py | 38 ++- anomavision/detect.py | 183 ++++++++++--- anomavision/efficientad_threshold.py | 8 +- anomavision/train.py | 4 +- 7 files changed, 456 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index c1ac7f0..b7d3e28 100644 --- a/README.md +++ b/README.md @@ -163,13 +163,14 @@ The complete setup and commands are in the [KV260 XModel Guide](docs/kv260_xmode 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 ``` diff --git a/anomavision/algorithm/efficientad/efficientad.py b/anomavision/algorithm/efficientad/efficientad.py index ff3b4cb..2cfae73 100644 --- a/anomavision/algorithm/efficientad/efficientad.py +++ b/anomavision/algorithm/efficientad/efficientad.py @@ -30,9 +30,15 @@ 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(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), ) @@ -43,10 +49,18 @@ def forward(self, 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): + 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"}: @@ -62,7 +76,10 @@ def __init__(self, device=torch.device("cpu"), model_size="s", lr=1e-4, 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.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)) @@ -77,15 +94,21 @@ def _normalise(self, x): @torch.no_grad() def _raw_map(self, x, teacher=None): - teacher_features = self.teacher(self._normalise(x)) if teacher is None else teacher + 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) + 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) + 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 @@ -101,7 +124,9 @@ def fit(self, dataloader, epochs=1): 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) + 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() @@ -110,7 +135,9 @@ def fit(self, dataloader, epochs=1): 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): + 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() @@ -142,7 +169,9 @@ def predict(self, batch, export=False): 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) + return self.forward( + batch.to(self.device, non_blocking=True).float(), export=export + ) def to_device(self, device): self.device = torch.device(device) @@ -153,13 +182,16 @@ def save_statistics(self, path: str, half: Optional[bool] = None): 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) + 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"): @@ -168,8 +200,12 @@ def load_statistics(path: str, device: str = "cpu"): 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 = 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") @@ -180,8 +216,12 @@ def build_efficientad_from_stats(stats, device="cpu"): 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 = 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 589ad2d..940e68d 100644 --- a/anomavision/autopilot.py +++ b/anomavision/autopilot.py @@ -1,4 +1,5 @@ """Production Autopilot for calibrated, hardware-aware anomaly deployment.""" + from __future__ import annotations import argparse @@ -20,17 +21,28 @@ from anomavision.config import load_config from anomavision.general import determine_device from anomavision.inference.model.wrapper import ModelWrapper -from anomavision.utils import compute_metrics, find_optimal_threshold, make_localization_mask +from anomavision.utils import ( + compute_metrics, + find_optimal_threshold, + make_localization_mask, +) def create_parser(add_help: bool = True) -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Select, calibrate, profile, and package a production anomaly model.", add_help=add_help) + 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("--efficientad_model", default=None, help="EfficientAD model artifact (.pt/.pth/.onnx).") + parser.add_argument( + "--efficientad_model", + default=None, + help="EfficientAD model artifact (.pt/.pth/.onnx).", + ) parser.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto") parser.add_argument("--batch_size", type=int, default=1) parser.add_argument("--num_workers", type=int, default=0) @@ -75,7 +87,13 @@ def _json_default(value: Any) -> Any: raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") -def _profile_model(model_path: str, dataloader: DataLoader, device: str, warmup: int, timing_batches: int) -> Dict[str, Any]: +def _profile_model( + model_path: str, + dataloader: DataLoader, + device: str, + warmup: int, + timing_batches: int, +) -> Dict[str, Any]: wrapper = ModelWrapper(model_path, device) timings = [] scores_all, maps_all, labels_all, masks_all = [], [], [], [] @@ -105,7 +123,11 @@ def _profile_model(model_path: str, dataloader: DataLoader, device: str, warmup: 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) + maps = ( + np.asarray(maps_all, dtype=np.float32) + if maps_all + else np.empty((0, 0, 0), dtype=np.float32) + ) masks = np.asarray(masks_all, dtype=np.float32) if masks.ndim == 4 and masks.shape[1] == 1: masks = masks[:, 0] @@ -113,50 +135,96 @@ def _profile_model(model_path: str, dataloader: DataLoader, device: str, warmup: 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 + 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 - localization = {"available": bool(len(maps) == len(labels) and maps.ndim == 3), "non_empty_fraction": None, - "mean_mask_area_fraction": None, "anomaly_non_empty_fraction": None, - "normal_false_positive_fraction": None, "anomaly_mean_mask_area_fraction": None, - "normal_mean_mask_area_fraction": None, "verdict": "unavailable"} - if localization["available"] and masks.shape == maps.shape and np.unique(masks).size > 1: + localization = { + "available": bool(len(maps) == len(labels) and maps.ndim == 3), + "non_empty_fraction": None, + "mean_mask_area_fraction": None, + "anomaly_non_empty_fraction": None, + "normal_false_positive_fraction": None, + "anomaly_mean_mask_area_fraction": None, + "normal_mean_mask_area_fraction": None, + "verdict": "unavailable", + } + if ( + localization["available"] + and masks.shape == maps.shape + and np.unique(masks).size > 1 + ): try: - pixel_auroc = float(roc_auc_score(masks.reshape(-1) > 0.5, maps.reshape(-1))) + pixel_auroc = float( + roc_auc_score(masks.reshape(-1) > 0.5, maps.reshape(-1)) + ) except ValueError: pass - loc_masks = make_localization_mask(maps, (scores >= threshold).astype(np.uint8)).astype(bool) + 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()) if anomaly.any(): - localization["anomaly_non_empty_fraction"] = float(non_empty[anomaly].mean()) - localization["anomaly_mean_mask_area_fraction"] = float(area[anomaly].mean()) + localization["anomaly_non_empty_fraction"] = float( + non_empty[anomaly].mean() + ) + 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_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 + 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": metrics, - "latency_ms": {"median": median_ms, "p95": p95_ms}, - "throughput_images_per_second": float(1000 / median_ms) if median_ms else 0.0, - "localization": localization, "samples": int(len(labels))} + return { + "model_path": str(Path(model_path).resolve()), + "model_format": Path(model_path).suffix.lower(), + "threshold": float(threshold), + "threshold_f1": float(threshold_f1), + "metrics": metrics, + "latency_ms": {"median": median_ms, "p95": p95_ms}, + "throughput_images_per_second": float(1000 / median_ms) if median_ms else 0.0, + "localization": localization, + "samples": int(len(labels)), + } -def _select(results: Dict[str, Dict[str, Any]], target_latency_ms: Optional[float]) -> str: +def _select( + results: Dict[str, Dict[str, Any]], target_latency_ms: Optional[float] +) -> str: eligible = results if target_latency_ms is not None: - eligible = {n: r for n, r in results.items() if r["latency_ms"]["p95"] <= target_latency_ms} + eligible = { + 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 n: (eligible[n]["metrics"].get("image_auroc") or 0.0, -eligible[n]["latency_ms"]["p95"])) + return max( + eligible, + 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: @@ -167,15 +235,30 @@ def _write_report(manifest: Dict[str, Any], output_dir: Path) -> None: for name, result in manifest["candidates"].items(): metrics, loc = result["metrics"], result["localization"] active = name == selected - cards.append(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'''{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" - 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}
Generated by AnomaVision Production Autopilot · manifest schema {manifest["schema_version"]}
''' - (output_dir / "production_autopilot_report.html").write_text(document, encoding="utf-8") - (output_dir / "localization_report.md").write_text(f"# AnomaVision Production Autopilot Report\n\n**Selected model:** `{selected}`\n\nSee `production_autopilot_report.html` for the full dashboard.\n", encoding="utf-8") +
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( + 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]: @@ -183,36 +266,104 @@ def run(args: argparse.Namespace) -> Dict[str, Any]: 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") if not dataset_path or not class_name: - raise ValueError("dataset_path and class_name are required in the CLI or config.") + raise ValueError( + "dataset_path and class_name are required in the CLI or config." + ) device = determine_device(args.device) - dataset = anomavision.MVTecDataset(dataset_path, class_name, is_train=False, resize=cfg.get("resize",224), crop_size=cfg.get("crop_size",224), normalize=cfg.get("normalize",True), mean=cfg.get("norm_mean"), std=cfg.get("norm_std")) - dataloader = DataLoader(dataset, batch_size=max(1,args.batch_size), shuffle=False, num_workers=max(0,args.num_workers), pin_memory=device.startswith("cuda")) + dataset = anomavision.MVTecDataset( + dataset_path, + class_name, + is_train=False, + resize=cfg.get("resize", 224), + crop_size=cfg.get("crop_size", 224), + normalize=cfg.get("normalize", True), + mean=cfg.get("norm_mean"), + std=cfg.get("norm_std"), + ) + dataloader = DataLoader( + dataset, + batch_size=max(1, args.batch_size), + shuffle=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)): + 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) + candidates[name] = _profile_model( + model_path, dataloader, device, args.warmup, args.timing_batches + ) if not candidates: - 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) - source = Path(candidates[selected]["model_path"]); packaged_model = output_dir / f"model{source.suffix}"; shutil.copy2(source,packaged_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) + 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"): + for candidate in (source.with_suffix(".pth"), source.parent / "model.pth"): if candidate.exists(): - sidecar = output_dir/candidate.name; shutil.copy2(candidate,sidecar); break + 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":5,"selected_model":selected,"selected_artifact":packaged_model.name,"calibration_artifact":sidecar.name if sidecar else None,"dataset":{"path":str(Path(dataset_path).resolve()),"class_name":class_name,"samples":len(dataset)},"preprocessing":{"resize":cfg.get("resize",224),"crop_size":cfg.get("crop_size",224),"normalize":cfg.get("normalize",True),"mean":cfg.get("norm_mean"),"std":cfg.get("norm_std")},"candidates":candidates,"target_latency_ms":args.target_latency_ms,"environment":{"python":sys.version.split()[0],"platform":platform.platform(),"torch":torch.__version__,"device":device}} - (output_dir/"deployment_manifest.json").write_text(json.dumps(manifest,indent=2,default=_json_default),encoding="utf-8") - _write_report(manifest,output_dir) + shutil.copy2(args.config, output_dir / Path(args.config).name) + manifest = { + "schema_version": 5, + "selected_model": selected, + "selected_artifact": packaged_model.name, + "calibration_artifact": sidecar.name if sidecar else None, + "dataset": { + "path": str(Path(dataset_path).resolve()), + "class_name": class_name, + "samples": len(dataset), + }, + "preprocessing": { + "resize": cfg.get("resize", 224), + "crop_size": cfg.get("crop_size", 224), + "normalize": cfg.get("normalize", True), + "mean": cfg.get("norm_mean"), + "std": cfg.get("norm_std"), + }, + "candidates": candidates, + "target_latency_ms": args.target_latency_ms, + "environment": { + "python": sys.version.split()[0], + "platform": platform.platform(), + "torch": torch.__version__, + "device": device, + }, + } + (output_dir / "deployment_manifest.json").write_text( + json.dumps(manifest, indent=2, default=_json_default), encoding="utf-8" + ) + _write_report(manifest, output_dir) return manifest def main(args: Optional[argparse.Namespace] = None) -> None: args = args or create_parser().parse_args() manifest = run(args) - print(json.dumps({"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)) + print( + json.dumps( + { + "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, + ) + ) if __name__ == "__main__": diff --git a/anomavision/cli.py b/anomavision/cli.py index b0f20f0..68080e9 100644 --- a/anomavision/cli.py +++ b/anomavision/cli.py @@ -43,6 +43,7 @@ def create_parser() -> argparse.ArgumentParser: try: from anomavision import __version__ + version_str = f"AnomaVision {__version__}" except ImportError: version_str = "AnomaVision" @@ -65,8 +66,10 @@ def create_parser() -> argparse.ArgumentParser: def _add_train_parser(subparsers) -> None: from anomavision.train import create_parser as _cp + subparsers.add_parser( - "train", help="Train a new anomaly detection model", + "train", + help="Train a new anomaly detection model", parents=[_cp(add_help=False)], formatter_class=argparse.ArgumentDefaultsHelpFormatter, ).set_defaults(func=_dispatch_train) @@ -74,8 +77,10 @@ def _add_train_parser(subparsers) -> None: def _add_export_parser(subparsers) -> None: from anomavision.export import create_parser as _cp + subparsers.add_parser( - "export", help="Export trained model to different formats", + "export", + help="Export trained model to different formats", parents=[_cp(add_help=False)], formatter_class=argparse.ArgumentDefaultsHelpFormatter, ).set_defaults(func=_dispatch_export) @@ -83,8 +88,10 @@ def _add_export_parser(subparsers) -> None: def _add_detect_parser(subparsers) -> None: from anomavision.detect import create_parser as _cp + subparsers.add_parser( - "detect", help="Run inference on images", + "detect", + help="Run inference on images", parents=[_cp(add_help=False)], formatter_class=argparse.ArgumentDefaultsHelpFormatter, ).set_defaults(func=_dispatch_detect) @@ -92,8 +99,10 @@ def _add_detect_parser(subparsers) -> None: def _add_eval_parser(subparsers) -> None: from anomavision.eval import create_parser as _cp + subparsers.add_parser( - "eval", help="Evaluate model performance", + "eval", + help="Evaluate model performance", parents=[_cp(add_help=False)], formatter_class=argparse.ArgumentDefaultsHelpFormatter, ).set_defaults(func=_dispatch_eval) @@ -101,8 +110,10 @@ def _add_eval_parser(subparsers) -> None: def _add_autopilot_parser(subparsers) -> None: from anomavision.autopilot import create_parser as _cp + subparsers.add_parser( - "autopilot", help="Calibrate, profile, and package a production model", + "autopilot", + help="Calibrate, profile, and package a production model", parents=[_cp(add_help=False)], formatter_class=argparse.ArgumentDefaultsHelpFormatter, ).set_defaults(func=_dispatch_autopilot) @@ -110,11 +121,13 @@ def _add_autopilot_parser(subparsers) -> None: def _dispatch_train(args: argparse.Namespace) -> None: from anomavision import train + train.main(args) def _dispatch_export(args: argparse.Namespace) -> None: from anomavision import export + export.main(args) @@ -125,29 +138,38 @@ def _dispatch_detect(args: argparse.Namespace) -> None: 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() + 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") + 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 + 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_module.main(args) def _dispatch_autopilot(args: argparse.Namespace) -> None: from anomavision import autopilot + autopilot.main(args) diff --git a/anomavision/detect.py b/anomavision/detect.py index 0a449fb..0a32809 100644 --- a/anomavision/detect.py +++ b/anomavision/detect.py @@ -32,13 +32,17 @@ def create_parser(add_help: bool = True): - parser = argparse.ArgumentParser(description="Run anomaly detection inference.", add_help=add_help) + parser = argparse.ArgumentParser( + 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("--device", type=str, default=None, choices=["auto", "cpu", "cuda"]) + parser.add_argument( + "--device", type=str, default=None, choices=["auto", "cpu", "cuda"] + ) 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) @@ -51,7 +55,12 @@ def create_parser(add_help: bool = 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"]) + parser.add_argument( + "--log_level", + type=str, + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + ) parser.add_argument("--detailed_timing", action="store_true") return parser @@ -64,7 +73,10 @@ def _load_efficientad_threshold(model_path: Path): continue try: artifact = torch.load(sidecar, map_location="cpu", weights_only=False) - if isinstance(artifact, dict) and artifact.get("algorithm") == "efficientad": + 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") @@ -105,7 +117,13 @@ def run_inference(args): 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 = ( + 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}") @@ -114,22 +132,40 @@ def run_inference(args): 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) + 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("algorithm=%s model=%s device=%s threshold=%s", algorithm_name, model_path, device_str, config.thresh) + logger.info( + "algorithm=%s model=%s device=%s threshold=%s", + algorithm_name, + model_path, + device_str, + config.thresh, + ) profilers = { "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(), + "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(), } with profilers["model_loading"]: @@ -149,31 +185,56 @@ def run_inference(args): if config.get("save_visualizations", False): results_path = increment_path( Path(config.get("viz_output_dir", "./visualizations")) - / config.algorithm / config.class_name / model_type.value.upper() / config.run_name, - exist_ok=config.get("overwrite", False), mkdir=True, + / config.algorithm + / config.class_name + / model_type.value.upper() + / config.run_name, + exist_ok=config.get("overwrite", False), + mkdir=True, ) with profilers["data_loading"]: 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")) + 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) + 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) + 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 - results = {"scores": [], "classifications": [], "images": [] if not stream_mode else None} + results = { + "scores": [], + "classifications": [], + "images": [] if not stream_mode else None, + } batch_count = 0 image_counter = 0 @@ -195,31 +256,55 @@ def run_inference(args): 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) + masks = make_localization_mask( + score_maps, is_anomaly, quantile=0.90 + ) else: masks = anomavision.classification(score_maps, config.thresh) 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["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"]: boundaries = anomavision.visualization.framed_boundary_images( - images, masks, is_anomaly, padding=config.get("viz_padding", 40)) + 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)) + 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) + [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") + 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", + ) plt.close(fig) finally: model.close() @@ -230,7 +315,11 @@ def run_inference(args): pass 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 + 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 @@ -239,12 +328,24 @@ def run_inference(args): logger.info("=" * 60) logger.info("ANOMAVISION PERFORMANCE SUMMARY") logger.info("=" * 60) - logger.info(f"Setup time: {profilers['setup'].accumulated_time * 1000:.2f} ms") - logger.info(f"Model loading time: {profilers['model_loading'].accumulated_time * 1000:.2f} ms") - logger.info(f"Data loading time: {profilers['data_loading'].accumulated_time * 1000:.2f} ms") - logger.info(f"Inference time: {profilers['inference'].accumulated_time * 1000:.2f} ms") - logger.info(f"Postprocessing time: {profilers['postprocessing'].accumulated_time * 1000:.2f} ms") - logger.info(f"Visualization time: {profilers['visualization'].accumulated_time * 1000:.2f} ms") + logger.info( + f"Setup time: {profilers['setup'].accumulated_time * 1000:.2f} ms" + ) + logger.info( + f"Model loading time: {profilers['model_loading'].accumulated_time * 1000:.2f} ms" + ) + logger.info( + f"Data loading time: {profilers['data_loading'].accumulated_time * 1000:.2f} ms" + ) + logger.info( + f"Inference time: {profilers['inference'].accumulated_time * 1000:.2f} ms" + ) + logger.info( + f"Postprocessing time: {profilers['postprocessing'].accumulated_time * 1000:.2f} ms" + ) + logger.info( + f"Visualization time: {profilers['visualization'].accumulated_time * 1000:.2f} ms" + ) logger.info(f"Total pipeline time: {total_pipeline_time * 1000:.2f} ms") logger.info("=" * 60) @@ -256,7 +357,9 @@ def run_inference(args): if avg_ms > 0: logger.info(f"Average inference time: {avg_ms:.2f} ms/batch") if batch_count > 0: - logger.info(f"Throughput: {throughput:.1f} images/sec (batch size: {config.get('batch_size', 1) or 1})") + logger.info( + f"Throughput: {throughput:.1f} images/sec (batch size: {config.get('batch_size', 1) or 1})" + ) logger.info("=" * 60) return { diff --git a/anomavision/efficientad_threshold.py b/anomavision/efficientad_threshold.py index fd58bc6..37a534b 100644 --- a/anomavision/efficientad_threshold.py +++ b/anomavision/efficientad_threshold.py @@ -30,10 +30,14 @@ def load_calibrated_threshold(model_path: str | Path) -> float: 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}") + 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}") + raise ValueError( + f"EfficientAD calibrated threshold is not finite: {candidate}" + ) return value raise FileNotFoundError( diff --git a/anomavision/train.py b/anomavision/train.py index aff915e..972b3fe 100644 --- a/anomavision/train.py +++ b/anomavision/train.py @@ -309,7 +309,9 @@ def run_training(args): 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)), + threshold_quantile=float( + config.get("efficientad_threshold_quantile", 0.995) + ), ) else: model = anomavision.Padim(