diff --git a/README.md b/README.md index 7f79370..4b8d9c6 100644 --- a/README.md +++ b/README.md @@ -1,131 +1,107 @@ -
-AnomaVision banner +# AnomaVision -
+

+ AnomaVision banner +

-[![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/) -[![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) +

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

-[**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) +AnomaVision supports **PaDiM** and lightweight **PatchCore**, image-level scores, pixel-level maps, and deployment exports. -
+

+ Open the AnomaVision live demo +

---- +## Why use it? -## 🤗 Live Demo +- 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. -> **Try AnomaVision instantly — no installation required.** +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. -[![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) +## Quickstart -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]" ``` ---- +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). -#### Option A — From Source (development) +### 2. Prepare data -```bash -git clone https://github.com/DeepKnowledge1/AnomaVision.git -cd AnomaVision - -# Create and activate a virtual environment -uv venv --python 3.11 .venv -source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1 +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/ ``` ---- +### 3. Train -#### Option B — From PyPI (production / quick start) +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 -# CPU · Mac, CI runners, edge devices -uv pip install "anomavision[cpu]" +anomavision train --config config.yml +``` -# 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 +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`. -#### 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 +109,58 @@ 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. - -
+## Visual overview +The same pipeline supports compact edge inference and spatial anomaly localization: -
-📊 Models & Performance -
+![AnomaVision architecture](docs/images/archti.png) -### MVTec AD — Average over 15 Classes +![PaDiM example visualization](notebooks/example_images/padim_example_image.png) -| 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%** | +![Lightweight PatchCore example visualization](notebooks/example_images/patchcore_example_image.png) -> CPU: Intel Core i9 (single process). GPU: NVIDIA A100. Batch size 1. -> Reproduce: `anomavision eval --config config.yml` +## Choosing a model -### VisA — Average over 12 Classes +| Model | Best starting point | Memory use | Production note | +|---|---|---:|---| +| PaDiM | Fast, simple baseline | Low | Recommended first experiment | +| Lightweight PatchCore | Lower-memory nearest-patch baseline | Very low by default | Use `coreset_ratio`, `max_memory_patches`, and `patch_grid` to control latency | -| Model | Image AUROC ↑ | Pixel AUROC ↑ | CPU FPS ↑ | -|---|---|---|---| -| **AnomaVision** | **0.812** | **0.962** | **44.8** | -| Anomalib PaDiM | 0.783 | 0.954 | 13.5 | +## Documentation -
-📋 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) - -
- -
-🔌 Integrations -
- -| 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 -``` +| 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) | -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 - -
- -
-Production (Gunicorn + Uvicorn) - -```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. +## Python API -
- - ---- - -## ❓ 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`. - -
- -
-CUDA version mismatch - -```bash -pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 +```python +import torch +from torch.utils.data import DataLoader +import anomavision + +train_set = anomavision.AnodetDataset("./dataset/bottle/train/good") +train_loader = DataLoader(train_set, batch_size=16, shuffle=False) + +model = anomavision.Padim(backbone="resnet18", device=torch.device("cpu")) +model.fit(train_loader) +batch = next(iter(train_loader)) +if isinstance(batch, (tuple, list)): + batch = batch[0] +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/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/autopilot.py b/anomavision/autopilot.py new file mode 100644 index 0000000..9ac4ad2 --- /dev/null +++ b/anomavision/autopilot.py @@ -0,0 +1,401 @@ +"""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 html import escape +from pathlib import Path +from typing import Any, Dict, Iterable, Optional + +import numpy as np +import torch +from sklearn.metrics import roc_auc_score +from torch.utils.data import DataLoader + +import anomavision +from anomavision.config import load_config +from anomavision.general import determine_device +from anomavision.inference.model.wrapper import ModelWrapper +from anomavision.utils import ( + compute_metrics, + find_optimal_threshold, + make_localization_mask, +) + + +def create_parser(add_help: bool = True) -> argparse.ArgumentParser: + """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 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 + + +def _to_numpy(value: Any) -> np.ndarray: + if isinstance(value, torch.Tensor): + return value.detach().cpu().numpy() + return np.asarray(value) + + +def _format_metric(value: Any) -> str: + return "N/A" if value is None else f"{float(value):.4f}" + + +def _format_percent(value: Any) -> str: + return "N/A" if value is None else f"{float(value):.1%}" + + +def _profile_model( + model_path: str, + dataloader: DataLoader, + device: str, + warmup: int, + timing_batches: int, +) -> Dict[str, Any]: + wrapper = ModelWrapper(model_path, device) + iterator = iter(dataloader) + try: + first = next(iterator) + except StopIteration: + wrapper.close() + raise ValueError("The evaluation dataset is empty.") + first_batch = first[0].to(device) + for _ in range(max(0, warmup)): + wrapper.predict(first_batch) + if device.startswith("cuda") and torch.cuda.is_available(): + torch.cuda.synchronize() + timings = [] + all_scores, all_maps, all_labels, all_masks = [], [], [], [] + count = 0 + for item in dataloader: + batch, _, labels, masks = item + batch = batch.to(device) + measure = count < max(1, timing_batches) + if measure: + start = time.perf_counter() + scores, maps = wrapper.predict(batch) + if device.startswith("cuda") and torch.cuda.is_available(): + torch.cuda.synchronize() + if measure: + timings.append(time.perf_counter() - start) + all_scores.extend(_to_numpy(scores).reshape(-1).tolist()) + if maps is not None: + all_maps.extend(_to_numpy(maps)) + all_labels.extend(_to_numpy(labels).reshape(-1).tolist()) + all_masks.extend(_to_numpy(masks)) + count += 1 + wrapper.close() + scores_np = np.asarray(all_scores, dtype=np.float32) + labels_np = np.asarray(all_labels, dtype=np.int64) + maps_np = ( + np.asarray(all_maps, dtype=np.float32) + if all_maps + else np.empty((0, 0, 0), dtype=np.float32) + ) + threshold, threshold_f1 = ( + find_optimal_threshold(labels_np, scores_np) + if len(np.unique(labels_np)) > 1 + else (float(np.median(scores_np)), 0.0) + ) + image_metrics = compute_metrics(labels_np, scores_np, thresh=threshold) + image_auroc = ( + image_metrics.get("auc_score") if len(np.unique(labels_np)) > 1 else None + ) + pixel_auroc = None + 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": 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)) + ) + except ValueError: + pixel_auroc = None + image_metrics["image_auroc"] = image_auroc + image_metrics["pixel_auroc"] = pixel_auroc + anomaly_labels = (scores_np >= threshold).astype(np.uint8) + if localization["available"]: + loc_masks = make_localization_mask(maps_np, anomaly_labels).astype(bool) + non_empty = loc_masks.reshape(len(loc_masks), -1).any(axis=1) + area = loc_masks.reshape(len(loc_masks), -1).mean(axis=1) + anomaly_idx = labels_np == 1 + normal_idx = labels_np == 0 + 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 { + "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") or 0.0, + -eligible[name]["latency_ms"]["p95"], + ), + ) + + +def _write_report(manifest: Dict[str, Any], output_dir: Path) -> None: + """Write a self-contained HTML dashboard and a Markdown fallback report.""" + selected = manifest["selected_model"] + selected_result = manifest["candidates"][selected] + target = manifest.get("target_latency_ms") + cards = [] + rows = [] + for name, result in manifest["candidates"].items(): + metrics = result["metrics"] + loc = result["localization"] + is_selected = name == selected + status = "Selected" if is_selected else "Candidate" + status_class = "selected" if is_selected else "candidate" + cards.append( + f'
{escape(name.upper())}{status}
' + f'
{_format_metric(metrics.get("image_auroc"))} image AUROC
' + f'
{_format_metric(metrics.get("pixel_auroc"))}pixel AUROC
{result["latency_ms"]["p95"]:.1f} msp95 latency
{_format_percent(loc.get("anomaly_non_empty_fraction"))}anomaly coverage
' + ) + rows.append( + f'{escape(name)}{_format_metric(metrics.get("image_auroc"))}{_format_metric(metrics.get("pixel_auroc"))}{result["latency_ms"]["median"]:.2f}{result["latency_ms"]["p95"]:.2f}{_format_percent(loc.get("anomaly_non_empty_fraction"))}{_format_percent(loc.get("normal_false_positive_fraction"))}{result["threshold"]:.6f}' + ) + target_text = ( + f"under {target:.1f} ms p95" + if target is not None + else "with the strongest measured accuracy/latency balance" + ) + environment_json = escape(json.dumps(manifest["environment"], indent=2)) + html = f""" + +AnomaVision Production Autopilot +
+
AnomaVision / Production Autopilot

Deployment confidence, before production.

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

Selected: {escape(selected)}Class: {escape(str(manifest["dataset"]["class_name"]))}Samples: {manifest["dataset"]["samples"]}Device: {escape(str(manifest["environment"].get("device", "unknown")))}
+
Recommendation
Deploy {escape(selected)} with threshold {selected_result["threshold"]:.6f}. It was selected {escape(target_text)}. Recheck this threshold on a production validation set before release.
+

Candidate overview

Measured on the same validation data
{"".join(cards)}
+

Detailed comparison

Higher AUROC and anomaly coverage are better; lower false positives and latency are better
{"".join(rows)}
ModelImage AUROCPixel AUROCMedian msP95 msAnomaly coverageNormal false positivesThreshold
+

Localization health

Selected model maps available{"PASS" if selected_result["localization"]["available"] else "CHECK"}
Anomaly images with localization{_format_percent(selected_result["localization"].get("anomaly_non_empty_fraction"))}
Normal images with false-positive maps{_format_percent(selected_result["localization"].get("normal_false_positive_fraction"))}
Anomaly mean mask area{_format_percent(selected_result["localization"].get("anomaly_mean_mask_area_fraction"))}
Localization verdict{escape(str(selected_result["localization"].get("verdict", "N/A")))}

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

Deployment artifact

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

Reproducibility environment

{environment_json}
+
""" + (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]: + """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": 2, + "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 # ============================================================ diff --git a/anomavision/detect.py b/anomavision/detect.py index ea0744b..a9587a0 100644 --- a/anomavision/detect.py +++ b/anomavision/detect.py @@ -30,7 +30,9 @@ from anomavision.utils import ( adaptive_gaussian_blur, get_logger, + make_localization_mask, merge_config, + resolve_threshold, setup_logging, ) @@ -199,6 +201,8 @@ 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) @@ -412,6 +416,17 @@ 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()) @@ -432,13 +447,7 @@ def run_inference(args): boundary_images = ( anomavision.visualization.framed_boundary_images( images, - ( - anomavision.classification( - score_maps, config.thresh - ) - if config.thresh - else np.zeros_like(score_maps) - ), + localization_masks, is_anomaly, padding=config.get("viz_padding", 40), ) @@ -447,17 +456,15 @@ 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 - else np.zeros_like(score_maps) - ), - color=viz_color, + highlighted_images = ( + anomavision.visualization.highlighted_images( + [images[i] for i in range(len(images))], + localization_masks, + color=viz_color, + ) ) # Save/Show diff --git a/anomavision/eval.py b/anomavision/eval.py index 1283099..9f27df9 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) @@ -412,9 +416,9 @@ def run_evaluation(args): for k, v in metrics.items(): if isinstance(v, float): - logger.info(f"{k.replace('_',' ').title():<28} {v:.6f}") + logger.info(f"{k.replace('_', ' ').title():<28} {v:.6f}") else: - logger.info(f"{k.replace('_',' ').title():<28} {v}") + logger.info(f"{k.replace('_', ' ').title():<28} {v}") logger.info("=" * 60) diff --git a/anomavision/export.py b/anomavision/export.py index 21e6da9..9dec613 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, @@ -59,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), @@ -75,7 +75,12 @@ 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: @@ -86,10 +91,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 @@ -145,6 +148,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.""" @@ -194,9 +231,15 @@ 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 @@ -399,6 +442,137 @@ 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, + 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. + + 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") + 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, + 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, + allow_random=False, + ) + if not samples: + 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") + ) + 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, + (min_batch, channels, height, width), + (opt_batch, channels, height, width), + (max_batch, 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 +764,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 +805,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 +826,42 @@ 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( + "--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" ) @@ -692,6 +902,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() @@ -716,7 +947,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) @@ -740,6 +971,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 +998,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 +1019,23 @@ 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, + min_batch=config.min_batch, + opt_batch=config.opt_batch, + max_batch=config.max_batch, + ) + is not None + ) + if config.format in ["openvino", "all"]: fp16_setting = ( None if config.precision == "auto" else (config.precision == "fp16") diff --git a/anomavision/inference/model/backends/tensorrt_backend.py b/anomavision/inference/model/backends/tensorrt_backend.py index 2ab68bc..50847bb 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,90 @@ 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) 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..bc4c8fd --- /dev/null +++ b/anomavision/patchcore.py @@ -0,0 +1,382 @@ +"""Lightweight PatchCore anomaly detection. + +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 Dict, List, Optional, Tuple + +import torch +import torch.nn.functional as F + +from .feature_extraction import ResnetEmbeddingsExtractor + + +class PatchCore(torch.nn.Module): + """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. 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``. + 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. + """ + + 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.02, + max_memory_patches: Optional[int] = 2048, + 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: + 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.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: + 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: + 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 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 + ) + 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 _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, 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. + + 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: + 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]: + bank = self._select_coreset(bank, keep) + 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]]: + """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) + flat = embeddings.reshape(-1, embeddings.shape[-1]) + # 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 + 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 + ) -> 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() + 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, + "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", + }, + path, + ) + + +def build_patchcore_from_stats( + stats: Dict, device: str = "cpu", force_precision: Optional[str] = None +) -> PatchCore: + """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"]), + layer_indices=list(stats["layer_indices"]), + memory_bank=bank, + 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)), + 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": + model = model.half() + return model 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/train.py b/anomavision/train.py index 86e5000..7761310 100644 --- a/anomavision/train.py +++ b/anomavision/train.py @@ -104,6 +104,43 @@ 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( + "--patch_grid", + type=int, + default=None, + help="PatchCore pooled grid size; use a smaller value for lower latency.", + ) + parser.add_argument( + "--search_chunk_size", + type=int, + default=None, + help="PatchCore query chunk size used to bound nearest-neighbor memory.", + ) + parser.add_argument( + "--coreset_method", + type=str, + choices=["kcenter", "random"], + default=None, + help="PatchCore coreset selection strategy; kcenter is the diverse default.", + ) + parser.add_argument( + "--coreset_seed", + type=int, + default=None, + help="Seed used for deterministic PatchCore coreset selection.", + ) parser.add_argument( "--output_model", type=str, @@ -234,31 +271,44 @@ 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, + 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( + 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 +320,7 @@ 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/anomavision/utils.py b/anomavision/utils.py index 34d0a66..2297ac2 100644 --- a/anomavision/utils.py +++ b/anomavision/utils.py @@ -272,6 +272,59 @@ 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. + + ``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) + 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): """Calculate image classifications from image scores. Args: @@ -283,14 +336,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/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/apps/anomavision_gui_tkinter.py b/apps/anomavision_gui_tkinter.py index 8e6d501..920e482 100644 --- a/apps/anomavision_gui_tkinter.py +++ b/apps/anomavision_gui_tkinter.py @@ -1838,7 +1838,7 @@ def start_inference(self): ) self.device_badge.config( - text=f"Device: {config['device'].upper() if config['device']!='auto' else 'Auto'}" + text=f"Device: {config['device'].upper() if config['device'] != 'auto' else 'Auto'}" ) self.infer_button.config(state=tk.DISABLED) @@ -2140,7 +2140,7 @@ def _show_about(self): pw, ph = self.root.winfo_width(), self.root.winfo_height() ww, wh = 760, 600 wx, wy = px + (pw - ww) // 2, py + (ph - wh) // 2 - win.geometry(f"{ww}x{wh}+{max(wx,0)}+{max(wy,0)}") + win.geometry(f"{ww}x{wh}+{max(wx, 0)}+{max(wy, 0)}") def close(): try: diff --git a/compare_with_anomalib.py b/compare_with_anomalib.py index b1ddfd8..350452c 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,48 @@ # 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 +111,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 +135,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 +193,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 +229,31 @@ 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 +272,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 +285,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 +353,17 @@ 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: - - pass + 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") # === 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 +398,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 +430,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 +482,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 +545,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 +571,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 +1076,13 @@ 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 +1116,9 @@ 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 +1129,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/config.yml b/config.yml index daa9bbf..ef17c96 100644 --- a/config.yml +++ b/config.yml @@ -14,7 +14,13 @@ 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.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 @@ -44,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 @@ -57,15 +65,24 @@ 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 +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 # Reserved (not actively used) +half: false # Legacy compatibility field +int8: false # Legacy compatibility field; use tensorrt_precision: int8 # ========================= # Streaming Configuration 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. --- 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 new file mode 100644 index 0000000..9f39bad --- /dev/null +++ b/docs/production_deployment.md @@ -0,0 +1,121 @@ +# 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.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. `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 + +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; 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. | +| 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. + +## 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. + +## 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. + +## Production Autopilot + +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`: + +```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. The training split is used to fit the artifacts; it is not mixed into the labeled test metrics. + +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`, `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. diff --git a/onnxruntime_cpp/oop_anomaly_detector.py b/onnxruntime_cpp/oop_anomaly_detector.py index 4d4f76c..747eb9d 100644 --- a/onnxruntime_cpp/oop_anomaly_detector.py +++ b/onnxruntime_cpp/oop_anomaly_detector.py @@ -196,7 +196,7 @@ def annotate(self, img: np.ndarray, r: Result, t_ms: float): cv2.rectangle(img, (5, 5), (420, 110), (0, 0, 0), -1) cv2.putText( img, - f"Score: {self._to_fixed(r.score,3)}", + f"Score: {self._to_fixed(r.score, 3)}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, @@ -214,7 +214,7 @@ def annotate(self, img: np.ndarray, r: Result, t_ms: float): ) cv2.putText( img, - f"Time: {self._to_fixed(t_ms,2)} ms", + f"Time: {self._to_fixed(t_ms, 2)} ms", (10, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.6, diff --git a/scripts/convert_to_tensorrt.py b/scripts/convert_to_tensorrt.py new file mode 100644 index 0000000..4aba584 --- /dev/null +++ b/scripts/convert_to_tensorrt.py @@ -0,0 +1,211 @@ +"""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_algorithm_thresholds_and_coreset.py b/tests/test_algorithm_thresholds_and_coreset.py new file mode 100644 index 0000000..79057d0 --- /dev/null +++ b/tests/test_algorithm_thresholds_and_coreset.py @@ -0,0 +1,51 @@ +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_autopilot.py b/tests/test_autopilot.py new file mode 100644 index 0000000..a5c954c --- /dev/null +++ b/tests/test_autopilot.py @@ -0,0 +1,65 @@ +import json +from pathlib import Path + +from anomavision.autopilot import _format_metric, _select, _write_report, create_parser + + +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": { + "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", + }, + } + + +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" + assert _select(candidates, target_latency_ms=None) == "padim" + + +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 html + assert "Deployment confidence, before production." in html + assert " 0 + assert masks[1].sum() == 0 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) 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])