From d6770e4cc1b16fc6fb6e733d440e7b1b04e12880 Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:19:42 +0000 Subject: [PATCH 01/22] feat(export): add native TensorRT and INT8 engine export --- anomavision/export.py | 178 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 175 insertions(+), 3 deletions(-) diff --git a/anomavision/export.py b/anomavision/export.py index 21e6da9..4c835a5 100644 --- a/anomavision/export.py +++ b/anomavision/export.py @@ -145,6 +145,40 @@ def forward(self, x): return self.m.predict(x, export=True) # Unified path for ONNX/OpenVINO +def _make_tensorrt_calibrator(trt, samples, cache_path, batch_size=1): + """Create a TensorRT INT8 entropy calibrator from NCHW NumPy samples.""" + import pycuda.driver as cuda + + class Calibrator(trt.IInt8EntropyCalibrator2): + def __init__(self): + super().__init__() + self._samples = iter(samples) + self._cache_path = Path(cache_path) + self._batch_size = int(batch_size) + self._device_input = None + + def get_batch_size(self): + return self._batch_size + + def get_batch(self, names): + try: + sample = np.ascontiguousarray(next(self._samples), dtype=np.float32) + except StopIteration: + return None + if self._device_input is None: + self._device_input = cuda.mem_alloc(sample.nbytes) + cuda.memcpy_htod(self._device_input, sample) + return [int(self._device_input)] + + def read_calibration_cache(self): + return self._cache_path.read_bytes() if self._cache_path.exists() else None + + def write_calibration_cache(self, cache): + self._cache_path.write_bytes(bytes(cache)) + + return Calibrator() + + class ModelExporter: """Professional model exporter with clean interface and device-aware precision.""" @@ -399,6 +433,111 @@ def export_onnx( self.logger.exception("onnx: failed after %.2fs", time.perf_counter() - t0) return None + def export_tensorrt( + self, + input_shape: Tuple[int, int, int, int] = (1, 3, 224, 224), + output_name: str = "model.engine", + dynamic_batch: bool = True, + precision: str = "fp16", + calib_dir: Optional[str] = None, + calib_samples: int = 100, + workspace_gb: float = 2.0, + ) -> Optional[Path]: + """Build a native TensorRT engine from an ONNX graph. + + TensorRT is imported lazily because it is an NVIDIA deployment dependency. + INT8 mode uses real calibration images and writes a reusable calibration cache. + """ + t0 = time.perf_counter() + temp_onnx = self.output_dir / (Path(output_name).stem + "_fp32.onnx") + try: + if self.device.type != "cuda": + raise RuntimeError("TensorRT export requires a CUDA device.") + try: + import tensorrt as trt + except ImportError as exc: + raise ImportError( + "TensorRT export requires NVIDIA TensorRT, PyCUDA, and a CUDA runtime." + ) from exc + if precision not in {"fp32", "fp16", "int8"}: + raise ValueError("TensorRT precision must be fp32, fp16, or int8") + if precision == "int8" and not calib_dir: + raise ValueError("INT8 TensorRT export requires --calib-dir") + + onnx_path = self.export_onnx( + input_shape=input_shape, + output_name=temp_onnx.name, + dynamic_batch=dynamic_batch, + force_precision="fp32", + ) + if onnx_path is None: + raise RuntimeError("Could not create the intermediate ONNX graph.") + + logger = trt.Logger(trt.Logger.WARNING) + builder = trt.Builder(logger) + network = builder.create_network( + 1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH) + ) + parser = trt.OnnxParser(network, logger) + with onnx_path.open("rb") as handle: + if not parser.parse(handle.read()): + errors = [str(parser.get_error(i)) for i in range(parser.num_errors)] + raise RuntimeError("TensorRT ONNX parse failed: " + " | ".join(errors)) + + build_config = builder.create_builder_config() + build_config.set_memory_pool_limit( + trt.MemoryPoolType.WORKSPACE, int(workspace_gb * (1 << 30)) + ) + if precision in {"fp16", "int8"}: + if not builder.platform_has_fast_fp16: + self.logger.warning("TensorRT platform does not report fast FP16 support.") + build_config.set_flag(trt.BuilderFlag.FP16) + if precision == "int8": + if not builder.platform_has_fast_int8: + self.logger.warning("TensorRT platform does not report fast INT8 support.") + build_config.set_flag(trt.BuilderFlag.INT8) + samples = load_calibration_images( + calib_dir, input_shape, max_samples=calib_samples + ) + if not samples: + raise ValueError("No calibration images were found for INT8 export.") + calibrator = _make_tensorrt_calibrator( + trt, samples, self.output_dir / (Path(output_name).stem + ".calib") + ) + build_config.int8_calibrator = calibrator + + if dynamic_batch: + profile = builder.create_optimization_profile() + input_tensor = network.get_input(0) + _, channels, height, width = input_shape + profile.set_shape( + input_tensor.name, + (1, channels, height, width), + (max(1, input_shape[0]), channels, height, width), + (max(4, input_shape[0]), channels, height, width), + ) + build_config.add_optimization_profile(profile) + + serialized = builder.build_serialized_network(network, build_config) + if serialized is None: + raise RuntimeError("TensorRT failed to build the engine.") + output_path = self.output_dir / output_name + output_path.write_bytes(bytes(serialized)) + self.logger.info( + "tensorrt: ok (%.2fs) file=%s size=%.1fMB precision=%s dynamic_batch=%s", + time.perf_counter() - t0, + output_path, + output_path.stat().st_size / (1024 * 1024), + precision.upper(), + dynamic_batch, + ) + return output_path + except Exception: + self.logger.exception("tensorrt: failed after %.2fs", time.perf_counter() - t0) + return None + finally: + temp_onnx.unlink(missing_ok=True) + def export_torchscript( self, input_shape: Tuple[int, int, int, int] = (1, 3, 224, 224), @@ -590,7 +729,7 @@ def export_openvino( def create_parser(add_help: bool = True) -> argparse.ArgumentParser: """Command line interface.""" parser = argparse.ArgumentParser( - description="Export PaDiM models to ONNX, TorchScript, and OpenVINO (with optional quantization)", + description="Export AnomaVision models to ONNX, TensorRT, TorchScript, and OpenVINO.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, add_help=add_help, ) @@ -631,7 +770,7 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: parser.add_argument( "--format", - choices=["onnx", "openvino", "torchscript", "all"], + choices=["onnx", "tensorrt", "openvino", "torchscript", "all"], help="Export format", ) @@ -652,6 +791,24 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: ) parser.add_argument("--opset", type=int, default=18, help="ONNX opset version") + parser.add_argument( + "--tensorrt-precision", + choices=["fp32", "fp16", "int8"], + default="fp16", + help="TensorRT engine precision", + ) + parser.add_argument( + "--calib-dir", + type=str, + default=None, + help="Directory of normal images used for TensorRT INT8 calibration", + ) + parser.add_argument( + "--workspace-gb", + type=float, + default=2.0, + help="TensorRT builder workspace limit in GiB", + ) parser.add_argument( "--static-batch", action="store_true", help="Disable dynamic batch size" ) @@ -740,6 +897,7 @@ def main(args=None): # Generate output names precision_suffix = "" if config.precision == "auto" else f"_{config.precision}" onnx_name = f"{model_stem}{precision_suffix}.onnx" + tensorrt_name = f"{model_stem}_{config.tensorrt_precision}.engine" openvino_name = f"{model_stem}_openvino{precision_suffix}" torchscript_name = f"{model_stem}{precision_suffix}.torchscript" @@ -766,7 +924,7 @@ def main(args=None): started = time.perf_counter() success = True - calib_dir = os.path.join( + calib_dir = config.calib_dir or os.path.join( os.path.realpath(config.dataset_path), config.class_name, "train", "good" ) @@ -787,6 +945,20 @@ def main(args=None): is not None ) + if config.format in ["tensorrt", "all"]: + success &= ( + exporter.export_tensorrt( + input_shape=tuple(input_shape), + output_name=tensorrt_name, + dynamic_batch=not config.static_batch, + precision=config.tensorrt_precision, + calib_dir=calib_dir, + calib_samples=config.calib_samples, + workspace_gb=config.workspace_gb, + ) + is not None + ) + if config.format in ["openvino", "all"]: fp16_setting = ( None if config.precision == "auto" else (config.precision == "fp16") From 7d639af98000995b91a76f102c6e053ea7d69b06 Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:19:58 +0000 Subject: [PATCH 02/22] feat(inference): implement TensorRT engine backend --- .../model/backends/tensorrt_backend.py | 133 +++++++++++------- 1 file changed, 80 insertions(+), 53 deletions(-) diff --git a/anomavision/inference/model/backends/tensorrt_backend.py b/anomavision/inference/model/backends/tensorrt_backend.py index 2ab68bc..5a01d88 100644 --- a/anomavision/inference/model/backends/tensorrt_backend.py +++ b/anomavision/inference/model/backends/tensorrt_backend.py @@ -1,11 +1,9 @@ -# inference/model/backends/tensorrt_backend.py - -""" -TensorRT backend β€” currently not implemented. -""" +"""TensorRT inference backend for native AnomaVision engines.""" from __future__ import annotations +import numpy as np + from anomavision.utils import get_logger from .base import Batch, InferenceBackend, ScoresMaps @@ -14,59 +12,88 @@ class TensorRTBackend(InferenceBackend): - """Stub for TensorRT backend.""" + """Execute a serialized TensorRT engine with PyCUDA.""" def __init__(self, model_path: str, device: str = "cuda"): - """Initialize TensorRT backend (currently not implemented). - - Placeholder for future TensorRT backend implementation. TensorRT provides - highly optimized inference for NVIDIA GPUs but requires additional - implementation work. - - Args: - model_path (str): Path to TensorRT engine file (.engine extension). - device (str, optional): Target device, should be "cuda". Defaults to "cuda". - - Raises: - NotImplementedError: Always raised as TensorRT is not yet implemented. - - Note: - TensorRT backend is planned for future implementation to provide - maximum performance on NVIDIA GPUs. - """ - - logger.warning("TensorRT backend is not implemented.") - raise NotImplementedError("TensorRT support not implemented yet.") + if not str(device).startswith("cuda"): + raise ValueError("TensorRT inference requires a CUDA device.") + try: + import pycuda.autoinit # noqa: F401 + import pycuda.driver as cuda + import tensorrt as trt + except ImportError as exc: + raise ImportError( + "TensorRT inference requires NVIDIA TensorRT and PyCUDA." + ) from exc + + self._cuda = cuda + self._trt = trt + self._logger = trt.Logger(trt.Logger.WARNING) + self._runtime = trt.Runtime(self._logger) + with open(model_path, "rb") as handle: + self.engine = self._runtime.deserialize_cuda_engine(handle.read()) + if self.engine is None: + raise RuntimeError(f"Could not deserialize TensorRT engine: {model_path}") + self.context = self.engine.create_execution_context() + self.stream = cuda.Stream() + self.input_name = next( + self.engine.get_tensor_name(i) + for i in range(self.engine.num_io_tensors) + if self.engine.get_tensor_mode(self.engine.get_tensor_name(i)) + == trt.TensorIOMode.INPUT + ) + self.output_names = [ + self.engine.get_tensor_name(i) + for i in range(self.engine.num_io_tensors) + if self.engine.get_tensor_mode(self.engine.get_tensor_name(i)) + == trt.TensorIOMode.OUTPUT + ] + logger.info( + "TensorRT engine loaded: input=%s outputs=%s", self.input_name, self.output_names + ) def predict(self, batch: Batch) -> ScoresMaps: - """Run TensorRT inference (not implemented). - - Args: - batch (Batch): Input batch for inference. - - Raises: - NotImplementedError: Always raised as TensorRT is not yet implemented. - """ - - raise NotImplementedError("TensorRT predict not implemented yet.") + if hasattr(batch, "detach"): + batch = batch.detach().cpu().numpy() + input_array = np.ascontiguousarray(batch, dtype=np.float32) + self.context.set_input_shape(self.input_name, tuple(input_array.shape)) + allocations = [] + host_outputs = [] + try: + input_device = self._cuda.mem_alloc(input_array.nbytes) + allocations.append(input_device) + self.context.set_tensor_address(self.input_name, int(input_device)) + for name in self.output_names: + shape = tuple(self.context.get_tensor_shape(name)) + dtype = self._trt.nptype(self.engine.get_tensor_dtype(name)) + host = np.empty(shape, dtype=dtype) + device = self._cuda.mem_alloc(host.nbytes) + allocations.append(device) + host_outputs.append(host) + self.context.set_tensor_address(name, int(device)) + self._cuda.memcpy_htod_async(input_device, input_array, self.stream) + if not self.context.execute_async_v3(self.stream.handle): + raise RuntimeError("TensorRT execution failed.") + for host, device in zip(host_outputs, allocations[1:]): + self._cuda.memcpy_dtoh_async(host, device, self.stream) + self.stream.synchronize() + finally: + for allocation in allocations: + allocation.free() + + if len(host_outputs) < 2: + return host_outputs[0], host_outputs[0] + return host_outputs[0], host_outputs[1] def close(self) -> None: - """Release TensorRT resources (not implemented). - - Placeholder for future resource cleanup implementation. - """ - pass + self.context = None + self.engine = None + self._runtime = None + self.stream = None def warmup(self, batch=None, runs: int = 2) -> None: - """Warm up TensorRT backend (not implemented). - - Args: - batch: Input batch for warmup. - runs (int, optional): Number of warmup iterations. - - Raises: - NotImplementedError: Always raised as TensorRT is not yet implemented. - """ - - logger.warning("TensorRT backend is not implemented; warm-up skipped.") - raise NotImplementedError("TensorRT warm-up not implemented yet.") + if batch is None: + raise ValueError("TensorRT warmup requires a sample batch.") + for _ in range(max(1, runs)): + self.predict(batch) + logger.info("TensorRT warm-up completed: runs=%d", runs) From 5d2913704e788cff5b2b2901097a97bf7ecee06b Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:21:03 +0000 Subject: [PATCH 03/22] feat(patchcore): add lightweight memory-bank pipeline --- anomavision/__init__.py | 1 + anomavision/export.py | 7 +- .../inference/model/backends/torch_backend.py | 14 +- anomavision/patchcore.py | 139 ++++++++++++++++++ anomavision/train.py | 48 ++++-- config.yml | 8 +- 6 files changed, 195 insertions(+), 22 deletions(-) create mode 100644 anomavision/patchcore.py diff --git a/anomavision/__init__.py b/anomavision/__init__.py index ed87261..aba2687 100644 --- a/anomavision/__init__.py +++ b/anomavision/__init__.py @@ -12,6 +12,7 @@ from .datasets.mvtec_dataset import MVTecDataset from .feature_extraction import ResnetEmbeddingsExtractor from .padim import Padim +from .patchcore import PatchCore from .sampling_methods.kcenter_greedy import kCenterGreedy from .test import optimal_threshold, visualize_eval_data, visualize_eval_pair from .utils import get_logger # Export for users diff --git a/anomavision/export.py b/anomavision/export.py index 4c835a5..9689923 100644 --- a/anomavision/export.py +++ b/anomavision/export.py @@ -35,6 +35,7 @@ from anomavision.padim_lite import ( # stats-only .pth β†’ runtime module build_padim_from_stats, ) +from anomavision.patchcore import build_patchcore_from_stats from anomavision.utils import ( create_image_transform, get_logger, @@ -228,9 +229,11 @@ def _load_model(self) -> torch.nn.Module: "layer_indices", "backbone", }.issubset(obj.keys()): - self.logger.info("GOING INTO STATS PATH - building PadimLite") + self.logger.info("Loading PaDiM statistics artifact") base = build_padim_from_stats(obj, device=str(self.device)) - self.logger.info("Export: built PadimLite from statistics (.pth).") + elif isinstance(obj, dict) and {"memory_bank", "layer_indices", "backbone"}.issubset(obj.keys()): + self.logger.info("Loading PatchCore memory-bank artifact") + base = build_patchcore_from_stats(obj, device=str(self.device)) else: self.logger.info("GOING INTO FULL MODEL PATH") base = obj diff --git a/anomavision/inference/model/backends/torch_backend.py b/anomavision/inference/model/backends/torch_backend.py index 234b3d7..aa2fb8e 100644 --- a/anomavision/inference/model/backends/torch_backend.py +++ b/anomavision/inference/model/backends/torch_backend.py @@ -9,9 +9,10 @@ import torch -from anomavision.padim_lite import ( # NEW: stats-only .pth β†’ runtime module +from anomavision.padim_lite import ( # stats-only .pth β†’ runtime module build_padim_from_stats, ) +from anomavision.patchcore import build_patchcore_from_stats from anomavision.utils import get_logger from .base import Batch, InferenceBackend, ScoresMaps @@ -87,10 +88,15 @@ def __init__( "layer_indices", "backbone", }.issubset(loaded_obj.keys()): - logger.info( - "Detected statistics-only artifact (.pth). Building PadimLite on CPU." - ) + logger.info("Detected PaDiM statistics artifact; building PadimLite.") model = build_padim_from_stats(loaded_obj, device=device) + elif isinstance(loaded_obj, dict) and { + "memory_bank", + "layer_indices", + "backbone", + }.issubset(loaded_obj.keys()): + logger.info("Detected PatchCore memory-bank artifact; building PatchCore.") + model = build_patchcore_from_stats(loaded_obj, device=device) else: model = loaded_obj diff --git a/anomavision/patchcore.py b/anomavision/patchcore.py new file mode 100644 index 0000000..43565c4 --- /dev/null +++ b/anomavision/patchcore.py @@ -0,0 +1,139 @@ +"""Lightweight PatchCore anomaly detector. + +The implementation intentionally keeps the PaDiM public contract: ``fit`` accepts a +DataLoader, ``predict`` returns ``(image_scores, score_map)``, and statistics can be +saved as a compact ``.pth`` artifact for deployment. +""" + +from __future__ import annotations + +from typing import List, Optional, Tuple + +import torch +import torch.nn.functional as F + +from .feature_extraction import ResnetEmbeddingsExtractor + + +class PatchCore(torch.nn.Module): + """PatchCore with a bounded, optionally randomly subsampled memory bank.""" + + def __init__( + self, + backbone: str = "resnet18", + device: torch.device = torch.device("cpu"), + layer_indices: Optional[List[int]] = None, + memory_bank: Optional[torch.Tensor] = None, + coreset_ratio: float = 0.1, + max_memory_patches: Optional[int] = 50000, + n_neighbors: int = 1, + ) -> None: + super().__init__() + if not 0 < coreset_ratio <= 1: + raise ValueError("coreset_ratio must be in the interval (0, 1].") + if n_neighbors != 1: + raise ValueError("This lightweight implementation supports n_neighbors=1 only.") + self.device = torch.device(device) + self.backbone = backbone + self.layer_indices = list(layer_indices or [0, 1]) + self.coreset_ratio = float(coreset_ratio) + self.max_memory_patches = max_memory_patches + self.n_neighbors = n_neighbors + self.embeddings_extractor = ResnetEmbeddingsExtractor(backbone, self.device) + if memory_bank is not None: + self.register_buffer("memory_bank", memory_bank.float().to(self.device)) + else: + self.register_buffer("memory_bank", torch.empty(0, 0, device=self.device)) + + @property + def is_fitted(self) -> bool: + return self.memory_bank.ndim == 2 and self.memory_bank.shape[0] > 0 + + def _extract(self, batch: torch.Tensor) -> Tuple[torch.Tensor, int, int]: + embeddings, width, height = self.embeddings_extractor( + batch.to(self.device), layer_indices=self.layer_indices + ) + return F.normalize(embeddings.float(), dim=-1), width, height + + @torch.no_grad() + def fit(self, dataloader: torch.utils.data.DataLoader, extractions: int = 1) -> None: + """Build a compact normal patch memory bank from one or more passes.""" + chunks = [] + for _ in range(extractions): + for item in dataloader: + batch = item[0] if isinstance(item, (tuple, list)) else item + embeddings, _, _ = self._extract(batch) + chunks.append(embeddings.reshape(-1, embeddings.shape[-1]).cpu()) + if not chunks: + raise ValueError("Cannot fit PatchCore with an empty dataloader.") + bank = torch.cat(chunks, dim=0) + keep = max(1, int(bank.shape[0] * self.coreset_ratio)) + if self.max_memory_patches is not None: + keep = min(keep, int(self.max_memory_patches)) + if keep < bank.shape[0]: + indices = torch.randperm(bank.shape[0])[:keep] + bank = bank[indices] + self.memory_bank = bank.to(self.device) + + @torch.no_grad() + def forward( + self, batch: torch.Tensor, return_map: bool = True, export: bool = False + ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + if not self.is_fitted: + raise RuntimeError("PatchCore is not fitted. Call fit() first.") + embeddings, width, height = self._extract(batch) + flat = embeddings.reshape(-1, embeddings.shape[-1]) + distances = torch.cdist(flat, self.memory_bank) + nearest = distances.amin(dim=1).reshape(batch.shape[0], width, height) + scores = nearest.flatten(1).amax(1) + if not return_map: + return scores, None + score_map = F.interpolate( + nearest.unsqueeze(1), size=batch.shape[-2:], mode="bilinear", align_corners=False + ).squeeze(1) + return scores, score_map + + def predict(self, batch: torch.Tensor, export: bool = False): + return self.forward(batch, return_map=True, export=export) + + def to_device(self, device: torch.device) -> None: + self.device = torch.device(device) + self.embeddings_extractor.to_device(self.device) + self.memory_bank = self.memory_bank.to(self.device) + + def save_statistics(self, path: str, half: Optional[bool] = False) -> None: + if not self.is_fitted: + raise RuntimeError("PatchCore is not fitted. Call fit() first.") + bank = self.memory_bank.detach().cpu() + if half: + bank = bank.half() + torch.save( + { + "memory_bank": bank, + "backbone": self.backbone, + "layer_indices": self.layer_indices, + "coreset_ratio": self.coreset_ratio, + "max_memory_patches": self.max_memory_patches, + "model_type": "patchcore", + "dtype": "fp16" if half else "fp32", + }, + path, + ) + + +def build_patchcore_from_stats( + stats: dict, device: str = "cpu", force_precision: Optional[str] = None +) -> PatchCore: + """Build a deployment-ready PatchCore from its compact memory bank.""" + bank = stats["memory_bank"].float().cpu() + model = PatchCore( + backbone=str(stats["backbone"]), + layer_indices=list(stats["layer_indices"]), + memory_bank=bank, + coreset_ratio=float(stats.get("coreset_ratio", 1.0)), + max_memory_patches=stats.get("max_memory_patches"), + device=torch.device(device), + ) + if force_precision == "fp16" and model.device.type == "cuda": + model = model.half() + return model diff --git a/anomavision/train.py b/anomavision/train.py index 86e5000..5beb056 100644 --- a/anomavision/train.py +++ b/anomavision/train.py @@ -104,6 +104,18 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: 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( "--output_model", type=str, @@ -234,31 +246,40 @@ def run_training(args): # === Model & Train === logger.info( - "cfg: backbone=%s | layers=%s | feat_dim=%d", + "cfg: algorithm=%s | backbone=%s | layers=%s", + config.algorithm, config.backbone, config.layer_indices, - config.feat_dim, ) - padim = anomavision.Padim( - backbone=config.backbone, - device=device, - layer_indices=config.layer_indices, - feat_dim=int(config.feat_dim), - ) + if str(config.algorithm).lower() == "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, + ) + else: + model = anomavision.Padim( + backbone=config.backbone, + device=device, + layer_indices=config.layer_indices, + feat_dim=int(config.feat_dim), + ) t_fit = time.perf_counter() - padim.fit(dl) + 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(padim, str(model_path)) + torch.save(model, str(model_path)) - # also save a compact stats-only artifact (anomalib-style) -> ".pth" + # Save a compact statistics/memory-bank artifact for deployment. stats_path = model_path.with_suffix(".pth") try: - padim.save_statistics(str(stats_path), half=True) + model.save_statistics(str(stats_path), half=True) logger.info("saved: slim statistics=%s", stats_path) except Exception as e: logger.warning("saving slim statistics failed: %s", e) @@ -270,7 +291,8 @@ def run_training(args): logger.info("=== Training done in %.2fs ===", time.perf_counter() - t0) # Return objects for external usage (e.g. MLOps pipeline) - return padim, config, run_dir, {"train": dl} + return model, config, run_dir, {"train": dl} + def main(args=None): diff --git a/config.yml b/config.yml index daa9bbf..a8b12aa 100644 --- a/config.yml +++ b/config.yml @@ -14,7 +14,9 @@ norm_std: [0.229, 0.224, 0.225] # Standard deviation for normalization # Model / training # ========================= backbone: "resnet18" # Backbone CNN architecture (resnet18 | wide_resnet50) -algorithm: "padim" # Algorithm to use (padim | patchcore) +algorithm: "padim" # Algorithm to use: padim or patchcore +coreset_ratio: 0.1 # PatchCore fraction of normal patches to retain +max_memory_patches: 50000 # PatchCore memory-bank cap 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 @@ -57,7 +59,7 @@ memory_efficient: true # Use memory efficient evaluation mode # ========================= # Export (export.py) # ========================= -format: "all" # Export format: onnx, torchscript, openvino, all +format: "all" # onnx, tensorrt, torchscript, openvino, all opset: 18 # ONNX opset version dynamic_batch: true # Allow dynamic batch size in exported model static_batch: false # Disable dynamic batch size (if true) @@ -65,7 +67,7 @@ optimize: false # Enable mobile optimization for TorchSc fp32: false # Export in FP32 precision (false => FP16 in OpenVINO) output_path: null # Optional explicit output filename half: false # Reserved (not actively used) -int8: false # Reserved (not actively used) +int8: false # Legacy; use --tensorrt-precision int8 # ========================= # Streaming Configuration From 8d0035828ffe760025fdeb713dc1ad0bacd80d66 Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:21:40 +0000 Subject: [PATCH 04/22] docs: simplify README and add deployment guide --- README.md | 530 +++++----------------------------- docs/production_deployment.md | 55 ++++ 2 files changed, 122 insertions(+), 463 deletions(-) create mode 100644 docs/production_deployment.md diff --git a/README.md b/README.md index 7f79370..363acfe 100644 --- a/README.md +++ b/README.md @@ -1,131 +1,75 @@ -
-AnomaVision banner +# AnomaVision -
+AnomaVision is a production-oriented library for **visual anomaly detection from normal images**. It supports PaDiM and lightweight PatchCore, image-level scores, pixel-level maps, and deployment exports. -[![PyPI](https://img.shields.io/pypi/v/anomavision?label=PyPI&color=blue)](https://pypi.org/project/anomavision/) -[![PyPI Downloads](https://img.shields.io/pypi/dm/anomavision?color=blue)](https://pypi.org/project/anomavision/) -[![Python](https://img.shields.io/badge/Python-3.10--3.12-blue)](https://www.python.org/) -[![PyTorch](https://img.shields.io/badge/PyTorch-2.0%2B-red)](https://pytorch.org/) +[![PyPI](https://img.shields.io/pypi/v/anomavision?label=PyPI)](https://pypi.org/project/anomavision/) [![License: MIT](https://img.shields.io/badge/License-MIT-green)](LICENSE) -[![ONNX](https://img.shields.io/badge/ONNX-Export%20Ready-orange)](https://onnx.ai/) -[![TensorRT](https://img.shields.io/badge/TensorRT-Supported-76b900)](https://developer.nvidia.com/tensorrt) -[![OpenVINO](https://img.shields.io/badge/OpenVINO-Supported-0071C5)](https://docs.openvino.ai/) -[![HuggingFace](https://img.shields.io/badge/πŸ€—%20Demo-Live-yellow)](https://huggingface.co/spaces/DeepKnowledge1/mvtec-anomaly-detection) -
+## Why use it? -[**Live Demo**](#-live-demo) Β· [**Docs**](docs/quickstart.md) Β· [**Quickstart**](#-quickstart) Β· [**Models**](#-models--performance) Β· [**Tasks**](#-tasks--modes) Β· [**Integrations**](#-integrations) Β· [**Issues**](https://github.com/DeepKnowledge1/AnomaVision/issues) Β· [**Discussions**](https://github.com/DeepKnowledge1/AnomaVision/discussions) +- Train with normal images only; anomaly labels are not required for training. +- Use **PaDiM** for the default fast baseline or **PatchCore** for a compact nearest-neighbor memory bank. +- Run inference through PyTorch, ONNX Runtime, OpenVINO, or native TensorRT. +- Export FP16 or calibrated INT8 TensorRT engines for NVIDIA production deployments. -
+Benchmark results and reproduction details are documented in [`docs/benchmark.md`](docs/benchmark.md). Treat benchmark numbers as workload-specific, and reproduce them on your own hardware before making production claims. ---- +## Quickstart -## πŸ€— Live Demo - -> **Try AnomaVision instantly β€” no installation required.** - -[![Open in Spaces](https://huggingface.co/datasets/huggingface/badges/resolve/main/open-in-hf-spaces-xl-dark.svg)](https://huggingface.co/spaces/DeepKnowledge1/mvtec-anomaly-detection) - -The live demo runs a **PaDiM model trained on MVTec bottle images** and shows: - -- 🌑️ **Anomaly Heatmap** β€” spatial score map highlighting defect regions -- πŸ–ΌοΈ **Overlay** β€” original image with anomaly contours drawn -- 🎭 **Predicted Mask** β€” binary segmentation of detected defects -- ⚑ **Real-time inference** β€” results in milliseconds on CPU - -Upload your own bottle image or pick from the provided samples to see anomaly detection in action. - ---- - -## What is AnomaVision? - -AnomaVision delivers **visual anomaly detection** optimized for production deployment. Based on PaDiM, it learns the distribution of normal images in a **single forward pass** β€” no labels, no segmentation masks, no lengthy training loops. - -The result: a 15 MB model that runs at **43 FPS on CPU** and **547 FPS on GPU**, with higher AUROC than the existing best-in-class baseline. - ---- - -## Why AnomaVision? - -- Train using only normal images -- No gradient-based training or epochs -- Fast CPU inference -- Image-level and pixel-level anomaly detection -- Export to ONNX, OpenVINO, TorchScript, and TensorRT -- CLI, Python API, REST API, and streaming support - -## πŸš€ Quickstart - -### Install - - -**Don't have `uv`?** Install it first β€” it's faster than pip and handles PyTorch's hardware routing correctly: +### 1. Install ```bash pip install uv +uv pip install "anomavision[cpu]" ``` ---- - -#### Option A β€” From Source (development) +For NVIDIA GPUs, choose the matching extra such as `anomavision[cu121]`. Source installation and environment setup are described in [`docs/installation.md`](docs/installation.md). -```bash -git clone https://github.com/DeepKnowledge1/AnomaVision.git -cd AnomaVision +### 2. Prepare data -# Create and activate a virtual environment -uv venv --python 3.11 .venv -source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1 +Use an MVTec-style directory. Training uses only the `good` images: -# Install with your hardware extra -uv sync --extra cpu # CPU -uv sync --extra cu121 # CUDA 12.1 +```text +dataset/ +└── bottle/ + β”œβ”€β”€ train/good/ + └── test/ + β”œβ”€β”€ good/ + └── scratch/ ``` ---- - -#### Option B β€” From PyPI (production / quick start) +### 3. Train ```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 +anomavision train --config config.yml ``` ---- +The default configuration trains PaDiM. To train lightweight PatchCore, set `algorithm: patchcore` in `config.yml` or pass the corresponding CLI option. The model and compact deployment artifact are saved under `model_data_path`. -#### Verify +### 4. Detect and evaluate ```bash -python -c "import anomavision, torch; print('βœ… Ready β€”', torch.__version__)" +anomavision detect --config config.yml --img_path ./dataset/bottle/test +anomavision eval --config config.yml ``` ---- - -### CLI - -AnomaVision ships a unified `anomavision` command β€” no need to run individual scripts directly. +### 5. Export ```bash -# Train -anomavision train --config config.yml - -# Detect (images or folder) -anomavision detect --config config.yml --img_path ./test_images --thresh 13.0 +# Portable ONNX export +anomavision export --config config.yml --format onnx -# Evaluate on MVTec -anomavision eval --config config.yml --enable_visualization +# Native TensorRT FP16 export +anomavision export --config config.yml --format tensorrt \ + --device cuda --tensorrt-precision fp16 -# Export to ONNX / TorchScript / OpenVINO / all -anomavision export --config config.yml --model model.pt --format all --precision fp16 +# Native TensorRT calibrated INT8 export +anomavision export --config config.yml --format tensorrt \ + --device cuda --tensorrt-precision int8 \ + --calib-dir ./dataset/bottle/train/good --calib-samples 100 ``` -Every command has full `--help`: +Every command provides help: ```bash anomavision --help @@ -133,385 +77,45 @@ anomavision train --help anomavision export --help ``` ---- - -
-🐍 Python API -
- -Use the Python API when you want to embed AnomaVision into a larger pipeline, -run it inside a notebook, or integrate it with your own data loading logic. - -```python - -dataset = anomavision.AnodetDataset( - image_directory_path="./dataset/bottle/train/good" -) - -loader = DataLoader(dataset, batch_size=16) - -model = anomavision.Padim( - backbone="resnet18", - device="cpu" -) - -model.fit(loader) - -scores, maps = model.predict(batch) -``` -See [API](docs/api.md) for the complete API reference. - - -
- - -
-🌐 REST API -
- -Use the REST API when you want to integrate AnomaVision into an existing service, -call it from any language, or expose it on a network without installing Python on the client. - -First, start the FastAPI server (keep this terminal open): -```bash -uvicorn apps.api.fastapi_app:app --host 0.0.0.0 --port 8000 -``` - -Then send images from any client: -```python -import requests - -with open("image.jpg", "rb") as f: - r = requests.post("http://localhost:8000/predict", files={"file": f}) - -print(r.json()["anomaly_score"]) # e.g. 14.3 -print(r.json()["is_anomaly"]) # True / False -``` - -Full docs at **http://localhost:8000/docs** once the server is running. - -
- - -
-πŸ“Š Models & Performance -
- -### MVTec AD β€” Average over 15 Classes - -| Model | Image AUROC ↑ | Pixel AUROC ↑ | CPU FPS ↑ | GPU FPS ↑ | Size ↓ | -|---|---|---|---|---|---| -| **AnomaVision** (resnet18) | **0.850** | **0.956** | **43.4** | **547** | **15 MB** | -| Anomalib PaDiM (baseline) | 0.810 | 0.935 | 13.0 | 356 | 40 MB | -| Ξ” | **+4.9%** | **+2.2%** | **+233%** | **+54%** | **βˆ’25%** | - -> CPU: Intel Core i9 (single process). GPU: NVIDIA A100. Batch size 1. -> Reproduce: `anomavision eval --config config.yml` - -### VisA β€” Average over 12 Classes - -| Model | Image AUROC ↑ | Pixel AUROC ↑ | CPU FPS ↑ | -|---|---|---|---| -| **AnomaVision** | **0.812** | **0.962** | **44.8** | -| Anomalib PaDiM | 0.783 | 0.954 | 13.5 | - -
-πŸ“‹ Per-class MVTec breakdown - -| Class | AV Image AUROC | AL Image AUROC | AV Pixel AUROC | AL Pixel AUROC | AV FPS | -|---|---|---|---|---|---| -| bottle | 0.997 | 0.996 | 0.984 | 0.987 | 42.2 | -| cable | 0.772 | 0.742 | 0.936 | 0.935 | 36.1 | -| capsule | 0.839 | 0.846 | 0.929 | 0.977 | 40.2 | -| carpet | 0.908 | 0.594 | 0.971 | 0.987 | 44.0 | -| grid | 0.881 | 0.832 | 0.964 | 0.965 | 41.3 | -| hazelnut | 0.984 | 0.949 | 0.978 | 0.974 | 29.0 | -| leather | 0.985 | 0.879 | 0.985 | 0.982 | 48.7 | -| metal_nut | 0.940 | 0.878 | 0.963 | 0.963 | 41.4 | -| pill | 0.793 | 0.773 | 0.957 | 0.964 | 45.4 | -| screw | 0.941 | 0.787 | 0.970 | 0.982 | 42.4 | -| tile | 0.851 | 0.876 | 0.969 | 0.971 | 46.0 | -| toothbrush | 0.978 | 0.883 | 0.993 | 0.989 | 44.8 | -| transistor | 0.800 | 0.853 | 0.968 | 0.962 | 42.2 | -| wood | 0.986 | 0.915 | 0.973 | 0.975 | 45.3 | -| zipper | 0.914 | 0.979 | 0.972 | 0.971 | 41.0 | - -
-
- - -
-🎯 Tasks & Modes - - -| Task | Train | Detect | Eval | Export | Stream | REST | -|---|:---:|:---:|:---:|:---:|:---:|:---:| -| Anomaly Detection (image score) | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | -| Anomaly Localization (pixel map) | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | -| Normal / Anomalous Classification | βœ… | βœ… | βœ… | βœ… | βœ… | βœ… | - -### Export Formats - -| Format | Flag | CPU | GPU | Edge | Quantization | -|---|---|:---:|:---:|:---:|:---:| -| PyTorch `.pt` | `pt` | βœ… | βœ… | β€” | β€” | -| ONNX `.onnx` | `onnx` | βœ… | βœ… | βœ… | INT8 dynamic / static | -| TorchScript `.torchscript` | `torchscript` | βœ… | βœ… | βœ… | β€” | -| OpenVINO (dir) | `openvino` | βœ… | β€” | βœ… | FP16 | -| TensorRT `.engine` | `engine` | β€” | βœ… | β€” | FP16 | -| C++ ONNX Runtime | β€” | βœ… | βœ… | βœ… | β€” | - -```bash -anomavision export \ - --model_data_path ./distributions/anomav_exp \ - --model model.pt \ - --format onnx \ - --precision fp16 \ - --quantize-dynamic -``` - -
- -
-πŸ“Ί Streaming Sources -
- -Run inference on **live sources** without changing your model or code: - -| Source | `stream_source.type` | Use case | -|---|---|---| -| Webcam | `webcam` | Lab / demo | -| Video file | `video` | Offline replay | -| MQTT | `mqtt` | Industrial IoT cameras | -| TCP socket | `tcp` | High-throughput line scanners | - -```yaml -# stream_config.yml -stream_mode: true -stream_source: - type: webcam - camera_id: 0 -model: model.onnx -thresh: 13.0 -enable_visualization: true -``` - -```bash -anomavision detect --config stream_config.yml -``` - -
- -
-βš™οΈ Configuration -
- -All scripts accept `--config config.yml` and CLI overrides. **CLI always wins.** - -```yaml -# Minimal working config.yml -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/anomav_exp - -model: model.onnx -device: auto # auto | cpu | cuda -thresh: 13.0 - -log_level: INFO -``` - -Full key reference: [`docs/config.md`](docs/config.md) +## Choosing a model -
+| Model | Best starting point | Memory use | Production note | +|---|---|---:|---| +| PaDiM | Fast, simple baseline | Low | Recommended first experiment | +| Lightweight PatchCore | Higher-fidelity patch retrieval with bounded memory | Configurable | Use `coreset_ratio` and `max_memory_patches` to control latency | -
-πŸ”Œ Integrations -
+## Documentation -| Integration | Description | +| Topic | Guide | |---|---| -| **FastAPI** | REST API β€” `/predict`, `/predict/batch`, Swagger UI at `/docs` | -| **Streamlit** | Browser demo β€” heatmap overlay, threshold slider, batch upload | -| **Gradio** | [Live HuggingFace Space](https://huggingface.co/spaces/DeepKnowledge1/mvtec-anomaly-detection) β€” try it instantly | -| **C++ Runtime** | ONNX + OpenCV, no Python required β€” see [`docs/cpp/`](docs/cpp/README.md) | -| **OpenVINO** | Intel CPU/VPU edge optimization | -| **TensorRT** | NVIDIA GPU maximum throughput | -| **INT8 Quantization** | Dynamic + static INT8 via ONNX Runtime | - -```bash -# Terminal 1 β€” backend -uvicorn apps.api.fastapi_app:app --host 0.0.0.0 --port 8000 - -# Terminal 2 β€” UI -streamlit run apps/ui/streamlit_app.py -- --port 8000 -``` - -Open **http://localhost:8501** - -
- -
-πŸ“‚ Dataset Format -
- -AnomaVision uses [MVTec AD](https://www.mvtec.com/company/research/datasets/mvtec-ad) layout. Custom datasets work with the same structure: - -``` -dataset/ -└── / - β”œβ”€β”€ train/ - β”‚ └── good/ ← normal images only (no anomalies needed) - └── test/ - β”œβ”€β”€ good/ ← normal test images - └── / ← anomalous test images (any subfolder name) -``` - -
- ---- - -
-πŸ—οΈ Architecture - -AnomaVision architecture - -
+| Installation | [`docs/installation.md`](docs/installation.md) | +| Five-minute workflow | [`docs/quickstart.md`](docs/quickstart.md) | +| CLI and configuration | [`docs/cli.md`](docs/cli.md), [`docs/config.md`](docs/config.md) | +| Python API | [`docs/api.md`](docs/api.md) | +| PatchCore and TensorRT deployment | [`docs/production_deployment.md`](docs/production_deployment.md) | +| Benchmark methodology | [`docs/benchmark.md`](docs/benchmark.md) | +| Troubleshooting | [`docs/troubleshooting.md`](docs/troubleshooting.md) | +| Contributing | [`docs/contributing.md`](docs/contributing.md) | -
-Production (Gunicorn + Uvicorn) +## Python API -```bash -gunicorn apps.api.fastapi_app:app \ - --workers 4 \ - --worker-class uvicorn.workers.UvicornWorker \ - --bind 0.0.0.0:8000 \ - --timeout 120 -``` -> **Production tip:** Serve ONNX or TensorRT models β€” `.pt` inference is 2–3Γ— slower than ONNX Runtime at batch size 1. - -
- - ---- - -## ❓ FAQ - -
-Training is slow on CPU - -Lower `resize` (e.g. `[128, 128]`), reduce `batch_size`, or use `--device cuda`. PaDiM training is a single forward pass β€” it should finish in under 30 s for most datasets even on CPU. - -
- -
-All anomaly scores are low / nothing detected - -Run `anomavision eval --config config.yml` first to see the score distribution histogram. Set `--thresh` just above the peak of the normal score distribution. Typical values: 10–20 for ResNet18 with default preprocessing. - -
- -
-RuntimeError: Input size mismatch during inference - -Your `resize` / `crop_size` must match what was used at training time. Load the config saved alongside the model: `--config ./distributions/anomav_exp/exp1/config.yml`. - -
+```python +import torch +from torch.utils.data import DataLoader +import anomavision -
-CUDA version mismatch +train_set = anomavision.AnodetDataset("./dataset/bottle/train/good") +train_loader = DataLoader(train_set, batch_size=16, shuffle=False) -```bash -pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 +model = anomavision.Padim(backbone="resnet18", device=torch.device("cpu")) +model.fit(train_loader) +scores, maps = model.predict(batch) ``` -Replace `cu121` with your actual CUDA version (`cu118`, `cu124`, etc.). - -
- -
-Unsupported operator during ONNX export - -Try `--opset 16`. If it still fails, use `--format torchscript` β€” TorchScript has no ONNX operator constraints. - -
- -
-Can I use my own dataset without MVTec structure? - -Yes. Put your normal training images in `/train/good/`. For evaluation, add test images under `/test//`. No anomalous images are needed at training time. - -
- -More: [`docs/troubleshooting.md`](docs/troubleshooting.md) - ---- - -## πŸ—ΊοΈ Roadmap - -- [ ] Pre-trained model zoo for all 15 MVTec classes -- [ ] Few-shot adaptation (5–10 anomalous examples) -- [ ] Native TensorRT export in `export.py` -- [ ] Pixel-level mask in REST `/predict` response -- [ ] ONNX Runtime Web (browser inference via WASM) -- [ ] Helm chart for Kubernetes deployment - -[Request a feature β†’](https://github.com/DeepKnowledge1/AnomaVision/discussions) - ---- - -## πŸ“š Documentation - -| | | -|---|---| -| [Quick Start](docs/quickstart.md) | Train β†’ detect β†’ eval β†’ export in 5 minutes | -| [CLI Reference](docs/cli.md) | All arguments for all `anomavision` subcommands | -| [Python API](docs/api.md) | Library usage and class reference | -| [Config Guide](docs/config.md) | Every YAML key explained | -| [Benchmarks](docs/benchmark.md) | Full per-class results vs Anomalib | -| [FastAPI Backend](docs/fastapi_backend.md) | REST API setup and endpoints | -| [C++ Inference](docs/cpp/README.md) | Deploy without Python | -| [Troubleshooting](docs/troubleshooting.md) | Common issues and fixes | -| [Contributing](docs/contributing.md) | Development workflow | - ---- - -## πŸ’¬ Community - -- πŸ› [Issues](https://github.com/DeepKnowledge1/AnomaVision/issues) β€” bug reports -- πŸ’‘ [Discussions](https://github.com/DeepKnowledge1/AnomaVision/discussions) β€” questions, ideas, show & tell -- πŸ€— [Live Demo](https://huggingface.co/spaces/DeepKnowledge1/mvtec-anomaly-detection) β€” try it in your browser -- πŸ“§ [deepp.knowledge@gmail.com](mailto:deepp.knowledge@gmail.com) β€” direct contact - ---- - -## Citation - -```bibtex -@software{anomavision2025, - title = {AnomaVision: Edge-Ready Visual Anomaly Detection}, - author = {DeepKnowledge Contributors}, - year = {2025}, - url = {https://github.com/DeepKnowledge1/AnomaVision}, -} -``` +## Community and adoption ---- +The most useful path to adoption is a small, reproducible example rather than more README text: publish one benchmark script, one production export example, a model card with hardware and preprocessing details, and a short comparison against Anomalib. Invite users to reproduce the result, report failures, and contribute adapters for their own datasets. See [`docs/production_deployment.md`](docs/production_deployment.md) for the project’s recommended release checklist. ## License -Released under the [MIT License](LICENSE). -Built on [Anodet](https://github.com/OpenAOI/anodet) β€” thanks to the original authors. +AnomaVision is released under the MIT License. See [`LICENSE`](LICENSE). diff --git a/docs/production_deployment.md b/docs/production_deployment.md new file mode 100644 index 0000000..051363f --- /dev/null +++ b/docs/production_deployment.md @@ -0,0 +1,55 @@ +# Production deployment + +This page contains the details that are useful after the first successful AnomaVision run. The main README intentionally keeps the beginner workflow short. + +## Lightweight PatchCore + +PatchCore stores normal training patches in a memory bank and scores each test patch by its nearest stored feature. This implementation follows the PaDiM design contract: training is `model.fit(dataloader)`, inference is `model.predict(batch)`, and both image scores and spatial maps are returned. + +Use the configuration below when inference latency or memory is more important than retaining every training patch: + +```yaml +algorithm: patchcore +backbone: resnet18 +layer_indices: [0, 1] +coreset_ratio: 0.1 +max_memory_patches: 50000 +``` + +`coreset_ratio` controls the fraction of extracted normal patches retained. `max_memory_patches` provides a hard upper bound. Start with `resnet18`, `[0, 1]`, and the defaults above; increase the memory cap only after measuring validation quality and latency on the target device. + +## TensorRT export + +TensorRT is an optional NVIDIA deployment dependency. The exporter first creates the model graph, then uses the native TensorRT builder and ONNX parser to produce a serialized `.engine` file. TensorRT is imported lazily, so CPU installations continue to support the other formats. + +```bash +# FP16 +anomavision export --config config.yml --format tensorrt \ + --device cuda --tensorrt-precision fp16 + +# Calibrated INT8 +anomavision export --config config.yml --format tensorrt \ + --device cuda --tensorrt-precision int8 \ + --calib-dir ./dataset/bottle/train/good \ + --calib-samples 100 --workspace-gb 4 +``` + +INT8 calibration images should represent the normal production input distribution and use the same resize, crop, and normalization settings as training. The calibration cache is written beside the engine and can be reused when the graph and preprocessing remain unchanged. TensorRT export must be performed on a machine with a compatible CUDA/TensorRT/PyCUDA installation; an engine is generally tied to the TensorRT and GPU environment in which it was built. + +## Verification checklist + +| Check | Recommended evidence | +|---|---| +| Accuracy | Report image and pixel AUROC with the exact dataset split. | +| Latency | Report warm-up policy, batch size, input shape, device, and percentile latency. | +| Memory | Record peak GPU memory and PatchCore memory-bank size. | +| Export parity | Compare PyTorch scores/maps with ONNX or TensorRT outputs on the same images. | +| Reproducibility | Save the effective config, dependency versions, commit SHA, and calibration directory description. | + +## Making the project popular + +Adoption is more likely when a project is easy to verify and easy to integrate. The recommended sequence is to publish a minimal reproducible benchmark command, a short model card, one production export example, and a small demo using a user-provided image. Keep claims tied to the benchmark script rather than to a single headline number. + +A useful release should include a comparison table with dataset split, preprocessing, backbone, hardware, batch size, and latency methodology. Link the raw results and invite independent reproduction. Then publish a short example showing how to load the exported artifact in an existing service. This creates three entry points: researchers can inspect the benchmark, engineers can copy the deployment path, and beginners can run the quickstart. + +For discoverability, use a clear repository description, topic tags such as `anomaly-detection`, `computer-vision`, `patchcore`, and `tensorrt`, a small release note for each version, and issue templates for bug reports and benchmark reproduction. Avoid unsupported claims such as β€œbest” unless the comparison protocol is public and repeatable. From c1352253ff92bc34b179e2ec05105ba9cc12c594 Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:34:59 +0000 Subject: [PATCH 05/22] docs(patchcore): document public API and deployment contract --- anomavision/patchcore.py | 166 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 156 insertions(+), 10 deletions(-) diff --git a/anomavision/patchcore.py b/anomavision/patchcore.py index 43565c4..17eb390 100644 --- a/anomavision/patchcore.py +++ b/anomavision/patchcore.py @@ -1,13 +1,13 @@ -"""Lightweight PatchCore anomaly detector. +"""Lightweight PatchCore anomaly detection. -The implementation intentionally keeps the PaDiM public contract: ``fit`` accepts a -DataLoader, ``predict`` returns ``(image_scores, score_map)``, and statistics can be -saved as a compact ``.pth`` artifact for deployment. +This module provides a bounded-memory PatchCore implementation that follows the +public design of :mod:`anomavision.padim`: fit on a normal-image DataLoader, predict +image scores and spatial maps, and save a compact deployment artifact. """ from __future__ import annotations -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple import torch import torch.nn.functional as F @@ -16,7 +16,47 @@ class PatchCore(torch.nn.Module): - """PatchCore with a bounded, optionally randomly subsampled memory bank.""" + """Memory-bank PatchCore detector with a production-oriented footprint. + + PatchCore extracts intermediate CNN patch embeddings from normal training images, + stores a bounded subset of those embeddings, and assigns each test patch the + distance to its nearest normal memory-bank patch. The image score is the maximum + patch distance; the pixel map is the patch-distance grid upsampled to the input + resolution. + + The public methods intentionally mirror :class:`anomavision.padim.Padim`, so the + model can be selected by the existing CLI training, inference, evaluation, and + export workflows. + + Example: + >>> model = PatchCore( + ... backbone="resnet18", + ... layer_indices=[0, 1], + ... coreset_ratio=0.1, + ... max_memory_patches=50000, + ... device=torch.device("cpu"), + ... ) + >>> model.fit(train_loader) + >>> image_scores, score_maps = model.predict(test_batch) + + Args: + backbone: Feature-extraction backbone. Supported values are ``resnet18`` and + ``wide_resnet50``. + device: Device used for feature extraction and nearest-neighbor distance. + layer_indices: ResNet feature stages to concatenate. Defaults to ``[0, 1]`` + to keep the lightweight model fast and compact. + memory_bank: Optional precomputed bank with shape ``(num_patches, dim)``. + Providing it creates a ready-to-infer model. + coreset_ratio: Fraction of extracted normal patches to retain. Must be in + ``(0, 1]``. Lower values reduce memory and inference time. + max_memory_patches: Hard upper bound on retained patches. ``None`` disables + the cap. + n_neighbors: Number of nearest neighbors. The lightweight implementation + currently supports only ``1``. + + Raises: + ValueError: If the coreset ratio or neighbor count is unsupported. + """ def __init__( self, @@ -47,9 +87,24 @@ def __init__( @property def is_fitted(self) -> bool: + """Return whether a non-empty normal memory bank is available.""" return self.memory_bank.ndim == 2 and self.memory_bank.shape[0] > 0 + @torch.no_grad() def _extract(self, batch: torch.Tensor) -> Tuple[torch.Tensor, int, int]: + """Extract normalized patch embeddings for a batch. + + Args: + batch: Input tensor with shape ``(B, C, H, W)``. + + Returns: + A tuple ``(embeddings, width, height)``. ``embeddings`` has shape + ``(B, width * height, feature_dim)`` and is L2-normalized per patch. + + Raises: + RuntimeError: Propagated if the configured backbone cannot process the + input tensor. + """ embeddings, width, height = self.embeddings_extractor( batch.to(self.device), layer_indices=self.layer_indices ) @@ -57,7 +112,26 @@ def _extract(self, batch: torch.Tensor) -> Tuple[torch.Tensor, int, int]: @torch.no_grad() def fit(self, dataloader: torch.utils.data.DataLoader, extractions: int = 1) -> None: - """Build a compact normal patch memory bank from one or more passes.""" + """Fit the detector from normal training images. + + The method extracts every normal patch, randomly retains the configured + coreset, and stores it as the memory bank. No anomaly labels or gradient + updates are required. A compact bank generally reduces both RAM/VRAM use and + the cost of the nearest-neighbor search during inference. + + Args: + dataloader: DataLoader yielding image tensors or ``(image, target)`` + tuples. Training images should be normal samples. + extractions: Number of passes over the DataLoader. Values greater than + one are useful when the loader applies random augmentations. + + Raises: + ValueError: If the DataLoader produces no batches. + + Example: + >>> model.fit(train_loader) + >>> print(model.memory_bank.shape) + """ chunks = [] for _ in range(extractions): for item in dataloader: @@ -79,6 +153,25 @@ def fit(self, dataloader: torch.utils.data.DataLoader, extractions: int = 1) -> def forward( self, batch: torch.Tensor, return_map: bool = True, export: bool = False ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + """Compute PatchCore anomaly scores and an optional spatial score map. + + Args: + batch: Input tensor with shape ``(B, C, H, W)`` using the same + preprocessing as training. + return_map: If ``True``, return a map resized to ``(H, W)``. Set to + ``False`` when only image-level scores are needed. + export: Retained for compatibility with PaDiM and export wrappers. The + current distance path is already tensor-export friendly. + + Returns: + A tuple ``(image_scores, score_map)``. ``image_scores`` has shape + ``(B,)``. ``score_map`` has shape ``(B, H, W)`` or is ``None`` when + ``return_map=False``. + + Raises: + RuntimeError: If :meth:`fit` has not been called and no memory bank was + supplied at construction time. + """ if not self.is_fitted: raise RuntimeError("PatchCore is not fitted. Call fit() first.") embeddings, width, height = self._extract(batch) @@ -93,15 +186,50 @@ def forward( ).squeeze(1) return scores, score_map - def predict(self, batch: torch.Tensor, export: bool = False): + def predict( + self, batch: torch.Tensor, export: bool = False + ) -> Tuple[torch.Tensor, torch.Tensor]: + """Run inference using the standard AnomaVision prediction contract. + + Args: + batch: Preprocessed input images with shape ``(B, C, H, W)``. + export: Forwarded to :meth:`forward` for ONNX/TensorRT wrapper + compatibility. + + Returns: + ``(image_scores, score_map)`` with shapes ``(B,)`` and ``(B, H, W)``. + + Example: + >>> scores, maps = model.predict(batch) + """ return self.forward(batch, return_map=True, export=export) def to_device(self, device: torch.device) -> None: + """Move the extractor and memory bank to a target device. + + Args: + device: Target PyTorch device, for example ``torch.device("cuda")`` or + ``torch.device("cpu")``. + """ self.device = torch.device(device) self.embeddings_extractor.to_device(self.device) self.memory_bank = self.memory_bank.to(self.device) def save_statistics(self, path: str, half: Optional[bool] = False) -> None: + """Save a compact PatchCore deployment artifact. + + The artifact contains the memory bank and the feature-extraction settings, + but not a duplicate copy of the fitted training loop. It can be loaded with + :func:`build_patchcore_from_stats` or through the standard PyTorch backend. + + Args: + path: Destination ``.pth`` path. + half: If ``True``, store the memory bank in FP16 to reduce file size. + Defaults to FP32 for CPU-safe numerical behavior. + + Raises: + RuntimeError: If the model has not been fitted. + """ if not self.is_fitted: raise RuntimeError("PatchCore is not fitted. Call fit() first.") bank = self.memory_bank.detach().cpu() @@ -122,9 +250,27 @@ def save_statistics(self, path: str, half: Optional[bool] = False) -> None: def build_patchcore_from_stats( - stats: dict, device: str = "cpu", force_precision: Optional[str] = None + stats: Dict, device: str = "cpu", force_precision: Optional[str] = None ) -> PatchCore: - """Build a deployment-ready PatchCore from its compact memory bank.""" + """Build a ready-to-infer PatchCore from a compact statistics artifact. + + Args: + stats: Dictionary created by :meth:`PatchCore.save_statistics`. It must + contain ``memory_bank``, ``backbone``, and ``layer_indices``. + device: Target device string such as ``"cpu"`` or ``"cuda"``. + force_precision: Optional precision override. ``"fp16"`` is applied only + when the target device is CUDA; CPU inference remains FP32. + + Returns: + A fitted :class:`PatchCore` instance ready for :meth:`PatchCore.predict`. + + Raises: + KeyError: If a required statistics key is missing. + + Example: + >>> stats = torch.load("patchcore.pth", weights_only=False) + >>> model = build_patchcore_from_stats(stats, device="cuda") + """ bank = stats["memory_bank"].float().cpu() model = PatchCore( backbone=str(stats["backbone"]), From 6a8ebab3b787ae7a21572d40edaf8e8cc3c051e4 Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:44:31 +0000 Subject: [PATCH 06/22] perf(patchcore): add ultra-light pooled and chunked inference --- anomavision/patchcore.py | 46 +++++++++++++++++++----- anomavision/train.py | 14 ++++++++ config.yml | 6 ++-- docs/production_deployment.md | 10 ++++-- tests/test_patchcore_ultralight.py | 57 ++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 13 deletions(-) create mode 100644 tests/test_patchcore_ultralight.py diff --git a/anomavision/patchcore.py b/anomavision/patchcore.py index 17eb390..ab5d6b6 100644 --- a/anomavision/patchcore.py +++ b/anomavision/patchcore.py @@ -50,7 +50,13 @@ class PatchCore(torch.nn.Module): coreset_ratio: Fraction of extracted normal patches to retain. Must be in ``(0, 1]``. Lower values reduce memory and inference time. max_memory_patches: Hard upper bound on retained patches. ``None`` disables - the cap. + the cap. The ultra-light default keeps at most 2,048 patches. + patch_grid: Optional square spatial grid used to pool embeddings before the + memory-bank search. ``14`` reduces a 224x224 ResNet stage to at most 196 + patches per image; ``None`` keeps the native feature grid. + search_chunk_size: Number of query patches processed per nearest-neighbor + chunk. Lower values reduce peak memory; higher values may improve GPU + throughput. n_neighbors: Number of nearest neighbors. The lightweight implementation currently supports only ``1``. @@ -64,8 +70,10 @@ def __init__( device: torch.device = torch.device("cpu"), layer_indices: Optional[List[int]] = None, memory_bank: Optional[torch.Tensor] = None, - coreset_ratio: float = 0.1, - max_memory_patches: Optional[int] = 50000, + coreset_ratio: float = 0.02, + max_memory_patches: Optional[int] = 2048, + patch_grid: Optional[int] = 14, + search_chunk_size: int = 1024, n_neighbors: int = 1, ) -> None: super().__init__() @@ -78,6 +86,12 @@ def __init__( self.layer_indices = list(layer_indices or [0, 1]) self.coreset_ratio = float(coreset_ratio) self.max_memory_patches = max_memory_patches + self.patch_grid = patch_grid + self.search_chunk_size = int(search_chunk_size) + if self.patch_grid is not None and self.patch_grid < 1: + raise ValueError("patch_grid must be positive or None.") + if self.search_chunk_size < 1: + raise ValueError("search_chunk_size must be positive.") self.n_neighbors = n_neighbors self.embeddings_extractor = ResnetEmbeddingsExtractor(backbone, self.device) if memory_bank is not None: @@ -108,7 +122,13 @@ def _extract(self, batch: torch.Tensor) -> Tuple[torch.Tensor, int, int]: embeddings, width, height = self.embeddings_extractor( batch.to(self.device), layer_indices=self.layer_indices ) - return F.normalize(embeddings.float(), dim=-1), width, height + embeddings = embeddings.float().reshape(batch.shape[0], width, height, -1) + if self.patch_grid is not None and (width > self.patch_grid or height > self.patch_grid): + embeddings = F.adaptive_avg_pool2d( + embeddings.permute(0, 3, 1, 2), (self.patch_grid, self.patch_grid) + ).permute(0, 2, 3, 1) + width, height = embeddings.shape[1:3] + return F.normalize(embeddings.reshape(batch.shape[0], width * height, -1), dim=-1), width, height @torch.no_grad() def fit(self, dataloader: torch.utils.data.DataLoader, extractions: int = 1) -> None: @@ -176,8 +196,14 @@ def forward( raise RuntimeError("PatchCore is not fitted. Call fit() first.") embeddings, width, height = self._extract(batch) flat = embeddings.reshape(-1, embeddings.shape[-1]) - distances = torch.cdist(flat, self.memory_bank) - nearest = distances.amin(dim=1).reshape(batch.shape[0], width, height) + # Embeddings are normalized, so squared cosine distance is 2 - 2 * dot. + # Chunking avoids materializing a query-by-memory distance matrix for the + # entire batch, which is the dominant memory cost in regular PatchCore. + nearest_chunks = [] + for query_chunk in flat.split(self.search_chunk_size, dim=0): + similarity = query_chunk @ self.memory_bank.transpose(0, 1) + nearest_chunks.append((2.0 - 2.0 * similarity.amax(dim=1)).clamp_min_(0).sqrt_()) + nearest = torch.cat(nearest_chunks).reshape(batch.shape[0], width, height) scores = nearest.flatten(1).amax(1) if not return_map: return scores, None @@ -242,6 +268,8 @@ def save_statistics(self, path: str, half: Optional[bool] = False) -> None: "layer_indices": self.layer_indices, "coreset_ratio": self.coreset_ratio, "max_memory_patches": self.max_memory_patches, + "patch_grid": self.patch_grid, + "search_chunk_size": self.search_chunk_size, "model_type": "patchcore", "dtype": "fp16" if half else "fp32", }, @@ -276,8 +304,10 @@ def build_patchcore_from_stats( backbone=str(stats["backbone"]), layer_indices=list(stats["layer_indices"]), memory_bank=bank, - coreset_ratio=float(stats.get("coreset_ratio", 1.0)), - max_memory_patches=stats.get("max_memory_patches"), + coreset_ratio=float(stats.get("coreset_ratio", 0.02)), + max_memory_patches=stats.get("max_memory_patches", 2048), + patch_grid=stats.get("patch_grid", 14), + search_chunk_size=int(stats.get("search_chunk_size", 1024)), device=torch.device(device), ) if force_precision == "fp16" and model.device.type == "cuda": diff --git a/anomavision/train.py b/anomavision/train.py index 5beb056..e79a045 100644 --- a/anomavision/train.py +++ b/anomavision/train.py @@ -116,6 +116,18 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: 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( "--output_model", type=str, @@ -259,6 +271,8 @@ def run_training(args): 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, ) else: model = anomavision.Padim( diff --git a/config.yml b/config.yml index a8b12aa..948ae01 100644 --- a/config.yml +++ b/config.yml @@ -15,8 +15,10 @@ norm_std: [0.229, 0.224, 0.225] # Standard deviation for normalization # ========================= backbone: "resnet18" # Backbone CNN architecture (resnet18 | wide_resnet50) algorithm: "padim" # Algorithm to use: padim or patchcore -coreset_ratio: 0.1 # PatchCore fraction of normal patches to retain -max_memory_patches: 50000 # PatchCore memory-bank cap +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 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 diff --git a/docs/production_deployment.md b/docs/production_deployment.md index 051363f..6e15620 100644 --- a/docs/production_deployment.md +++ b/docs/production_deployment.md @@ -12,11 +12,15 @@ Use the configuration below when inference latency or memory is more important t algorithm: patchcore backbone: resnet18 layer_indices: [0, 1] -coreset_ratio: 0.1 -max_memory_patches: 50000 +coreset_ratio: 0.02 +max_memory_patches: 2048 +patch_grid: 14 +search_chunk_size: 1024 ``` -`coreset_ratio` controls the fraction of extracted normal patches retained. `max_memory_patches` provides a hard upper bound. Start with `resnet18`, `[0, 1]`, and the defaults above; increase the memory cap only after measuring validation quality and latency on the target device. +`coreset_ratio` controls the fraction of extracted normal patches retained. `max_memory_patches` provides a hard upper bound. `patch_grid` pools the native feature map to a small spatial grid, and `search_chunk_size` prevents a full query-by-memory distance matrix from being allocated. Start with `resnet18`, `[0]`, a 14x14 grid, and the defaults above; increase the memory cap only after measuring validation quality and latency on the target device. + +The implementation uses normalized embeddings and chunked matrix multiplication rather than `torch.cdist` over the entire batch. This keeps the working memory bounded while preserving a nearest-normal-patch score. It is intentionally an ultra-light approximation of full PatchCore: the lower memory and patch count can reduce accuracy, so validate the trade-off on the target defect classes. ## TensorRT export diff --git a/tests/test_patchcore_ultralight.py b/tests/test_patchcore_ultralight.py new file mode 100644 index 0000000..f91c07e --- /dev/null +++ b/tests/test_patchcore_ultralight.py @@ -0,0 +1,57 @@ +import torch +from torch.utils.data import DataLoader, TensorDataset + +import anomavision.patchcore as patchcore_module + + +class FakeExtractor(torch.nn.Module): + backbone_name = "resnet18" + + def __init__(self, backbone_name, device): + super().__init__() + self.device = torch.device(device) + + def forward(self, batch, layer_indices=None): + batch_size = batch.shape[0] + # A 4x4 native feature map makes pooling and patch-count assertions explicit. + embeddings = batch.mean(dim=(1, 2, 3)).view(batch_size, 1, 1).expand(batch_size, 16, 3) + return embeddings, 4, 4 + + def to_device(self, device): + self.device = torch.device(device) + + +def test_ultralight_patchcore_bounds_patches_and_search(monkeypatch): + monkeypatch.setattr(patchcore_module, "ResnetEmbeddingsExtractor", FakeExtractor) + model = patchcore_module.PatchCore( + device="cpu", + layer_indices=[0], + coreset_ratio=1.0, + max_memory_patches=3, + patch_grid=2, + search_chunk_size=1, + ) + loader = DataLoader(TensorDataset(torch.randn(4, 3, 8, 8)), batch_size=2) + model.fit(loader) + + assert model.memory_bank.shape[0] <= 3 + scores, maps = model.predict(torch.randn(2, 3, 8, 8)) + assert scores.shape == (2,) + assert maps.shape == (2, 8, 8) + + +def test_patchcore_stats_round_trip_preserves_ultralight_settings(monkeypatch, tmp_path): + monkeypatch.setattr(patchcore_module, "ResnetEmbeddingsExtractor", FakeExtractor) + model = patchcore_module.PatchCore( + device="cpu", patch_grid=3, search_chunk_size=7, max_memory_patches=11 + ) + model.memory_bank = torch.randn(5, 3) + path = tmp_path / "patchcore.pth" + model.save_statistics(str(path)) + restored = patchcore_module.build_patchcore_from_stats( + torch.load(path, weights_only=False), device="cpu" + ) + + assert restored.patch_grid == 3 + assert restored.search_chunk_size == 7 + assert restored.max_memory_patches == 11 From 5724706fd4096d5a01e5f0c6f649dc4831dc4d69 Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:47:13 +0000 Subject: [PATCH 07/22] docs: improve beginner README flow --- README.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 363acfe..ea1bc2f 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,23 @@ dataset/ ### 3. Train +Before running the command, open `config.yml` and set `dataset_path` to the folder that contains your class folder, for example `./dataset`. Keep `class_name: bottle` if your data is stored under `./dataset/bottle/`. + ```bash anomavision train --config config.yml ``` -The default configuration trains PaDiM. To train lightweight PatchCore, set `algorithm: patchcore` in `config.yml` or pass the corresponding CLI option. The model and compact deployment artifact are saved under `model_data_path`. +The default configuration trains PaDiM. To use the ultra-light PatchCore path, change these values in `config.yml`: + +```yaml +algorithm: patchcore +layer_indices: [0] +coreset_ratio: 0.02 +max_memory_patches: 2048 +patch_grid: 14 +``` + +The model and compact deployment artifact are saved under `model_data_path`. ### 4. Detect and evaluate @@ -82,7 +94,7 @@ anomavision export --help | Model | Best starting point | Memory use | Production note | |---|---|---:|---| | PaDiM | Fast, simple baseline | Low | Recommended first experiment | -| Lightweight PatchCore | Higher-fidelity patch retrieval with bounded memory | Configurable | Use `coreset_ratio` and `max_memory_patches` to control latency | +| Lightweight PatchCore | Lower-memory nearest-patch baseline | Very low by default | Use `coreset_ratio`, `max_memory_patches`, and `patch_grid` to control latency | ## Documentation @@ -109,6 +121,9 @@ train_loader = DataLoader(train_set, batch_size=16, shuffle=False) model = anomavision.Padim(backbone="resnet18", device=torch.device("cpu")) model.fit(train_loader) +batch = next(iter(train_loader)) +if isinstance(batch, (tuple, list)): + batch = batch[0] scores, maps = model.predict(batch) ``` From 21fbe9b6b08797ff89a7435519006e083f8bdf95 Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:05:59 +0000 Subject: [PATCH 08/22] bench: align AnomaVision and Anomalib comparison --- compare_with_anomalib.py | 222 ++++++++++++++++++++++++--------------- docs/benchmark.md | 19 ++-- 2 files changed, 151 insertions(+), 90 deletions(-) diff --git a/compare_with_anomalib.py b/compare_with_anomalib.py index b1ddfd8..abd67a6 100644 --- a/compare_with_anomalib.py +++ b/compare_with_anomalib.py @@ -1,6 +1,9 @@ """ -Comprehensive comparison between your anomaly detection implementation and Anomalib. -This script benchmarks both implementations on model size, speed, performance, and memory usage. +Reproducible comparison between AnomaVision and Anomalib PaDiM. +Both implementations use the same MVTec split, 224x224 inputs, ImageNet normalization, +ResNet18 layer1-equivalent features, batch size, warm-up count, timing loop, and raw +score-map evaluation contract. The report distinguishes full checkpoints from compact +AnomaVision deployment statistics. Requirements: pip install anomalib torch torchvision numpy pandas matplotlib seaborn tabulate psutil @@ -11,8 +14,11 @@ import argparse import gc +import inspect import json import os +import platform +import random import sys import time import warnings @@ -32,6 +38,47 @@ # Suppress warnings for cleaner output warnings.filterwarnings("ignore") +# One parity contract for both implementations. +IMAGE_SIZE = (224, 224) +NORMALIZE = True +BATCH_SIZE = 8 +WARMUP_ITERS = 3 +TIMING_ITERS = 100 +SEED = 42 + + +def set_seed(seed: int = SEED) -> None: + """Make model initialization and benchmark ordering reproducible.""" + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + + +def build_anomalib_mvtec(MVTec, root: Path, category: str, batch_size: int): + """Build Anomalib MVTec data with explicit parity settings where supported.""" + kwargs = { + "root": str(root), + "category": category, + "image_size": IMAGE_SIZE, + "train_batch_size": batch_size, + "eval_batch_size": batch_size, + "num_workers": 0, + "pin_memory": False, + } + parameters = inspect.signature(MVTec).parameters + if "normalization" in parameters: + try: + from anomalib.data import NormalizationMethod + kwargs["normalization"] = NormalizationMethod.IMAGENET + except (ImportError, AttributeError): + kwargs["normalization"] = "imagenet" + elif "normalize" in parameters: + kwargs["normalize"] = NORMALIZE + return MVTec(**{key: value for key, value in kwargs.items() if key in parameters}) + + # =============================== # Data Classes for Results # =============================== @@ -63,18 +110,22 @@ class ModelMetrics: # Additional info backbone: str = "" device: str = "" + compact_model_size_mb: float = 0.0 + environment: Dict[str, str] = None def __post_init__(self): if self.export_size_mb is None: self.export_size_mb = {} + if self.environment is None: + self.environment = {} -def evaluate_model_with_wrapper(model, test_dataloader): - """ - Evaluate AnomaVision model using the ModelWrapper inference interface - Returns: (images, image_classifications_target, masks_target, image_scores, score_maps) +def evaluate_model_with_wrapper(model, test_dataloader, device): + """Evaluate a model using the shared ``predict`` contract on the shared device. + + The name is retained for compatibility with older benchmark reports, but the + fair benchmark now passes both implementations as in-memory PyTorch models. """ - from anomavision.general import determine_device all_images = [] all_image_classifications_target = [] @@ -83,12 +134,11 @@ def evaluate_model_with_wrapper(model, test_dataloader): all_score_maps = [] batch_count = 0 - device_str = determine_device("cpu") try: for batch_idx, (batch, images, image_targets, mask_targets) in enumerate( test_dataloader ): - batch = batch.to(device_str) + batch = batch.to(device) image_scores, score_maps = model.predict(batch) @@ -142,10 +192,14 @@ def evaluate_model_with_wrapper(model, test_dataloader): class BenchmarkRunner: """Main benchmark runner class.""" - def __init__(self, dataset_path: str, class_name: str, device: str = "auto"): + def __init__( + self, dataset_path: str, class_name: str, device: str = "auto", seed: int = SEED + ): self.dataset_path = Path(dataset_path) self.class_name = class_name self.device = self._setup_device(device) + self.seed = int(seed) + set_seed(self.seed) self.results = {} # Setup paths @@ -174,11 +228,27 @@ def _get_memory_usage(self) -> float: return process.memory_info().rss / 1024 / 1024 def _reset_memory(self): - """Reset memory tracking.""" + """Reset memory tracking before constructing the measured object.""" gc.collect() if self.device.type == "cuda": torch.cuda.empty_cache() - torch.cuda.reset_peak_memory_stats() + torch.cuda.reset_peak_memory_stats(self.device) + + def _environment(self) -> Dict[str, str]: + """Return versions and hardware details stored with every result.""" + return { + "python": platform.python_version(), + "torch": torch.__version__, + "device": str(self.device), + "cuda": str(torch.version.cuda), + "gpu": torch.cuda.get_device_name(self.device) if self.device.type == "cuda" else "cpu", + "seed": str(self.seed), + "image_size": f"{IMAGE_SIZE[0]}x{IMAGE_SIZE[1]}", + "normalize": str(NORMALIZE), + "batch_size": str(BATCH_SIZE), + "warmup_iters": str(WARMUP_ITERS), + "timing_iters": str(TIMING_ITERS), + } # =============================== # Your Implementation @@ -197,13 +267,12 @@ def benchmark_your_implementation(self) -> ModelMetrics: # Import your modules import anomavision from anomavision import MVTecDataset, Padim - from anomavision.general import determine_device - from anomavision.inference.model.wrapper import ModelWrapper - from anomavision.utils import adaptive_gaussian_blur + set_seed(self.seed) metrics = ModelMetrics(name="Your PaDiM") metrics.backbone = "resnet18" metrics.device = str(self.device) + metrics.environment = self._environment() # === 1. Setup Datasets === print("\n1. Loading datasets...") @@ -211,25 +280,35 @@ def benchmark_your_implementation(self) -> ModelMetrics: self.dataset_path, self.class_name, is_train=True, - resize=224, - normalize=True, + resize=IMAGE_SIZE, + crop_size=IMAGE_SIZE, + normalize=NORMALIZE, ) test_dataset = MVTecDataset( self.dataset_path, self.class_name, is_train=False, - resize=224, - normalize=True, + resize=IMAGE_SIZE, + crop_size=IMAGE_SIZE, + normalize=NORMALIZE, ) - batch_size = 8 + batch_size = BATCH_SIZE train_loader = DataLoader( - train_dataset, batch_size=batch_size, shuffle=False, num_workers=0 + train_dataset, + batch_size=batch_size, + shuffle=False, + num_workers=0, + pin_memory=False, ) test_loader = DataLoader( - test_dataset, batch_size=batch_size, shuffle=False, num_workers=0 + test_dataset, + batch_size=batch_size, + shuffle=False, + num_workers=0, + pin_memory=False, ) print(f" Train samples: {len(train_dataset)}") @@ -269,25 +348,18 @@ def benchmark_your_implementation(self) -> ModelMetrics: except Exception as e: raise RuntimeError(f"Error saving statistics: {e}") - metrics.model_size_mb = stats_path.stat().st_size / (1024 * 1024) - print(f" Model size: {metrics.model_size_mb:.2f} MB") - - # Try to save statistics version - try: - stats_path = self.your_model_path / "padim_model.pth" - model.save_statistics(str(stats_path)) - stats_size = stats_path.stat().st_size / (1024 * 1024) - print(f" Statistics file size: {stats_size:.2f} MB") - except Exception: + metrics.model_size_mb = model_path.stat().st_size / (1024 * 1024) + metrics.compact_model_size_mb = stats_path.stat().st_size / (1024 * 1024) + print(f" Full PyTorch model size: {metrics.model_size_mb:.2f} MB") + print(f" Compact statistics size: {metrics.compact_model_size_mb:.2f} MB") - pass # === 4. Export Sizes === print("\n4. Testing export formats...") # ONNX export try: - dummy_input = torch.randn(1, 3, 224, 224).to(self.device) + dummy_input = torch.randn(1, 3, *IMAGE_SIZE, device=self.device) onnx_path = self.your_model_path / "model.onnx" torch.onnx.export( model, @@ -322,33 +394,23 @@ def benchmark_your_implementation(self) -> ModelMetrics: # === 5. Inference Speed === print("\n5. Measuring inference speed...") - device_str = determine_device(self.device.type) - + model.eval() self._reset_memory() - - model = ModelWrapper(model_path, device_str) - # model.eval() - inference_times = [] start_memory = self._get_memory_usage() - - batch = torch.randn(1, 3, 224, 224).to(self.device) + inference_times = [] + batch = torch.randn(1, 3, *IMAGE_SIZE, device=self.device) with torch.no_grad(): - # Warmup - for _ in range(3): - _ = model.warmup(batch) - - # Actual timing - for _ in range(100): - + for _ in range(WARMUP_ITERS): + _ = model.predict(batch) + if self.device.type == "cuda": + torch.cuda.synchronize(self.device) + for _ in range(TIMING_ITERS): if self.device.type == "cuda": - torch.cuda.synchronize() - + torch.cuda.synchronize(self.device) start = time.perf_counter() _ = model.predict(batch) - if self.device.type == "cuda": - torch.cuda.synchronize() - + torch.cuda.synchronize(self.device) inference_times.append(time.perf_counter() - start) metrics.inference_memory_mb = self._get_memory_usage() - start_memory @@ -364,11 +426,8 @@ def benchmark_your_implementation(self) -> ModelMetrics: # Run evaluation images, y_true, masks_true, y_score, masks_score = ( - evaluate_model_with_wrapper(model, test_loader) + evaluate_model_with_wrapper(model, test_loader, self.device) ) - # images, image_classifications_target, masks_target, image_scores, score_maps = evaluate_model_with_wrapper(model, test_loader) - - masks_score = adaptive_gaussian_blur(masks_score, kernel_size=33, sigma=4) # anodet.visualize_eval_data( # y_true, @@ -419,19 +478,16 @@ def benchmark_anomalib(self) -> ModelMetrics: from pytorch_lightning import Trainer from pytorch_lightning.callbacks import ModelCheckpoint + set_seed(self.seed) metrics = ModelMetrics(name="Anomalib PaDiM") metrics.backbone = "resnet18" metrics.device = str(self.device) - batch_size = 8 + metrics.environment = self._environment() + batch_size = BATCH_SIZE # === 1. Setup Data Module === print("\n1. Loading datasets...") - datamodule = MVTec( - root=str(self.dataset_path), - category=self.class_name, - # image_size=(224, 224), - train_batch_size=batch_size, - eval_batch_size=batch_size, - num_workers=0, + datamodule = build_anomalib_mvtec( + MVTec, self.dataset_path, self.class_name, batch_size ) datamodule.setup() @@ -485,7 +541,7 @@ def benchmark_anomalib(self) -> ModelMetrics: trainer.trainer.save_checkpoint(str(model_path)) metrics.model_size_mb = model_path.stat().st_size / (1024 * 1024) - print(f" Model size: {metrics.model_size_mb:.2f} MB") + print(f" Full PyTorch checkpoint size: {metrics.model_size_mb:.2f} MB") # === 4. Export Sizes === print("\n4. Testing export formats...") @@ -511,30 +567,24 @@ def benchmark_anomalib(self) -> ModelMetrics: # === 5. Inference Speed === print("\n5. Measuring inference speed...") - self._reset_memory() - model.eval() + self._reset_memory() inference_times = [] start_memory = self._get_memory_usage() - batch = torch.randn(1, 3, 224, 224).to(self.device) + batch = torch.randn(1, 3, *IMAGE_SIZE, device=self.device) with torch.no_grad(): - # Warmup - for _ in range(3): + for _ in range(WARMUP_ITERS): _ = model(batch) - - # Actual timing - for _ in range(100): - + if self.device.type == "cuda": + torch.cuda.synchronize(self.device) + for _ in range(TIMING_ITERS): if self.device.type == "cuda": - torch.cuda.synchronize() - + torch.cuda.synchronize(self.device) start = time.perf_counter() _ = model(batch) - if self.device.type == "cuda": - torch.cuda.synchronize() - + torch.cuda.synchronize(self.device) inference_times.append(time.perf_counter() - start) metrics.inference_memory_mb = self._get_memory_usage() - start_memory @@ -1022,6 +1072,10 @@ def main(): help="Device to use for testing", ) + parser.add_argument( + "--seed", type=int, default=SEED, help="Random seed shared by both implementations" + ) + parser.add_argument( "--all_classes", action="store_true", help="Run comparison on all MVTec classes" ) @@ -1055,7 +1109,7 @@ def main(): print("=" * 60) try: - runner = BenchmarkRunner(args.dataset_path, class_name, args.device) + runner = BenchmarkRunner(args.dataset_path, class_name, args.device, args.seed) results = runner.run_comparison() all_results[class_name] = results except Exception as e: @@ -1066,7 +1120,9 @@ def main(): generate_summary_report(all_results) else: # Run on single class - runner = BenchmarkRunner(args.dataset_path, args.class_name, args.device) + runner = BenchmarkRunner( + args.dataset_path, args.class_name, args.device, args.seed + ) results = runner.run_comparison() print("\n" + "=" * 60) diff --git a/docs/benchmark.md b/docs/benchmark.md index cbe53fe..43ac0ef 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -1,10 +1,15 @@ # πŸ“Š Benchmarks -We compare **AnomaVision** against **Anomalib** using **(PaDiM baseline)** on **MVTec AD** and **Visa** datasets. +This page records **historical preliminary results** for AnomaVision versus Anomalib PaDiM. The original measurements were collected before the parity fixes in [`compare_with_anomalib.py`](../compare_with_anomalib.py), so they should not be treated as the final fair-comparison result. +The corrected script now aligns image size, normalization, batch size, warm-up, timing, memory baselines, score-map post-processing, and full-checkpoint size reporting. Rerun it before making performance claims: -Metrics: **Image AUROC, Pixel AUROC, FPS, Model Size, and Memory Usage**. +```bash +python compare_with_anomalib.py --dataset_path /path/to/mvtec --class_name bottle --device cuda +``` + +Metrics: **Image AUROC, Pixel AUROC, FPS, Full Checkpoint Size, Compact Artifact Size, and Memory Usage**. --- @@ -15,10 +20,10 @@ Metrics: **Image AUROC, Pixel AUROC, FPS, Model Size, and Memory Usage**. | **Image AUROC ↑** | **0.8499** | 0.8102 | | **Pixel AUROC ↑** | **0.9562** | 0.9354 | | **FPS ↑** | **43.41** | 13.03 | -| **Size (MB) ↓** | **30.5** | 40.5 | +| **Historical size (MB) ↓** | **30.5 compact** | 40.5 full checkpoint | | **Memory (MB) ↓** | **1647** | 1696 | -βœ… AnomaVision is **+4% higher Image AUROC**, **+2% higher Pixel AUROC**, and **3Γ— faster**. +These historical values are directional only; rerun the corrected script before interpreting the differences as a fair superiority claim. --- @@ -51,10 +56,10 @@ Metrics: **Image AUROC, Pixel AUROC, FPS, Model Size, and Memory Usage**. | **Image AUROC ↑** | **0.8123** | 0.7825 | | **Pixel AUROC ↑** | **0.9618** | 0.9542 | | **FPS ↑** | **44.76** | 13.52 | -| **Size (MB) ↓** | **30.5** | 40.5 | +| **Historical size (MB) ↓** | **30.5 compact** | 40.5 full checkpoint | | **Memory (MB) ↓** | **2638** | 2796 | -βœ… On Visa, AnomaVision is **+3% better on Image AUROC**, **+0.7% on Pixel AUROC**, and **3.3Γ— faster**. +The historical VisA table is retained for reference, but the current comparison script supports MVTec classes only. A separate VisA runner is required to reproduce these values. --- @@ -92,6 +97,6 @@ Metrics: **Image AUROC, Pixel AUROC, FPS, Model Size, and Memory Usage**. Visa Benchmark Results

Visa Dataset

-πŸ‘‰ Benchmarks confirm: **AnomaVision is edge-ready, lightweight, and faster β€” without sacrificing accuracy.** +Use the corrected benchmark before making production claims about accuracy, speed, memory, or model size. --- From b25aab16182470ab01550a2e0a3d2f102db2ae7b Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:18:49 +0000 Subject: [PATCH 09/22] feat(export): automate PaDiM and PatchCore TensorRT conversion --- anomavision/export.py | 49 ++++++-- docs/production_deployment.md | 19 ++++ scripts/convert_to_tensorrt.py | 178 ++++++++++++++++++++++++++++++ tests/test_convert_to_tensorrt.py | 43 ++++++++ 4 files changed, 278 insertions(+), 11 deletions(-) create mode 100644 scripts/convert_to_tensorrt.py create mode 100644 tests/test_convert_to_tensorrt.py diff --git a/anomavision/export.py b/anomavision/export.py index 9689923..368cb02 100644 --- a/anomavision/export.py +++ b/anomavision/export.py @@ -60,13 +60,12 @@ def load_calibration_images( normalize: bool = True, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], + allow_random: bool = True, ): """ Load calibration images using the same preprocessing as AnodetDataset. Returns a list of np.ndarrays shaped (1,C,H,W). """ - from glob import glob - h, w = input_shape[2], input_shape[3] transform = create_image_transform( resize=(h, w), @@ -76,7 +75,11 @@ def load_calibration_images( std=std, ) - paths = glob(os.path.join(img_dir, "*.png"))[:max_samples] + paths = sorted( + p + for p in Path(img_dir).rglob("*") + if p.is_file() and p.suffix.lower() in {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"} + )[:max_samples] samples = [] for p in paths: @@ -87,10 +90,8 @@ def load_calibration_images( except Exception as e: print(f"Skipping {p}, error {e}") - if not samples: - # Fallback to random data if no images found + if not samples and allow_random: samples = [np.random.rand(*input_shape).astype("float32") for _ in range(32)] - return samples @@ -445,6 +446,9 @@ def export_tensorrt( calib_dir: Optional[str] = None, calib_samples: int = 100, workspace_gb: float = 2.0, + min_batch: int = 1, + opt_batch: Optional[int] = None, + max_batch: Optional[int] = None, ) -> Optional[Path]: """Build a native TensorRT engine from an ONNX graph. @@ -466,6 +470,12 @@ def export_tensorrt( raise ValueError("TensorRT precision must be fp32, fp16, or int8") if precision == "int8" and not calib_dir: raise ValueError("INT8 TensorRT export requires --calib-dir") + if min_batch < 1: + raise ValueError("min_batch must be at least 1") + opt_batch = int(opt_batch or max(min_batch, input_shape[0])) + max_batch = int(max_batch or max(opt_batch, 4)) + if not min_batch <= opt_batch <= max_batch: + raise ValueError("Batch profile must satisfy min_batch <= opt_batch <= max_batch") onnx_path = self.export_onnx( input_shape=input_shape, @@ -500,10 +510,15 @@ def export_tensorrt( self.logger.warning("TensorRT platform does not report fast INT8 support.") build_config.set_flag(trt.BuilderFlag.INT8) samples = load_calibration_images( - calib_dir, input_shape, max_samples=calib_samples + calib_dir, + input_shape, + max_samples=calib_samples, + allow_random=False, ) if not samples: - raise ValueError("No calibration images were found for INT8 export.") + raise ValueError( + f"No calibration images found in {calib_dir}; random calibration is disabled for TensorRT." + ) calibrator = _make_tensorrt_calibrator( trt, samples, self.output_dir / (Path(output_name).stem + ".calib") ) @@ -515,9 +530,9 @@ def export_tensorrt( _, channels, height, width = input_shape profile.set_shape( input_tensor.name, - (1, channels, height, width), - (max(1, input_shape[0]), channels, height, width), - (max(4, input_shape[0]), channels, height, width), + (min_batch, channels, height, width), + (opt_batch, channels, height, width), + (max_batch, channels, height, width), ) build_config.add_optimization_profile(profile) @@ -812,6 +827,15 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: default=2.0, help="TensorRT builder workspace limit in GiB", ) + parser.add_argument( + "--min-batch", type=int, default=1, help="TensorRT dynamic profile minimum batch" + ) + parser.add_argument( + "--opt-batch", type=int, default=1, help="TensorRT dynamic profile optimal batch" + ) + parser.add_argument( + "--max-batch", type=int, default=4, help="TensorRT dynamic profile maximum batch" + ) parser.add_argument( "--static-batch", action="store_true", help="Disable dynamic batch size" ) @@ -958,6 +982,9 @@ def main(args=None): calib_dir=calib_dir, calib_samples=config.calib_samples, workspace_gb=config.workspace_gb, + min_batch=config.min_batch, + opt_batch=config.opt_batch, + max_batch=config.max_batch, ) is not None ) diff --git a/docs/production_deployment.md b/docs/production_deployment.md index 6e15620..4428a84 100644 --- a/docs/production_deployment.md +++ b/docs/production_deployment.md @@ -57,3 +57,22 @@ Adoption is more likely when a project is easy to verify and easy to integrate. A useful release should include a comparison table with dataset split, preprocessing, backbone, hardware, batch size, and latency methodology. Link the raw results and invite independent reproduction. Then publish a short example showing how to load the exported artifact in an existing service. This creates three entry points: researchers can inspect the benchmark, engineers can copy the deployment path, and beginners can run the quickstart. For discoverability, use a clear repository description, topic tags such as `anomaly-detection`, `computer-vision`, `patchcore`, and `tensorrt`, a small release note for each version, and issue templates for bug reports and benchmark reproduction. Avoid unsupported claims such as β€œbest” unless the comparison protocol is public and repeatable. + +## Automatic TensorRT conversion + +Use `scripts/convert_to_tensorrt.py` when you already have a compact PaDiM statistics artifact or an ultra-light PatchCore memory-bank artifact. The utility detects the artifact type and delegates model loading and TensorRT construction to the shared export pipeline. + +```bash +python scripts/convert_to_tensorrt.py ` + --model ./model_data/patchcore/bottle/run/model.pth ` + --output-dir ./engines/bottle ` + --precision int8 ` + --device cuda ` + --calib-dir ./dataset/bottle/train/good ` + --calib-samples 100 ` + --min-batch 1 --opt-batch 1 --max-batch 4 +``` + +For FP16, omit the calibration directory and change `--precision int8` to `--precision fp16`. INT8 conversion requires real normal calibration images; the TensorRT path no longer falls back to random calibration data. The utility accepts PNG, JPEG, BMP, TIFF, and nested calibration-image directories, writes a reusable calibration cache beside the engine, and deserializes the generated engine for validation unless `--skip-validation` is supplied. + +The input artifact may be either a PaDiM `.pth` statistics file or a PatchCore artifact containing its memory bank and feature settings. The default dynamic profile is batch 1/1/4; override it when the deployment workload has a different batch distribution. diff --git a/scripts/convert_to_tensorrt.py b/scripts/convert_to_tensorrt.py new file mode 100644 index 0000000..27d0b78 --- /dev/null +++ b/scripts/convert_to_tensorrt.py @@ -0,0 +1,178 @@ +"""Convert AnomaVision PaDiM or ultra-light PatchCore artifacts to TensorRT. + +This command detects the compact statistics format written by PaDiM/PatchCore, +loads it through the shared :class:`ModelExporter`, and builds a native TensorRT +engine using the repository's current FP16/INT8 pipeline. + +Examples: + python scripts/convert_to_tensorrt.py \ + --model model_data/patchcore/bottle/run/model.pth \ + --output-dir engines/bottle \ + --calib-dir dataset/bottle/train/good \ + --precision int8 --device cuda + + python scripts/convert_to_tensorrt.py \ + --model model_data/padim/bottle/run/model.pth \ + --output-dir engines/bottle \ + --precision fp16 --device cuda +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path +from typing import Iterable, Tuple + +import torch + +# Allow `python scripts/convert_to_tensorrt.py` from a source checkout. +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from anomavision.export import ModelExporter + + +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"} +PADIM_KEYS = {"mean", "cov_inv", "channel_indices", "layer_indices", "backbone"} +PATCHCORE_KEYS = {"memory_bank", "layer_indices", "backbone"} + + +def _logger() -> logging.Logger: + """Create a concise standalone logger for conversion output.""" + logger = logging.getLogger("anomavision.convert_tensorrt") + if not logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + return logger + + +def _image_files(directory: Path) -> Iterable[Path]: + """Yield supported calibration images in deterministic order.""" + return sorted( + path + for path in directory.rglob("*") + if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS + ) + + +def detect_artifact(model_path: Path) -> Tuple[str, object]: + """Load an artifact and identify it as PaDiM, PatchCore, or a full model. + + Args: + model_path: Path to a compact ``.pth``/``.pt`` artifact. + + Returns: + A pair containing the detected model name and the loaded object. + + Raises: + FileNotFoundError: If ``model_path`` does not exist. + ValueError: If the artifact is not a supported AnomaVision model. + """ + if not model_path.is_file(): + raise FileNotFoundError(f"Model artifact does not exist: {model_path}") + artifact = torch.load(model_path, map_location="cpu", weights_only=False) + if isinstance(artifact, dict) and PADIM_KEYS.issubset(artifact): + return "padim", artifact + if isinstance(artifact, dict) and PATCHCORE_KEYS.issubset(artifact): + return "patchcore", artifact + if isinstance(artifact, torch.nn.Module): + name = artifact.__class__.__name__.lower() + if "padim" in name: + return "padim", artifact + if "patchcore" in name: + return "patchcore", artifact + raise ValueError( + "Unsupported artifact. Expected PaDiM statistics, PatchCore memory-bank " + "statistics, or a serialized PaDiM/PatchCore module." + ) + + +def validate_engine(engine_path: Path, input_name: str = "input") -> None: + """Deserialize a TensorRT engine and verify that it has an input tensor.""" + try: + import tensorrt as trt + except ImportError as exc: + raise RuntimeError( + "TensorRT is required for engine validation; the engine was still built." + ) from exc + runtime = trt.Runtime(trt.Logger(trt.Logger.ERROR)) + with engine_path.open("rb") as handle: + engine = runtime.deserialize_cuda_engine(handle.read()) + if engine is None: + raise RuntimeError(f"TensorRT could not deserialize {engine_path}") + if hasattr(engine, "num_io_tensors"): + names = [engine.get_tensor_name(i) for i in range(engine.num_io_tensors)] + else: + names = [engine.get_binding_name(i) for i in range(engine.num_bindings)] + if input_name not in names: + raise RuntimeError(f"Engine inputs/outputs {names} do not include '{input_name}'") + + +def build_parser() -> argparse.ArgumentParser: + """Build the conversion CLI parser.""" + parser = argparse.ArgumentParser( + description="Convert an AnomaVision PaDiM or PatchCore artifact to TensorRT." + ) + parser.add_argument("--model", type=Path, required=True, help="Input .pth/.pt model artifact") + parser.add_argument("--output-dir", type=Path, required=True, help="Directory for the engine and calibration cache") + parser.add_argument("--output-name", default=None, help="Engine filename; defaults to _.engine") + parser.add_argument("--precision", choices=("fp32", "fp16", "int8"), default="int8") + parser.add_argument("--device", default="cuda", help="CUDA device used to build the engine") + parser.add_argument("--input-shape", nargs=4, type=int, default=(1, 3, 224, 224), metavar=("N", "C", "H", "W")) + parser.add_argument("--min-batch", type=int, default=1) + parser.add_argument("--opt-batch", type=int, default=1) + parser.add_argument("--max-batch", type=int, default=4) + parser.add_argument("--calib-dir", type=Path, default=None, help="Normal calibration images; required for INT8") + parser.add_argument("--calib-samples", type=int, default=100) + parser.add_argument("--workspace-gb", type=float, default=2.0) + parser.add_argument("--static-batch", action="store_true", help="Build a fixed-batch engine") + parser.add_argument("--skip-validation", action="store_true") + return parser + + +def main(argv=None) -> int: + """Convert one artifact and return a process exit code.""" + args = build_parser().parse_args(argv) + logger = _logger() + model_type, artifact = detect_artifact(args.model) + logger.info("detected %s artifact: %s", model_type, args.model) + + shape = tuple(args.input_shape) + if args.precision == "int8": + if args.calib_dir is None or not args.calib_dir.is_dir(): + raise SystemExit("INT8 conversion requires an existing --calib-dir.") + calibration_images = list(_image_files(args.calib_dir)) + if not calibration_images: + raise SystemExit(f"No supported calibration images found in {args.calib_dir}.") + logger.info("using %d calibration images (limit=%d)", len(calibration_images), args.calib_samples) + + args.output_dir.mkdir(parents=True, exist_ok=True) + output_name = args.output_name or f"{args.model.stem}_{args.precision}.engine" + exporter = ModelExporter(args.model, args.output_dir, logger, device=args.device) + result = exporter.export_tensorrt( + input_shape=shape, + output_name=output_name, + dynamic_batch=not args.static_batch, + precision=args.precision, + calib_dir=str(args.calib_dir) if args.calib_dir else None, + calib_samples=args.calib_samples, + workspace_gb=args.workspace_gb, + min_batch=args.min_batch, + opt_batch=args.opt_batch, + max_batch=args.max_batch, + ) + if result is None: + return 1 + if not args.skip_validation: + validate_engine(result) + logger.info("conversion complete: %s", result) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_convert_to_tensorrt.py b/tests/test_convert_to_tensorrt.py new file mode 100644 index 0000000..e68828a --- /dev/null +++ b/tests/test_convert_to_tensorrt.py @@ -0,0 +1,43 @@ +import torch + +from scripts.convert_to_tensorrt import _image_files, detect_artifact + + +def test_detect_padim_statistics(tmp_path): + path = tmp_path / "padim.pth" + torch.save( + { + "mean": torch.zeros(3), + "cov_inv": torch.eye(3), + "channel_indices": torch.tensor([0, 1, 2]), + "layer_indices": [0], + "backbone": "resnet18", + }, + path, + ) + model_type, _ = detect_artifact(path) + assert model_type == "padim" + + +def test_detect_patchcore_statistics(tmp_path): + path = tmp_path / "patchcore.pth" + torch.save( + { + "memory_bank": torch.randn(4, 3), + "layer_indices": [0], + "backbone": "resnet18", + }, + path, + ) + model_type, _ = detect_artifact(path) + assert model_type == "patchcore" + + +def test_calibration_images_are_recursive_and_deterministic(tmp_path): + nested = tmp_path / "nested" + nested.mkdir() + (tmp_path / "b.png").write_bytes(b"placeholder") + (nested / "a.jpg").write_bytes(b"placeholder") + (nested / "ignored.txt").write_bytes(b"placeholder") + paths = list(_image_files(tmp_path)) + assert [path.name for path in paths] == ["b.png", "a.jpg"] From c6faf9d74145a9dfe69f095e8c12dd2f4f82eb6c Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 07:47:09 +0000 Subject: [PATCH 10/22] fix(detect): classify high anomaly scores correctly --- anomavision/static/AnomaVision/utils.py | 6 +++--- anomavision/utils.py | 8 ++------ tests/test_padim.py | 6 +++--- tests/test_threshold_classification.py | 16 ++++++++++++++++ 4 files changed, 24 insertions(+), 12 deletions(-) create mode 100644 tests/test_threshold_classification.py diff --git a/anomavision/static/AnomaVision/utils.py b/anomavision/static/AnomaVision/utils.py index 481e8b7..d8e2646 100644 --- a/anomavision/static/AnomaVision/utils.py +++ b/anomavision/static/AnomaVision/utils.py @@ -21,13 +21,13 @@ def to_batch(images: List[np.ndarray]) -> np.ndarray: def classification(image_scores: np.ndarray, thresh: float) -> np.ndarray: """ - Classify images as anomalous (0) or normal (1) based on threshold. + Classify images as normal (0) or anomalous (1) based on threshold. Args: image_scores: A 1D array of image anomaly scores. thresh: Threshold value to determine anomaly. Returns: - An array of classifications: 0 = anomaly, 1 = normal. + An array of classifications: 0 = normal, 1 = anomaly. """ - return np.where(image_scores < thresh, 1, 0) + return np.where(image_scores >= thresh, 1, 0) diff --git a/anomavision/utils.py b/anomavision/utils.py index 34d0a66..87a7a26 100644 --- a/anomavision/utils.py +++ b/anomavision/utils.py @@ -283,14 +283,10 @@ def classification(image_scores, thresh: float): """ if isinstance(image_scores, torch.Tensor): - image_classifications = image_scores.clone() - image_classifications[image_classifications < thresh] = 1 - image_classifications[image_classifications >= thresh] = 0 + image_classifications = (image_scores >= thresh).to(dtype=torch.int64) elif isinstance(image_scores, np.ndarray): - image_classifications = image_scores.copy() - image_classifications[image_classifications < thresh] = 1 - image_classifications[image_classifications >= thresh] = 0 + image_classifications = (image_scores >= thresh).astype(np.int64) else: raise TypeError("image_scores must be a torch.Tensor or numpy.ndarray") diff --git a/tests/test_padim.py b/tests/test_padim.py index e8bbc83..5cb9806 100644 --- a/tests/test_padim.py +++ b/tests/test_padim.py @@ -195,8 +195,8 @@ def test_classification_function(self): # Run classification classifications = anomavision.classification(scores, threshold) - # Check results (below threshold = normal=1, above = anomaly=0) - expected = torch.tensor([1, 0, 1, 0]) + # Check results (below threshold = normal=0, above = anomaly=1) + expected = torch.tensor([0, 1, 0, 1]) assert torch.equal(classifications, expected) def test_classification_with_numpy(self): @@ -206,7 +206,7 @@ def test_classification_with_numpy(self): classifications = anomavision.classification(scores, threshold) - expected = np.array([1, 0, 1, 0]) + expected = np.array([0, 1, 0, 1]) np.testing.assert_array_equal(classifications, expected) diff --git a/tests/test_threshold_classification.py b/tests/test_threshold_classification.py new file mode 100644 index 0000000..bd488d2 --- /dev/null +++ b/tests/test_threshold_classification.py @@ -0,0 +1,16 @@ +import numpy as np +import torch + +from anomavision.utils import classification + + +def test_numpy_threshold_marks_high_scores_anomalous(): + scores = np.array([0.0, 2.0, 13.0]) + np.testing.assert_array_equal(classification(scores, 2.0), [0, 1, 1]) + np.testing.assert_array_equal(classification(scores, 13.0), [0, 0, 1]) + + +def test_torch_threshold_marks_high_scores_anomalous(): + scores = torch.tensor([0.0, 2.0, 13.0]) + expected = torch.tensor([0, 1, 1], dtype=torch.int64) + assert torch.equal(classification(scores, 2.0), expected) From c4b40d4045d56dfc9f027d2e9844d794d6e21bdf Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:36:29 +0000 Subject: [PATCH 11/22] fix(export): default optional calibration settings --- anomavision/export.py | 23 ++++++++++++++++++++++- tests/test_export_config_defaults.py | 15 +++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 tests/test_export_config_defaults.py diff --git a/anomavision/export.py b/anomavision/export.py index 368cb02..42f7514 100644 --- a/anomavision/export.py +++ b/anomavision/export.py @@ -876,6 +876,27 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: return parser +def _apply_export_defaults(config): + """Populate optional export settings omitted by older configs or dispatchers.""" + defaults = { + "calib_dir": None, + "calib_samples": 100, + "workspace_gb": 2.0, + "min_batch": 1, + "opt_batch": 1, + "max_batch": 4, + "tensorrt_precision": "fp16", + "quantize_dynamic": False, + "quantize_static": False, + "static_batch": False, + "optimize": False, + } + for key, value in defaults.items(): + if not hasattr(config, key): + config[key] = value + return config + + def main(args=None): if args is None: args = create_parser().parse_args() @@ -900,7 +921,7 @@ def main(args=None): if not cfg: cfg = {} - config = edict(merge_config(args, cfg)) + config = _apply_export_defaults(edict(merge_config(args, cfg))) # Setup logging & logger setup_logging(enabled=True, log_level=config.log_level, log_to_file=True) diff --git a/tests/test_export_config_defaults.py b/tests/test_export_config_defaults.py new file mode 100644 index 0000000..e215023 --- /dev/null +++ b/tests/test_export_config_defaults.py @@ -0,0 +1,15 @@ +from easydict import EasyDict + +from anomavision.export import _apply_export_defaults + + +def test_export_defaults_fill_optional_tensorrt_fields(): + config = _apply_export_defaults(EasyDict()) + assert config.calib_dir is None + assert config.calib_samples == 100 + assert config.workspace_gb == 2.0 + assert config.min_batch == 1 + assert config.opt_batch == 1 + assert config.max_batch == 4 + assert config.tensorrt_precision == "fp16" + assert config.static_batch is False From 1b62e32280ab25ad5db64588258a0e257339c4a3 Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:45:44 +0000 Subject: [PATCH 12/22] docs(config): include complete export defaults --- config.yml | 15 ++++++++++++--- tests/test_config_template.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 tests/test_config_template.py diff --git a/config.yml b/config.yml index 948ae01..877b40c 100644 --- a/config.yml +++ b/config.yml @@ -63,13 +63,22 @@ memory_efficient: true # Use memory efficient evaluation mode # ========================= 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 -fp32: false # Export in FP32 precision (false => FP16 in OpenVINO) output_path: null # Optional explicit output filename -half: false # Reserved (not actively used) -int8: false # Legacy; use --tensorrt-precision int8 +half: false # Legacy compatibility field +int8: false # Legacy compatibility field; use tensorrt_precision: int8 # ========================= # Streaming Configuration diff --git a/tests/test_config_template.py b/tests/test_config_template.py new file mode 100644 index 0000000..ac4280e --- /dev/null +++ b/tests/test_config_template.py @@ -0,0 +1,31 @@ +from pathlib import Path + +import yaml + + +REQUIRED_EXPORT_KEYS = { + "format", + "opset", + "precision", + "tensorrt_precision", + "dynamic_batch", + "static_batch", + "min_batch", + "opt_batch", + "max_batch", + "workspace_gb", + "calib_dir", + "calib_samples", + "quantize_dynamic", + "quantize_static", + "optimize", +} + + +def test_config_template_contains_all_export_defaults(): + config_path = Path(__file__).parents[1] / "config.yml" + with config_path.open(encoding="utf-8") as handle: + config = yaml.safe_load(handle) + assert REQUIRED_EXPORT_KEYS.issubset(config) + assert config["calib_dir"] is None + assert config["min_batch"] <= config["opt_batch"] <= config["max_batch"] From 53c18fa745e2a67cce8b87bf3503f65e52bdf503 Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:24:24 +0000 Subject: [PATCH 13/22] fix(patchcore): calibrate thresholds and select diverse coresets --- anomavision/detect.py | 6 +- anomavision/eval.py | 6 +- anomavision/patchcore.py | 60 +++++++++++++++++-- anomavision/train.py | 15 +++++ anomavision/utils.py | 13 ++++ config.yml | 6 +- docs/production_deployment.md | 16 +++++ .../test_algorithm_thresholds_and_coreset.py | 48 +++++++++++++++ tests/test_config_template.py | 3 + 9 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 tests/test_algorithm_thresholds_and_coreset.py diff --git a/anomavision/detect.py b/anomavision/detect.py index ea0744b..1946484 100644 --- a/anomavision/detect.py +++ b/anomavision/detect.py @@ -31,6 +31,7 @@ adaptive_gaussian_blur, get_logger, merge_config, + resolve_threshold, setup_logging, ) @@ -199,6 +200,7 @@ def run_inference(args): # Merge config with CLI args config = edict(merge_config(args, cfg)) + config.thresh = resolve_threshold(config) # Setup logging setup_logging(enabled=True, log_level=config.log_level, log_to_file=True) @@ -436,7 +438,7 @@ def run_inference(args): anomavision.classification( score_maps, config.thresh ) - if config.thresh + if config.thresh is not None else np.zeros_like(score_maps) ), is_anomaly, @@ -454,7 +456,7 @@ def run_inference(args): # Dummy mask if threshold not set ( anomavision.classification(score_maps, config.thresh) - if config.thresh + if config.thresh is not None else np.zeros_like(score_maps) ), color=viz_color, diff --git a/anomavision/eval.py b/anomavision/eval.py index 1283099..cc52d7c 100644 --- a/anomavision/eval.py +++ b/anomavision/eval.py @@ -23,6 +23,7 @@ find_optimal_threshold, get_logger, merge_config, + resolve_threshold, setup_logging, ) @@ -218,6 +219,7 @@ def run_evaluation(args): cfg = load_config(str(config_path)) if config_path.exists() else {} config = edict(merge_config(args, cfg)) + config.thresh = resolve_threshold(config) setup_logging(enabled=True, log_level=config.log_level, log_to_file=True) logger = get_logger("anomavision.eval") @@ -316,9 +318,11 @@ def run_evaluation(args): # Compute Metrics if config.thresh is None: - best_thresh, _ = find_optimal_threshold(labels, scores) + best_thresh, best_f1 = find_optimal_threshold(labels, scores) + logger.info("threshold: auto-selected %.6f (F1=%.4f)", best_thresh, best_f1) else: best_thresh = config.thresh + logger.info("threshold: configured %.6f", best_thresh) metrics = compute_metrics(labels, scores, thresh=best_thresh) diff --git a/anomavision/patchcore.py b/anomavision/patchcore.py index ab5d6b6..db7995b 100644 --- a/anomavision/patchcore.py +++ b/anomavision/patchcore.py @@ -59,6 +59,9 @@ class PatchCore(torch.nn.Module): throughput. n_neighbors: Number of nearest neighbors. The lightweight implementation currently supports only ``1``. + coreset_method: ``"kcenter"`` for deterministic greedy farthest-point + selection, or ``"random"`` for the faster legacy baseline. + coreset_seed: Seed used to choose the initial k-center point or random subset. Raises: ValueError: If the coreset ratio or neighbor count is unsupported. @@ -75,6 +78,8 @@ def __init__( patch_grid: Optional[int] = 14, search_chunk_size: int = 1024, n_neighbors: int = 1, + coreset_method: str = "kcenter", + coreset_seed: int = 42, ) -> None: super().__init__() if not 0 < coreset_ratio <= 1: @@ -88,6 +93,10 @@ def __init__( self.max_memory_patches = max_memory_patches self.patch_grid = patch_grid self.search_chunk_size = int(search_chunk_size) + self.coreset_method = str(coreset_method).lower() + self.coreset_seed = int(coreset_seed) + if self.coreset_method not in {"kcenter", "random"}: + raise ValueError("coreset_method must be 'kcenter' or 'random'.") if self.patch_grid is not None and self.patch_grid < 1: raise ValueError("patch_grid must be positive or None.") if self.search_chunk_size < 1: @@ -130,12 +139,52 @@ def _extract(self, batch: torch.Tensor) -> Tuple[torch.Tensor, int, int]: width, height = embeddings.shape[1:3] return F.normalize(embeddings.reshape(batch.shape[0], width * height, -1), dim=-1), width, height + @torch.no_grad() + def _select_coreset(self, bank: torch.Tensor, keep: int) -> torch.Tensor: + """Select a diverse memory bank with greedy farthest-point k-center. + + Distances are computed in chunks, so selection does not allocate the full + ``(num_patches, keep)`` distance matrix. The bank is normalized before + selection, making Euclidean distance equivalent to the cosine distance + used during inference. + """ + if keep >= bank.shape[0]: + return bank + if self.coreset_method == "random": + generator = torch.Generator(device="cpu").manual_seed(self.coreset_seed) + return bank[torch.randperm(bank.shape[0], generator=generator)[:keep]] + + bank = F.normalize(bank.float(), dim=-1) + n = bank.shape[0] + generator = torch.Generator(device="cpu").manual_seed(self.coreset_seed) + first = int(torch.randint(n, (1,), generator=generator).item()) + selected = torch.empty(keep, dtype=torch.long) + selected[0] = first + min_dist = torch.full((n,), float("inf"), dtype=torch.float32) + chunk = max(1, self.search_chunk_size) + for start in range(0, n, chunk): + end = min(start + chunk, n) + distances = 1.0 - bank[start:end] @ bank[first] + min_dist[start:end] = distances + min_dist[first] = -float("inf") + for index in range(1, keep): + center = int(torch.argmax(min_dist).item()) + selected[index] = center + for start in range(0, n, chunk): + end = min(start + chunk, n) + distances = 1.0 - bank[start:end] @ bank[center] + min_dist[start:end] = torch.minimum(min_dist[start:end], distances) + min_dist[center] = -float("inf") + return bank[selected] + @torch.no_grad() def fit(self, dataloader: torch.utils.data.DataLoader, extractions: int = 1) -> None: """Fit the detector from normal training images. - The method extracts every normal patch, randomly retains the configured - coreset, and stores it as the memory bank. No anomaly labels or gradient + The method extracts every normal patch, selects a bounded diverse + k-center coreset by default, and stores it as the memory bank. Set + ``coreset_method='random'`` only for a faster but less representative + baseline. No anomaly labels or gradient updates are required. A compact bank generally reduces both RAM/VRAM use and the cost of the nearest-neighbor search during inference. @@ -165,8 +214,7 @@ def fit(self, dataloader: torch.utils.data.DataLoader, extractions: int = 1) -> if self.max_memory_patches is not None: keep = min(keep, int(self.max_memory_patches)) if keep < bank.shape[0]: - indices = torch.randperm(bank.shape[0])[:keep] - bank = bank[indices] + bank = self._select_coreset(bank, keep) self.memory_bank = bank.to(self.device) @torch.no_grad() @@ -270,6 +318,8 @@ def save_statistics(self, path: str, half: Optional[bool] = False) -> None: "max_memory_patches": self.max_memory_patches, "patch_grid": self.patch_grid, "search_chunk_size": self.search_chunk_size, + "coreset_method": self.coreset_method, + "coreset_seed": self.coreset_seed, "model_type": "patchcore", "dtype": "fp16" if half else "fp32", }, @@ -308,6 +358,8 @@ def build_patchcore_from_stats( max_memory_patches=stats.get("max_memory_patches", 2048), patch_grid=stats.get("patch_grid", 14), search_chunk_size=int(stats.get("search_chunk_size", 1024)), + coreset_method=str(stats.get("coreset_method", "kcenter")), + coreset_seed=int(stats.get("coreset_seed", 42)), device=torch.device(device), ) if force_precision == "fp16" and model.device.type == "cuda": diff --git a/anomavision/train.py b/anomavision/train.py index e79a045..0d1fedd 100644 --- a/anomavision/train.py +++ b/anomavision/train.py @@ -128,6 +128,19 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: 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, @@ -273,6 +286,8 @@ def run_training(args): 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)), ) else: model = anomavision.Padim( diff --git a/anomavision/utils.py b/anomavision/utils.py index 87a7a26..ebd6ba9 100644 --- a/anomavision/utils.py +++ b/anomavision/utils.py @@ -272,6 +272,19 @@ def image_score(patch_scores: torch.Tensor) -> torch.Tensor: return image_scores +def resolve_threshold(config): + """Resolve an algorithm-specific threshold with a backward-compatible fallback. + + ``thresh_patchcore`` and ``thresh_padim`` take precedence over the legacy + shared ``thresh`` value. Explicit zero is preserved as a valid threshold. + ``None`` means that no fixed inference threshold was configured. + """ + algorithm = str(config.get("algorithm", "")).lower() + specific_key = f"thresh_{algorithm}" + specific = config.get(specific_key, None) + return specific if specific is not None else config.get("thresh", None) + + def classification(image_scores, thresh: float): """Calculate image classifications from image scores. Args: diff --git a/config.yml b/config.yml index 877b40c..ef17c96 100644 --- a/config.yml +++ b/config.yml @@ -19,6 +19,8 @@ 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 @@ -48,7 +50,9 @@ viz_color: "128,0,128" # RGB color for visualization overlays # Inference (detect.py) # ========================= img_path: "D:/01-DATA/test" # Path to test images for inference -thresh: 13.0 # Threshold for anomaly detection +thresh: null # Legacy fallback; prefer algorithm-specific thresholds +thresh_padim: null # PaDiM score threshold; null lets eval auto-select +thresh_patchcore: null # 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 diff --git a/docs/production_deployment.md b/docs/production_deployment.md index 4428a84..7903590 100644 --- a/docs/production_deployment.md +++ b/docs/production_deployment.md @@ -76,3 +76,19 @@ python scripts/convert_to_tensorrt.py ` For FP16, omit the calibration directory and change `--precision int8` to `--precision fp16`. INT8 conversion requires real normal calibration images; the TensorRT path no longer falls back to random calibration data. The utility accepts PNG, JPEG, BMP, TIFF, and nested calibration-image directories, writes a reusable calibration cache beside the engine, and deserializes the generated engine for validation unless `--skip-validation` is supplied. The input artifact may be either a PaDiM `.pth` statistics file or a PatchCore artifact containing its memory bank and feature settings. The default dynamic profile is batch 1/1/4; override it when the deployment workload has a different batch distribution. + +## Algorithm-specific thresholds and PatchCore coreset selection + +PaDiM and PatchCore produce different score scales. PaDiM uses Mahalanobis distances, while PatchCore uses bounded normalized nearest-neighbor distances, so a single threshold should not be shared between them. + +Use separate values in `config.yml`: + +```yaml +thresh: null +thresh_padim: null +thresh_patchcore: null +``` + +With an algorithm-specific value set to `null`, `eval` selects a threshold from the evaluation labels and logs the selected value. For production `detect`, copy the threshold selected on a separate validation set into the corresponding field, for example `thresh_patchcore: 0.35`. A threshold of `0.0` is valid and remains active. + +PatchCore now uses deterministic greedy k-center selection by default instead of random memory-bank sampling. This improves coverage of normal feature space while retaining the configured `coreset_ratio` and `max_memory_patches` limits. Set `coreset_method: random` only when you explicitly prefer faster training over representative memory-bank coverage. diff --git a/tests/test_algorithm_thresholds_and_coreset.py b/tests/test_algorithm_thresholds_and_coreset.py new file mode 100644 index 0000000..9ebde31 --- /dev/null +++ b/tests/test_algorithm_thresholds_and_coreset.py @@ -0,0 +1,48 @@ +import numpy as np +import torch + +from anomavision.patchcore import PatchCore +from anomavision.utils import resolve_threshold + + +def test_algorithm_threshold_overrides_legacy_threshold(): + config = { + "algorithm": "patchcore", + "thresh": 13.0, + "thresh_patchcore": 0.35, + "thresh_padim": 8.0, + } + assert resolve_threshold(config) == 0.35 + config["algorithm"] = "padim" + assert resolve_threshold(config) == 8.0 + config["algorithm"] = "unknown" + assert resolve_threshold(config) == 13.0 + + +def test_zero_algorithm_threshold_is_preserved(): + assert resolve_threshold({"algorithm": "patchcore", "thresh": 13.0, "thresh_patchcore": 0.0}) == 0.0 + + +def test_kcenter_selection_is_deterministic_and_bounded(): + bank = torch.tensor( + [[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0], [0.0, -1.0], [0.7, 0.7]] + ) + first = PatchCore( + device=torch.device("cpu"), + memory_bank=bank, + max_memory_patches=3, + search_chunk_size=2, + coreset_seed=7, + ) + second = PatchCore( + device=torch.device("cpu"), + memory_bank=bank, + max_memory_patches=3, + search_chunk_size=2, + coreset_seed=7, + ) + selected_first = first._select_coreset(bank, 3) + selected_second = second._select_coreset(bank, 3) + assert selected_first.shape == (3, 2) + assert torch.equal(selected_first, selected_second) + assert np.unique(selected_first.numpy(), axis=0).shape[0] == 3 diff --git a/tests/test_config_template.py b/tests/test_config_template.py index ac4280e..0f0f103 100644 --- a/tests/test_config_template.py +++ b/tests/test_config_template.py @@ -19,6 +19,8 @@ "quantize_dynamic", "quantize_static", "optimize", + "coreset_method", + "coreset_seed", } @@ -29,3 +31,4 @@ def test_config_template_contains_all_export_defaults(): assert REQUIRED_EXPORT_KEYS.issubset(config) assert config["calib_dir"] is None assert config["min_batch"] <= config["opt_batch"] <= config["max_batch"] + assert config["coreset_method"] == "kcenter" From b36d8c673d112b10f2af2b9b50aca235f0339a38 Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:39:44 +0000 Subject: [PATCH 14/22] fix(visualization): correct anomaly colors and localization masks --- anomavision/visualization/frame.py | 4 +-- anomavision/visualization/heatmap.py | 5 ++-- anomavision/visualization/highlight.py | 2 +- tests/test_visualization_localization.py | 31 ++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 tests/test_visualization_localization.py diff --git a/anomavision/visualization/frame.py b/anomavision/visualization/frame.py index ec0b80e..eb41ecc 100644 --- a/anomavision/visualization/frame.py +++ b/anomavision/visualization/frame.py @@ -36,9 +36,9 @@ def frame_by_anomalies( old_height, old_width = image.shape[:-1] if image_classifications[i]: - f_image = frame_image(image, padding=padding, color=non_ano_color) - else: f_image = frame_image(image, padding=padding, color=ano_color) + else: + f_image = frame_image(image, padding=padding, color=non_ano_color) f_image = cv2.resize( f_image, (old_width, old_height), interpolation=cv2.INTER_AREA diff --git a/anomavision/visualization/heatmap.py b/anomavision/visualization/heatmap.py index 5bee476..e5f1230 100644 --- a/anomavision/visualization/heatmap.py +++ b/anomavision/visualization/heatmap.py @@ -78,13 +78,12 @@ def heatmap_image( patch_scores = to_numpy(patch_scores).copy() if isinstance(mask, (np.ndarray, torch.Tensor)): - mask = to_numpy(mask).copy() - mask = np.logical_not(mask).astype(np.uint8) + mask = (to_numpy(mask).copy() > 0).astype(np.uint8) if min_v and max_v: patch_scores = normalize_patch_scores(patch_scores, min_v=min_v, max_v=max_v) - patch_scores = (1 - patch_scores) * 255 + patch_scores = np.clip(patch_scores, 0.0, 1.0) * 255 patch_scores = patch_scores.astype(np.uint8) color_map = cv2.applyColorMap(patch_scores, colormap=cv2.COLORMAP_JET) heatmap = blend_image(image, color_map, alpha=alpha, mask=mask) diff --git a/anomavision/visualization/highlight.py b/anomavision/visualization/highlight.py index 8b9a59e..4560c82 100644 --- a/anomavision/visualization/highlight.py +++ b/anomavision/visualization/highlight.py @@ -57,7 +57,7 @@ def highlighted_image( """ image = to_numpy(image).copy() mask = to_numpy(patch_classification).copy() - mask = np.logical_not(mask).astype(np.uint8) + mask = (mask > 0).astype(np.uint8) mask_height, mask_width = mask.shape mask_shape = (mask_height, mask_width, 3) diff --git a/tests/test_visualization_localization.py b/tests/test_visualization_localization.py new file mode 100644 index 0000000..fc4f69f --- /dev/null +++ b/tests/test_visualization_localization.py @@ -0,0 +1,31 @@ +import numpy as np +import torch +from PIL import Image + +from anomavision.visualization.frame import frame_by_anomalies +from anomavision.visualization.heatmap import heatmap_image +from anomavision.visualization.highlight import highlighted_image + + +def test_anomaly_frame_is_red_and_normal_frame_is_green(): + images = np.full((2, 12, 12, 3), 128, dtype=np.uint8) + framed = frame_by_anomalies(images, np.array([1, 0]), padding=2) + assert tuple(framed[0, 0, 0]) == (255, 0, 0) + assert tuple(framed[1, 0, 0]) == (0, 255, 0) + + +def test_highlight_uses_anomaly_mask_directly(): + image = np.zeros((8, 8, 3), dtype=np.uint8) + mask = np.zeros((8, 8), dtype=np.uint8) + mask[2:6, 2:6] = 1 + result = highlighted_image(image, mask, color=(255, 0, 0), alpha=1.0) + assert result[3, 3, 0] > 200 + assert result[0, 0].sum() == 0 + + +def test_heatmap_high_scores_are_rendered_in_anomaly_direction(): + image = np.zeros((8, 8, 3), dtype=np.uint8) + scores = np.zeros((8, 8), dtype=np.float32) + scores[2:6, 2:6] = 1.0 + result = heatmap_image(image, scores, alpha=1.0) + assert not np.array_equal(result[3, 3], result[0, 0]) From 971e9b2074c433f659f405b5b92333f647577edc Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:52:23 +0000 Subject: [PATCH 15/22] fix(patchcore): restore localized visualization output --- anomavision/detect.py | 28 +++++++------ anomavision/utils.py | 40 ++++++++++++++++++- .../test_patchcore_visualization_contract.py | 18 +++++++++ 3 files changed, 72 insertions(+), 14 deletions(-) create mode 100644 tests/test_patchcore_visualization_contract.py diff --git a/anomavision/detect.py b/anomavision/detect.py index 1946484..8ca9cd0 100644 --- a/anomavision/detect.py +++ b/anomavision/detect.py @@ -30,6 +30,7 @@ from anomavision.utils import ( adaptive_gaussian_blur, get_logger, + make_localization_mask, merge_config, resolve_threshold, setup_logging, @@ -201,6 +202,7 @@ def run_inference(args): # Merge config with CLI args config = edict(merge_config(args, cfg)) config.thresh = resolve_threshold(config) + algorithm_name = str(config.get("algorithm", "")).lower() # Setup logging setup_logging(enabled=True, log_level=config.log_level, log_to_file=True) @@ -414,6 +416,15 @@ def run_inference(args): 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()) @@ -434,13 +445,7 @@ def run_inference(args): boundary_images = ( anomavision.visualization.framed_boundary_images( images, - ( - anomavision.classification( - score_maps, config.thresh - ) - if config.thresh is not None - else np.zeros_like(score_maps) - ), + localization_masks, is_anomaly, padding=config.get("viz_padding", 40), ) @@ -449,16 +454,13 @@ def run_inference(args): 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))], - # Dummy mask if threshold not set - ( - anomavision.classification(score_maps, config.thresh) - if config.thresh is not None - else np.zeros_like(score_maps) - ), + localization_masks, + color=viz_color, ) diff --git a/anomavision/utils.py b/anomavision/utils.py index ebd6ba9..6bca2e6 100644 --- a/anomavision/utils.py +++ b/anomavision/utils.py @@ -272,6 +272,37 @@ def image_score(patch_scores: torch.Tensor) -> torch.Tensor: return image_scores +def make_localization_mask(score_maps, image_classifications, quantile: float = 0.90): + """Create spatial anomaly masks from score maps without reusing image thresholds. + + Image-level scores and pixel-level maps have different distributions. For each + image classified as anomalous, this keeps the highest-scoring spatial regions + using a robust per-image quantile and a relative-to-maximum floor. Normal images + receive an empty mask. + """ + if isinstance(score_maps, torch.Tensor): + maps = score_maps.detach().cpu().numpy() + else: + maps = np.asarray(score_maps) + labels = np.asarray(image_classifications).reshape(-1) + if maps.ndim != 3: + raise ValueError(f"score_maps must have shape (B,H,W), got {maps.shape}") + if not 0.0 < quantile < 1.0: + raise ValueError("quantile must be between 0 and 1") + masks = np.zeros_like(maps, dtype=np.uint8) + for index, score_map in enumerate(maps): + if index >= len(labels) or not bool(labels[index]): + continue + finite = np.nan_to_num(score_map, nan=0.0, posinf=0.0, neginf=0.0) + minimum = float(finite.min()) + maximum = float(finite.max()) + if maximum <= minimum + 1e-8: + continue + cutoff = max(float(np.quantile(finite, quantile)), minimum + 0.5 * (maximum - minimum)) + masks[index] = (finite >= cutoff).astype(np.uint8) + return masks + + def resolve_threshold(config): """Resolve an algorithm-specific threshold with a backward-compatible fallback. @@ -282,7 +313,14 @@ def resolve_threshold(config): algorithm = str(config.get("algorithm", "")).lower() specific_key = f"thresh_{algorithm}" specific = config.get(specific_key, None) - return specific if specific is not None else config.get("thresh", None) + if specific is not None: + return specific + legacy = config.get("thresh", None) + if algorithm == "patchcore" and legacy is not None and float(legacy) > 2.0: + # PatchCore cosine distance is bounded near [0, 2]; do not reuse a + # PaDiM-scale threshold such as 13.0. + return config.get("patchcore_default_threshold", 0.35) + return legacy def classification(image_scores, thresh: float): diff --git a/tests/test_patchcore_visualization_contract.py b/tests/test_patchcore_visualization_contract.py new file mode 100644 index 0000000..0a0d8fd --- /dev/null +++ b/tests/test_patchcore_visualization_contract.py @@ -0,0 +1,18 @@ +import numpy as np + +from anomavision.utils import make_localization_mask, resolve_threshold + + +def test_patchcore_does_not_reuse_padim_scale_threshold(): + config = {"algorithm": "patchcore", "thresh": 13.0} + assert resolve_threshold(config) == 0.35 + + +def test_patchcore_localization_mask_keeps_only_high_regions_for_anomaly(): + score_maps = np.zeros((2, 8, 8), dtype=np.float32) + score_maps[0, 3:5, 3:5] = 1.0 + score_maps[1, 3:5, 3:5] = 1.0 + masks = make_localization_mask(score_maps, np.array([1, 0])) + assert masks[0, 3:5, 3:5].all() + assert masks[0].sum() > 0 + assert masks[1].sum() == 0 From aed5c2e75edbd3608ce8c7696a1c12a6c8752e57 Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:34:30 +0000 Subject: [PATCH 16/22] feat(autopilot): add production model selection pipeline --- anomavision/autopilot.py | 193 +++++++++++++++++++++++++++++++++++++++ anomavision/cli.py | 18 ++++ 2 files changed, 211 insertions(+) create mode 100644 anomavision/autopilot.py diff --git a/anomavision/autopilot.py b/anomavision/autopilot.py new file mode 100644 index 0000000..41a79ef --- /dev/null +++ b/anomavision/autopilot.py @@ -0,0 +1,193 @@ +"""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, Iterable, Optional + +import numpy as np +import torch +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: + """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("--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 test images used for calibration/profiling.") + 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) + iterator = iter(dataloader) + try: + first = next(iterator) + except StopIteration: + wrapper.close() + raise ValueError("The evaluation dataset is empty.") + first_batch = first[0].to(device) + for _ in range(max(0, warmup)): + wrapper.predict(first_batch) + if device.startswith("cuda") and torch.cuda.is_available(): + torch.cuda.synchronize() + timings = [] + all_scores, all_maps, all_labels, all_masks = [], [], [], [] + count = 0 + for item in dataloader: + batch, _, labels, masks = item + batch = batch.to(device) + start = time.perf_counter() + scores, maps = wrapper.predict(batch) + if device.startswith("cuda") and torch.cuda.is_available(): + torch.cuda.synchronize() + elapsed = time.perf_counter() - start + timings.append(elapsed) + 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(list(masks)) + count += 1 + if count >= timing_batches: + break + 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) + anomaly_labels = (scores_np >= threshold).astype(np.uint8) + localization = {"available": bool(len(maps_np) == len(labels_np) and maps_np.ndim == 3), "non_empty_fraction": 0.0, "mean_mask_area_fraction": 0.0} + if localization["available"]: + loc_masks = make_localization_mask(maps_np, anomaly_labels) + localization["non_empty_fraction"] = float(np.mean(loc_masks.reshape(len(loc_masks), -1).sum(axis=1) > 0)) + localization["mean_mask_area_fraction"] = float(loc_masks.mean()) + 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 + 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()}, + "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 = {name: result for name, result in results.items() if result["latency_ms"]["p95"] <= target_latency_ms} + if not eligible: + eligible = results + return max(eligible, key=lambda name: (eligible[name]["metrics"].get("image_auroc", 0.0), -eligible[name]["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"**Reason:** highest image AUROC among models meeting the latency target, or highest AUROC when no candidate met it.", + "", + "## Deployment recommendation", + "", + f"Use threshold `{manifest['candidates'][selected]['threshold']:.6f}` for `{selected}`. The threshold was calibrated on the evaluation split and should be rechecked on a production validation set before release.", + "", + "## Candidate comparison", + "", + "| Model | Image AUROC | Pixel AUROC | Median ms | P95 ms | Localization | Threshold |", + "|---|---:|---:|---:|---:|---:|---:|", + ] + for name, result in manifest["candidates"].items(): + metrics = result["metrics"] + loc = result["localization"] + lines.append(f"| {name} | {metrics.get('image_auroc', 0.0):.4f} | {metrics.get('pixel_auroc', 0.0):.4f} | {result['latency_ms']['median']:.2f} | {result['latency_ms']['p95']:.2f} | {loc['non_empty_fraction']:.2%} non-empty | {result['threshold']:.6f} |") + environment_json = json.dumps(manifest["environment"], indent=2) + lines.extend(["", "## Reproducibility", "", "```json", environment_json, "```", ""]) + (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.") + 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)): + if model_path: + candidates[name] = _profile_model(model_path, dataloader, device, args.warmup, args.timing_batches) + if not candidates: + raise ValueError("Provide at least one of --padim_model or --patchcore_model.") + 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) + manifest = { + "schema_version": 1, + "selected_model": selected, + "selected_artifact": str(packaged_model.name), + "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() diff --git a/anomavision/cli.py b/anomavision/cli.py index babe599..dce4e75 100644 --- a/anomavision/cli.py +++ b/anomavision/cli.py @@ -68,6 +68,7 @@ def create_parser() -> argparse.ArgumentParser: _add_export_parser(subparsers) _add_detect_parser(subparsers) _add_eval_parser(subparsers) + _add_autopilot_parser(subparsers) return parser @@ -133,6 +134,17 @@ def _add_eval_parser(subparsers) -> None: ).set_defaults(func=_dispatch_eval) +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", + 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. @@ -163,6 +175,12 @@ def _dispatch_eval(args: argparse.Namespace) -> None: eval_module.main(args) +def _dispatch_autopilot(args: argparse.Namespace) -> None: + from anomavision import autopilot + + autopilot.main(args) + + # ============================================================ # Entry point # ============================================================ From 86be754711b89dbfc72420ca1890f3ded32e736c Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:34:36 +0000 Subject: [PATCH 17/22] docs(autopilot): add deployment report and usage guide --- docs/production_deployment.md | 18 +++++++++++++++++ tests/test_autopilot.py | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/test_autopilot.py diff --git a/docs/production_deployment.md b/docs/production_deployment.md index 7903590..99860fb 100644 --- a/docs/production_deployment.md +++ b/docs/production_deployment.md @@ -92,3 +92,21 @@ thresh_patchcore: null With an algorithm-specific value set to `null`, `eval` selects a threshold from the evaluation labels and logs the selected value. For production `detect`, copy the threshold selected on a separate validation set into the corresponding field, for example `thresh_patchcore: 0.35`. A threshold of `0.0` is valid and remains active. PatchCore now uses deterministic greedy k-center selection by default instead of random memory-bank sampling. This improves coverage of normal feature space while retaining the configured `coreset_ratio` and `max_memory_patches` limits. Set `coreset_method: random` only when you explicitly prefer faster training over representative memory-bank coverage. + +## Production Autopilot + +Production Autopilot evaluates available PaDiM and ultra-light PatchCore artifacts on the same validation data, calibrates a separate threshold for each algorithm, measures median and p95 latency on the selected hardware, checks whether localization maps are non-empty, and packages the selected artifact with a deployment manifest and report. + +Run it on CPU with: + +```powershell +anomavision autopilot ` + --config config.yml ` + --padim_model .\distributions\padim\bottle\run\model.pt ` + --patchcore_model .\distributions\patchcore\bottle\run\model.pt ` + --device cpu ` + --target_latency_ms 50 ` + --output_dir .\production_package +``` + +The output contains `model.*`, `deployment_manifest.json`, and `localization_report.md`. The manifest records preprocessing, calibrated thresholds, metrics, latency, localization sanity checks, selected model, and runtime environment. Recheck the selected threshold on a production validation set before release. diff --git a/tests/test_autopilot.py b/tests/test_autopilot.py new file mode 100644 index 0000000..273662c --- /dev/null +++ b/tests/test_autopilot.py @@ -0,0 +1,38 @@ +import json +from pathlib import Path + +from anomavision.autopilot import _select, _write_report, create_parser + + +def _candidate(image_auroc, p95, threshold=0.3): + return { + "threshold": threshold, + "metrics": {"image_auroc": image_auroc, "pixel_auroc": image_auroc - 0.1}, + "latency_ms": {"median": p95 / 2, "p95": p95}, + "localization": {"non_empty_fraction": 0.5}, + } + + +def test_autopilot_selects_best_eligible_model(): + candidates = {"padim": _candidate(0.95, 80), "patchcore": _candidate(0.90, 20)} + assert _select(candidates, target_latency_ms=30) == "patchcore" + assert _select(candidates, target_latency_ms=None) == "padim" + + +def test_autopilot_report_contains_manifest_summary(tmp_path): + manifest = { + "selected_model": "patchcore", + "candidates": {"patchcore": _candidate(0.9, 20)}, + "environment": {"device": "cpu"}, + } + _write_report(manifest, tmp_path) + report = (tmp_path / "localization_report.md").read_text(encoding="utf-8") + assert "Production Autopilot Report" in report + assert "patchcore" in report + assert "0.9000" in report + + +def test_autopilot_parser_exposes_production_controls(): + args = create_parser().parse_args(["--config", "config.yml", "--target_latency_ms", "50"]) + assert args.target_latency_ms == 50 + assert args.output_dir == "./production_package" From eb10e4398594874b080ca5fdbb04c1894fbc1f97 Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:45:31 +0000 Subject: [PATCH 18/22] feat(autopilot): add self-contained HTML deployment report --- anomavision/autopilot.py | 60 ++++++++++++++++++++++++----------- docs/production_deployment.md | 2 +- tests/test_autopilot.py | 13 ++++++-- 3 files changed, 53 insertions(+), 22 deletions(-) diff --git a/anomavision/autopilot.py b/anomavision/autopilot.py index 41a79ef..bf26ca5 100644 --- a/anomavision/autopilot.py +++ b/anomavision/autopilot.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +from html import escape import json import platform import shutil @@ -121,29 +122,50 @@ def _select(results: Dict[str, Dict[str, Any]], target_latency_ms: Optional[floa 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"] - lines = [ - "# AnomaVision Production Autopilot Report", - "", - f"**Selected model:** `{selected}`", - f"**Reason:** highest image AUROC among models meeting the latency target, or highest AUROC when no candidate met it.", - "", - "## Deployment recommendation", - "", - f"Use threshold `{manifest['candidates'][selected]['threshold']:.6f}` for `{selected}`. The threshold was calibrated on the evaluation split and should be rechecked on a production validation set before release.", - "", - "## Candidate comparison", - "", - "| Model | Image AUROC | Pixel AUROC | Median ms | P95 ms | Localization | Threshold |", - "|---|---:|---:|---:|---:|---:|---:|", - ] + 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"] - lines.append(f"| {name} | {metrics.get('image_auroc', 0.0):.4f} | {metrics.get('pixel_auroc', 0.0):.4f} | {result['latency_ms']['median']:.2f} | {result['latency_ms']['p95']:.2f} | {loc['non_empty_fraction']:.2%} non-empty | {result['threshold']:.6f} |") - environment_json = json.dumps(manifest["environment"], indent=2) - lines.extend(["", "## Reproducibility", "", "```json", environment_json, "```", ""]) - (output_dir / "localization_report.md").write_text("\n".join(lines), encoding="utf-8") + 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'
{metrics.get("image_auroc", 0.0):.4f} image AUROC
' + f'
{metrics.get("pixel_auroc", 0.0):.4f}pixel AUROC
{result["latency_ms"]["p95"]:.1f} msp95 latency
{loc["non_empty_fraction"]:.1%}non-empty maps
' + ) + rows.append( + f'{escape(name)}{metrics.get("image_auroc", 0.0):.4f}{metrics.get("pixel_auroc", 0.0):.4f}{result["latency_ms"]["median"]:.2f}{result["latency_ms"]["p95"]:.2f}{loc["non_empty_fraction"]:.1%}{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 lower latency are better
{"".join(rows)}
ModelImage AUROCPixel AUROCMedian msP95 msMaps non-emptyThreshold
+

Localization health

Selected model maps available{"PASS" if selected_result["localization"]["available"] else "CHECK"}
Selected model non-empty maps{selected_result["localization"]["non_empty_fraction"]:.1%}
Selected model mean mask area{selected_result["localization"]["mean_mask_area_fraction"]:.1%}

A low non-empty rate can indicate a threshold, score-scale, export, or model-sensitivity problem.

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 = ["# AnomaVision Production Autopilot Report", "", f"**Selected model:** `{selected}`", "", "See `production_autopilot_report.html` for the full dashboard.", ""] + (output_dir / "localization_report.md").write_text("\\n".join(markdown), encoding="utf-8") def run(args: argparse.Namespace) -> Dict[str, Any]: diff --git a/docs/production_deployment.md b/docs/production_deployment.md index 99860fb..ae9850f 100644 --- a/docs/production_deployment.md +++ b/docs/production_deployment.md @@ -109,4 +109,4 @@ anomavision autopilot ` --output_dir .\production_package ``` -The output contains `model.*`, `deployment_manifest.json`, and `localization_report.md`. The manifest records preprocessing, calibrated thresholds, metrics, latency, localization sanity checks, selected model, and runtime environment. Recheck the selected threshold on a production validation set before release. +The output contains `model.*`, `deployment_manifest.json`, `production_autopilot_report.html`, and `localization_report.md`. Open `production_autopilot_report.html` in any browser for the polished dashboard; it is self-contained and needs no internet connection or additional assets. The manifest records preprocessing, calibrated thresholds, metrics, latency, localization sanity checks, selected model, and runtime environment. Recheck the selected threshold on a production validation set before release. diff --git a/tests/test_autopilot.py b/tests/test_autopilot.py index 273662c..ab90a04 100644 --- a/tests/test_autopilot.py +++ b/tests/test_autopilot.py @@ -7,9 +7,10 @@ def _candidate(image_auroc, p95, threshold=0.3): return { "threshold": threshold, + "model_format": ".pt", "metrics": {"image_auroc": image_auroc, "pixel_auroc": image_auroc - 0.1}, "latency_ms": {"median": p95 / 2, "p95": p95}, - "localization": {"non_empty_fraction": 0.5}, + "localization": {"available": True, "non_empty_fraction": 0.5, "mean_mask_area_fraction": 0.08}, } @@ -21,15 +22,23 @@ def test_autopilot_selects_best_eligible_model(): def test_autopilot_report_contains_manifest_summary(tmp_path): manifest = { + "schema_version": 1, "selected_model": "patchcore", + "selected_artifact": "model.pt", + "dataset": {"class_name": "bottle", "samples": 4}, + "preprocessing": {"resize": 224}, + "target_latency_ms": 50, "candidates": {"patchcore": _candidate(0.9, 20)}, "environment": {"device": "cpu"}, } _write_report(manifest, tmp_path) report = (tmp_path / "localization_report.md").read_text(encoding="utf-8") + html = (tmp_path / "production_autopilot_report.html").read_text(encoding="utf-8") assert "Production Autopilot Report" in report assert "patchcore" in report - assert "0.9000" in report + assert "0.9000" in html + assert "Deployment confidence, before production." in html + assert " Date: Sun, 16 Aug 2026 19:01:03 +0000 Subject: [PATCH 19/22] fix(autopilot): evaluate complete dataset and report AUROC --- anomavision/autopilot.py | 43 ++++++++++++++++++++++++----------- docs/production_deployment.md | 9 ++++++++ tests/test_autopilot.py | 7 +++++- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/anomavision/autopilot.py b/anomavision/autopilot.py index bf26ca5..0dafeac 100644 --- a/anomavision/autopilot.py +++ b/anomavision/autopilot.py @@ -14,6 +14,7 @@ import numpy as np import torch +from sklearn.metrics import roc_auc_score from torch.utils.data import DataLoader import anomavision @@ -40,7 +41,7 @@ def create_parser(add_help: bool = True) -> argparse.ArgumentParser: parser.add_argument("--warmup", type=int, default=3) parser.add_argument("--timing_batches", type=int, default=20) parser.add_argument("--target_latency_ms", type=float, default=None) - parser.add_argument("--validation_split", type=float, default=1.0, help="Fraction of test images used for calibration/profiling.") + parser.add_argument("--validation_split", type=float, default=1.0, help="Fraction of the complete labeled test split used for calibration; 1.0 uses every sample.") parser.add_argument("--output_dir", type=str, default="./production_package") parser.add_argument("--copy_config", action="store_true", default=True) return parser @@ -52,6 +53,10 @@ 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 _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) @@ -71,28 +76,40 @@ def _profile_model(model_path: str, dataloader: DataLoader, device: str, warmup: for item in dataloader: batch, _, labels, masks = item batch = batch.to(device) - start = time.perf_counter() + measure = count < max(1, timing_batches) + if measure: + start = time.perf_counter() scores, maps = wrapper.predict(batch) if device.startswith("cuda") and torch.cuda.is_available(): torch.cuda.synchronize() - elapsed = time.perf_counter() - start - timings.append(elapsed) + 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(list(masks)) + all_masks.extend(_to_numpy(masks)) count += 1 - if count >= timing_batches: - break 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) - anomaly_labels = (scores_np >= threshold).astype(np.uint8) + image_auroc = image_metrics.get("auc_score") if len(np.unique(labels_np)) > 1 else None + pixel_auroc = None + masks_np = np.asarray(all_masks, dtype=np.float32) + if masks_np.ndim == 4 and masks_np.shape[1] == 1: + masks_np = masks_np[:, 0] localization = {"available": bool(len(maps_np) == len(labels_np) and maps_np.ndim == 3), "non_empty_fraction": 0.0, "mean_mask_area_fraction": 0.0} + 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))) + 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) localization["non_empty_fraction"] = float(np.mean(loc_masks.reshape(len(loc_masks), -1).sum(axis=1) > 0)) @@ -104,7 +121,7 @@ def _profile_model(model_path: str, dataloader: DataLoader, device: str, warmup: "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 image_metrics.items()}, "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, @@ -118,7 +135,7 @@ def _select(results: Dict[str, Dict[str, Any]], target_latency_ms: Optional[floa eligible = {name: result for name, result in results.items() if result["latency_ms"]["p95"] <= target_latency_ms} if not eligible: eligible = results - return max(eligible, key=lambda name: (eligible[name]["metrics"].get("image_auroc", 0.0), -eligible[name]["latency_ms"]["p95"])) + return max(eligible, key=lambda name: (eligible[name]["metrics"].get("image_auroc") or 0.0, -eligible[name]["latency_ms"]["p95"])) def _write_report(manifest: Dict[str, Any], output_dir: Path) -> None: @@ -136,11 +153,11 @@ def _write_report(manifest: Dict[str, Any], output_dir: Path) -> None: status_class = "selected" if is_selected else "candidate" cards.append( f'
{escape(name.upper())}{status}
' - f'
{metrics.get("image_auroc", 0.0):.4f} image AUROC
' - f'
{metrics.get("pixel_auroc", 0.0):.4f}pixel AUROC
{result["latency_ms"]["p95"]:.1f} msp95 latency
{loc["non_empty_fraction"]:.1%}non-empty maps
' + 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
{loc["non_empty_fraction"]:.1%}non-empty maps
' ) rows.append( - f'{escape(name)}{metrics.get("image_auroc", 0.0):.4f}{metrics.get("pixel_auroc", 0.0):.4f}{result["latency_ms"]["median"]:.2f}{result["latency_ms"]["p95"]:.2f}{loc["non_empty_fraction"]:.1%}{result["threshold"]:.6f}' + 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}{loc["non_empty_fraction"]:.1%}{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)) diff --git a/docs/production_deployment.md b/docs/production_deployment.md index ae9850f..e09becf 100644 --- a/docs/production_deployment.md +++ b/docs/production_deployment.md @@ -97,6 +97,15 @@ PatchCore now uses deterministic greedy k-center selection by default instead of Production Autopilot evaluates available PaDiM and ultra-light PatchCore artifacts on the same validation data, calibrates a separate threshold for each algorithm, measures median and p95 latency on the selected hardware, checks whether localization maps are non-empty, and packages the selected artifact with a deployment manifest and report. +Set the complete dataset root and class in `config.yml`: + +```yaml +dataset_path: "D:/01-DATA" +class_name: "bottle" +``` + +The root must contain `bottle/train/good`, `bottle/test/good`, defect folders, and `bottle/ground_truth`. Autopilot uses the complete labeled `test` split by default (`--validation_split 1.0`) so threshold calibration and AUROC evaluation include all available normal and defective test images. + Run it on CPU with: ```powershell diff --git a/tests/test_autopilot.py b/tests/test_autopilot.py index ab90a04..de555d6 100644 --- a/tests/test_autopilot.py +++ b/tests/test_autopilot.py @@ -1,7 +1,7 @@ import json from pathlib import Path -from anomavision.autopilot import _select, _write_report, create_parser +from anomavision.autopilot import _format_metric, _select, _write_report, create_parser def _candidate(image_auroc, p95, threshold=0.3): @@ -14,6 +14,11 @@ def _candidate(image_auroc, p95, threshold=0.3): } +def test_autopilot_formats_unavailable_metrics_honestly(): + assert _format_metric(None) == "N/A" + assert _format_metric(0.91234) == "0.9123" + + def test_autopilot_selects_best_eligible_model(): candidates = {"padim": _candidate(0.95, 80), "patchcore": _candidate(0.90, 20)} assert _select(candidates, target_latency_ms=30) == "patchcore" From 665fc52d9de8066d56624d34d49e81636a5a45dc Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:29:26 +0000 Subject: [PATCH 20/22] fix(autopilot): report algorithm-aware localization health --- anomavision/autopilot.py | 43 +++++++++++++++++++++++++++++++--------- tests/test_autopilot.py | 5 ++++- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/anomavision/autopilot.py b/anomavision/autopilot.py index 0dafeac..c86b84d 100644 --- a/anomavision/autopilot.py +++ b/anomavision/autopilot.py @@ -57,6 +57,10 @@ 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]: wrapper = ModelWrapper(model_path, device) iterator = iter(dataloader) @@ -101,7 +105,16 @@ def _profile_model(model_path: str, dataloader: DataLoader, device: str, warmup: masks_np = np.asarray(all_masks, dtype=np.float32) if masks_np.ndim == 4 and masks_np.shape[1] == 1: masks_np = masks_np[:, 0] - localization = {"available": bool(len(maps_np) == len(labels_np) and maps_np.ndim == 3), "non_empty_fraction": 0.0, "mean_mask_area_fraction": 0.0} + localization = { + "available": bool(len(maps_np) == len(labels_np) and maps_np.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_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))) @@ -111,9 +124,21 @@ def _profile_model(model_path: str, dataloader: DataLoader, device: str, warmup: 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) - localization["non_empty_fraction"] = float(np.mean(loc_masks.reshape(len(loc_masks), -1).sum(axis=1) > 0)) - localization["mean_mask_area_fraction"] = float(loc_masks.mean()) + loc_masks = make_localization_mask(maps_np, anomaly_labels).astype(bool) + non_empty = loc_masks.reshape(len(loc_masks), -1).any(axis=1) + area = loc_masks.reshape(len(loc_masks), -1).mean(axis=1) + anomaly_idx = labels_np == 1 + normal_idx = labels_np == 0 + 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 return { @@ -154,10 +179,10 @@ def _write_report(manifest: Dict[str, Any], output_dir: Path) -> None: 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
{loc["non_empty_fraction"]:.1%}non-empty maps
' + 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}{loc["non_empty_fraction"]:.1%}{result["threshold"]:.6f}' + 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)) @@ -175,8 +200,8 @@ def _write_report(manifest: Dict[str, Any], output_dir: Path) -> None:
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 lower latency are better
{"".join(rows)}
ModelImage AUROCPixel AUROCMedian msP95 msMaps non-emptyThreshold
-

Localization health

Selected model maps available{"PASS" if selected_result["localization"]["available"] else "CHECK"}
Selected model non-empty maps{selected_result["localization"]["non_empty_fraction"]:.1%}
Selected model mean mask area{selected_result["localization"]["mean_mask_area_fraction"]:.1%}

A low non-empty rate can indicate a threshold, score-scale, export, or model-sensitivity problem.

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"}
+

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") @@ -208,7 +233,7 @@ def run(args: argparse.Namespace) -> Dict[str, Any]: packaged_model = output_dir / f"model{selected_source.suffix}" shutil.copy2(selected_source, packaged_model) manifest = { - "schema_version": 1, + "schema_version": 2, "selected_model": selected, "selected_artifact": str(packaged_model.name), "dataset": {"path": str(Path(dataset_path).resolve()), "class_name": class_name, "samples": len(dataset)}, diff --git a/tests/test_autopilot.py b/tests/test_autopilot.py index de555d6..2bd3b8c 100644 --- a/tests/test_autopilot.py +++ b/tests/test_autopilot.py @@ -10,7 +10,7 @@ def _candidate(image_auroc, p95, threshold=0.3): "model_format": ".pt", "metrics": {"image_auroc": image_auroc, "pixel_auroc": image_auroc - 0.1}, "latency_ms": {"median": p95 / 2, "p95": p95}, - "localization": {"available": True, "non_empty_fraction": 0.5, "mean_mask_area_fraction": 0.08}, + "localization": {"available": True, "non_empty_fraction": 0.5, "mean_mask_area_fraction": 0.08, "anomaly_non_empty_fraction": 0.75, "normal_false_positive_fraction": 0.05, "anomaly_mean_mask_area_fraction": 0.12, "verdict": "healthy"}, } @@ -44,6 +44,9 @@ def test_autopilot_report_contains_manifest_summary(tmp_path): assert "0.9000" in html assert "Deployment confidence, before production." in html assert " Date: Sun, 16 Aug 2026 19:34:20 +0000 Subject: [PATCH 21/22] docs: update autopilot and localization guidance --- docs/cli.md | 36 +++++++++++++++++++++++-- docs/config.md | 49 ++++++++++++++++++++++++++++++++--- docs/production_deployment.md | 8 +++--- 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 7be7f40..7938329 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,12 +1,13 @@ # πŸ› οΈ CLI Reference -AnomaVision provides a unified `anomavision` command with four subcommands: +AnomaVision provides a unified `anomavision` command with five subcommands: ```bash anomavision train # Train a PaDiM anomaly detection model anomavision detect # Run inference on test images anomavision eval # Evaluate performance on MVTec-style datasets -anomavision export # Export models to ONNX, TorchScript, or OpenVINO +anomavision export # Export models to ONNX, TorchScript, OpenVINO, or TensorRT +anomavision autopilot # Calibrate, profile, and package a production model ``` Each subcommand accepts both **CLI arguments** and **config files** (`--config config.yml`). @@ -20,6 +21,7 @@ anomavision train --help anomavision detect --help anomavision eval --help anomavision export --help +anomavision autopilot --help ``` --- @@ -169,6 +171,36 @@ anomavision export \ --- +## 5. Production Autopilot β€” `anomavision autopilot` + +Production Autopilot compares PaDiM and ultra-light PatchCore artifacts on the same complete labeled test split, calibrates algorithm-specific thresholds, measures latency, checks localization health, and creates a deployment package. + +Set the complete dataset root in `config.yml`: + +```yaml +dataset_path: "D:/01-DATA" +class_name: "bottle" +``` + +Use the command below on a CPU-only Windows machine: + +```powershell +anomavision autopilot ` + --config config.yml ` + --padim_model .\distributions\padim\bottle\anomav_exp\model.pt ` + --patchcore_model .\distributions\patchcore\bottle\anomav_exp\model.pt ` + --dataset_path "D:/01-DATA" ` + --class_name bottle ` + --device cpu ` + --validation_split 1.0 ` + --target_latency_ms 50 ` + --output_dir .\production_package +``` + +The generated `production_autopilot_report.html` shows image AUROC, pixel AUROC, anomaly localization coverage, normal-image false-positive localization, anomaly mean mask area, latency, thresholds, and a localization verdict. `N/A` means the required spatial map or ground-truth mask was unavailable; it is not a zero score. + +--- + ## Config File + CLI Override Pattern All subcommands follow the same priority: **CLI args > config file > defaults**. diff --git a/docs/config.md b/docs/config.md index 728bc99..0b5c9cd 100644 --- a/docs/config.md +++ b/docs/config.md @@ -49,7 +49,9 @@ python train.py --config config.yml | `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 | Anomaly threshold. | +| `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. | @@ -59,7 +61,20 @@ python train.py --config config.yml --- -## 4. Evaluation +## 4. PatchCore + +| 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. | + +--- + +## 5. Evaluation | Key | Type | Default | Description | | ------------------ | ---- | ------- | -------------------------------- | @@ -70,7 +85,7 @@ python train.py --config config.yml --- -## 5. Export +## 6. Export | Key | Type | Default | Description | | ------------------ | ---- | ------- | --------------------------------------------------------- | @@ -82,10 +97,22 @@ python train.py --config config.yml | `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. | --- -## 6. Logging +## 7. Production Autopilot + +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. + +--- + +## 8. Logging | Key | Type | Default | Description | | ----------- | ---- | ------- | ---------------------------------------------------- | @@ -121,6 +148,20 @@ 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 ``` --- diff --git a/docs/production_deployment.md b/docs/production_deployment.md index e09becf..9f39bad 100644 --- a/docs/production_deployment.md +++ b/docs/production_deployment.md @@ -44,7 +44,7 @@ INT8 calibration images should represent the normal production input distributio | Check | Recommended evidence | |---|---| -| Accuracy | Report image and pixel AUROC with the exact dataset split. | +| Accuracy | Report image and pixel AUROC with the exact dataset split; use `N/A` when spatial maps or masks are unavailable. | | Latency | Report warm-up policy, batch size, input shape, device, and percentile latency. | | Memory | Record peak GPU memory and PatchCore memory-bank size. | | Export parity | Compare PyTorch scores/maps with ONNX or TensorRT outputs on the same images. | @@ -95,7 +95,7 @@ PatchCore now uses deterministic greedy k-center selection by default instead of ## Production Autopilot -Production Autopilot evaluates available PaDiM and ultra-light PatchCore artifacts on the same validation data, calibrates a separate threshold for each algorithm, measures median and p95 latency on the selected hardware, checks whether localization maps are non-empty, and packages the selected artifact with a deployment manifest and report. +Production Autopilot evaluates available PaDiM and ultra-light PatchCore artifacts on the same labeled data, calibrates a separate threshold for each algorithm, measures median and p95 latency on the selected hardware, computes image and pixel AUROC when valid targets exist, checks localization health, and packages the selected artifact with a deployment manifest and report. Set the complete dataset root and class in `config.yml`: @@ -104,7 +104,7 @@ dataset_path: "D:/01-DATA" class_name: "bottle" ``` -The root must contain `bottle/train/good`, `bottle/test/good`, defect folders, and `bottle/ground_truth`. Autopilot uses the complete labeled `test` split by default (`--validation_split 1.0`) so threshold calibration and AUROC evaluation include all available normal and defective test images. +The root must contain `bottle/train/good`, `bottle/test/good`, defect folders, and `bottle/ground_truth`. Autopilot uses the complete labeled `test` split by default (`--validation_split 1.0`) so threshold calibration and AUROC evaluation include all available normal and defective test images. The training split is used to fit the artifacts; it is not mixed into the labeled test metrics. Run it on CPU with: @@ -118,4 +118,4 @@ anomavision autopilot ` --output_dir .\production_package ``` -The output contains `model.*`, `deployment_manifest.json`, `production_autopilot_report.html`, and `localization_report.md`. Open `production_autopilot_report.html` in any browser for the polished dashboard; it is self-contained and needs no internet connection or additional assets. The manifest records preprocessing, calibrated thresholds, metrics, latency, localization sanity checks, selected model, and runtime environment. Recheck the selected threshold on a production validation set before release. +The output contains `model.*`, `deployment_manifest.json`, `production_autopilot_report.html`, and `localization_report.md`. Open `production_autopilot_report.html` in any browser for the polished dashboard; it is self-contained and needs no internet connection or additional assets. The dashboard reports image AUROC, pixel AUROC, anomaly localization coverage, normal-image false-positive localization, anomaly mean mask area, and a localization verdict. `Anomaly coverage` is the fraction of defective images with a non-empty mask; `normal false positives` is the fraction of normal images with a non-empty mask. These are diagnostics, not substitutes for pixel AUROC. The manifest records preprocessing, calibrated thresholds, metrics, latency, localization health, selected model, and runtime environment. Recheck the selected threshold on a production validation set before release. From 73c6d655ddffea219b3378c12f6db8f0c5594ed2 Mon Sep 17 00:00:00 2001 From: DeepKnowledge1 <6.6887716e+07+DeepKnowledge1@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:22:30 +0000 Subject: [PATCH 22/22] chore: finalize production feature validation --- README.md | 38 ++- anomavision/autopilot.py | 226 ++++++++++++++---- anomavision/detect.py | 19 +- anomavision/eval.py | 4 +- anomavision/export.py | 48 +++- .../model/backends/tensorrt_backend.py | 4 +- anomavision/patchcore.py | 27 ++- anomavision/train.py | 1 - anomavision/utils.py | 4 +- apps/anomavision_gui_tkinter.py | 4 +- compare_with_anomalib.py | 17 +- onnxruntime_cpp/oop_anomaly_detector.py | 4 +- scripts/convert_to_tensorrt.py | 55 ++++- .../test_algorithm_thresholds_and_coreset.py | 11 +- tests/test_autopilot.py | 14 +- tests/test_config_template.py | 1 - tests/test_patchcore_ultralight.py | 8 +- 17 files changed, 382 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index ea1bc2f..4b8d9c6 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,29 @@ # AnomaVision -AnomaVision is a production-oriented library for **visual anomaly detection from normal images**. It supports PaDiM and lightweight PatchCore, image-level scores, pixel-level maps, and deployment exports. - -[![PyPI](https://img.shields.io/pypi/v/anomavision?label=PyPI)](https://pypi.org/project/anomavision/) -[![License: MIT](https://img.shields.io/badge/License-MIT-green)](LICENSE) +

+ AnomaVision banner +

+ +

+ Production-oriented visual anomaly detection from normal images. +

+ +

+ PyPI version + PyPI downloads + Python 3.10 to 3.12 + PyTorch 2.0 or newer + ONNX export ready + TensorRT supported + OpenVINO supported + MIT license +

+ +AnomaVision supports **PaDiM** and lightweight **PatchCore**, image-level scores, pixel-level maps, and deployment exports. + +

+ Open the AnomaVision live demo +

## Why use it? @@ -89,6 +109,16 @@ anomavision train --help anomavision export --help ``` +## Visual overview + +The same pipeline supports compact edge inference and spatial anomaly localization: + +![AnomaVision architecture](docs/images/archti.png) + +![PaDiM example visualization](notebooks/example_images/padim_example_image.png) + +![Lightweight PatchCore example visualization](notebooks/example_images/patchcore_example_image.png) + ## Choosing a model | Model | Best starting point | Memory use | Production note | diff --git a/anomavision/autopilot.py b/anomavision/autopilot.py index c86b84d..9ac4ad2 100644 --- a/anomavision/autopilot.py +++ b/anomavision/autopilot.py @@ -3,12 +3,12 @@ from __future__ import annotations import argparse -from html import escape import json import platform import shutil import sys import time +from html import escape from pathlib import Path from typing import Any, Dict, Iterable, Optional @@ -21,7 +21,11 @@ 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: @@ -30,18 +34,39 @@ def create_parser(add_help: bool = True) -> 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, 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("--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, + help="Fraction of the complete labeled test split used for calibration; 1.0 uses every sample.", + ) parser.add_argument("--output_dir", type=str, default="./production_package") parser.add_argument("--copy_config", action="store_true", default=True) return parser @@ -61,7 +86,13 @@ 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: @@ -97,10 +128,20 @@ def _profile_model(model_path: str, dataloader: DataLoader, device: str, warmup: 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) + 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 + image_auroc = ( + image_metrics.get("auc_score") if len(np.unique(labels_np)) > 1 else None + ) pixel_auroc = None masks_np = np.asarray(all_masks, dtype=np.float32) if masks_np.ndim == 4 and masks_np.shape[1] == 1: @@ -115,9 +156,15 @@ def _profile_model(model_path: str, dataloader: DataLoader, device: str, warmup: "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))) + 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 @@ -131,36 +178,73 @@ def _profile_model(model_path: str, dataloader: DataLoader, device: str, warmup: 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 + 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" + 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 + 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 + ) 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 image_metrics.items() + }, "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 = { + name: result + for name, result in results.items() + if result["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 name: ( + eligible[name]["metrics"].get("image_auroc") or 0.0, + -eligible[name]["latency_ms"]["p95"], + ), + ) def _write_report(manifest: Dict[str, Any], output_dir: Path) -> None: @@ -184,9 +268,13 @@ def _write_report(manifest: Dict[str, Any], output_dir: Path) -> None: 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" + 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''' + html = f""" AnomaVision Production Autopilot