davidclara commited on
Commit
6fbf967
·
verified ·
1 Parent(s): 70f27ca

Upload inference.py

Browse files
Files changed (1) hide show
  1. inference.py +128 -0
inference.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run a davidclara/building-block-vectorization model from Hugging Face on a map image.
2
+
3
+ Averages predictions across cross-validation folds with a Gaussian-weighted
4
+ sliding window, thresholds with the ensemble threshold from config.json, and
5
+ writes a single binary PNG mask.
6
+
7
+ Example:
8
+ python inference.py \\
9
+ --hf-repo davidclara/building-block-vectorization \\
10
+ --model-name unet_scse \\
11
+ --image map.jpg \\
12
+ --out mask.png
13
+ """
14
+
15
+ import argparse
16
+ import inspect
17
+ import json
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+ import segmentation_models_pytorch as smp
22
+ import torch
23
+ from huggingface_hub import hf_hub_download
24
+ from PIL import Image
25
+ from safetensors.torch import load_file
26
+
27
+ Image.MAX_IMAGE_PIXELS = None
28
+ IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
29
+ IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
30
+ SMP = {
31
+ "unet": smp.Unet,
32
+ "unetpp": smp.UnetPlusPlus,
33
+ "deeplabv3p": smp.DeepLabV3Plus,
34
+ "fpn": smp.FPN,
35
+ "pan": smp.PAN,
36
+ }
37
+
38
+
39
+ def build_model(model_cfg: dict) -> torch.nn.Module:
40
+ cfg = dict(model_cfg)
41
+ name = cfg.pop("name")
42
+ cfg["classes"] = cfg.pop("num_classes")
43
+ cfg["encoder_weights"] = None # weights come from safetensors
44
+ cls = SMP[name]
45
+ accepted = set(inspect.signature(cls).parameters)
46
+ return cls(**{k: v for k, v in cfg.items() if k in accepted})
47
+
48
+
49
+ def gaussian_kernel(size: int, sigma_ratio: float = 0.125) -> np.ndarray:
50
+ sigma = size * sigma_ratio
51
+ ax = np.arange(size) - (size - 1) / 2.0
52
+ g1d = np.exp(-(ax**2) / (2 * sigma**2))
53
+ g2d = np.outer(g1d, g1d)
54
+ return (g2d / g2d.max()).astype(np.float32)
55
+
56
+
57
+ def sliding_window_ensemble(models, img, patch, stride, n_classes, device):
58
+ _, H, W = img.shape
59
+ probs_sum = np.zeros((n_classes, H, W), dtype=np.float32)
60
+ weight_sum = np.zeros((H, W), dtype=np.float32)
61
+ kernel = gaussian_kernel(patch)
62
+ rows = sorted({*range(0, max(H - patch, 0) + 1, stride), max(H - patch, 0)})
63
+ cols = sorted({*range(0, max(W - patch, 0) + 1, stride), max(W - patch, 0)})
64
+ with torch.no_grad():
65
+ for r in rows:
66
+ for c in cols:
67
+ tile = img[:, r : r + patch, c : c + patch]
68
+ ph, pw = patch - tile.shape[1], patch - tile.shape[2]
69
+ if ph or pw:
70
+ tile = np.pad(tile, ((0, 0), (0, ph), (0, pw)))
71
+ x = torch.from_numpy(tile).unsqueeze(0).to(device)
72
+ # average sigmoid(logits) across folds (matches predict.py)
73
+ fold_probs = None
74
+ for m in models:
75
+ logits = m(x).cpu().numpy()[0]
76
+ p = 1.0 / (1.0 + np.exp(-np.clip(logits, -88, 88)))
77
+ fold_probs = p if fold_probs is None else fold_probs + p
78
+ fold_probs = fold_probs / len(models)
79
+ h, w = patch - ph, patch - pw
80
+ g = kernel[:h, :w]
81
+ probs_sum[:, r : r + h, c : c + w] += fold_probs[:, :h, :w] * g[None]
82
+ weight_sum[r : r + h, c : c + w] += g
83
+ return probs_sum / np.maximum(weight_sum, 1e-8)
84
+
85
+
86
+ def main() -> None:
87
+ ap = argparse.ArgumentParser()
88
+ ap.add_argument("--hf-repo", required=True)
89
+ ap.add_argument("--model-name", required=True)
90
+ ap.add_argument("--image", required=True)
91
+ ap.add_argument("--out", default="mask.png")
92
+ args = ap.parse_args()
93
+
94
+ cfg_path = hf_hub_download(args.hf_repo, f"{args.model_name}/config.json")
95
+ cfg = json.loads(Path(cfg_path).read_text())
96
+ n_folds = cfg["n_folds"]
97
+ fold_paths = [
98
+ hf_hub_download(args.hf_repo, f"{args.model_name}/model_f{i}.safetensors")
99
+ for i in range(n_folds)
100
+ ]
101
+
102
+ device = "cuda" if torch.cuda.is_available() else ("mps" if torch.backends.mps.is_available() else "cpu")
103
+ classes = cfg["class_names"]
104
+ patch = cfg["patch_size"]
105
+ normalize_mode = cfg.get("normalize_mode", "imagenet")
106
+ thrs = cfg.get("ensemble_thresholds") or {}
107
+ thr = np.array([thrs.get(n, 0.5) for n in classes], dtype=np.float32).reshape(-1, 1, 1)
108
+
109
+ img = np.asarray(Image.open(args.image).convert("RGB"), dtype=np.float32) / 255.0
110
+ if normalize_mode == "imagenet":
111
+ img = (img - IMAGENET_MEAN) / IMAGENET_STD
112
+ img = img.transpose(2, 0, 1)
113
+
114
+ models = []
115
+ for wts_path in fold_paths:
116
+ m = build_model(cfg["model"]).to(device).eval()
117
+ m.load_state_dict(load_file(wts_path))
118
+ models.append(m)
119
+ print(f"Loaded {n_folds} fold(s), device={device}, normalize_mode={normalize_mode}")
120
+
121
+ probs = sliding_window_ensemble(models, img, patch, patch // 2, len(classes), device)
122
+ binary = (probs > thr).astype(np.uint8)[0]
123
+ Image.fromarray(binary * 255, "L").save(args.out)
124
+ print(f"Wrote mask to {args.out}")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()