knownmax commited on
Commit
ed4e653
·
verified ·
1 Parent(s): c46d5b2

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,15 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/bottle_defect.png filter=lfs diff=lfs merge=lfs -text
37
+ examples/bottle_good.png filter=lfs diff=lfs merge=lfs -text
38
+ examples/carpet_defect.png filter=lfs diff=lfs merge=lfs -text
39
+ examples/carpet_good.png filter=lfs diff=lfs merge=lfs -text
40
+ examples/hazelnut_defect.png filter=lfs diff=lfs merge=lfs -text
41
+ examples/hazelnut_good.png filter=lfs diff=lfs merge=lfs -text
42
+ examples/leather_defect.png filter=lfs diff=lfs merge=lfs -text
43
+ examples/leather_good.png filter=lfs diff=lfs merge=lfs -text
44
+ examples/tile_defect.png filter=lfs diff=lfs merge=lfs -text
45
+ examples/tile_good.png filter=lfs diff=lfs merge=lfs -text
46
+ examples/wood_defect.png filter=lfs diff=lfs merge=lfs -text
47
+ examples/wood_good.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,28 @@
1
  ---
2
- title: Patchcore Anomaly Detection
3
- emoji: 📊
4
- colorFrom: green
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.17.3
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Industrial Anomaly Detection — PatchCore Demo
3
+ emoji: 🔍
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: "6.17.3"
 
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ ## Industrial Anomaly Detection with PatchCore
13
+
14
+ Detects surface defects in industrial images using **PatchCore** (Roth et al., CVPR 2022) —
15
+ a training-free method that builds a memory bank of normal patch features and flags anomalies
16
+ as regions far from that bank.
17
+
18
+ **Supported categories (15):** bottle, cable, capsule, carpet, grid, hazelnut, leather,
19
+ metal_nut, pill, screw, tile, toothbrush, transistor, wood, zipper
20
+
21
+ **How it works:** Upload any product image → nearest-neighbour distance from patch features
22
+ to the memory bank → pixel-level anomaly heatmap + image-level score and pass/fail verdict.
23
+
24
+ **Backbone:** WideResNet-101-2 (ImageNet pretrained, layers 2 + 3 concatenated → 1536-dim descriptors)
25
+
26
+ **Note:** Thresholds are not stored in `metrics.json` (only AUROC/PRO metrics are saved).
27
+ The verdict currently uses a default threshold of 0.5 — calibrate per category from the
28
+ score distributions in `results/{category}/score_distribution.png`.
__pycache__/app.cpython-310.pyc ADDED
Binary file (4.8 kB). View file
 
