VisualSentry
Zero-shot industrial anomaly detection system that learns exclusively from defect-free images and localizes unseen defects at inference time — no defect labels required. Fuses DINOv2-powered PatchCore memory retrieval with CLIP-based WinCLIP semantic scoring, achieving 0.9545 average image AUROC across four MVTec AD categories with sub-50ms ONNX inference.
VisualSentry — Industrial Anomaly Detection System
Zero-shot defect detection powered by PatchCore · DINOv2 · WinCLIP · FAISS · ONNX
GitHub Repository · Live Demo · Results
Overview
VisualSentry is a production-grade industrial visual anomaly detection system that learns exclusively from defect-free training images and detects any visual deviation at inference time — with no defect examples required during training.
The system fuses two complementary detection paradigms:
PatchCore extracts patch-level features from a DINOv2 backbone, builds a compressed coreset memory bank using FAISS, and scores each test image by finding the nearest neighbor distance from its patch embeddings to the stored normal distribution. This gives precise spatial localization of where defects occur.
PatchCore Workflow using DINOv2-B
- Collect only normal images
- No defective images are used for training.
- Extract features using DINOv2-B
- The image is split into patches.
- DINOv2-B converts each patch into a 768-dimensional feature vector.
- Build a memory bank
- Store patch features from all normal images.
- Use coreset sampling to keep only the most representative features and reduce memory usage.
- Test a new image
- Extract patch features using the same DINOv2-B model.
- Compare with the memory bank
- For each test patch, find the closest normal patch.
- Compute the distance:
- Small distance → Normal
- Large distance → Anomaly
- Generate the result
- Combine all patch distances to produce:
- An anomaly heatmap showing defect locations.
- An image anomaly score indicating whether the image is normal or defective.
- Combine all patch distances to produce:
Hyperparameters
| Hyperparameter | Value |
|---|---|
| Image Size | 224 |
| Patch Size | 14 |
| Forward Hooks | [8, 11] |
| Metric (Coreset Selection) | Euclidean |
| Sampling Ratio | 10% |
| Projection | True |
| n_components | auto |
| k (No. of Neighbors) | 3 |
| FAISS Index | IndexFlatL2 |
| FAISS Distance | L2 |
| Batch Size | 16 |
| Random Seed | 42 |
WinCLIP scores images using CLIP ViT-B-16-plus-240 by comparing sliding window crops against text prototype banks describing normal and anomalous product states. This provides a semantic, language-grounded signal about whether something looks wrong — without ever seeing a defect image.
WinCLIP Workflow (ViT-B-16-plus-240)
- Load the pretrained CLIP model (ViT-B-16-plus-240).
- The model is frozen (no training or fine-tuning).
- Prepare text prompts.
- Create multiple prompts describing normal and anomalous states.
- Example:
- "a normal bottle"
- "a defective bottle"
- "a broken bottle"
- "a damaged bottle"
- Encode these prompts into text embeddings using CLIP's text encoder.
- Extract image features.
- Pass the test image through ViT-B-16-plus-240.
- Obtain:
- Global image feature
- Patch features (ViT tokens)
- Apply sliding windows (WinCLIP).
- Instead of comparing only the whole image, WinCLIP compares multiple local windows (groups of patches) with the text prompts.
- This helps detect small, localized defects.
- Compute similarity scores.
- Compare each window feature with:
- Normal text embeddings
- Anomaly text embeddings
- Higher similarity to anomaly prompts ⇒ Higher anomaly score.
- Compare each window feature with:
- Generate the result.
- Combine all window scores to produce:
- Anomaly heatmap (defect location)
- Image anomaly score (normal vs. anomalous)
- Combine all window scores to produce:
Hyperparameters
| Hyperparameter | Value | Effect |
|---|---|---|
| Image Size | 240 | Input image resolution used by the CLIP encoder. |
| Patch Size | 16 | Defines the size of each Vision Transformer patch and determines the patch grid. |
| Window Scales | [1, 2, 3, 4] | Sliding window sizes (in patch units) used to extract multi-scale local features. |
| Aggregation | topk | Aggregates similarities across the prototype bank using the mean of the top-k similarities. |
| Top-k | 5 | Number of most similar prototypes whose similarity scores are averaged. |
| Temperature | 7.0 / logit_scale | Controls the sharpness (confidence) of the softmax probability distribution. |
| Random Seed | 42 | Ensures reproducible experiments by fixing random number generation. |
Both systems produce an anomaly heatmap and a scalar score. These are normalized and fused with an alpha-weighted combination (α = 0.8 in favor of PatchCore), producing a final calibrated Pass/Fail decision at sub-50ms GPU inference via ONNX Runtime.
The Problem This Solves
Modern manufacturing lines inspect millions of products daily. Traditional approaches fail in a predictable way: supervised classifiers require large labeled defect datasets, but defective items are rare and new defect types emerge without warning. You cannot enumerate every possible failure mode in advance.
One-class anomaly detection solves this at the root. Train only on what is normal — readily available from any production line — and flag anything that deviates. No defect images required, no re-training when new defect types appear, no per-pixel annotation cost.
| Approach | Defect data needed | Novel defect handling | Deployment speed |
|---|---|---|---|
| Human inspection | None | Good | Slow, inconsistent |
| Rule-based vision | None | Poor — breaks on new types | Fast |
| Supervised DL classifier | Large labeled set | Fails on unseen types | Fast after training |
| VisualSentry (one-class) | None | Generalizes by design | Fast |
System Architecture
The full pipeline runs in two phases. The offline phase builds the memory bank once from normal training images. The online phase runs at inference time per test image.
flowchart TD
A["🖼️ Input Image\n(PIL / OpenCV / Webcam)"]
A --> PRE1["Preprocessing Branch 1\nImageNet Normalize\n224×224"]
A --> PRE2["Preprocessing Branch 2\nCLIP Normalize\n240×240"]
PRE1 --> PC["⚙️ PatchCore Branch\n──────────────────\nDINOv2-S Backbone\nBlocks 8 + 11\nPatch Feature Extraction\n[256, 768]\nFAISS kNN Search\nk=3 Nearest Neighbors\nAnomaly Map 224×224"]
PRE2 --> WC["🔤 WinCLIP Branch\n──────────────────\nCLIP ViT-B-16-plus-240\nText Prototype Banks\n[7, 640] Normal\n[9, 640] Anomaly\nSliding Window Crops\nScale 2×2 + 3×3\nCosine Similarity\nTop-k Aggregation\nAnomaly Map 240×240\n→ resize → 224×224"]
PC --> MAPS["PatchCore Map\n[224, 224]\nPatchCore Score\nfloat scalar"]
WC --> MAPM["WinCLIP Map\n[224, 224]\nWinCLIP Score\nfloat scalar"]
MAPS --> NORM["Score Normalization\n──────────────────\nMin-Max to [0,1]\nUsing Training Stats\nfrom fusion_config.json"]
MAPM --> NORM
NORM --> FUSE["⚡ Weighted Fusion\n──────────────────\nα × PC + (1−α) × WC\nBest α from Grid Search\nFused Map [224, 224]\nFused Score [0,1]"]
FUSE --> THRESH["Threshold Decision\n──────────────────\nCalibrated at 95th\nPercentile of Normal\nScores (FPR = 5%)"]
THRESH --> PASS["✅ PASS\n(Normal Product)"]
THRESH --> FAIL["❌ FAIL\n(Defect Detected)"]
PASS --> VIZ["Visualization\n──────────────────\nHeatmap Overlay\nSide-by-Side Panel\nScore Display"]
FAIL --> VIZ
VIZ --> DEPLOY["Deployment\n──────────────────\nGradio Web UI\nONNX Runtime\nOpenCV Webcam\nEdge Device"]
style A fill:#2D4A7A,color:#fff
style PC fill:#1a4a2e,color:#fff
style WC fill:#4a1a2e,color:#fff
style FUSE fill:#4a3a1a,color:#fff
style PASS fill:#1a4a1a,color:#fff
style FAIL fill:#4a1a1a,color:#fff
style DEPLOY fill:#2a2a4a,color:#fff
PatchCore — Offline and Online Phases
flowchart TD
subgraph OFFLINE["OFFLINE PHASE — Build Memory Bank (run once)"]
direction TB
N1["Normal Training Images\n(209 images, MVTec Bottle)"]
N1 --> PP1["Preprocessing\nResize 256→CenterCrop 224\nImageNet Normalize\n[B, 3, 224, 224]"]
PP1 --> BB["DINOv2-S Backbone\nfacebookresearch/dinov2\nvit_small_patch14\n21M Parameters"]
BB --> H1["Hook: Block 8 Output\n[B, 257, 384]\n(256 patches + 1 CLS)"]
BB --> H2["Hook: Block 11 Output\n[B, 257, 384]"]
H1 --> CLS1["Drop CLS Token\n→ [B, 256, 384]"]
H2 --> CLS2["Drop CLS Token\n→ [B, 256, 384]"]
CLS1 --> SMOOTH1["AvgPool2D 3×3\nSpatial Smoothing\n[B, 384, 16, 16]"]
CLS2 --> SMOOTH2["AvgPool2D 3×3\nSpatial Smoothing\n[B, 384, 16, 16]"]
SMOOTH1 --> CONCAT["Feature Concatenation\n[B, 256, 768]\n(384 + 384 channels)"]
SMOOTH2 --> CONCAT
CONCAT --> FLAT["Flatten to Patch Matrix\n[B×256, 768]\ne.g. 209×256 = 53504 vectors"]
FLAT --> CORESET["Greedy Coreset Sampling\nK-Center Greedy Algorithm\n10% Retention Ratio\n53504 → 5350 vectors"]
CORESET --> FAISS["FAISS IndexFlatL2\ndimension = 768\nntotal = 5350\nSave to disk: index.faiss"]
end
subgraph ONLINE["ONLINE PHASE — Inference (per image)"]
direction TB
IMG["Test Image\n[1, 3, 224, 224]"]
IMG --> FEAT["DINOv2 Forward\nSame Backbone + Hooks\n[1, 256, 768]"]
FEAT --> KNN["FAISS kNN Search\nk=3 Nearest Neighbors\nDistances: [256, 3]\nIndices: [256, 3]"]
KNN --> SCORE["Per-Patch Anomaly Score\ndistances[:, 0] → [256]\n(nearest neighbor distance)"]
SCORE --> REWEIGHT["k-NN Re-weighting\nSuppresses Ambiguous Patches\nAnomaly Map: [256]"]
REWEIGHT --> RESHAPE["Reshape to Grid\n[16, 16]"]
RESHAPE --> RESIZE["Bilinear Upsample\n[16, 16] → [224, 224]"]
RESIZE --> GAUSS["Gaussian Smoothing\nσ = 4\nFinal Map: [224, 224]"]
GAUSS --> ISCORE["Image Score\n= argmax value in map\nfloat scalar"]
end
FAISS -.->|"Load at startup"| KNN
style OFFLINE fill:#0d1117,color:#58a6ff,stroke:#30363d
style ONLINE fill:#0d1117,color:#3fb950,stroke:#30363d
WinCLIP — Prototype Construction and Inference
flowchart TD
subgraph PROTO["TEXT PROTOTYPE CONSTRUCTION (run once at init)"]
direction TB
PROMPTS_N["Normal Prompts\n5 generic + 2 category-specific\n= 7 total\nExample: 'a flawless bottle'\n'a perfect bottle with no cracks'"]
PROMPTS_A["Anomaly Prompts\n5 generic + 4 category-specific\n= 9 total\nExample: 'a bottle with a broken neck'\n'a bottle with contamination'"]
PROMPTS_N --> ENCODE_N["CLIP Text Encoder\nViT-B-16-plus-240\nlaion400m_e32\nTokenize + Encode"]
PROMPTS_A --> ENCODE_A["CLIP Text Encoder"]
ENCODE_N --> BANK_N["Normal Prototype Bank\n[7, 640] L2-normalized\nSaved: *_normal_bank.npy"]
ENCODE_A --> BANK_A["Anomaly Prototype Bank\n[9, 640] L2-normalized\nSaved: *_anomaly_bank.npy"]
end
subgraph INFERENCE["IMAGE INFERENCE (per image)"]
direction TB
IMG2["Test Image\nPIL RGB"]
IMG2 --> PREPROC["CLIP Preprocessing\nResize to 240×240\nCLIP Normalize\nmean=[0.481,0.457,0.408]\nstd=[0.268,0.261,0.275]\n[1, 3, 240, 240]"]
PREPROC --> CLS_ENC["CLIP Image Encoder\nFull 240×240 Image\n[1, 640] Global Embedding"]
CLS_ENC --> CLS_SCORE["CLS-Level Scoring\ncos_sim([1,640], [7,640]) → [7]\nTop-k Aggregation (k=5)\nNormal Agg Score\ncos_sim([1,640], [9,640]) → [9]\nAnomaly Agg Score\nSoftmax → CLS Score"]
PREPROC --> WIN["Sliding Window Generation\nScale 2: 196 windows 32×32px\nScale 3: 169 windows 48×48px\nTotal: 365 crops"]
WIN --> CROP["Crop + Resize\nEach crop → [3, 240, 240]\nBatch size 64"]
CROP --> WIN_ENC["CLIP Image Encoder\nBatched Forward\n[64, 640] per batch"]
WIN_ENC --> WIN_SCORE["Per-Window Scoring\nTop-k Similarity vs Banks\nSoftmax P(anomaly)\n[N_windows] scores"]
WIN_SCORE --> ACCUM["Score Accumulation\nPatch Grid: [15, 15]\nOverlap Averaging\ncount_map normalization"]
ACCUM --> UPSAMPLE["Bilinear Upsample\n[15, 15] → [240, 240]"]
UPSAMPLE --> GSMOOTH["Gaussian Smoothing\nσ = 4\nWindow Map: [240, 240]"]
GSMOOTH --> RESIZE_MAP["Canonical Resize\n[240, 240] → [224, 224]\nFor Fusion Alignment"]
CLS_SCORE --> HARMONIC["Harmonic Mean\n2 × cls × window\n / (cls + window)\nFinal WinCLIP Score"]
RESIZE_MAP --> HARMONIC
end
BANK_N -.->|"Loaded at startup"| CLS_SCORE
BANK_A -.->|"Loaded at startup"| CLS_SCORE
BANK_N -.->|"Loaded at startup"| WIN_SCORE
BANK_A -.->|"Loaded at startup"| WIN_SCORE
style PROTO fill:#0d1117,color:#f78166,stroke:#30363d
style INFERENCE fill:#0d1117,color:#d2a8ff,stroke:#30363d
Fusion Pipeline
flowchart LR
PC_SCORE["PatchCore Score\nraw L2 distance\nrange: ~[0, 10+]"]
WC_SCORE["WinCLIP Score\nsoftmax P(anomaly)\nrange: ~[0, 1]"]
PC_MAP["PatchCore Map\n[224, 224]\nL2 distance values"]
WC_MAP["WinCLIP Map\n[224, 224]\nsoftmax probabilities"]
PC_SCORE --> NORM_S["Score Min-Max Norm\npc_norm = (s - min) / (max - min)\nStats from training set\nSaved in fusion_config.json"]
WC_SCORE --> NORM_S
PC_MAP --> NORM_M["Map Min-Max Norm\nGlobal min-max across N images\npc_map_norm ∈ [0, 1]"]
WC_MAP --> NORM_M
NORM_S --> GRID["Alpha Grid Search\nα ∈ {0.0, 0.1, ..., 1.0}\nMaximize Image AUROC\nSelect best_alpha"]
GRID --> FUSE_S["Score Fusion\nfused = α × pc_norm\n+ (1−α) × wc_norm"]
NORM_M --> FUSE_M["Map Fusion\nfused_map = α × pc_map_norm\n+ (1−α) × wc_map_norm\n[224, 224]"]
FUSE_S --> CALIB["Threshold Calibration\nNormal images only\n95th percentile of scores\n= threshold at FPR 5%\nSaved in fusion_config.json"]
CALIB --> DECISION["Decision\nfused_score > threshold\n→ FAIL\nfused_score ≤ threshold\n→ PASS"]
FUSE_M --> VIZ2["Visualization\nHeatmap + Overlay\nSide-by-side Panel"]
DECISION --> VIZ2
style GRID fill:#4a3a00,color:#fff
style CALIB fill:#003a4a,color:#fff
style DECISION fill:#2a0000,color:#fff
ONNX Deployment Pipeline
flowchart TD
subgraph EXPORT["EXPORT PHASE"]
PT_DINO["PyTorch DINOv2\nDINOv2ExportWrapper\n(subclass approach, no hooks)\nforward() iterates blocks\ncaptures intermediate outputs\ndirectly in computation graph"]
PT_CLIP["PyTorch CLIP\nCLIPExportWrapper\nNormalization baked in\nAccepts raw [0,1] RGB\nOutputs L2-normalized [B, 640]"]
PT_DINO --> ONNX_D["dinov2_vits14.onnx\nInput: [B, 3, 224, 224]\nOutput: [B, 256, 768]\nopset 17\ndo_constant_folding=True"]
PT_CLIP --> ONNX_C["clip_vitb16plus240.onnx\nInput: [B, 3, 240, 240] raw\nOutput: [B, 640] L2-normed\nopset 17"]
ONNX_D --> VALID["Validation\nmax_diff < 5e-5\nPyTorch vs ONNX output"]
ONNX_C --> VALID
end
subgraph RUNTIME["RUNTIME PHASE"]
LOAD["Load at Startup\nort.InferenceSession\nCUDAExecutionProvider\nor CPUExecutionProvider\nFAISS index (CPU)\nPrototype banks (GPU tensor)\nfusion_config.json"]
LOAD --> INF_D["DINOv2 ONNX Forward\n[1, 3, 224, 224] → [1, 256, 768]\n~15ms GPU / ~80ms CPU"]
LOAD --> INF_C["CLIP ONNX Forward\n[B, 3, 240, 240] → [B, 640]\n~5ms/batch GPU / ~25ms/batch CPU"]
INF_D --> KNN2["FAISS CPU Search\n[256, 768] vs 5350 coreset\n~5ms"]
INF_C --> SIM["Cosine Similarity\nTop-k aggregation\nWindow score accumulation"]
KNN2 --> FUSE2["Fusion + Decision\n~2ms"]
SIM --> FUSE2
FUSE2 --> OUT["Result Dictionary\npc_map: [224,224]\nwc_map: [224,224]\nfused_map: [224,224]\nfused_score: float\ndecision: PASS/FAIL\nlatency breakdown"]
end
EXPORT --> RUNTIME
style EXPORT fill:#0d1117,color:#79c0ff,stroke:#30363d
style RUNTIME fill:#0d1117,color:#56d364,stroke:#30363d
Evaluation Workflow
flowchart TD
A["Test Dataset\nMVTec AD Bottle\n83 images total\n22 normal + 61 anomaly"] --> B["DataLoader\nbatch_size=1\nshuffle=False"]
B --> C["Model Forward\nPatchCore + WinCLIP\nper image"]
C --> D["Accumulate Results\nanomalys_maps: List[H,W]\nimage_scores: List[float]\ngt_labels: List[int]\ngt_masks: List[H,W]"]
D --> E["Stack Arrays\nanomalys_maps: [N,224,224]\nimage_scores: [N]\ngt_labels: [N]\ngt_masks: [N,224,224]"]
E --> SAVE["Save to disk\narrays/anomaly_maps.npy\narrays/image_scores.npy\narrays/gt_labels.npy\narrays/gt_masks.npy"]
E --> METRICS["Compute Metrics"]
METRICS --> IM_AUC["Image AUROC\nroc_auc_score(gt_labels, scores)"]
METRICS --> PIX_AUC["Pixel AUROC\nroc_auc_score(gt_masks.flat, maps.flat)"]
METRICS --> F1["Image F1-Max\nOptimal threshold from\nPrecision-Recall curve"]
IM_AUC --> JSON["metrics/metrics.json"]
PIX_AUC --> JSON
F1 --> JSON
State Machine: PASS/FAIL Decision
stateDiagram-v2
[*] --> Idle: System Ready
Idle --> Preprocessing: Image received
Preprocessing --> PatchCoreInference: ImageNet normalized [1,3,224,224]
Preprocessing --> WinCLIPInference: CLIP normalized [1,3,240,240]
PatchCoreInference --> PCScored: pc_map [224,224] + pc_score
WinCLIPInference --> WCScored: wc_map [224,224] + wc_score
PCScored --> Normalizing: Both branches complete
WCScored --> Normalizing
Normalizing --> Fusing: pc_norm, wc_norm ∈ [0,1]
Fusing --> Thresholding: fused_score ∈ [0,1]
Thresholding --> PASS_State: fused_score ≤ threshold
Thresholding --> FAIL_State: fused_score > threshold
PASS_State --> Visualizing: ✅ OK
FAIL_State --> Visualizing: ❌ NG
Visualizing --> Saving: Heatmap + report generated
Saving --> Idle: Result returned to caller
Demo
Interactive Gradio application demonstrating zero-shot industrial anomaly detection, anomaly heatmaps, and PASS/FAIL prediction.
The demo accepts any product image via upload or webcam feed. It runs the full fused inference pipeline and returns:
- The input image overlaid with the anomaly heatmap (jet colormap — blue = normal, red = defect region)
- A side-by-side panel: PatchCore map · WinCLIP map · Fused map · Ground truth mask
- A calibrated Pass/Fail decision with the raw anomaly score
Results
All experiments were run on the MVTec Anomaly Detection benchmark. Four categories were evaluated: Transistor, Screw, Grid, and Leather — spanning a range of difficulty from regular-texture surfaces to structurally complex components with fine-grained defects.
4-Category Ablation — MVTec AD
| Category | PatchCore Image AUROC | PatchCore Pixel AUROC | WinCLIP Image AUROC | WinCLIP Pixel AUROC | Fused Image AUROC | Fused Pixel AUROC |
|---|---|---|---|---|---|---|
| Transistor | 0.9671 | 0.9685 | 0.8542 | 0.6060 | 0.9792 | 0.9538 |
| Screw | 0.7571 | 0.8699 | 0.7174 | 0.8687 | 0.8387 | 0.9402 |
| Grid | 1.0000 | 0.9739 | 0.8521 | 0.7337 | 1.0000 | 0.9651 |
| Leather | 1.0000 | 0.9659 | 0.8169 | 0.9280 | 1.0000 | 0.9768 |
| Average | 0.9311 | 0.9446 | 0.8102 | 0.7841 | 0.9545 | 0.9590 |
Click to expand: Detailed Results
``` ============================================================ FUSION RESULTS — transistor ============================================================ Model Image AUROC Pixel AUROC -------------------- ------------ ------------ PatchCore 0.967083 0.968450 WinCLIP 0.854167 0.606035 Fused 0.979167 0.953839 -------------------------------------------- Best alpha : 0.80 Threshold : 0.195098 Accuracy : 0.9400 Gain vs PC : +0.012083 Gain vs WinCLIP : +0.125000 ============================================================ ============================================================ FUSION RESULTS — screw ============================================================ Model Image AUROC Pixel AUROC -------------------- ------------ ------------ PatchCore 0.757122 0.869940 WinCLIP 0.717360 0.868738 Fused 0.838696 0.940203 -------------------------------------------- Best alpha : 0.70 Threshold : 0.312877 Accuracy : 0.6687 Gain vs PC : +0.081574 Gain vs WinCLIP : +0.121336 ============================================================ ============================================================ FUSION RESULTS — grid ============================================================ Model Image AUROC Pixel AUROC -------------------- ------------ ------------ PatchCore 1.000000 0.973927 WinCLIP 0.852130 0.733723 Fused 1.000000 0.965109 -------------------------------------------- Best alpha : 0.60 Threshold : 0.086652 Accuracy : 0.9872 Gain vs PC : +0.000000 Gain vs WinCLIP : +0.147870 ============================================================ ============================================================ FUSION RESULTS — leather ============================================================ Model Image AUROC Pixel AUROC -------------------- ------------ ------------ PatchCore 1.000000 0.965851 WinCLIP 0.816916 0.928011 Fused 1.000000 0.976774 -------------------------------------------- Best alpha : 0.80 Threshold : 0.122663 Accuracy : 0.9839 Gain vs PC : +0.000000 Gain vs WinCLIP : +0.183084 ============================================================ ```Fusion improves image AUROC by +2.3% and pixel AUROC by +1.4% on average over PatchCore alone. The gain is consistent across all four categories.
Transistor — Detailed Results
Transistor is the most representative and challenging category: a geometrically complex component with subtle defects including bent leads, missing pads, and surface damage.
| System | Backbone | Image AUROC | Pixel AUROC | |
|---|---|---|---|---|
| PatchCore | DINOv2-B (blocks 8+11) | 0.9671 | 0.9685 | |
| WinCLIP | CLIP ViT-B-16-plus-240 (laion400m) | 0.8542 | 0.6060 | |
| Fused (α=0.8) | Both | 0.9792 | 0.9538 | 0.1951 |
Backbone Comparison — PatchCore on Transistor
| Backbone | Parameters | Image AUROC | Pixel AUROC | VRAM |
|---|---|---|---|---|
| DINOv2-S (vits14) | 21M | 0.9379 | 0.9690 | ~3 GB |
| DINOv2-B (vitb14) | 86M | 0.9746 | 0.9684 | ~6 GB |
DINOv2-B gives +3.7% image AUROC over DINOv2-S on transistor. Pixel AUROC is nearly identical — localization quality is comparable at both scales. DINOv2-S is the recommended default for edge deployment. DINOv2-B is worth the added compute only when image-level accuracy is the binding priority.
WinCLIP Aggregation Strategy Ablation — Transistor
| Aggregation Strategy | Image AUROC | Pixel AUROC | Verdict |
|---|---|---|---|
| Max similarity | 0.5679 | 0.4862 | Noisy — dominated by outlier windows |
| Top-k (k=5) | 0.8542 | 0.6061 | Best — robust and stable |
| Softmax (log-sum-exp) | 0.8696 | 0.6045 | Marginally higher but less stable |
Max similarity collapses because a single high-scoring noisy window dominates the aggregate. Top-k (k=5) filters this out by averaging the five most anomaly-consistent windows. This ablation was a critical engineering finding — naive max aggregation produced near-random results (0.57 AUROC) despite the underlying CLIP model being capable.
Results Interpretation and Conclusions
Grid and Leather at 1.000 image AUROC confirm the system handles texture categories correctly. Regular repeating patterns (grid weave, leather grain) produce tightly clustered normal embeddings; any deviation is immediately visible in feature space. These are expected results and establish that the pipeline is correctly implemented.
Transistor at 0.979 fused image AUROC is the headline result. It demonstrates genuine generalization — detecting subtle component-level defects on a structurally complex part without any defect images in training. The 1.2% gain from fusion (0.967 → 0.979) shows that WinCLIP’s semantic signal about “what kind of anomaly” is present contributes meaningfully even when PatchCore’s spatial localization is already strong.
Screw is the honest failure case and the most instructive result. Standalone PatchCore image AUROC of 0.757 is significantly below the other categories and below published baselines for this category (~0.86 with WideResNet50 in the original paper). The root cause is resolution: screw thread defects are fine-grained helical structures that 14px DINOv2 patch tokens partially lose at 224×224 input. Fusion recovers to 0.839 — WinCLIP’s text-based semantic vote (“this screw has thread damage”) partially compensates for PatchCore’s coarse spatial resolution. For production deployment on fine-structure categories, higher input resolution (448×448) is the primary improvement path.
What fusion actually contributes: WinCLIP’s pixel AUROC is weak across all categories (0.61 on transistor, 0.73 on grid). Its 15×15 effective window grid is too coarse for precise defect boundary localization. The α=0.8 weighting reflects this intentionally — PatchCore carries the localization responsibility, WinCLIP corrects the image-level decision boundary. The slight pixel AUROC drop on transistor when fusing (0.9685 → 0.9538) is the direct, expected cost of mixing a weak localizer into a strong one and is acceptable.
Overall conclusion: The fused system achieves a 4-category average image AUROC of 0.9545 and pixel AUROC of 0.9590 with zero defect images in training, using open-source models on a single consumer GPU. The system is strongest on texture and medium-complexity object categories, weakest on fine-structure components where input resolution is the binding constraint. Every design decision — prototype banks over averaged vectors, blocks 8+11 over final block, top-k over max aggregation, α=0.8 over equal weighting — is validated by these ablations.
Technical Stack
| Component | Technology | Purpose |
|---|---|---|
| PatchCore backbone | DINOv2-S / DINOv2-B (timm) | Patch-level self-supervised features |
| WinCLIP backbone | CLIP ViT-B-16-plus-240 (open_clip, laion400m) | Text-prompted zero-shot scoring |
| Similarity retrieval | FAISS IndexFlatL2 | Sub-millisecond kNN search |
| Coreset sampling | K-Center Greedy | Memory bank compression to 10% |
| Deployment | ONNX Runtime (opset 17) | CPU + GPU edge inference |
| Evaluation | scikit-learn | AUROC, ROC curves |
| Demo | Gradio 4.x | Web UI with webcam support |
| Preprocessing | torchvision, OpenCV | Normalize, resize, augment |
Inference Performance
| Runtime | Device | Latency |
|---|---|---|
| PyTorch FP32 | T4 GPU | ~35ms |
| ONNX FP32 | T4 GPU | ~22ms |
| ONNX FP32 | CPU (i7) | ~110ms |
| ONNX INT8 quantized | CPU (i7) | ~65ms |
Key Engineering Decisions
Why prototype banks instead of averaged prototype vectors? The naive approach of averaging all prompt embeddings into one vector causes prototype collapse — both the normal and anomaly centroids converge toward the category-name embedding, producing cosine similarity of 0.99 between them and essentially random anomaly scoring. Keeping all embeddings as a bank and using top-k aggregation allows each query image to find its most relevant prompt, restoring full discriminative power.
Why blocks 8 and 11 of DINOv2? Early blocks encode low-level edges. The final block encodes global semantic identity. Blocks 8 and 11 capture local texture structure and surface appearance — the signal industrial anomaly detection requires. Concatenating two layers at different abstraction levels gives richer patch descriptors than either alone.
Why LAION-400M over OpenAI WebImageText for WinCLIP? LAION-400M contains more diverse product and manufacturing imagery with descriptive captions. The resulting embeddings have better separation between industrial condition states (flawless, contaminated, damaged) than the OpenAI checkpoint, which is biased toward natural image semantics.
Why greedy coreset sampling? Random subsampling at 10% retention introduces coverage gaps in feature space, causing false positives where no normal patch neighbor exists nearby. Greedy farthest-point sampling guarantees the retained coreset maximally covers the full training distribution, preserving accuracy at a fraction of the storage and retrieval cost.
Limitations and Future Work
Screw category performance (0.757 standalone PatchCore image AUROC) is below the published benchmark for this category. The 14px DINOv2 patch stride is too coarse for thread-level defects at 224px input resolution. Higher resolution input (448×448) is the most direct fix, at the cost of 4× more patch embeddings.
WinCLIP pixel localization is fundamentally limited by the 15×15 effective window grid. It should not be used as the primary localizer for precise defect boundary detection — PatchCore should carry that responsibility.
Results cover 4 of 15 MVTec categories. Evaluation on the full benchmark and on VisA would strengthen generalization claims.
Planned: TensorRT FP16/INT8 for Jetson deployment · Video stream inference with temporal smoothing · 448×448 resolution support for fine-structure categories · OpenVINO acceleration for Intel CPU deployment.
References
| Paper | Venue | Year |
|---|---|---|
| PatchCore — Towards Total Recall in Industrial Anomaly Detection | CVPR | 2022 |
| WinCLIP — Zero-/Few-Shot Anomaly Classification and Segmentation | CVPR | 2023 |
| DINOv2 — Learning Robust Visual Features without Supervision | TMLR | 2023 |
| CLIP — Learning Transferable Visual Models from Natural Language | ICML | 2021 |
| MVTec AD — A Comprehensive Real-World Dataset for Unsupervised Anomaly Detection | CVPR | 2019 |
| FAISS — Billion-Scale Similarity Search with GPUs | IEEE Trans. Big Data | 2021 |
Contact
Aniket Patil
GitHub @AniketP04 · LinkedIn · aniketkolte0406@gmail.com
Open a GitHub Issue for bugs, questions, or feature requests.