File size: 14,224 Bytes
8065faa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
# Training on your own data

Pretraining reads **WebDataset `.tar` shards**; finetuning is supervised and reads **image/mask
directories**.

```
data/
  pretrain/                  # stage 1: WebDataset shards
    customdata-000.tar
    customdata-001.tar
    ...
  finetune/                  # stage 2: supervised splits
    train/  images/  masks/
    val/    images/  masks/
    test/   images/  masks/
```

`data/` does not have to be local. Configs support env vars and default reads
`${DATA_ROOT:-data}/...`, so you can either symlink it

```bash
ln -s /mnt/big-disk/affmae-data data
```

or point the variable elsewhere and leave the repository alone:

```bash
export DATA_ROOT=/mnt/big-disk/affmae-data
```

Relative paths resolve against the **repository root**, not your shell's working
directory, so training scripts work from anywhere. The same applies to
`CHECKPOINT_ROOT` (default `weights`) and `AFFMAE_OUTPUT_DIR` (default `output`).

---

## Stage 1 β€” pretraining data (WebDataset)

### Shard layout

Pretraining is masked autoencoding, so just images. Each
shard is an uncompressed `.tar` of images, and `webdataset` groups files by
basename, so one image per basename is all that is required:

```
customdata-000.tar
β”œβ”€β”€ 000000.png
β”œβ”€β”€ 000001.png
β”œβ”€β”€ 000002.png
└── ...
```

Three things to consider when building shards:
1. **The extension is the key.** The loader calls `.decode("pil")` and then reads
   `sample["png"]`, so files must be `.png`. Rename before packing, or change the
   `map_dict(png=...)` key in `affmae/data/pretrain_dataset.py` to match your
   extension.
2. **Shards should be roughly equal in size**, ideally 100–1000 images each.
   Under multi-GPU training each rank gets a disjoint set of shards, so one giant
   shard leaves other ranks idle.
3. **Have at least as many shards as `num_workers` Γ— ranks.** Shards are split
   across DataLoader workers *and* across ranks, so a worker that gets none makes
   WebDataset raise *"No samples found in dataset; perhaps you have fewer shards
   than workers."* Three shards with `num_workers: 8` fails, and it fails at the
   first batch rather than at startup.

### Building shards

```python
import webdataset as wds
from pathlib import Path

images = sorted(Path("/my/raw/images").glob("*.png"))

# maxcount caps images per shard; %03d must match the brace range in the config
with wds.ShardWriter("data/pretrain/customdata-%03d.tar", maxcount=1000) as sink:
    for i, path in enumerate(images):
        sink.write({"__key__": f"{i:08d}", "png": path.read_bytes()})
```

`ShardWriter` prints the shard count when it finishes. If it wrote
`customdata-000.tar` through `customdata-031.tar`, the config pattern is
`customdata-{000..031}.tar` β€” the brace range is **inclusive** and the digit
count must match the filenames exactly.

### Counting your samples

A WebDataset is an `IterableDataset` with no length, so the epoch boundary comes
from `data.total_samples` in the config rather than from the data. Getting it
wrong does not crash: too high repeats images, too low silently truncates the
epoch. Count once:

```bash
python -c "
import tarfile, glob
n = sum(sum(1 for m in tarfile.open(f) if m.name.endswith('.png'))
        for f in glob.glob('data/pretrain/*.tar'))
print(n)
"
```

### Running it

```bash
cp configs/template_pretrain_512.yaml configs/my_pretrain.yaml
# edit data.path, data.total_samples, data.in_channels
python pretrain.py --config configs/my_pretrain.yaml
```

Multi-GPU is mostly untested (use at your own rick) with `torchrun`; `data.batch_size` is the **global** batch and is divided
across ranks, so the effective batch does not change with GPU count:

```bash
torchrun --nproc_per_node=4 pretrain.py --config configs/my_pretrain.yaml
```

---

## Stage 2 β€” finetuning data (images and masks)

### Directory layout

```
data/finetune/
  train/
    images/   img_0001.tif   img_0002.tif   ...
    masks/    img_0001.tiff  img_0002.tiff  ...
  val/        images/  masks/
  test/       images/  masks/
```

Things to consider for finetuning on your data:
- **Images and masks pair by filename stem.** `img_0001.tif` needs
  `img_0001.tiff`. The loader takes the *intersection* of the two directories, so
  an unpaired file is skipped **without warning**.
- **Image extension is configurable** via `data.input_ext` (default `.tif`).
  **The mask extension is fixed at `.tiff`** in
  `affmae/data/finetune_dataset.py`.
- `val/` is the split evaluated during training. `test/` is read only by
  `evaluate.py`. All three are required by the templates; point `val` and `test`
  at the same files if you only have two splits.

