Model card / docstring: state the input scaling that actually works (clip [0,200] / 255)

#1
Files changed (2) hide show
  1. README.md +197 -193
  2. modeling_inkdetection.py +239 -239
README.md CHANGED
@@ -1,193 +1,197 @@
1
- ---
2
- library_name: transformers
3
- license: mit
4
- tags:
5
- - vesuvius-challenge
6
- - ink-detection
7
- - herculaneum
8
- - resnet3d
9
- - u-net
10
- - 3d-segmentation
11
- - volumetric-imaging
12
- pipeline_tag: image-segmentation
13
- ---
14
-
15
- # PHerc.1667-iteration-4
16
-
17
- > **Trained on segment l_2 with l_2_inklabels4.png (24,773 tiles).**
18
-
19
- Ablation 4/5 — 24,773 training tiles.
20
-
21
- This is one of **six sibling models** released together — five label
22
- ablations on segment `l_2` (`ink1`–`ink5`, increasing label coverage)
23
- and one cross-segment baseline (`ink0`). The full family is listed
24
- at the bottom of this card.
25
-
26
- ## Preview
27
-
28
- `l_2` (training segment) prediction with the training label overlaid in
29
- magenta, and `l_5` (held-out segment) prediction. All panels are
30
- downsampled 16× and rotated 180° to match the publication-figure
31
- convention. The full-resolution `last.ckpt` outputs are at 43008 × ~30000
32
- voxels.
33
-
34
- | training label | l_2 prediction | l_5 prediction |
35
- |----------------|----------------|----------------|
36
- | ![label](./preview_label.png) | ![l_2 pred](./preview_l_2.png) | ![l_5 pred](./preview_l_5.png) |
37
-
38
-
39
- ## Architecture in one paragraph
40
-
41
- A 3-D volumetric input `(B, 1, 62, 256, 256)` is encoded by a
42
- **ResNet3D-50** backbone (Hara, Kataoka & Satoh, 2018; initialised from
43
- the Kinetics-700 release `r3d50_KM_200ep.pth` with conv1 weights
44
- summed across RGB → 1 grayscale channel). Each of the four backbone
45
- stages is collapsed along the z (depth) axis with `torch.max`,
46
- producing a 2-D feature pyramid `{(256,64,64), (512,32,32),
47
- (1024,16,16), (2048,8,8)}`. A small **2-D U-Net decoder** upsamples
48
- coarse-to-fine with concatenated skip connections; a 1×1 conv head
49
- produces a single sigmoid logit channel at quarter resolution
50
- `(B, 1, 64, 64)`. Training uses `0.5·Dice + 0.5·SoftBCE` against the
51
- label down-interpolated to 64×64.
52
-
53
- ## Quick start
54
-
55
- ```python
56
- import torch
57
- from transformers import AutoModel
58
-
59
- model = AutoModel.from_pretrained(
60
- "YoussefMoNader/PHerc.1667-iteration-4",
61
- trust_remote_code=True,
62
- ).eval().cuda()
63
-
64
- # Input: float32, shape (B, 1, D=62, H=256, W=256).
65
- # Intensity should already be in roughly [0, 1] (the training pipeline
66
- # clipped raw uint8 layers to [0, 200] then applied Normalize(mean=0, std=1)
67
- # which keeps the magnitude small).
68
- x = torch.randn(1, 1, 62, 256, 256, device="cuda")
69
-
70
- with torch.no_grad():
71
- out = model(x)
72
-
73
- print(out.logits.shape) # torch.Size([1, 1, 64, 64])
74
- prob = torch.sigmoid(out.logits) # ink probability per pixel
75
- ```
76
-
77
- ## Full-segment inference (tiling)
78
-
79
- The model only sees 256×256 windows. For a full scroll segment you
80
- need to slide the window across the (padded) layer stack and average
81
- overlapping predictions:
82
-
83
- ```python
84
- import numpy as np, cv2, torch
85
- import torch.nn.functional as F
86
- from transformers import AutoModel
87
-
88
- model = AutoModel.from_pretrained(
89
- "YoussefMoNader/PHerc.1667-iteration-4", trust_remote_code=True,
90
- ).eval().cuda()
91
-
92
- WINDOW, STRIDE = 256, 128 # 128 = 2x oversample; 64 for 8x oversample
93
- D = 62 # number of z-layers
94
-
95
- # image: (H, W, D) uint8 stack of the 62 layers, padded to multiples of 256.
96
- # fmask: (H, W) uint8 fragment mask (0 = outside, 255 = inside).
97
- H, W, _ = image.shape
98
- mask_pred = np.zeros((H, W), dtype=np.float32)
99
- mask_count = np.zeros((H, W), dtype=np.float32)
100
-
101
- with torch.no_grad():
102
- for y in range(0, H - WINDOW + 1, STRIDE):
103
- for x in range(0, W - WINDOW + 1, STRIDE):
104
- if np.any(fmask[y:y+WINDOW, x:x+WINDOW] == 0):
105
- continue
106
- tile = image[y:y+WINDOW, x:x+WINDOW] # (256,256,62)
107
- t = torch.from_numpy(tile).permute(2, 0, 1) # (62,256,256)
108
- t = t.unsqueeze(0).unsqueeze(0).float().cuda() # (1,1,62,256,256)
109
- logits = model(t).logits # (1,1,64,64)
110
- prob = torch.sigmoid(logits)
111
- prob = F.interpolate(prob, scale_factor=4,
112
- mode="bilinear").squeeze().cpu().numpy()
113
- mask_pred[y:y+WINDOW, x:x+WINDOW] += prob
114
- mask_count[y:y+WINDOW, x:x+WINDOW] += 1.0
115
-
116
- pred = np.divide(mask_pred, mask_count,
117
- out=np.zeros_like(mask_pred),
118
- where=mask_count != 0)
119
- cv2.imwrite("prediction.png", np.clip(pred * 255, 0, 255).astype(np.uint8))
120
- ```
121
-
122
- ## Training summary
123
-
124
- | | |
125
- |---|---|
126
- | **Backbone** | ResNet3D-50 (3-D conv, BN, ReLU residual blocks) |
127
- | **Encoder init** | `r3d50_KM_200ep.pth` (Kinetics-700), conv1 summed across RGB |
128
- | **Decoder** | 2-D U-Net (3 up-blocks: bilinear 2× + concat skip + 3×3 conv + BN + ReLU) |
129
- | **Output** | 1 channel, sigmoid logit, quarter-resolution (64×64) |
130
- | **Loss** | 0.5 × Dice + 0.5 × SoftBCE (smooth = 0.25) |
131
- | **Optimizer** | AdamW, OneCycle lr 2e-5 → 3e-4, pct_start = 0.15 |
132
- | **Batch** | 2 (effective 8 via accumulate 4), 16-mixed, grad-clip 1.0 |
133
- | **Max steps** | 12,396 (= 3 epochs over the densest ablation label) |
134
- | **Training segment(s)** | `l_2` |
135
- | **Training label** | `l_2_inklabels4.png` |
136
- | **Training tiles** (256×256 sub-tiles at stride 64) | **24,773** |
137
- | **Final train loss (`_epoch`)** | **0.5256** |
138
- | **Final train loss (`_step`, single-batch noise)** | 0.5005 |
139
- | **Wandb** | [vesuvius-challenge/paper/l2_ink4_l5infer](https://wandb.ai/vesuvius-challenge/paper/runs/h73rxb10) |
140
- | **Random seed** | 130697 |
141
- | **Determinism** | `cudnn.deterministic = True`, `cudnn.benchmark = False` |
142
- | **Hardware** | 1 × NVIDIA H100 80 GB; ≈ 2 h end-to-end (load + train + inference) |
143
-
144
- ## Files
145
-
146
- | file | size | description |
147
- |------|------|-------------|
148
- | `config.json` | 1 KB | architecture + provenance metadata; loaded by `AutoConfig` |
149
- | `configuration_inkdetection.py` | 2 KB | `InkDetectionConfig(PretrainedConfig)` |
150
- | `modeling_inkdetection.py` | 9 KB | self-contained `InkDetectionModel(PreTrainedModel)` |
151
- | `model.safetensors` | 319 MB | converted weights (338 tensors) |
152
- | `last.ckpt` | 963 MB | original PyTorch-Lightning checkpoint (incl. optimizer + LR-scheduler state) — load with `torch.load(...)["state_dict"]` |
153
- | `preview_l_2.png` | ~700 KB | low-res preview of the l_2 prediction (1/16 scale, 180° rotated) |
154
- | `preview_l_5.png` | ~2 MB | low-res preview of the l_5 (held-out) prediction |
155
- | `preview_label.png` | ~50 KB | the training label, same scale + rotation |
156
-
157
- The HuggingFace weights are **bit-perfect identical** to the original
158
- PyTorch-Lightning checkpoint (verified `max abs diff = 0.0e+00` on
159
- identical inputs). Use `model.safetensors` for `AutoModel.from_pretrained`;
160
- use `last.ckpt` only if you want to resume training from the saved
161
- optimizer / scheduler state.
162
-
163
- ## The model family
164
-
165
- | model | training segment(s) | label | tiles | effective epochs |
166
- |-------|---------------------|-------|-------|-------------------|
167
- | [`PHerc.1667-iteration-0`](https://huggingface.co/scrollprize/PHerc.1667-iteration-0) | 500p2a + 658 + 20250910185200 + 20250919125754* | (cross-segment baseline) | 20,075 | ~5 |
168
- | [`PHerc.1667-iteration-1`](https://huggingface.co/scrollprize/PHerc.1667-iteration-1) | `l_2` | `l_2_inklabels.png` | 3,396 | ~30 |
169
- | [`PHerc.1667-iteration-2`](https://huggingface.co/scrollprize/PHerc.1667-iteration-2) | `l_2` | `l_2_inklabels2.png` | 8,970 | ~12 |
170
- | [`PHerc.1667-iteration-3`](https://huggingface.co/scrollprize/PHerc.1667-iteration-3) | `l_2` | `l_2_inklabels3.png` | 15,286 | ~7 |
171
- | [`PHerc.1667-iteration-4`](https://huggingface.co/scrollprize/PHerc.1667-iteration-4) | `l_2` | `l_2_inklabels4.png` | 24,773 | ~5 |
172
- | [`PHerc.1667-iteration-5`](https://huggingface.co/scrollprize/PHerc.1667-iteration-5) | `l_2` | `l_2_inklabels5.png` | 33,061 | 3 |
173
-
174
- All six share the architecture, hyperparameters, and a fixed step
175
- budget of 12,396 optimizer steps; the only thing that varies between
176
- rows is the supervising label (or, for ink0, the training segments).
177
-
178
- ## Citation
179
-
180
- If you use this model in published work, please cite the Vesuvius
181
- Challenge and the underlying ResNet3D paper:
182
-
183
- ```bibtex
184
- @inproceedings{hara2018can,
185
- title = {Can spatiotemporal 3D CNNs retrace the history of 2D CNNs and ImageNet?},
186
- author = {Hara, Kensho and Kataoka, Hirokatsu and Satoh, Yutaka},
187
- booktitle = {CVPR}, year = {2018},
188
- }
189
- ```
190
-
191
- ## Licence
192
-
193
- MIT.
 
 
 
 
 
1
+ ---
2
+ library_name: transformers
3
+ license: mit
4
+ tags:
5
+ - vesuvius-challenge
6
+ - ink-detection
7
+ - herculaneum
8
+ - resnet3d
9
+ - u-net
10
+ - 3d-segmentation
11
+ - volumetric-imaging
12
+ pipeline_tag: image-segmentation
13
+ ---
14
+
15
+ # PHerc.1667-iteration-4
16
+
17
+ > **Trained on segment l_2 with l_2_inklabels4.png (24,773 tiles).**
18
+
19
+ Ablation 4/5 — 24,773 training tiles.
20
+
21
+ This is one of **six sibling models** released together — five label
22
+ ablations on segment `l_2` (`ink1`–`ink5`, increasing label coverage)
23
+ and one cross-segment baseline (`ink0`). The full family is listed
24
+ at the bottom of this card.
25
+
26
+ ## Preview
27
+
28
+ `l_2` (training segment) prediction with the training label overlaid in
29
+ magenta, and `l_5` (held-out segment) prediction. All panels are
30
+ downsampled 16× and rotated 180° to match the publication-figure
31
+ convention. The full-resolution `last.ckpt` outputs are at 43008 × ~30000
32
+ voxels.
33
+
34
+ | training label | l_2 prediction | l_5 prediction |
35
+ |----------------|----------------|----------------|
36
+ | ![label](./preview_label.png) | ![l_2 pred](./preview_l_2.png) | ![l_5 pred](./preview_l_5.png) |
37
+
38
+
39
+ ## Architecture in one paragraph
40
+
41
+ A 3-D volumetric input `(B, 1, 62, 256, 256)` is encoded by a
42
+ **ResNet3D-50** backbone (Hara, Kataoka & Satoh, 2018; initialised from
43
+ the Kinetics-700 release `r3d50_KM_200ep.pth` with conv1 weights
44
+ summed across RGB → 1 grayscale channel). Each of the four backbone
45
+ stages is collapsed along the z (depth) axis with `torch.max`,
46
+ producing a 2-D feature pyramid `{(256,64,64), (512,32,32),
47
+ (1024,16,16), (2048,8,8)}`. A small **2-D U-Net decoder** upsamples
48
+ coarse-to-fine with concatenated skip connections; a 1×1 conv head
49
+ produces a single sigmoid logit channel at quarter resolution
50
+ `(B, 1, 64, 64)`. Training uses `0.5·Dice + 0.5·SoftBCE` against the
51
+ label down-interpolated to 64×64.
52
+
53
+ ## Quick start
54
+
55
+ ```python
56
+ import torch
57
+ from transformers import AutoModel
58
+
59
+ model = AutoModel.from_pretrained(
60
+ "YoussefMoNader/PHerc.1667-iteration-4",
61
+ trust_remote_code=True,
62
+ ).eval().cuda()
63
+
64
+ # Input: float32, shape (B, 1, D=62, H=256, W=256).
65
+ # Explicitly reproduce clipping followed by Albumentations'
66
+ # Normalize(mean=0, std=1, max_pixel_value=255): clip(raw, 0, 200) / 255.
67
+ # This is intensity scaling, not per-tile z-score normalization.
68
+ # This synthetic tensor demonstrates the call shape only, not ink quality.
69
+ raw = torch.randint(0, 256, (1, 1, 62, 256, 256), device="cuda")
70
+ x = raw.float().clamp(0, 200) / 255.0
71
+
72
+ with torch.no_grad():
73
+ out = model(x)
74
+
75
+ print(out.logits.shape) # torch.Size([1, 1, 64, 64])
76
+ prob = torch.sigmoid(out.logits) # ink probability per pixel
77
+ ```
78
+
79
+ ## Full-segment inference (tiling)
80
+
81
+ The model only sees 256×256 windows. For a full scroll segment you
82
+ need to slide the window across the (padded) layer stack and average
83
+ overlapping predictions:
84
+
85
+ ```python
86
+ import numpy as np, cv2, torch
87
+ import torch.nn.functional as F
88
+ from transformers import AutoModel
89
+
90
+ model = AutoModel.from_pretrained(
91
+ "YoussefMoNader/PHerc.1667-iteration-4", trust_remote_code=True,
92
+ ).eval().cuda()
93
+
94
+ WINDOW, STRIDE = 256, 128 # 128 = 2x oversample; 64 for 8x oversample
95
+ D = 62 # number of z-layers
96
+
97
+ # image: (H, W, D) uint8 stack of the 62 layers, padded to multiples of 256.
98
+ # fmask: (H, W) uint8 fragment mask (0 = outside, 255 = inside).
99
+ H, W, _ = image.shape
100
+ mask_pred = np.zeros((H, W), dtype=np.float32)
101
+ mask_count = np.zeros((H, W), dtype=np.float32)
102
+
103
+ with torch.no_grad():
104
+ for y in range(0, H - WINDOW + 1, STRIDE):
105
+ for x in range(0, W - WINDOW + 1, STRIDE):
106
+ if np.any(fmask[y:y+WINDOW, x:x+WINDOW] == 0):
107
+ continue
108
+ tile = image[y:y+WINDOW, x:x+WINDOW] # (256,256,62)
109
+ # Apply the same input scaling as the quick start; keep image unchanged.
110
+ tile = np.clip(tile.astype(np.float32), 0, 200) / 255.0
111
+ t = torch.from_numpy(tile).permute(2, 0, 1) # (62,256,256)
112
+ t = t.unsqueeze(0).unsqueeze(0).float().cuda() # (1,1,62,256,256)
113
+ logits = model(t).logits # (1,1,64,64)
114
+ prob = torch.sigmoid(logits)
115
+ prob = F.interpolate(prob, scale_factor=4,
116
+ mode="bilinear").squeeze().cpu().numpy()
117
+ mask_pred[y:y+WINDOW, x:x+WINDOW] += prob
118
+ mask_count[y:y+WINDOW, x:x+WINDOW] += 1.0
119
+
120
+ pred = np.divide(mask_pred, mask_count,
121
+ out=np.zeros_like(mask_pred),
122
+ where=mask_count != 0)
123
+ cv2.imwrite("prediction.png", np.clip(pred * 255, 0, 255).astype(np.uint8))
124
+ ```
125
+
126
+ ## Training summary
127
+
128
+ | | |
129
+ |---|---|
130
+ | **Backbone** | ResNet3D-50 (3-D conv, BN, ReLU residual blocks) |
131
+ | **Encoder init** | `r3d50_KM_200ep.pth` (Kinetics-700), conv1 summed across RGB |
132
+ | **Decoder** | 2-D U-Net (3 up-blocks: bilinear 2× + concat skip + 3×3 conv + BN + ReLU) |
133
+ | **Output** | 1 channel, sigmoid logit, quarter-resolution (64×64) |
134
+ | **Loss** | 0.5 × Dice + 0.5 × SoftBCE (smooth = 0.25) |
135
+ | **Optimizer** | AdamW, OneCycle lr 2e-5 → 3e-4, pct_start = 0.15 |
136
+ | **Batch** | 2 (effective 8 via accumulate 4), 16-mixed, grad-clip 1.0 |
137
+ | **Max steps** | 12,396 (= 3 epochs over the densest ablation label) |
138
+ | **Training segment(s)** | `l_2` |
139
+ | **Training label** | `l_2_inklabels4.png` |
140
+ | **Training tiles** (256×256 sub-tiles at stride 64) | **24,773** |
141
+ | **Final train loss (`_epoch`)** | **0.5256** |
142
+ | **Final train loss (`_step`, single-batch noise)** | 0.5005 |
143
+ | **Wandb** | [vesuvius-challenge/paper/l2_ink4_l5infer](https://wandb.ai/vesuvius-challenge/paper/runs/h73rxb10) |
144
+ | **Random seed** | 130697 |
145
+ | **Determinism** | `cudnn.deterministic = True`, `cudnn.benchmark = False` |
146
+ | **Hardware** | 1 × NVIDIA H100 80 GB; ≈ 2 h end-to-end (load + train + inference) |
147
+
148
+ ## Files
149
+
150
+ | file | size | description |
151
+ |------|------|-------------|
152
+ | `config.json` | 1 KB | architecture + provenance metadata; loaded by `AutoConfig` |
153
+ | `configuration_inkdetection.py` | 2 KB | `InkDetectionConfig(PretrainedConfig)` |
154
+ | `modeling_inkdetection.py` | 9 KB | self-contained `InkDetectionModel(PreTrainedModel)` |
155
+ | `model.safetensors` | 319 MB | converted weights (338 tensors) |
156
+ | `last.ckpt` | 963 MB | original PyTorch-Lightning checkpoint (incl. optimizer + LR-scheduler state) — load with `torch.load(...)["state_dict"]` |
157
+ | `preview_l_2.png` | ~700 KB | low-res preview of the l_2 prediction (1/16 scale, 180° rotated) |
158
+ | `preview_l_5.png` | ~2 MB | low-res preview of the l_5 (held-out) prediction |
159
+ | `preview_label.png` | ~50 KB | the training label, same scale + rotation |
160
+
161
+ The HuggingFace weights are **bit-perfect identical** to the original
162
+ PyTorch-Lightning checkpoint (verified `max abs diff = 0.0e+00` on
163
+ identical inputs). Use `model.safetensors` for `AutoModel.from_pretrained`;
164
+ use `last.ckpt` only if you want to resume training from the saved
165
+ optimizer / scheduler state.
166
+
167
+ ## The model family
168
+
169
+ | model | training segment(s) | label | tiles | effective epochs |
170
+ |-------|---------------------|-------|-------|-------------------|
171
+ | [`PHerc.1667-iteration-0`](https://huggingface.co/scrollprize/PHerc.1667-iteration-0) | 500p2a + 658 + 20250910185200 + 20250919125754* | (cross-segment baseline) | 20,075 | ~5 |
172
+ | [`PHerc.1667-iteration-1`](https://huggingface.co/scrollprize/PHerc.1667-iteration-1) | `l_2` | `l_2_inklabels.png` | 3,396 | ~30 |
173
+ | [`PHerc.1667-iteration-2`](https://huggingface.co/scrollprize/PHerc.1667-iteration-2) | `l_2` | `l_2_inklabels2.png` | 8,970 | ~12 |
174
+ | [`PHerc.1667-iteration-3`](https://huggingface.co/scrollprize/PHerc.1667-iteration-3) | `l_2` | `l_2_inklabels3.png` | 15,286 | ~7 |
175
+ | [`PHerc.1667-iteration-4`](https://huggingface.co/scrollprize/PHerc.1667-iteration-4) | `l_2` | `l_2_inklabels4.png` | 24,773 | ~5 |
176
+ | [`PHerc.1667-iteration-5`](https://huggingface.co/scrollprize/PHerc.1667-iteration-5) | `l_2` | `l_2_inklabels5.png` | 33,061 | 3 |
177
+
178
+ All six share the architecture, hyperparameters, and a fixed step
179
+ budget of 12,396 optimizer steps; the only thing that varies between
180
+ rows is the supervising label (or, for ink0, the training segments).
181
+
182
+ ## Citation
183
+
184
+ If you use this model in published work, please cite the Vesuvius
185
+ Challenge and the underlying ResNet3D paper:
186
+
187
+ ```bibtex
188
+ @inproceedings{hara2018can,
189
+ title = {Can spatiotemporal 3D CNNs retrace the history of 2D CNNs and ImageNet?},
190
+ author = {Hara, Kensho and Kataoka, Hirokatsu and Satoh, Yutaka},
191
+ booktitle = {CVPR}, year = {2018},
192
+ }
193
+ ```
194
+
195
+ ## Licence
196
+
197
+ MIT.
modeling_inkdetection.py CHANGED
@@ -1,239 +1,239 @@
1
- """HuggingFace-style wrapper around the ResNet3D-50 + 2-D U-Net ink-detection model.
2
-
3
- This file is **self-contained** — vendored ResNet3D-50 (Hara et al., 2018)
4
- inline with the decoder so a downloader only needs `transformers` and
5
- `torch`. Loadable via:
6
-
7
- from transformers import AutoModel
8
- model = AutoModel.from_pretrained(
9
- "<user>/<repo>", trust_remote_code=True
10
- )
11
-
12
- Input: float32 tensor of shape `(B, 1, D, H, W)` or `(B, D, H, W)`,
13
- where D = 62, H = W = 256 (intensity already z-score normalised).
14
- Output: `ModelOutput(logits=<sigmoid logits, shape (B, 1, H/4, W/4)>)`.
15
- """
16
- from dataclasses import dataclass
17
- from functools import partial
18
- from typing import List, Optional, Tuple
19
-
20
- import torch
21
- import torch.nn as nn
22
- import torch.nn.functional as F
23
-
24
- from transformers import PreTrainedModel
25
- from transformers.modeling_outputs import ModelOutput
26
-
27
- from .configuration_inkdetection import InkDetectionConfig
28
-
29
-
30
- # =============================================================================
31
- # Vendored ResNet3D-50 (Hara, Kataoka & Satoh, 2018)
32
- # =============================================================================
33
- def _conv3x3x3(in_planes, out_planes, stride=1):
34
- return nn.Conv3d(in_planes, out_planes, kernel_size=3,
35
- stride=stride, padding=1, bias=False)
36
-
37
-
38
- def _conv1x1x1(in_planes, out_planes, stride=1):
39
- return nn.Conv3d(in_planes, out_planes, kernel_size=1,
40
- stride=stride, bias=False)
41
-
42
-
43
- class _Bottleneck(nn.Module):
44
- expansion = 4
45
-
46
- def __init__(self, in_planes, planes, stride=1, downsample=None):
47
- super().__init__()
48
- self.conv1 = _conv1x1x1(in_planes, planes)
49
- self.bn1 = nn.BatchNorm3d(planes)
50
- self.conv2 = _conv3x3x3(planes, planes, stride)
51
- self.bn2 = nn.BatchNorm3d(planes)
52
- self.conv3 = _conv1x1x1(planes, planes * self.expansion)
53
- self.bn3 = nn.BatchNorm3d(planes * self.expansion)
54
- self.relu = nn.ReLU(inplace=True)
55
- self.downsample = downsample
56
- self.stride = stride
57
-
58
- def forward(self, x):
59
- residual = x
60
- out = self.relu(self.bn1(self.conv1(x)))
61
- out = self.relu(self.bn2(self.conv2(out)))
62
- out = self.bn3(self.conv3(out))
63
- if self.downsample is not None:
64
- residual = self.downsample(x)
65
- out = self.relu(out + residual)
66
- return out
67
-
68
-
69
- class _ResNet3D(nn.Module):
70
- """ResNet3D-50 backbone returning the 4 intermediate feature maps."""
71
-
72
- def __init__(self, n_input_channels=1, block_inplanes=(64, 128, 256, 512),
73
- layers=(3, 4, 6, 3), conv1_t_size=7, conv1_t_stride=1):
74
- super().__init__()
75
- self.in_planes = block_inplanes[0]
76
- self.conv1 = nn.Conv3d(
77
- n_input_channels, self.in_planes,
78
- kernel_size=(conv1_t_size, 7, 7),
79
- stride=(conv1_t_stride, 2, 2),
80
- padding=(conv1_t_size // 2, 3, 3),
81
- bias=False,
82
- )
83
- self.bn1 = nn.BatchNorm3d(self.in_planes)
84
- self.relu = nn.ReLU(inplace=True)
85
- self.maxpool = nn.MaxPool3d(
86
- kernel_size=(1, 3, 3), stride=(1, 2, 2), padding=(0, 1, 1)
87
- )
88
- self.layer1 = self._make_layer(block_inplanes[0], layers[0], stride=1)
89
- self.layer2 = self._make_layer(block_inplanes[1], layers[1], stride=2)
90
- self.layer3 = self._make_layer(block_inplanes[2], layers[2], stride=2)
91
- self.layer4 = self._make_layer(block_inplanes[3], layers[3], stride=2)
92
-
93
- def _make_layer(self, planes, blocks, stride=1):
94
- downsample = None
95
- if stride != 1 or self.in_planes != planes * _Bottleneck.expansion:
96
- downsample = nn.Sequential(
97
- _conv1x1x1(self.in_planes,
98
- planes * _Bottleneck.expansion, stride),
99
- nn.BatchNorm3d(planes * _Bottleneck.expansion),
100
- )
101
- layers = [_Bottleneck(self.in_planes, planes, stride, downsample)]
102
- self.in_planes = planes * _Bottleneck.expansion
103
- for _ in range(1, blocks):
104
- layers.append(_Bottleneck(self.in_planes, planes))
105
- return nn.Sequential(*layers)
106
-
107
- def forward(self, x) -> List[torch.Tensor]:
108
- x = self.relu(self.bn1(self.conv1(x)))
109
- x = self.maxpool(x)
110
- x1 = self.layer1(x)
111
- x2 = self.layer2(x1)
112
- x3 = self.layer3(x2)
113
- x4 = self.layer4(x3)
114
- return [x1, x2, x3, x4]
115
-
116
-
117
- # =============================================================================
118
- # 2-D U-Net decoder
119
- # =============================================================================
120
- class _Decoder(nn.Module):
121
- def __init__(self, encoder_dims, upscale):
122
- super().__init__()
123
- self.convs = nn.ModuleList([
124
- nn.Sequential(
125
- nn.Conv2d(encoder_dims[i] + encoder_dims[i - 1],
126
- encoder_dims[i - 1], 3, 1, 1, bias=False),
127
- nn.BatchNorm2d(encoder_dims[i - 1]),
128
- nn.ReLU(inplace=True),
129
- )
130
- for i in range(1, len(encoder_dims))
131
- ])
132
- self.logit = nn.Conv2d(encoder_dims[0], 1, 1, 1, 0)
133
- self.up = nn.Upsample(scale_factor=upscale, mode='bilinear')
134
-
135
- def forward(self, feature_maps):
136
- for i in range(len(feature_maps) - 1, 0, -1):
137
- f_up = F.interpolate(feature_maps[i], scale_factor=2,
138
- mode='bilinear')
139
- f = torch.cat([feature_maps[i - 1], f_up], dim=1)
140
- feature_maps[i - 1] = self.convs[i - 1](f)
141
- return self.up(self.logit(feature_maps[0]))
142
-
143
-
144
- # =============================================================================
145
- # HuggingFace ModelOutput + PreTrainedModel
146
- # =============================================================================
147
- @dataclass
148
- class InkDetectionOutput(ModelOutput):
149
- """Output of `InkDetectionModel.forward`.
150
-
151
- - `logits`: pre-sigmoid prediction at quarter resolution. Shape
152
- `(B, 1, H / 4, W / 4)`. Apply `torch.sigmoid` for probabilities.
153
- """
154
- logits: torch.FloatTensor = None
155
- loss: Optional[torch.FloatTensor] = None
156
-
157
-
158
- class InkDetectionModel(PreTrainedModel):
159
- """Vesuvius Challenge ink-detection model.
160
-
161
- Pipeline:
162
- 1. 3-D volume `(B, 1, D, H, W)` enters a ResNet3D-50 backbone.
163
- 2. Each of the 4 stages is collapsed along the z (depth) axis
164
- with `torch.max` -> 2-D feature pyramid.
165
- 3. A small 2-D U-Net decoder upsamples coarse-to-fine with
166
- concatenated skip connections.
167
- 4. A 1x1 conv head produces 1 logit channel.
168
- """
169
- config_class = InkDetectionConfig
170
- base_model_prefix = "inkdetection"
171
-
172
- def __init__(self, config: InkDetectionConfig):
173
- super().__init__(config)
174
- layers_map = {50: (3, 4, 6, 3),
175
- 101: (3, 4, 23, 3),
176
- 152: (3, 8, 36, 3)}
177
- if config.backbone_depth not in layers_map:
178
- raise ValueError(
179
- f"Unsupported backbone_depth={config.backbone_depth}; "
180
- "expected one of 50, 101, 152."
181
- )
182
- self.backbone = _ResNet3D(
183
- n_input_channels=config.in_channels,
184
- block_inplanes=(64, 128, 256, 512),
185
- layers=layers_map[config.backbone_depth],
186
- )
187
- self.decoder = _Decoder(
188
- encoder_dims=list(config.backbone_channels),
189
- upscale=config.decoder_upscale,
190
- )
191
- # No init from scratch — weights are loaded from the published
192
- # checkpoint via `from_pretrained`.
193
-
194
- def forward(
195
- self,
196
- pixel_values: torch.FloatTensor,
197
- labels: Optional[torch.FloatTensor] = None,
198
- return_dict: Optional[bool] = True,
199
- **kwargs,
200
- ) -> InkDetectionOutput:
201
- # Accept (B, D, H, W) or (B, 1, D, H, W)
202
- if pixel_values.ndim == 4:
203
- pixel_values = pixel_values.unsqueeze(1)
204
- if pixel_values.ndim != 5:
205
- raise ValueError(
206
- f"pixel_values must be 4-D (B, D, H, W) or 5-D (B, 1, D, H, W); "
207
- f"got shape {tuple(pixel_values.shape)}"
208
- )
209
- feats = self.backbone(pixel_values)
210
- pooled = [torch.max(f, dim=2)[0] for f in feats]
211
- logits = self.decoder(pooled)
212
-
213
- loss = None
214
- if labels is not None:
215
- # Dice + SoftBCE in [0, 1] target space, label assumed to
216
- # already be down-interpolated to logits.shape[-2:].
217
- sig = torch.sigmoid(logits)
218
- inter = (sig * labels).sum(dim=(-2, -1))
219
- denom = sig.sum(dim=(-2, -1)) + labels.sum(dim=(-2, -1))
220
- dice = (1.0 - (2.0 * inter + 1.0) / (denom + 1.0)).mean()
221
- bce = F.binary_cross_entropy_with_logits(logits, labels)
222
- loss = 0.5 * dice + 0.5 * bce
223
-
224
- if not return_dict:
225
- return (loss, logits) if loss is not None else (logits,)
226
- return InkDetectionOutput(logits=logits, loss=loss)
227
-
228
- @torch.no_grad()
229
- def predict_probability(self, pixel_values: torch.FloatTensor) -> torch.FloatTensor:
230
- """Convenience: sigmoid probabilities at quarter resolution."""
231
- return torch.sigmoid(self(pixel_values).logits)
232
-
233
-
234
- # Register the config so HF's mapping infrastructure recognises model_type.
235
- # Note: AutoModel.from_pretrained(..., trust_remote_code=True) reads `auto_map`
236
- # from config.json — no explicit register call is required at import time, but
237
- # it does not hurt to keep this association in place.
238
- InkDetectionConfig.register_for_auto_class("AutoConfig")
239
- InkDetectionModel.register_for_auto_class("AutoModel")
 
1
+ """HuggingFace-style wrapper around the ResNet3D-50 + 2-D U-Net ink-detection model.
2
+
3
+ This file is **self-contained** — vendored ResNet3D-50 (Hara et al., 2018)
4
+ inline with the decoder so a downloader only needs `transformers` and
5
+ `torch`. Loadable via:
6
+
7
+ from transformers import AutoModel
8
+ model = AutoModel.from_pretrained(
9
+ "<user>/<repo>", trust_remote_code=True
10
+ )
11
+
12
+ Input: float32 tensor of shape `(B, 1, D, H, W)` or `(B, D, H, W)`,
13
+ where D = 62, H = W = 256 (raw uint8 clipped to [0, 200] and divided by 255; not z-score normalised).
14
+ Output: `ModelOutput(logits=<sigmoid logits, shape (B, 1, H/4, W/4)>)`.
15
+ """
16
+ from dataclasses import dataclass
17
+ from functools import partial
18
+ from typing import List, Optional, Tuple
19
+
20
+ import torch
21
+ import torch.nn as nn
22
+ import torch.nn.functional as F
23
+
24
+ from transformers import PreTrainedModel
25
+ from transformers.modeling_outputs import ModelOutput
26
+
27
+ from .configuration_inkdetection import InkDetectionConfig
28
+
29
+
30
+ # =============================================================================
31
+ # Vendored ResNet3D-50 (Hara, Kataoka & Satoh, 2018)
32
+ # =============================================================================
33
+ def _conv3x3x3(in_planes, out_planes, stride=1):
34
+ return nn.Conv3d(in_planes, out_planes, kernel_size=3,
35
+ stride=stride, padding=1, bias=False)
36
+
37
+
38
+ def _conv1x1x1(in_planes, out_planes, stride=1):
39
+ return nn.Conv3d(in_planes, out_planes, kernel_size=1,
40
+ stride=stride, bias=False)
41
+
42
+
43
+ class _Bottleneck(nn.Module):
44
+ expansion = 4
45
+
46
+ def __init__(self, in_planes, planes, stride=1, downsample=None):
47
+ super().__init__()
48
+ self.conv1 = _conv1x1x1(in_planes, planes)
49
+ self.bn1 = nn.BatchNorm3d(planes)
50
+ self.conv2 = _conv3x3x3(planes, planes, stride)
51
+ self.bn2 = nn.BatchNorm3d(planes)
52
+ self.conv3 = _conv1x1x1(planes, planes * self.expansion)
53
+ self.bn3 = nn.BatchNorm3d(planes * self.expansion)
54
+ self.relu = nn.ReLU(inplace=True)
55
+ self.downsample = downsample
56
+ self.stride = stride
57
+
58
+ def forward(self, x):
59
+ residual = x
60
+ out = self.relu(self.bn1(self.conv1(x)))
61
+ out = self.relu(self.bn2(self.conv2(out)))
62
+ out = self.bn3(self.conv3(out))
63
+ if self.downsample is not None:
64
+ residual = self.downsample(x)
65
+ out = self.relu(out + residual)
66
+ return out
67
+
68
+
69
+ class _ResNet3D(nn.Module):
70
+ """ResNet3D-50 backbone returning the 4 intermediate feature maps."""
71
+
72
+ def __init__(self, n_input_channels=1, block_inplanes=(64, 128, 256, 512),
73
+ layers=(3, 4, 6, 3), conv1_t_size=7, conv1_t_stride=1):
74
+ super().__init__()
75
+ self.in_planes = block_inplanes[0]
76
+ self.conv1 = nn.Conv3d(
77
+ n_input_channels, self.in_planes,
78
+ kernel_size=(conv1_t_size, 7, 7),
79
+ stride=(conv1_t_stride, 2, 2),
80
+ padding=(conv1_t_size // 2, 3, 3),
81
+ bias=False,
82
+ )
83
+ self.bn1 = nn.BatchNorm3d(self.in_planes)
84
+ self.relu = nn.ReLU(inplace=True)
85
+ self.maxpool = nn.MaxPool3d(
86
+ kernel_size=(1, 3, 3), stride=(1, 2, 2), padding=(0, 1, 1)
87
+ )
88
+ self.layer1 = self._make_layer(block_inplanes[0], layers[0], stride=1)
89
+ self.layer2 = self._make_layer(block_inplanes[1], layers[1], stride=2)
90
+ self.layer3 = self._make_layer(block_inplanes[2], layers[2], stride=2)
91
+ self.layer4 = self._make_layer(block_inplanes[3], layers[3], stride=2)
92
+
93
+ def _make_layer(self, planes, blocks, stride=1):
94
+ downsample = None
95
+ if stride != 1 or self.in_planes != planes * _Bottleneck.expansion:
96
+ downsample = nn.Sequential(
97
+ _conv1x1x1(self.in_planes,
98
+ planes * _Bottleneck.expansion, stride),
99
+ nn.BatchNorm3d(planes * _Bottleneck.expansion),
100
+ )
101
+ layers = [_Bottleneck(self.in_planes, planes, stride, downsample)]
102
+ self.in_planes = planes * _Bottleneck.expansion
103
+ for _ in range(1, blocks):
104
+ layers.append(_Bottleneck(self.in_planes, planes))
105
+ return nn.Sequential(*layers)
106
+
107
+ def forward(self, x) -> List[torch.Tensor]:
108
+ x = self.relu(self.bn1(self.conv1(x)))
109
+ x = self.maxpool(x)
110
+ x1 = self.layer1(x)
111
+ x2 = self.layer2(x1)
112
+ x3 = self.layer3(x2)
113
+ x4 = self.layer4(x3)
114
+ return [x1, x2, x3, x4]
115
+
116
+
117
+ # =============================================================================
118
+ # 2-D U-Net decoder
119
+ # =============================================================================
120
+ class _Decoder(nn.Module):
121
+ def __init__(self, encoder_dims, upscale):
122
+ super().__init__()
123
+ self.convs = nn.ModuleList([
124
+ nn.Sequential(
125
+ nn.Conv2d(encoder_dims[i] + encoder_dims[i - 1],
126
+ encoder_dims[i - 1], 3, 1, 1, bias=False),
127
+ nn.BatchNorm2d(encoder_dims[i - 1]),
128
+ nn.ReLU(inplace=True),
129
+ )
130
+ for i in range(1, len(encoder_dims))
131
+ ])
132
+ self.logit = nn.Conv2d(encoder_dims[0], 1, 1, 1, 0)
133
+ self.up = nn.Upsample(scale_factor=upscale, mode='bilinear')
134
+
135
+ def forward(self, feature_maps):
136
+ for i in range(len(feature_maps) - 1, 0, -1):
137
+ f_up = F.interpolate(feature_maps[i], scale_factor=2,
138
+ mode='bilinear')
139
+ f = torch.cat([feature_maps[i - 1], f_up], dim=1)
140
+ feature_maps[i - 1] = self.convs[i - 1](f)
141
+ return self.up(self.logit(feature_maps[0]))
142
+
143
+
144
+ # =============================================================================
145
+ # HuggingFace ModelOutput + PreTrainedModel
146
+ # =============================================================================
147
+ @dataclass
148
+ class InkDetectionOutput(ModelOutput):
149
+ """Output of `InkDetectionModel.forward`.
150
+
151
+ - `logits`: pre-sigmoid prediction at quarter resolution. Shape
152
+ `(B, 1, H / 4, W / 4)`. Apply `torch.sigmoid` for probabilities.
153
+ """
154
+ logits: torch.FloatTensor = None
155
+ loss: Optional[torch.FloatTensor] = None
156
+
157
+
158
+ class InkDetectionModel(PreTrainedModel):
159
+ """Vesuvius Challenge ink-detection model.
160
+
161
+ Pipeline:
162
+ 1. 3-D volume `(B, 1, D, H, W)` enters a ResNet3D-50 backbone.
163
+ 2. Each of the 4 stages is collapsed along the z (depth) axis
164
+ with `torch.max` -> 2-D feature pyramid.
165
+ 3. A small 2-D U-Net decoder upsamples coarse-to-fine with
166
+ concatenated skip connections.
167
+ 4. A 1x1 conv head produces 1 logit channel.
168
+ """
169
+ config_class = InkDetectionConfig
170
+ base_model_prefix = "inkdetection"
171
+
172
+ def __init__(self, config: InkDetectionConfig):
173
+ super().__init__(config)
174
+ layers_map = {50: (3, 4, 6, 3),
175
+ 101: (3, 4, 23, 3),
176
+ 152: (3, 8, 36, 3)}
177
+ if config.backbone_depth not in layers_map:
178
+ raise ValueError(
179
+ f"Unsupported backbone_depth={config.backbone_depth}; "
180
+ "expected one of 50, 101, 152."
181
+ )
182
+ self.backbone = _ResNet3D(
183
+ n_input_channels=config.in_channels,
184
+ block_inplanes=(64, 128, 256, 512),
185
+ layers=layers_map[config.backbone_depth],
186
+ )
187
+ self.decoder = _Decoder(
188
+ encoder_dims=list(config.backbone_channels),
189
+ upscale=config.decoder_upscale,
190
+ )
191
+ # No init from scratch — weights are loaded from the published
192
+ # checkpoint via `from_pretrained`.
193
+
194
+ def forward(
195
+ self,
196
+ pixel_values: torch.FloatTensor,
197
+ labels: Optional[torch.FloatTensor] = None,
198
+ return_dict: Optional[bool] = True,
199
+ **kwargs,
200
+ ) -> InkDetectionOutput:
201
+ # Accept (B, D, H, W) or (B, 1, D, H, W)
202
+ if pixel_values.ndim == 4:
203
+ pixel_values = pixel_values.unsqueeze(1)
204
+ if pixel_values.ndim != 5:
205
+ raise ValueError(
206
+ f"pixel_values must be 4-D (B, D, H, W) or 5-D (B, 1, D, H, W); "
207
+ f"got shape {tuple(pixel_values.shape)}"
208
+ )
209
+ feats = self.backbone(pixel_values)
210
+ pooled = [torch.max(f, dim=2)[0] for f in feats]
211
+ logits = self.decoder(pooled)
212
+
213
+ loss = None
214
+ if labels is not None:
215
+ # Dice + SoftBCE in [0, 1] target space, label assumed to
216
+ # already be down-interpolated to logits.shape[-2:].
217
+ sig = torch.sigmoid(logits)
218
+ inter = (sig * labels).sum(dim=(-2, -1))
219
+ denom = sig.sum(dim=(-2, -1)) + labels.sum(dim=(-2, -1))
220
+ dice = (1.0 - (2.0 * inter + 1.0) / (denom + 1.0)).mean()
221
+ bce = F.binary_cross_entropy_with_logits(logits, labels)
222
+ loss = 0.5 * dice + 0.5 * bce
223
+
224
+ if not return_dict:
225
+ return (loss, logits) if loss is not None else (logits,)
226
+ return InkDetectionOutput(logits=logits, loss=loss)
227
+
228
+ @torch.no_grad()
229
+ def predict_probability(self, pixel_values: torch.FloatTensor) -> torch.FloatTensor:
230
+ """Convenience: sigmoid probabilities at quarter resolution."""
231
+ return torch.sigmoid(self(pixel_values).logits)
232
+
233
+
234
+ # Register the config so HF's mapping infrastructure recognises model_type.
235
+ # Note: AutoModel.from_pretrained(..., trust_remote_code=True) reads `auto_map`
236
+ # from config.json — no explicit register call is required at import time, but
237
+ # it does not hurt to keep this association in place.
238
+ InkDetectionConfig.register_for_auto_class("AutoConfig")
239
+ InkDetectionModel.register_for_auto_class("AutoModel")