app.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PatchCore Anomaly Detection — Gradio demo.
3
+
4
+ Loads per-category models from results/{category}/patchcore_model.pt and runs
5
+ CPU inference. Thresholds are not stored in metrics.json (only AUROC/PRO metrics
6
+ are saved), so the verdict threshold defaults to 0.5 — adjust FALLBACK_THRESHOLD
7
+ below after inspecting per-category score distributions.
8
+ """
9
+
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ import matplotlib.cm as cm
16
+ import torch
17
+ import torchvision.transforms as T
18
+ from PIL import Image
19
+
20
+
21
+ THRESHOLDS_FILE = Path(__file__).parent / "thresholds.json"
22
+ _thresholds = json.loads(THRESHOLDS_FILE.read_text()) if THRESHOLDS_FILE.exists() else {}
23
+
24
+ def load_threshold(category: str) -> float:
25
+ if category in _thresholds:
26
+ return _thresholds[category]["threshold"] # calibrated from train/good/
27
+ return 0.5 # fallback — run calibrate_thresholds.py to fix this
28
+
29
+ REPO_ROOT = Path(__file__).parent
30
+ RESULTS_DIR = REPO_ROOT / "results"
31
+ DATA_DIR = REPO_ROOT / "anomaly_ds"
32
+
33
+ sys.path.insert(0, str(REPO_ROOT / "src"))
34
+ from patchcore import PatchCore
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Category discovery
39
+ # ---------------------------------------------------------------------------
40
+
41
+ def discover_categories() -> list[str]:
42
+ return sorted(
43
+ d.name for d in RESULTS_DIR.iterdir()
44
+ if d.is_dir() and (d / "patchcore_model.pt").exists()
45
+ )
46
+
47
+
48
+ AVAILABLE_CATEGORIES = discover_categories()
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Model cache (loaded on first use, stays in RAM)
53
+ # ---------------------------------------------------------------------------
54
+
55
+ _model_cache: dict = {}
56
+
57
+
58
+ def load_model(category: str) -> PatchCore:
59
+ if category in _model_cache:
60
+ return _model_cache[category]
61
+
62
+ model_path = str(RESULTS_DIR / category / "patchcore_model.pt")
63
+ model = PatchCore(device="cpu", faiss_gpu=False)
64
+ model.load(model_path)
65
+ _model_cache[category] = model
66
+ return model
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Preprocessing — must match IMAGE_TRANSFORM in src/dataset.py exactly:
71
+ # Resize(256) → CenterCrop(224) → ToTensor → Normalize(ImageNet)
72
+ # ---------------------------------------------------------------------------
73
+
74
+ _transform = T.Compose([
75
+ T.Resize(256),
76
+ T.CenterCrop(224),
77
+ T.ToTensor(),
78
+ T.Normalize(mean=[0.485, 0.456, 0.406],
79
+ std=[0.229, 0.224, 0.225]),
80
+ ])
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Heatmap overlay (visualize.py only exposes a private numpy helper;
85
+ # this PIL version is self-contained and avoids the Agg backend conflict)
86
+ # ---------------------------------------------------------------------------
87
+
88
+ def overlay_heatmap(image: Image.Image, anomaly_map: np.ndarray, alpha: float = 0.45) -> Image.Image:
89
+ image_rgb = image.resize((224, 224)).convert("RGB")
90
+ norm = (anomaly_map - anomaly_map.min()) / (anomaly_map.max() - anomaly_map.min() + 1e-8)
91
+ heatmap_rgba = cm.jet(norm)
92
+ heatmap_rgb = (heatmap_rgba[:, :, :3] * 255).astype(np.uint8)
93
+ heatmap_pil = Image.fromarray(heatmap_rgb).resize(image_rgb.size)
94
+ return Image.blend(image_rgb, heatmap_pil, alpha)
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Main predict function
99
+ # ---------------------------------------------------------------------------
100
+
101
+ def predict(category: str, image: Image.Image):
102
+ if image is None:
103
+ return None, "No image provided.", None
104
+
105
+ model = load_model(category)
106
+ threshold = load_threshold(category)
107
+
108
+ tensor = _transform(image.convert("RGB")).unsqueeze(0) # [1, 3, 224, 224]
109
+
110
+ with torch.no_grad():
111
+ # predict() returns (image_score: float, anomaly_map: np.ndarray [224,224])
112
+ image_score, anomaly_map = model.predict(tensor)
113
+
114
+ verdict = "✅ NORMAL" if image_score < threshold else "❌ ANOMALY"
115
+ heatmap = overlay_heatmap(image, anomaly_map)
116
+
117
+ return float(image_score), verdict, heatmap
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Example images (first 4 categories, one normal + one defective each)
122
+ # ---------------------------------------------------------------------------
123
+
124
+ def build_examples() -> list:
125
+ examples_dir = REPO_ROOT / "examples"
126
+ if not examples_dir.exists():
127
+ return []
128
+ examples = []
129
+ for img_path in sorted(examples_dir.glob("*.png")):
130
+ cat = img_path.stem.rsplit("_", 1)[0] # "bottle_good" → "bottle"
131
+ if cat in AVAILABLE_CATEGORIES:
132
+ examples.append([cat, str(img_path)])
133
+ return examples
134
+
135
+
136
+ EXAMPLES = build_examples()
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Gradio UI
141
+ # ---------------------------------------------------------------------------
142
+
143
+ import gradio as gr
144
+
145
+ with gr.Blocks(title="PatchCore — Industrial Anomaly Detection") as demo:
146
+ gr.Markdown("## PatchCore — Industrial Anomaly Detection")
147
+ gr.Markdown(
148
+ "Select a product category, upload an image (or use your camera), "
149
+ "and see the anomaly score and a pixel-level heatmap.\n\n"
150
+ f"**Available categories ({len(AVAILABLE_CATEGORIES)}):** "
151
+ + ", ".join(AVAILABLE_CATEGORIES)
152
+ )
153
+
154
+ with gr.Row():
155
+ with gr.Column(scale=1):
156
+ category_dd = gr.Dropdown(
157
+ choices=AVAILABLE_CATEGORIES,
158
+ value=AVAILABLE_CATEGORIES[0],
159
+ label="Product category",
160
+ )
161
+ image_in = gr.Image(
162
+ type="pil",
163
+ label="Input image",
164
+ sources=["upload", "webcam"],
165
+ )
166
+ run_btn = gr.Button("Detect Anomalies", variant="primary")
167
+
168
+ with gr.Column(scale=1):
169
+ score_out = gr.Number(label="Anomaly score")
170
+ verdict_out = gr.Textbox(label="Verdict")
171
+ heatmap_out = gr.Image(type="pil", label="Anomaly heatmap overlay")
172
+
173
+ gr.Examples(
174
+ examples=EXAMPLES,
175
+ inputs=[category_dd, image_in],
176
+ label="Try an example",
177
+ )
178
+
179
+ run_btn.click(
180
+ fn=predict,
181
+ inputs=[category_dd, image_in],
182
+ outputs=[score_out, verdict_out, heatmap_out],
183
+ )
184
+
185
+ if __name__ == "__main__":
186
+ demo.launch(theme=gr.themes.Soft())
examples/bottle_defect.png ADDED

Git LFS Details

  • SHA256: 11a691a06a33dd78c5fcab6a822caa48c0d72f27f660e19f5abd9b5aa392b0eb
  • Pointer size: 131 Bytes
  • Size of remote file: 526 kB
examples/bottle_good.png ADDED

Git LFS Details

  • SHA256: 9e7bfe658efe9b6db2607a96405b4ac6880936eaf93b89c85bcd311122ceb57b
  • Pointer size: 131 Bytes
  • Size of remote file: 533 kB
examples/carpet_defect.png ADDED

Git LFS Details

  • SHA256: 12dff41957a900a38a9def83edbea55006c9190109678fc29b0d96b546ecae28
  • Pointer size: 132 Bytes
  • Size of remote file: 1.82 MB
examples/carpet_good.png ADDED

Git LFS Details

  • SHA256: 22c6a63be1eec01601689abb9e794b69ee0aefc1addd9233c63b7e02c70dc0b5
  • Pointer size: 132 Bytes
  • Size of remote file: 1.85 MB
examples/hazelnut_defect.png ADDED

Git LFS Details

  • SHA256: e26b54282b6b11286760905ca2880af594561b606dfa0643859d6ee6027cf8c2
  • Pointer size: 132 Bytes
  • Size of remote file: 1.28 MB
examples/hazelnut_good.png ADDED

Git LFS Details

  • SHA256: 32034b75a2dca53cff86c4833e00a25cd65939859820e96ce9b980359cb08955
  • Pointer size: 132 Bytes
  • Size of remote file: 1.26 MB
examples/leather_defect.png ADDED

Git LFS Details

  • SHA256: c0bc61600c97dede10e577255c6d40c66412b8350b830582a3e128c3a868a34e
  • Pointer size: 132 Bytes
  • Size of remote file: 1.53 MB
examples/leather_good.png ADDED

Git LFS Details

  • SHA256: 64609f35b4b8c88daf42fcc9c34d359d160ea9b88ac8c537a5a8e672b813c18a
  • Pointer size: 132 Bytes
  • Size of remote file: 1.51 MB
examples/tile_defect.png ADDED

Git LFS Details

  • SHA256: d10be1f6ec065f83d54c03b4093d7d87dfcc77617d8eb0df9f5a053aece4becf
  • Pointer size: 132 Bytes
  • Size of remote file: 1.01 MB
examples/tile_good.png ADDED

Git LFS Details

  • SHA256: 179ff21a9d65e9e6371c6383ffa970adfa04502725a3c4d654e08d8377c0b781
  • Pointer size: 131 Bytes
  • Size of remote file: 979 kB
examples/wood_defect.png ADDED

Git LFS Details

  • SHA256: ff5173b6d04d3b94a7d08faa35fba597f60ba466d52e2a0d91773fd334766aa9
  • Pointer size: 132 Bytes
  • Size of remote file: 1.56 MB
examples/wood_good.png ADDED

Git LFS Details

  • SHA256: a76a11277bbe4a558a6ee947bceca800bc331ba0f9ac2077589cecfdccbcee0a
  • Pointer size: 132 Bytes
  • Size of remote file: 1.53 MB
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.0
2
+ torch==2.1.0+cpu
3
+ torchvision==0.16.0+cpu
4
+ --extra-index-url https://download.pytorch.org/whl/cpu
5
+ faiss-cpu>=1.7.4
6
+ timm>=0.9.0
7
+ Pillow>=9.0
8
+ numpy>=1.24
9
+ scipy>=1.10
10
+ matplotlib>=3.7
results/bottle/patchcore_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b7a9e581289b5d32a4530cf9952c220c07551503bfbb81711685ae6a11f63cc3
3
+ size 20138721
results/cable/patchcore_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e1bdd948541d11e4d7ab20522053630e8c7ef7fa1459e0289c044a701830f49a
3
+ size 21582561
results/capsule/patchcore_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bcd0c1ca16f83054e63553647235524a44baccfe49b25d54ab0b5e1a038f28a8
3
+ size 21100257
results/carpet/patchcore_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:191caefcd4a2b469ca4e7002af5d941a459f134dde065c1f04e298fcf6833e02
3
+ size 26976993
results/grid/patchcore_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2d5c934d56e7a9902e4058b88601f210b26d7a1a48439611dc6144f9d3598bd9
3
+ size 25437921
results/hazelnut/patchcore_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:74aed0cb018220a4a512452a2abe2ac1faf49f2009087df47bdf64e5d42c3ca6
3
+ size 37670625
results/leather/patchcore_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3861c6717c9c3581cf5730215446d6e5aead5ca4b4366dfc4ee3d93c1eb15040
3
+ size 23607009
results/metal_nut/patchcore_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:35194312f53d8c4b1f94e35932bf1be18c5c8dab00df6d63bbae9b64d59585e9
3
+ size 21198561
results/pill/patchcore_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c5f31b19dcd8768ac66566d17f52abaff4c0f344c17f908513e305f1678eccba
3
+ size 25726689
results/screw/patchcore_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:340dd308c267495f1ebf41f8ac5ddda2b1e54564dd4846d52527b7c0b7631103
3
+ size 30832353
results/tile/patchcore_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a49a4172b2fbf0bbdc4f0251042fc4ae9d01975fe715e0d8bacee389a79fab57
3
+ size 22160097
results/toothbrush/patchcore_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0046ec9960f4b08714fbd2ca2fe12d66aa691b146559d7dbffbbb70d48228fcb
3
+ size 5783265
results/transistor/patchcore_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:235c110540b698cf7cfdbbbbd2b3e4bd558091cc408621ad84dd38cb50c6cf7f
3
+ size 20522721
results/wood/patchcore_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:28f3d64f6ddf1acd45ca177fc8b79ca6d77044dbee08088f019ee078b22745b7
3
+ size 23797473
results/zipper/patchcore_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6b8d90f9958306b868985bcabc2a02079875713a4921bc43d7dbb6dd6695f7cd
3
+ size 23124705
src/__pycache__/coreset.cpython-310.pyc ADDED
Binary file (2.41 kB). View file
 
src/__pycache__/feature_extractor.cpython-310.pyc ADDED
Binary file (2.82 kB). View file
 
src/__pycache__/patchcore.cpython-310.pyc ADDED
Binary file (7.16 kB). View file
 
src/coreset.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Greedy coreset subsampling via Johnson-Lindenstrauss projection + furthest-point selection.
3
+ Pure PyTorch — no sklearn dependency at subsampling time.
4
+
5
+ Algorithm:
6
+ 1. Project features: F_proj = F @ R, R ~ N(0, 1/sqrt(128)), shape [N, 128]
7
+ 2. Pick a random starting point, seed the coreset C
8
+ 3. Maintain min-distance-to-coreset vector `min_dists` for all remaining points
9
+ 4. Greedily add the point with maximum min-distance
10
+ 5. Repeat until |C| = ceil(ratio * N)
11
+
12
+ """
13
+
14
+ import math
15
+ import torch
16
+
17
+
18
+ PROJ_DIM = 128
19
+ CHUNK_SIZE = 1000 # rows of the full set to process per distance batch
20
+
21
+
22
+ def _pairwise_l2sq_chunked(
23
+ query: torch.Tensor, # [M, D]
24
+ bank: torch.Tensor, # [K, D]
25
+ ) -> torch.Tensor:
26
+ """
27
+ Returns squared L2 distance matrix [M, K] without materializing a huge intermediate.
28
+ Uses the (a-b)^2 = a^2 - 2ab + b^2 expansion.
29
+ """
30
+ # ||a||^2 [M, 1]
31
+ a2 = (query ** 2).sum(dim=1, keepdim=True)
32
+ # ||b||^2 [1, K]
33
+ b2 = (bank ** 2).sum(dim=1, keepdim=True).t()
34
+ # -2 <a, b> [M, K]
35
+ ab = torch.mm(query, bank.t())
36
+ dist2 = a2 + b2 - 2 * ab
37
+ dist2.clamp_(min=0.0) # numerical safety
38
+ return dist2
39
+
40
+
41
+ def subsample_coreset(features: torch.Tensor, ratio: float) -> torch.Tensor:
42
+ """
43
+ Greedy furthest-point coreset subsampling.
44
+
45
+ Args:
46
+ features : [N, D] float32 tensor on GPU (or CPU)
47
+ ratio : fraction of points to keep (e.g. 0.01 = 1%)
48
+
49
+ Returns:
50
+ [M, D] coreset tensor on the same device as `features`,
51
+ where M = ceil(ratio * N).
52
+ """
53
+ N, D = features.shape
54
+ M = max(1, math.ceil(ratio * N))
55
+ device = features.device
56
+
57
+ # --- Johnson-Lindenstrauss projection for distance computation only ---
58
+ torch.manual_seed(42)
59
+ R = torch.randn(D, PROJ_DIM, device=device) / math.sqrt(PROJ_DIM)
60
+ F_proj = features @ R # [N, PROJ_DIM] (used only for distances)
61
+
62
+ # --- Initialise with a random seed point ---
63
+ start_idx = torch.randint(0, N, (1,)).item()
64
+ coreset_indices = [start_idx]
65
+
66
+ # min distance from each point to the current coreset (in projected space)
67
+ # Initialise with distance to the first coreset point
68
+ first = F_proj[start_idx].unsqueeze(0) # [1, PROJ_DIM]
69
+ min_dists = _pairwise_l2sq_chunked(F_proj, first).squeeze(1) # [N]
70
+
71
+ # --- Greedy furthest-point loop ---
72
+ for _ in range(M - 1):
73
+ new_idx = int(min_dists.argmax().item())
74
+ coreset_indices.append(new_idx)
75
+
76
+ # Update min_dists: distance from every point to the newly added coreset member
77
+ new_pt = F_proj[new_idx].unsqueeze(0) # [1, PROJ_DIM]
78
+
79
+ # Chunked to avoid OOM
80
+ for start in range(0, N, CHUNK_SIZE):
81
+ end = min(start + CHUNK_SIZE, N)
82
+ chunk_dists = _pairwise_l2sq_chunked(
83
+ F_proj[start:end], new_pt
84
+ ).squeeze(1) # [chunk]
85
+ min_dists[start:end] = torch.minimum(
86
+ min_dists[start:end], chunk_dists
87
+ )
88
+
89
+ idx_tensor = torch.tensor(coreset_indices, dtype=torch.long, device=device)
90
+ return features[idx_tensor] # [M, D] full-dim vectors
src/feature_extractor.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Feature extractor using WideResNet-101-2 backbone (timm).
3
+ Extracts multi-scale patch descriptors from layer2 (stride 8) and layer3 (stride 16).
4
+ """
5
+
6
+ import torch
7
+ import torch.nn as nn
8
+ import torch.nn.functional as F
9
+ import timm
10
+
11
+
12
+ class PatchFeatureExtractor(nn.Module):
13
+ """
14
+ Extracts L2-normalized patch descriptors from WideResNet-101-2.
15
+
16
+ For a 224×224 input:
17
+ - layer2 output: [B, 512, 28, 28]
18
+ - layer3 output: [B, 1024, 14, 14]
19
+
20
+ Both are spatially aligned to 28×28 via adaptive avg pooling on layer3,
21
+ then concatenated → [B, 1536, 28, 28].
22
+ Each of the 28×28 = 784 spatial locations yields a 1536-dim descriptor.
23
+
24
+ No training — runs in eval mode with torch.no_grad().
25
+ """
26
+
27
+ def __init__(self, backbone: str = "wide_resnet101_2", pretrained: bool = True):
28
+ super().__init__()
29
+ # features_only=True exposes intermediate feature maps
30
+ self.backbone = timm.create_model(
31
+ backbone,
32
+ pretrained=pretrained,
33
+ features_only=True,
34
+ out_indices=(1, 2), # layer2=index1 (512ch), layer3=index2 (1024ch)
35
+ )
36
+ self.backbone.eval()
37
+ for param in self.backbone.parameters():
38
+ param.requires_grad_(False)
39
+
40
+ @torch.no_grad()
41
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
42
+ """
43
+ Args:
44
+ x: [B, 3, 224, 224] ImageNet-normalised images.
45
+ Returns:
46
+ patch_features: [B, H*W, 1536] L2-normalised patch descriptors
47
+ where H=W=28 for 224px input.
48
+ """
49
+ features = self.backbone(x) # list: [layer2, layer3]
50
+ f2 = features[0] # [B, 512, 28, 28]
51
+ f3 = features[1] # [B, 1024, 14, 14]
52
+
53
+ # Upsample layer3 to match layer2 spatial size
54
+ f3_up = F.adaptive_avg_pool2d(f3, output_size=f2.shape[-2:]) # [B, 1024, 28, 28]
55
+
56
+ # Concatenate along channel dim
57
+ combined = torch.cat([f2, f3_up], dim=1) # [B, 1536, 28, 28]
58
+
59
+ B, C, H, W = combined.shape
60
+ # Reshape to [B, H*W, C]
61
+ patch_features = combined.permute(0, 2, 3, 1).reshape(B, H * W, C)
62
+
63
+ # L2 normalize each descriptor
64
+ patch_features = F.normalize(patch_features, p=2, dim=-1)
65
+
66
+ return patch_features
67
+
68
+ @torch.no_grad()
69
+ def extract_patch_features(self, images: torch.Tensor) -> torch.Tensor:
70
+ """
71
+ Convenience method: flatten all patches across batch.
72
+ Args:
73
+ images: [B, 3, 224, 224]
74
+ Returns:
75
+ [B * H * W, 1536] — ready for coreset subsampling or memory bank
76
+ """
77
+ patch_features = self.forward(images) # [B, 784, 1536]
78
+ B, N, C = patch_features.shape
79
+ return patch_features.reshape(B * N, C)
src/patchcore.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PatchCore model: fit (feature extraction + coreset indexing) and predict (KNN scoring).
3
+
4
+ No training loop — PatchCore is purely:
5
+ fit : one forward pass over normal images → coreset memory bank
6
+ predict: KNN distance query for each test patch → anomaly map + image score
7
+ """
8
+
9
+ import warnings
10
+ from pathlib import Path
11
+ from typing import Tuple
12
+
13
+ import numpy as np
14
+ import torch
15
+ import torch.nn.functional as F
16
+ from scipy.ndimage import gaussian_filter
17
+ from tqdm import tqdm
18
+
19
+ from feature_extractor import PatchFeatureExtractor
20
+ from coreset import subsample_coreset
21
+
22
+
23
+ class PatchCore:
24
+ """
25
+ PatchCore anomaly detector.
26
+
27
+ Args:
28
+ backbone : timm model name (default: 'wide_resnet101_2')
29
+ coreset_ratio : fraction of patches to keep in memory bank (default: 0.01)
30
+ device : torch device string (default: 'cuda')
31
+ faiss_gpu : use faiss GPU index (default: True, fallback to CPU)
32
+ gaussian_sigma: sigma for anomaly map smoothing (default: 4)
33
+ """
34
+
35
+ PATCH_GRID = 28 # spatial grid size for 224px input (both axes)
36
+ FEAT_DIM = 1536 # layer2 (512) + layer3 (1024)
37
+
38
+ def __init__(
39
+ self,
40
+ backbone: str = "wide_resnet101_2",
41
+ coreset_ratio: float = 0.01,
42
+ device: str = "cuda",
43
+ faiss_gpu: bool = True,
44
+ gaussian_sigma: float = 4.0,
45
+ ):
46
+ self.backbone = backbone
47
+ self.coreset_ratio = coreset_ratio
48
+ self.device = torch.device(device if torch.cuda.is_available() else "cpu")
49
+ self.faiss_gpu = faiss_gpu
50
+ self.gaussian_sigma = gaussian_sigma
51
+
52
+ self.extractor = PatchFeatureExtractor(backbone=backbone, pretrained=True)
53
+ self.extractor = self.extractor.to(self.device)
54
+
55
+ self.memory_bank: torch.Tensor = None # [M, 1536]
56
+ self._faiss_index = None
57
+ self._index_backend = None
58
+
59
+ # ------------------------------------------------------------------
60
+ # Fit
61
+ # ------------------------------------------------------------------
62
+
63
+ def fit(self, train_loader) -> None:
64
+ """
65
+ Extract patch features from all normal training images,
66
+ run coreset subsampling, build faiss index.
67
+ """
68
+ print("[PatchCore] Extracting features from training images …")
69
+ all_features = []
70
+
71
+ self.extractor.eval()
72
+ with torch.no_grad():
73
+ for batch in tqdm(train_loader, desc=" Feature extraction", leave=False):
74
+ if isinstance(batch, (list, tuple)):
75
+ imgs = batch[0]
76
+ else:
77
+ imgs = batch
78
+ imgs = imgs.to(self.device)
79
+ feats = self.extractor.extract_patch_features(imgs) # [B*784, 1536]
80
+ all_features.append(feats.cpu())
81
+
82
+ all_features = torch.cat(all_features, dim=0) # [N_total, 1536]
83
+ print(f"[PatchCore] Total patches before coreset: {len(all_features):,}")
84
+
85
+ # Move to GPU for fast coreset computation
86
+ all_features = all_features.to(self.device)
87
+
88
+ print(f"[PatchCore] Running coreset subsampling (ratio={self.coreset_ratio}) …")
89
+ self.memory_bank = subsample_coreset(all_features, self.coreset_ratio)
90
+ print(f"[PatchCore] Memory bank size after coreset: {len(self.memory_bank):,} "
91
+ f"({self.memory_bank.element_size() * self.memory_bank.numel() / 1e6:.1f} MB)")
92
+
93
+ self._build_faiss_index()
94
+
95
+ def _build_faiss_index(self) -> None:
96
+ """Build KNN backend from the memory bank (faiss preferred, torch fallback)."""
97
+ bank_np = self.memory_bank.cpu().numpy().astype(np.float32)
98
+
99
+ try:
100
+ import faiss
101
+
102
+ d = bank_np.shape[1]
103
+ index_flat = faiss.IndexFlatL2(d)
104
+
105
+ if self.faiss_gpu and torch.cuda.is_available():
106
+ try:
107
+ res = faiss.StandardGpuResources()
108
+ self._faiss_index = faiss.index_cpu_to_gpu(res, 0, index_flat)
109
+ self._index_backend = "faiss-gpu"
110
+ print("[PatchCore] Using faiss GPU index.")
111
+ except Exception as e:
112
+ warnings.warn(f"[PatchCore] faiss GPU index failed ({e}); falling back to faiss CPU.")
113
+ self._faiss_index = index_flat
114
+ self._index_backend = "faiss-cpu"
115
+ else:
116
+ self._faiss_index = index_flat
117
+ self._index_backend = "faiss-cpu"
118
+
119
+ self._faiss_index.add(bank_np)
120
+
121
+ except Exception as e:
122
+ warnings.warn(
123
+ "[PatchCore] faiss is unavailable/incompatible; "
124
+ f"falling back to torch KNN search. Details: {e}"
125
+ )
126
+ # Keep a contiguous float bank on the configured device for fallback KNN.
127
+ self.memory_bank = self.memory_bank.float().contiguous().to(self.device)
128
+ self._faiss_index = None
129
+ self._index_backend = "torch"
130
+
131
+ def _search_knn(self, feats_np: np.ndarray, k: int = 1) -> Tuple[np.ndarray, np.ndarray]:
132
+ """Return (squared_l2_distances, indices) for nearest neighbours."""
133
+ if self._index_backend in ("faiss-gpu", "faiss-cpu"):
134
+ return self._faiss_index.search(feats_np, k=k)
135
+
136
+ # Torch fallback for environments where faiss import fails (e.g., NumPy ABI mismatch).
137
+ query = torch.from_numpy(feats_np).to(self.device, dtype=torch.float32)
138
+ bank = self.memory_bank
139
+
140
+ # Chunking keeps peak memory usage bounded for larger memory banks.
141
+ chunk_size = 1024
142
+ dist_chunks = []
143
+ idx_chunks = []
144
+
145
+ with torch.no_grad():
146
+ for start in range(0, query.shape[0], chunk_size):
147
+ q = query[start:start + chunk_size] # [Q, D]
148
+ d2 = torch.sum((q[:, None, :] - bank[None, :, :]) ** 2, dim=-1) # [Q, M]
149
+ vals, idxs = torch.topk(d2, k=k, dim=1, largest=False)
150
+ dist_chunks.append(vals)
151
+ idx_chunks.append(idxs)
152
+
153
+ distances = torch.cat(dist_chunks, dim=0).detach().cpu().numpy().astype(np.float32)
154
+ indices = torch.cat(idx_chunks, dim=0).detach().cpu().numpy().astype(np.int64)
155
+ return distances, indices
156
+
157
+ # ------------------------------------------------------------------
158
+ # Predict
159
+ # ------------------------------------------------------------------
160
+
161
+ @torch.no_grad()
162
+ def predict(self, image_tensor: torch.Tensor) -> Tuple[float, np.ndarray]:
163
+ """
164
+ Compute anomaly score and pixel-level anomaly map for a single image.
165
+
166
+ Args:
167
+ image_tensor: [1, 3, 224, 224] normalised image tensor
168
+
169
+ Returns:
170
+ image_score : float — max patch distance (image-level anomaly score)
171
+ anomaly_map : np.ndarray [224, 224] — smoothed, upsampled patch distance map
172
+ """
173
+ image_tensor = image_tensor.to(self.device)
174
+
175
+ # Extract patch features: [1*784, 1536]
176
+ feats = self.extractor.extract_patch_features(image_tensor)
177
+ feats_np = feats.cpu().numpy().astype(np.float32)
178
+
179
+ # KNN distance query (k=1 nearest neighbour)
180
+ distances, _ = self._search_knn(feats_np, k=1) # [N_patches, 1]
181
+ patch_scores = distances[:, 0] # [N_patches] squared L2 distances
182
+
183
+ # Compute actual grid size from number of patches (robust to different feature extractors)
184
+ num_patches = len(patch_scores)
185
+ patch_grid = int(np.sqrt(num_patches))
186
+ assert patch_grid * patch_grid == num_patches, \
187
+ f"Non-square patch grid: {num_patches} patches (expected {patch_grid}²)"
188
+
189
+ # Reshape to spatial grid
190
+ score_map = patch_scores.reshape(patch_grid, patch_grid)
191
+
192
+ # Upsample to 224×224 via bilinear interpolation
193
+ score_tensor = torch.from_numpy(score_map).unsqueeze(0).unsqueeze(0) # [1,1,G,G]
194
+ score_upsampled = F.interpolate(
195
+ score_tensor, size=(224, 224), mode="bilinear", align_corners=False
196
+ ).squeeze().numpy() # [224, 224]
197
+
198
+ # Gaussian smoothing
199
+ anomaly_map = gaussian_filter(score_upsampled, sigma=self.gaussian_sigma)
200
+
201
+ # Image-level score: max patch distance
202
+ image_score = float(patch_scores.max())
203
+
204
+ return image_score, anomaly_map
205
+
206
+ # ------------------------------------------------------------------
207
+ # Save / Load
208
+ # ------------------------------------------------------------------
209
+
210
+ def save(self, path: str) -> None:
211
+ """Serialize memory bank and config to a .pt file."""
212
+ torch.save(
213
+ {
214
+ "memory_bank": self.memory_bank.cpu(),
215
+ "backbone": self.backbone,
216
+ "coreset_ratio": self.coreset_ratio,
217
+ "gaussian_sigma": self.gaussian_sigma,
218
+ },
219
+ path,
220
+ )
221
+ print(f"[PatchCore] Model saved to {path}")
222
+
223
+ def load(self, path: str) -> None:
224
+ """Load memory bank and config from a .pt file, rebuild faiss index."""
225
+ ckpt = torch.load(path, map_location="cpu")
226
+ self.memory_bank = ckpt["memory_bank"].to(self.device)
227
+ self.backbone = ckpt["backbone"]
228
+ self.coreset_ratio = ckpt["coreset_ratio"]
229
+ self.gaussian_sigma = ckpt["gaussian_sigma"]
230
+ self._build_faiss_index()
231
+ print(f"[PatchCore] Model loaded from {path} "
232
+ f"(memory bank: {len(self.memory_bank):,} vectors)")
src/visualize.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Visualization utilities for PatchCore anomaly detection results.
3
+
4
+ Produces:
5
+ - anomaly_grid.png : 8 anomalous + 8 normal samples with heatmap overlays
6
+ - roc_curves.png : Image AUROC | Pixel AUROC | PRO curve (3-subplot figure)
7
+ - score_distribution.png : KDE of anomaly scores, normal vs anomalous
8
+ """
9
+
10
+ import os
11
+ from pathlib import Path
12
+ from typing import List, Optional, Tuple
13
+
14
+ import numpy as np
15
+ import matplotlib
16
+ matplotlib.use("Agg")
17
+ import matplotlib.pyplot as plt
18
+ import matplotlib.cm as cm
19
+ import seaborn as sns
20
+ import cv2
21
+
22
+ IMAGENET_MEAN = np.array([0.485, 0.456, 0.406])
23
+ IMAGENET_STD = np.array([0.229, 0.224, 0.225])
24
+
25
+
26
+ def _denormalize(tensor_img) -> np.ndarray:
27
+ """Convert [3, H, W] normalised tensor to [H, W, 3] uint8 numpy image."""
28
+ img = tensor_img.cpu().numpy().transpose(1, 2, 0)
29
+ img = img * IMAGENET_STD + IMAGENET_MEAN
30
+ img = np.clip(img, 0, 1)
31
+ return (img * 255).astype(np.uint8)
32
+
33
+
34
+ def _heatmap_overlay(img_np: np.ndarray, anomaly_map: np.ndarray, alpha: float = 0.4) -> np.ndarray:
35
+ """Overlay jet colormap anomaly map onto RGB image."""
36
+ score_norm = (anomaly_map - anomaly_map.min()) / (anomaly_map.max() - anomaly_map.min() + 1e-8)
37
+ heatmap = (cm.jet(score_norm)[:, :, :3] * 255).astype(np.uint8)
38
+ overlay = (alpha * heatmap + (1 - alpha) * img_np).astype(np.uint8)
39
+ return overlay
40
+
41
+
42
+ def save_anomaly_grid(
43
+ test_dataset,
44
+ anomaly_maps: List[np.ndarray],
45
+ scores: List[float],
46
+ labels: List[int],
47
+ save_path: str,
48
+ n: int = 16,
49
+ ) -> None:
50
+ """
51
+ Select 8 anomalous + 8 normal samples.
52
+ For each: original image | GT mask | anomaly heatmap overlay.
53
+ """
54
+ os.makedirs(Path(save_path).parent, exist_ok=True)
55
+
56
+ anomaly_idx = [i for i, l in enumerate(labels) if l == 1]
57
+ normal_idx = [i for i, l in enumerate(labels) if l == 0]
58
+
59
+ n_each = n // 2
60
+ # Sample uniformly if more samples than needed
61
+ if len(anomaly_idx) > n_each:
62
+ step = len(anomaly_idx) // n_each
63
+ anomaly_idx = anomaly_idx[::step][:n_each]
64
+ if len(normal_idx) > n_each:
65
+ step = len(normal_idx) // n_each
66
+ normal_idx = normal_idx[::step][:n_each]
67
+
68
+ selected = anomaly_idx[:n_each] + normal_idx[:n_each]
69
+ actual_n = len(selected)
70
+
71
+ fig, axes = plt.subplots(actual_n, 3, figsize=(9, actual_n * 3))
72
+ if actual_n == 1:
73
+ axes = axes[np.newaxis, :]
74
+
75
+ for row, idx in enumerate(selected):
76
+ img_tensor, label, gt_mask_tensor, defect_type = test_dataset[idx]
77
+
78
+ img_np = _denormalize(img_tensor)
79
+ gt_np = gt_mask_tensor.squeeze().cpu().numpy()
80
+ amap = anomaly_maps[idx]
81
+ score = scores[idx]
82
+ tag = "ANOMALY" if label == 1 else "NORMAL"
83
+ color = "red" if label == 1 else "green"
84
+
85
+ overlay = _heatmap_overlay(img_np, amap)
86
+
87
+ axes[row, 0].imshow(img_np)
88
+ axes[row, 0].set_title(f"[{tag}] score={score:.3f}", color=color, fontsize=8)
89
+ axes[row, 0].axis("off")
90
+
91
+ axes[row, 1].imshow(gt_np, cmap="gray", vmin=0, vmax=1)
92
+ axes[row, 1].set_title("GT Mask", fontsize=8)
93
+ axes[row, 1].axis("off")
94
+
95
+ axes[row, 2].imshow(overlay)
96
+ axes[row, 2].set_title("Anomaly Heatmap", fontsize=8)
97
+ axes[row, 2].axis("off")
98
+
99
+ plt.tight_layout()
100
+ plt.savefig(save_path, dpi=300, bbox_inches="tight")
101
+ plt.close(fig)
102
+ print(f" Saved anomaly grid → {save_path}")
103
+
104
+
105
+ def save_roc_curves(
106
+ image_roc: dict,
107
+ pixel_roc_auroc: float,
108
+ pro_curve: dict,
109
+ save_path: str,
110
+ ) -> None:
111
+ """
112
+ 3-subplot figure: Image AUROC | Pixel AUROC | PRO curve.
113
+ """
114
+ os.makedirs(Path(save_path).parent, exist_ok=True)
115
+
116
+ fig, axes = plt.subplots(1, 3, figsize=(15, 5))
117
+
118
+ # --- Image AUROC ---
119
+ ax = axes[0]
120
+ ax.plot(image_roc["fpr"], image_roc["tpr"], lw=2,
121
+ label=f'AUROC={image_roc["auroc"]:.3f}')
122
+ ax.plot([0, 1], [0, 1], "k--", lw=1)
123
+ # Mark operating point (Youden's J)
124
+ thr = image_roc["threshold"]
125
+ fpr_arr, tpr_arr = image_roc["fpr"], image_roc["tpr"]
126
+ j = tpr_arr - fpr_arr
127
+ best = int(np.argmax(j))
128
+ ax.scatter(fpr_arr[best], tpr_arr[best], marker="o", color="red", zorder=5,
129
+ label=f"Threshold={thr:.3f}")
130
+ ax.set_xlabel("FPR")
131
+ ax.set_ylabel("TPR")
132
+ ax.set_title("Image-level ROC")
133
+ ax.legend(fontsize=8)
134
+ ax.grid(True, alpha=0.3)
135
+
136
+ # --- Pixel AUROC (text only — full pixel ROC arrays not stored to save memory) ---
137
+ ax = axes[1]
138
+ ax.text(0.5, 0.5, f"Pixel AUROC\n{pixel_roc_auroc:.4f}",
139
+ ha="center", va="center", fontsize=20, transform=ax.transAxes,
140
+ bbox=dict(boxstyle="round", facecolor="lightblue", alpha=0.5))
141
+ ax.set_title("Pixel-level AUROC")
142
+ ax.axis("off")
143
+
144
+ # --- PRO curve ---
145
+ ax = axes[2]
146
+ fpr_pro = pro_curve["fpr_array"]
147
+ pro_pro = pro_curve["pro_array"]
148
+ ax.plot(fpr_pro, pro_pro, lw=2, label=f'PRO-AUC={pro_curve["pro_auc"]:.3f}')
149
+ ax.axvline(x=0.3, color="red", linestyle="--", lw=1, label="FPR=0.3 cutoff")
150
+ ax.set_xlabel("Mean FPR")
151
+ ax.set_ylabel("Mean Per-Region Overlap")
152
+ ax.set_title("PRO Curve")
153
+ ax.legend(fontsize=8)
154
+ ax.grid(True, alpha=0.3)
155
+
156
+ plt.tight_layout()
157
+ plt.savefig(save_path, dpi=300, bbox_inches="tight")
158
+ plt.close(fig)
159
+ print(f" Saved ROC curves → {save_path}")
160
+
161
+
162
+ def save_score_distribution(
163
+ scores: List[float],
164
+ labels: List[int],
165
+ save_path: str,
166
+ ) -> None:
167
+ """KDE plot of anomaly score distributions: normal vs anomalous with overlap shading."""
168
+ os.makedirs(Path(save_path).parent, exist_ok=True)
169
+
170
+ scores = np.array(scores)
171
+ labels = np.array(labels)
172
+ normal_scores = scores[labels == 0]
173
+ anomaly_scores = scores[labels == 1]
174
+
175
+ fig, ax = plt.subplots(figsize=(8, 5))
176
+
177
+ if len(normal_scores) > 1:
178
+ sns.kdeplot(normal_scores, ax=ax, label="Normal", color="green", fill=True, alpha=0.4)
179
+ if len(anomaly_scores) > 1:
180
+ sns.kdeplot(anomaly_scores, ax=ax, label="Anomalous", color="red", fill=True, alpha=0.4)
181
+
182
+ ax.set_xlabel("Anomaly Score (max patch distance)")
183
+ ax.set_ylabel("Density")
184
+ ax.set_title("Score Distribution: Normal vs Anomalous")
185
+ ax.legend()
186
+ ax.grid(True, alpha=0.3)
187
+
188
+ plt.tight_layout()
189
+ plt.savefig(save_path, dpi=300, bbox_inches="tight")
190
+ plt.close(fig)
191
+ print(f" Saved score dist → {save_path}")
thresholds.json ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "bottle": {
3
+ "threshold": 0.2890068769454956,
4
+ "percentile": 95,
5
+ "n_images": 209,
6
+ "score_min": 0.24581491947174072,
7
+ "score_mean": 0.2680253663131495,
8
+ "score_max": 0.3055186867713928
9
+ },
10
+ "cable": {
11
+ "threshold": 0.5094758927822113,
12
+ "percentile": 95,
13
+ "n_images": 224,
14
+ "score_min": 0.4513741135597229,
15
+ "score_mean": 0.4833795888615506,
16
+ "score_max": 0.536372184753418
17
+ },
18
+ "capsule": {
19
+ "threshold": 0.1864305555820465,
20
+ "percentile": 95,
21
+ "n_images": 219,
22
+ "score_min": 0.1464100480079651,
23
+ "score_mean": 0.16722383836632995,
24
+ "score_max": 0.20267021656036377
25
+ },
26
+ "carpet": {
27
+ "threshold": 0.2884562313556671,
28
+ "percentile": 95,
29
+ "n_images": 280,
30
+ "score_min": 0.25853419303894043,
31
+ "score_mean": 0.27355455990348543,
32
+ "score_max": 0.3000943660736084
33
+ },
34
+ "grid": {
35
+ "threshold": 0.2998784899711609,
36
+ "percentile": 95,
37
+ "n_images": 264,
38
+ "score_min": 0.25811439752578735,
39
+ "score_mean": 0.28114455396478827,
40
+ "score_max": 0.3175504207611084
41
+ },
42
+ "hazelnut": {
43
+ "threshold": 0.38583904504776,
44
+ "percentile": 95,
45
+ "n_images": 391,
46
+ "score_min": 0.32867467403411865,
47
+ "score_mean": 0.36304525600369936,
48
+ "score_max": 0.4044198989868164
49
+ },
50
+ "leather": {
51
+ "threshold": 0.2573680400848389,
52
+ "percentile": 95,
53
+ "n_images": 245,
54
+ "score_min": 0.22839152812957764,
55
+ "score_mean": 0.244711997071091,
56
+ "score_max": 0.27069514989852905
57
+ },
58
+ "metal_nut": {
59
+ "threshold": 0.4237459897994995,
60
+ "percentile": 95,
61
+ "n_images": 220,
62
+ "score_min": 0.3752959370613098,
63
+ "score_mean": 0.40138462700627064,
64
+ "score_max": 0.4571906328201294
65
+ },
66
+ "pill": {
67
+ "threshold": 0.3175112664699554,
68
+ "percentile": 95,
69
+ "n_images": 267,
70
+ "score_min": 0.2706294059753418,
71
+ "score_mean": 0.295599180214414,
72
+ "score_max": 0.3364473581314087
73
+ },
74
+ "screw": {
75
+ "threshold": 0.3218137860298157,
76
+ "percentile": 95,
77
+ "n_images": 320,
78
+ "score_min": 0.275600790977478,
79
+ "score_mean": 0.30216500014066694,
80
+ "score_max": 0.35416650772094727
81
+ },
82
+ "tile": {
83
+ "threshold": 0.4006050288677215,
84
+ "percentile": 95,
85
+ "n_images": 230,
86
+ "score_min": 0.35888969898223877,
87
+ "score_mean": 0.3827742672484854,
88
+ "score_max": 0.4205925464630127
89
+ },
90
+ "toothbrush": {
91
+ "threshold": 0.4440391033887863,
92
+ "percentile": 95,
93
+ "n_images": 60,
94
+ "score_min": 0.38699811697006226,
95
+ "score_mean": 0.4185704320669174,
96
+ "score_max": 0.4575169086456299
97
+ },
98
+ "transistor": {
99
+ "threshold": 0.4263874530792236,
100
+ "percentile": 95,
101
+ "n_images": 213,
102
+ "score_min": 0.3608095049858093,
103
+ "score_mean": 0.4002147546396569,
104
+ "score_max": 0.44323837757110596
105
+ },
106
+ "wood": {
107
+ "threshold": 0.3080090880393982,
108
+ "percentile": 95,
109
+ "n_images": 247,
110
+ "score_min": 0.2672466039657593,
111
+ "score_mean": 0.2905003445351172,
112
+ "score_max": 0.3450666666030884
113
+ },
114
+ "zipper": {
115
+ "threshold": 0.18321026563644408,
116
+ "percentile": 95,
117
+ "n_images": 240,
118
+ "score_min": 0.15239810943603516,
119
+ "score_mean": 0.16628144855300586,
120
+ "score_max": 0.19208133220672607
121
+ }
122
+ }