### Mask format
Masks are multi-channel (ie multi-label) TIFFs, one binary channel per annotated structure β€” **not** a single-channel label map
with integer class IDs.

`data.indices` selects which channels to train on, and background is added
implicitly:

```yaml
data:
  indices: [0, 1]      # train on mask channels 0 and 1
  num_classes: 3       # len(indices) + 1 for background
```

So `num_classes` is typically `len(indices) + 1`, depending on loss selected, and `train.class_weighting` must
have exactly `num_classes` entries, background first. A wrong length fails at
loss construction with *"weight tensor should be defined either for all or no classes"*.

The selected channels are flattened into a single label map in `__getitem__`:
each channel is binarized at `THRESHOLD = 10`, and a pixel is assigned
`position_in_indices + 1`. Two consequences:

- **Class IDs follow the order of `indices`, not the channel numbers.**
  `indices: [2, 3]` makes channel 2 class 1 and channel 3 class 2.
- **Channels are applied in order, so on overlapping annotations the later channel wins.** Put the class you care most about last.

A single-channel integer label map is **not** supported β€” `__getitem__` unpacks
`C, H, W` and a 2-D mask raises a `ValueError`. One-hot it into channels when preparing the data.

Raise a class's weight if it is rare β€” the shipped `[0.2, 2.0, 3.0]` deliberately down-weights background.

### Running it

```bash
cp configs/template_finetune_512.yaml configs/my_finetune.yaml
# edit data.base_path, data.indices, data.num_classes,
#      train.class_weighting, model.pretrained_ckpt_path
python finetune.py --config configs/my_finetune.yaml
```

You do not have to pretrain first. To start from our released backbone, fetch it
once and point the config at the result:

```python
from affmae.data.weights import EMWeights
print(EMWeights.AFFMAE_BASE_PRETRAIN_512.fetch())
# -> weights/pretrain/ckpt_epoch_399_affmae_fpw.pth
```

```yaml
model:
  pretrained_ckpt_path: "${CHECKPOINT_ROOT:-weights}/pretrain/ckpt_epoch_399_affmae_fpw.pth"
```

The encoder architecture keys in your config must match that checkpoint, so keep
the `aff_*` block from the template unchanged unless you are pretraining yourself.
See the weights table in the README for every released checkpoint.

---

## Checklist before a long run

Cheap to check, expensive to discover at hour six.

| Check | Symptom if wrong |
|---|---|
| `data.total_samples` matches your shard count | epoch silently truncated or images repeated |
| Brace range digits match filenames | `FileNotFoundError` on the first batch |
| `num_classes == len(indices) + 1` | crash in the loss, or a dead output channel |
| `len(class_weighting) == num_classes` | *"weight tensor should be defined either for all or no classes"* |
| `in_channels` matches your images (1 grey, 3 RGB) | shape mismatch in the patch embedding |
| Encoder architecture keys identical to pretraining | checkpoint loads with missing/unexpected keys |
| Dataset size is what you expect | unpaired image/mask stems dropped silently |
| Shards >= `num_workers` x ranks | *"No samples found in dataset"* at the first batch |
| `stats.py` matches your data | washed-out or clipped figures; slower convergence |
| CLAHE removed if not wanted | real intensity differences flattened |
| Info-bar crop removed for non-EM images | bottom of bright images silently truncated |

Sanity-check the pipeline before committing to a full run:

```bash
python -c "
from affmae.config import load_config
from affmae.data.finetune_dataset import build_finetune_dataloader
cfg = load_config('configs/my_finetune.yaml')
loader = build_finetune_dataloader(cfg, is_train=True)   # False reads val/
img, mask = next(iter(loader))[:2]
print('image', img.shape, img.dtype)
print('mask ', mask.shape, 'classes present', mask.unique().tolist())
"
```

`mask.unique()` should be class indices in `[0, num_classes)`. If you see values
above that, `indices` and `num_classes` disagree.

---

## Adapting the preprocessing

Everything in this section is tuned for greyscale electron micrographs. None of it
is wrong for other data, but none of it is neutral either β€” these are the four
places where "trained on EM" is baked in.

### 1. Images are single-channel

We trained on greyscale. Two consequences:

- `data.in_channels: 1` in the config. Set it to 3 for RGB; the patch embedding
  and the normalization tuple both follow it.
- The **pretraining loader converts to greyscale regardless**:
  `apply_custom_processing` in `affmae/data/pretrain_dataset.py` calls
  `pil_image.convert("L")`. For RGB pretraining, remove that call β€” otherwise
  `in_channels: 3` silently receives a replicated grey channel.

### 2. Normalization statistics

`affmae/data/stats.py` holds four numbers measured on our data:

```python
IMAGE_MEAN = 0.6266           # labelled FPW split: finetuning and inference
IMAGE_STD  = 0.2259
PRETRAIN_IMAGE_MEAN = 0.5562  # unlabelled pretraining corpus
PRETRAIN_IMAGE_STD  = 0.2396
```

**Change these for your own data.** They are used by the loaders *and* by the
renderers, which denormalize with the same numbers β€” so a mismatch shows up as
washed-out or clipped figures long before you suspect the statistics.

Measure yours with:

```bash
python scripts/calc_norm.py --config configs/my_pretrain.yaml
```

It reports mean/std over your shards and prints the currently configured values
next to them. It measures the *raw* pipeline deliberately: an earlier version read
through the normalized pipeline and reported the residual (~0, ~1), which looks
like a passing result and is useless.

For multi-channel data, note these are scalars broadcast across channels
(`(mean,) * n_channels`). Per-channel statistics need `create_transforms` in
`affmae/data/pretrain_dataset.py` changed to take tuples.

### 3. CLAHE

Contrast-limited adaptive histogram equalization is applied in **four** places,
because EM micrographs have low local contrast. If your images are already
well-exposed, this will flatten real intensity differences and you should remove
it:

| Where | What | Flag? |
|---|---|---|
| `affmae/data/preprocess.py` | `apply_clahe`, `clipLimit=4.25`, `8x8` tiles | yes β€” `preprocess_image(..., use_clahe=False)` |
| `affmae/data/finetune_dataset.py:116, 214` | same parameters, inline | **no flag, edit the call** |
| `affmae/data/pretrain_dataset.py:80` | `clipLimit=4.0`, inside `apply_custom_processing` | **no flag, edit the call** |
| `affmae/data/pretrain_dataset.py:58` | random CLAHE, an augmentation | part of `random_transform` |

Only the inference path takes a flag. The dataset classes hardcode it, so removing
CLAHE from training means editing those call sites. If you do, **re-measure the
normalization statistics afterwards** β€” CLAHE changes the intensity distribution,
so the old mean/std no longer describe your inputs.

### 4. The microscope info bar

`apply_custom_processing` in `affmae/data/pretrain_dataset.py` crops the bottom of
the image when it finds a row that is more than 95% pure white below the 60%
height mark. That removes the scale-bar and metadata strip our microscope burns
into every frame.

This is the most EM-specific step in the pipeline and the most likely to damage
other data: any image with a genuinely bright bottom edge β€” a light background, a
white border β€” gets silently truncated, and because the crop is variable-height
the resize then changes the aspect ratio per image. **Remove it unless your images
have the same burned-in strip.**

### 5. Augmentation

`ElasticTransform` in `affmae/data/transforms.py` plus affine and photometric
transforms are tuned for tissue sections, which deform plausibly. For rigid
subjects (documents, industrial parts) elastic warping invents geometry that
cannot occur; see `scripts/visualize_aug.py` to look at what the current pipeline
does to your images before training on it:

```bash
python scripts/visualize_aug.py --base-path data/finetune --output output/aug.png
```

## Adapting the architecture

Defaults are AFF-Base at 512px with patch size 8. What is worth changing:

| Goal | Change |
|---|---|
| Larger images | `data.img_size` 768 or 1024, and lower `batch_size` |
| Smaller/faster model | shorten `aff_depths`, narrow `aff_embed_dims` |
| More tokens kept per stage | raise `aff_ds_rates` (0.4 keeps 40% per stage) |
| Different masking difficulty | `model.mask_ratio`, pretraining only |
| Out of memory | lower `batch_size`, raise `train.num_accum` to compensate |

Two big constraints:

- **`decoder_depth` must equal the number of encoder stages** (4 for AFF-Base),
  one decoder block per stage.
- **Encoder keys must match between pretraining and finetuning.** The finetune
  config re-declares `aff_embed_dims`, `aff_depths`, `aff_num_heads`,
  `aff_nbhd_sizes`, `aff_cluster_size`, `aff_ds_rates`, `aff_mlp_ratio` and
  `aff_merging_method`; any disagreement means the checkpoint does not load into
  the encoder.

`img_size` is the exception β€” it may differ between stages. Cost is driven by the
token count, which scales with `img_size / patch_size` rather than with pixel
count, so moving from 512 to 1024 at patch size 8 doubles the grid on each axis.

Leave `cluster_attention_backend` and `decoder_deform_backend` at `"auto"` unless
you have profiled the alternative. `"auto"` selects the fused Triton kernels on
CUDA and the PyTorch fallback elsewhere, so the same config runs on CPU, CUDA and
Apple silicon; see *Hardware and Kernel Coverage* in the README.