kaveh commited on
Commit
1d4bac8
·
1 Parent(s): 23ab7e2

experiments: dataset audit, evaluation, baselines and ablations

Browse files
.gitignore CHANGED
@@ -11,3 +11,6 @@ S2FApp/ckp/spheroid/ckp_spheroid_FN.pth
11
  S2FApp/ckp/single_cell/ckp_singlecell_GN.pth
12
  S2FApp/ckp/spheroid/ckp_spheroid_GN.pth
13
  S2FApp/*.docx
 
 
 
 
11
  S2FApp/ckp/single_cell/ckp_singlecell_GN.pth
12
  S2FApp/ckp/spheroid/ckp_spheroid_GN.pth
13
  S2FApp/*.docx
14
+
15
+ # experiments: trained ablation checkpoints (large)
16
+ experiments/runs/
experiments/README.md ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # S2F experiments: data audit, evaluation, baselines and ablations
2
+
3
+ This folder holds the code for every number reported in the paper beyond the training of the released checkpoints:
4
+ - the dataset audit and split manifests;
5
+ - the evaluation of the released models;
6
+ - the baselines and ablation study.
7
+
8
+ Run every command from the repository root (`Shape2Force/`); a GPU is used automatically when available.
9
+
10
+ ```
11
+ experiments/
12
+ layout.py dataset layout, per-set metadata (Young's modulus, TFM settings), manifest helpers
13
+ prepare_dataset.py audit legacy data vs dataset/, copy missing images, write splits/*.csv
14
+ manifest_data.py data loader driven by a split manifest (same preprocessing as data/cell_dataset.py)
15
+ ablation_models.py plain U-Net and S2F generator variants
16
+ losses.py per-image WFM-RME reconstruction loss (as used for the released model)
17
+ configs.py shared training protocol + one entry per variant
18
+ train.py train one variant
19
+ evaluate.py evaluate released checkpoints / baselines / trained runs on all manifests
20
+ run_all.py run the steps above in order
21
+ splits/ split manifests (tracked in git)
22
+ runs/ training outputs (git-ignored)
23
+ results/ audit and evaluation tables
24
+ ```
25
+
26
+ ## Data
27
+
28
+ `dataset/` (git-ignored, Zenodo record 10.5281/zenodo.18457879) contains:
29
+ - `singlecell/train` (236), `singlecell/test` (46);
30
+ - `spheroid/train` (129), `spheroid/test` (28).
31
+
32
+ The released single-cell model was trained on a larger split (`all_tune`: 280 training / 81 validation pairs, 1, 6 and 10 kPa). The paper also reports independent test sets. `prepare_dataset.py` reconstructs all of this from the original data tree and adds the missing images to `dataset/`:
33
+
34
+ ```
35
+ dataset/singlecell_extra/<set>/<sample>/ all_tune images that are not in singlecell/ (1 and 10 kPa)
36
+ dataset/singlecell_external/<set>/<sample>/ independent test sets (DMSO, Y-27632, TGF-beta, fibronectin, ...)
37
+ ```
38
+
39
+ Each manifest in `splits/` lists one sample per row:
40
+ - the sample folder relative to `dataset/`;
41
+ - condition, Young's modulus and TFM settings: colour limit of the heatmap (`ythreshold_Pa`, 500 Pa for single cells and 200 Pa for spheroids) and pixel-size setting;
42
+ - the substrate setting the original code fed to the network (`substrate_legacy`) and the nominal one (`substrate_nominal`);
43
+ - the MD5 of the bright-field image.
44
+
45
+ ## Steps
46
+
47
+ ```bash
48
+ # 1. audit only: what is in dataset/ vs the original tree, all_tune composition, leakage check
49
+ python -m experiments.run_all --legacy-root <path>/cellforce/dataset --steps audit
50
+
51
+ # 2. complete dataset/ and write splits/*.csv (then commit experiments/splits/)
52
+ python -m experiments.run_all --legacy-root <path>/cellforce/dataset --steps prepare
53
+
54
+ # 3. released checkpoints, substrate sweep, input-free baseline, image-only stage-1 model
55
+ python -m experiments.run_all --steps eval_released --stage1 <path>/outputs/checkpoints/last/last_checkpoint_final.pth
56
+
57
+ # 4. baselines / ablations (about 7-8 h each on an 11 GB GPU; resumable), then their evaluation
58
+ python -m experiments.run_all --steps train,eval_runs --variants s2f_full,unet,s2f_no_gan,mask_only,s2f_no_substrate
59
+ ```
60
+
61
+ A single run: `python -m experiments.train --variant unet --seed 0`.
62
+
63
+ ### GPUs and run time
64
+
65
+ - `--steps train` runs one job per GPU in parallel (default `--gpus 0,1`). Each job sees only its own GPU (`CUDA_VISIBLE_DEVICES`), and the next variant starts as soon as a GPU is free.
66
+ - A model is never split across GPUs: with batch size 2, that would harm batch-norm/GAN training.
67
+ - `train.py` refuses to run without CUDA unless `--allow-cpu` is given.
68
+ - Augmentation runs in `--num-workers` DataLoader processes (default 2 per job; use 0 if RAM is short).
69
+ - Finished runs are skipped and interrupted runs resume from `last_checkpoint.pth`, so the command can simply be re-run.
70
+ - Run time on an 11 GB GPU (2080 Ti class) is about 4-5 min per epoch, i.e. about 7-8 h per variant (100 epochs). On two GPUs:
71
+ - the 5 core variants take about 16-24 h;
72
+ - all 14 variants take about 55 h.
73
+ - Progress is logged to `experiments/runs/<variant>_seed<seed>/train.log` and `history.json`.
74
+
75
+ For a quick check that everything runs, add `--epochs 1 --max-batches 2` (and `--allow-cpu` without a GPU).
76
+
77
+ ## Training protocol (all variants)
78
+
79
+ Every variant is trained from scratch on `alltune_train` (280 pairs) with the same settings:
80
+ - 100 epochs, batch size 2, 1024 × 1024 px, the augmentations of `data/augmentations.py`;
81
+ - Adam (lr 1e-4, β = 0.5/0.999), cosine decay after 10 % linear warm-up;
82
+ - loss = 10 × per-image WFM-RME + 1 × adversarial (PatchGAN, BCE) + 100 × MSE of mean intensity.
83
+
84
+ The checkpoint with the lowest reconstruction loss on `alltune_val` (81 pairs) is kept. Variants change one component each (`configs.py`):
85
+
86
+ | variant | change |
87
+ |---|---|
88
+ | `s2f_full` | none (from-scratch counterpart of the released model) |
89
+ | `unet` | plain U-Net (Ronneberger et al. 2015), no attention modules, no discriminator |
90
+ | `unet_gan` | plain U-Net + PatchGAN (pix2pix-style) |
91
+ | `mask_only` | binary cell mask instead of bright field (needs `cell_mask.tif` in each sample folder) |
92
+ | `s2f_no_gan` | no discriminator (λ_GAN = 0) |
93
+ | `s2f_no_force` | no stress-consistency term (λ_force = 0) |
94
+ | `s2f_gan_0.1`, `s2f_gan_5` | λ_GAN = 0.1 / 5 |
95
+ | `s2f_no_substrate` | bright field only (no substrate channels) |
96
+ | `s2f_nominal_substrate` | substrate channels from the nominal Young's modulus of each image |
97
+ | `s2f_no_multiscale` | no multi-scale input module |
98
+ | `s2f_bridge_no_cbam` | bottleneck without CBAM |
99
+ | `s2f_bridge_plain` | bottleneck without CBAM, Global Context Module and hierarchical attention |
100
+ | `s2f_no_attention_gates` | skip connections without attention gates |
101
+
102
+ The released checkpoint was obtained by staged fine-tuning (see Methods). `evaluate.py` reports it alongside the from-scratch variants.
103
+
104
+ ## Metrics
105
+
106
+ `evaluate.py` computes every metric per image and reports the mean and SD across images:
107
+ - MSE, MS-SSIM and pixel-wise Pearson r;
108
+ - per-image WFM-RME;
109
+ - mean traction stress in Pa (heatmap value × colour limit);
110
+ - the fraction of saturated ground-truth pixels;
111
+ - inference time.
112
+
113
+ Its outputs are `results/eval/per_image.csv`, `summary.csv` and `model_params.csv`.
experiments/__init__.py ADDED
File without changes
experiments/ablation_models.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Generators for the baseline / ablation study. All variants output tanh in [-1, 1], like the
3
+ released S2F generator, so they share one training and evaluation path.
4
+
5
+ arch='s2f' S2FGenerator (models/s2f_model.py) with optional switches:
6
+ use_multi_scale_input (False -> single 3x3 input conv)
7
+ bridge 'cbam' (paper) | 'cnn' (no CBAM) | 'plain' (dilated conv block only:
8
+ no CBAM, no Global Context Module, no hierarchical attention)
9
+ attention_gates False -> skip connections pass encoder features unchanged
10
+ arch='unet' plain U-Net (Ronneberger et al., 2015): 4 levels, 64-1024 channels, double 3x3 conv + BN +
11
+ ReLU, transposed-conv upsampling, concatenation skips; no attention modules.
12
+ """
13
+ import torch
14
+ import torch.nn as nn
15
+
16
+ from models.blocks import ResidualBlock
17
+ from models.s2f_model import PatchGANDiscriminator, S2FGenerator
18
+
19
+
20
+ class _PassSkip(nn.Module):
21
+ """Replaces an attention gate: returns the encoder skip feature unchanged."""
22
+ def forward(self, g, x):
23
+ return x
24
+
25
+
26
+ def _double_conv(i, o):
27
+ return nn.Sequential(nn.Conv2d(i, o, 3, padding=1, bias=False), nn.BatchNorm2d(o), nn.ReLU(inplace=True),
28
+ nn.Conv2d(o, o, 3, padding=1, bias=False), nn.BatchNorm2d(o), nn.ReLU(inplace=True))
29
+
30
+
31
+ class UNet(nn.Module):
32
+ def __init__(self, in_channels=1, out_channels=1, base=64):
33
+ super().__init__()
34
+ c = [base, base * 2, base * 4, base * 8, base * 16]
35
+ self.downs = nn.ModuleList([_double_conv(in_channels, c[0])] + [_double_conv(c[i], c[i + 1]) for i in range(3)])
36
+ self.pool = nn.MaxPool2d(2)
37
+ self.bottleneck = _double_conv(c[3], c[4])
38
+ self.ups = nn.ModuleList([nn.ConvTranspose2d(c[i + 1], c[i], 2, stride=2) for i in reversed(range(4))])
39
+ self.decs = nn.ModuleList([_double_conv(c[i] * 2, c[i]) for i in reversed(range(4))])
40
+ self.out = nn.Sequential(nn.Conv2d(c[0], out_channels, 1), nn.Tanh())
41
+
42
+ def forward(self, x):
43
+ skips = []
44
+ for d in self.downs:
45
+ x = d(x); skips.append(x); x = self.pool(x)
46
+ x = self.bottleneck(x)
47
+ for up, dec, s in zip(self.ups, self.decs, reversed(skips)):
48
+ x = dec(torch.cat([up(x), s], dim=1))
49
+ return self.out(x)
50
+
51
+
52
+ def build_generator(arch="s2f", in_channels=3, use_multi_scale_input=True, bridge="cbam",
53
+ attention_gates=True, **_):
54
+ if arch == "unet":
55
+ return UNet(in_channels=in_channels)
56
+ if arch != "s2f":
57
+ raise ValueError(f"unknown arch {arch}")
58
+ g = S2FGenerator(in_channels=in_channels, out_channels=1, img_size=1024,
59
+ bridge_type="cnn" if bridge in ("cnn", "plain") else "cbam",
60
+ use_multi_scale_input=use_multi_scale_input)
61
+ if bridge == "plain":
62
+ g.bridge = nn.Sequential(nn.Conv2d(512, 1024, 3, padding=2, dilation=2), nn.BatchNorm2d(1024),
63
+ nn.ReLU(inplace=True), ResidualBlock(1024, 1024))
64
+ if not attention_gates:
65
+ g.att4, g.att3, g.att2, g.att1 = _PassSkip(), _PassSkip(), _PassSkip(), _PassSkip()
66
+ return g
67
+
68
+
69
+ def build_discriminator(in_channels=3):
70
+ return PatchGANDiscriminator(in_channels=in_channels + 1, ndf=64, n_layers=3)
71
+
72
+
73
+ def n_params(module):
74
+ return sum(p.numel() for p in module.parameters())
experiments/configs.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Training protocol shared by every baseline / ablation run, and the variants.
3
+
4
+ All variants are trained FROM SCRATCH with the same protocol, split, number of epochs and
5
+ checkpoint-selection rule, so that differences are attributable to the ablated component only.
6
+ (The released single-cell checkpoint was obtained by staged fine-tuning, see the Methods; the
7
+ 's2f_full' variant is its from-scratch counterpart under this protocol.)
8
+ """
9
+ import copy
10
+
11
+ PROTOCOL = dict(
12
+ train_manifest="alltune_train", # 280 pairs, released model's training split
13
+ val_manifest="alltune_val", # 81 pairs, used for checkpoint selection only
14
+ epochs=100,
15
+ batch_size=2,
16
+ img_size=1024,
17
+ augment=True, # data/augmentations.AdvancedAugmentations
18
+ g_lr=1e-4, d_lr=1e-4, beta1=0.5, beta2=0.999,
19
+ warmup_frac=0.1, # cosine schedule with linear warm-up
20
+ lambda_rec=10.0, # per-image WFM-RME
21
+ lambda_gan=1.0, # vanilla (BCE) PatchGAN loss
22
+ lambda_force=100.0, # MSE between mean predicted and mean target intensity
23
+ arch="s2f", in_channels=3, use_multi_scale_input=True, bridge="cbam", attention_gates=True,
24
+ substrate_mode="legacy", # see experiments/manifest_data.py
25
+ input_mode="bf",
26
+ seed=0,
27
+ )
28
+
29
+ VARIANTS = {
30
+ # reference
31
+ "s2f_full": dict(),
32
+ # baselines
33
+ "unet": dict(arch="unet", lambda_gan=0.0), # plain U-Net, no attention, no discriminator
34
+ "unet_gan": dict(arch="unet"), # pix2pix-style U-Net + PatchGAN
35
+ "mask_only": dict(input_mode="mask"), # binary cell mask instead of bright field
36
+ # loss ablations
37
+ "s2f_no_gan": dict(lambda_gan=0.0),
38
+ "s2f_no_force": dict(lambda_force=0.0),
39
+ "s2f_gan_0.1": dict(lambda_gan=0.1),
40
+ "s2f_gan_5": dict(lambda_gan=5.0),
41
+ # input ablations
42
+ "s2f_no_substrate": dict(in_channels=1, substrate_mode="none"),
43
+ "s2f_nominal_substrate": dict(substrate_mode="nominal"),
44
+ # architecture ablations
45
+ "s2f_no_multiscale": dict(use_multi_scale_input=False),
46
+ "s2f_bridge_no_cbam": dict(bridge="cnn"),
47
+ "s2f_bridge_plain": dict(bridge="plain"),
48
+ "s2f_no_attention_gates": dict(attention_gates=False),
49
+ }
50
+
51
+ # Recommended order if GPU time is limited (about 7-8 h per run of 100 epochs on an 11 GB GPU)
52
+ PRIORITY = ["s2f_full", "unet", "s2f_no_gan", "mask_only", "s2f_no_substrate",
53
+ "s2f_no_multiscale", "s2f_bridge_plain", "s2f_no_force", "s2f_no_attention_gates",
54
+ "s2f_bridge_no_cbam", "s2f_nominal_substrate", "unet_gan", "s2f_gan_0.1", "s2f_gan_5"]
55
+
56
+
57
+ def get_config(variant, **overrides):
58
+ if variant not in VARIANTS:
59
+ raise KeyError(f"unknown variant '{variant}'. Options: {', '.join(VARIANTS)}")
60
+ cfg = copy.deepcopy(PROTOCOL)
61
+ cfg.update(VARIANTS[variant])
62
+ cfg.update({k: v for k, v in overrides.items() if v is not None})
63
+ cfg["variant"] = variant
64
+ if cfg["substrate_mode"] == "none":
65
+ cfg["in_channels"] = 1
66
+ return cfg
experiments/evaluate.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Evaluate released checkpoints, baselines and trained ablation runs on every split manifest.
3
+
4
+ python -m experiments.evaluate --released --meanmap # released models + input-free baseline
5
+ python -m experiments.evaluate --released --sweep # + substrate-setting sensitivity
6
+ python -m experiments.evaluate --stage1 <path>/last_checkpoint_final.pth # image-only stage-1 model
7
+ python -m experiments.evaluate --runs # every experiments/runs/*/best_checkpoint.pth
8
+
9
+ Metrics are computed PER IMAGE (mean and SD are then taken across images):
10
+ mse, ms_ssim, pixel_r (Pearson), rme (per-image WFM-RME), mean_gt / mean_pred (heatmap units),
11
+ mean_gt_Pa / mean_pred_Pa (x colour-scale limit of the set, 500 Pa single cells, 200 Pa spheroids),
12
+ gt_frac_saturated (pixels >= 0.98 of the colour limit), inference time.
13
+ Outputs (experiments/results/eval/ by default; existing rows of other models are kept):
14
+ per_image.csv, summary.csv
15
+ """
16
+ import argparse
17
+ import glob
18
+ import os
19
+ import time
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+ import torch
24
+
25
+ from experiments.ablation_models import build_generator, n_params
26
+ from experiments.layout import DEFAULT_DATASET_ROOT, RESULTS_DIR, RUNS_DIR, S2F_ROOT, list_manifests
27
+ from experiments.losses import PerImageWFMRMELoss
28
+ from experiments.manifest_data import make_loader
29
+ from models.s2f_model import create_s2f_model, create_settings_channels
30
+ from utils.metrics import TorchMetricsWrapper
31
+ from utils.substrate_settings import compute_settings_normalization
32
+
33
+ CONFIG_PATH = os.path.join(S2F_ROOT, "config", "substrate_settings.json")
34
+ SWEEP_KEYS = ["PDMS_1kPa_3um", "fibroblasts_PDMS", "PDMS_10kPa_3um"]
35
+
36
+
37
+ def released_models(device):
38
+ out = {}
39
+ g, _ = create_s2f_model(in_channels=3, model_type="s2f")
40
+ g.load_state_dict(torch.load(os.path.join(S2F_ROOT, "ckp/single_cell/ckp_singlecell_GN.pth"), map_location="cpu",
41
+ weights_only=False)["generator_state_dict"], strict=True)
42
+ out["released_singlecell"] = dict(model=g.to(device).eval(), kind="singlecell", in_channels=3, tanh=True)
43
+ g, _ = create_s2f_model(in_channels=1, model_type="s2f")
44
+ g.load_state_dict(torch.load(os.path.join(S2F_ROOT, "ckp/spheroid/ckp_spheroid_GN.pth"), map_location="cpu",
45
+ weights_only=False)["generator_state_dict"], strict=True)
46
+ out["released_spheroid_GN"] = dict(model=g.to(device).eval(), kind="spheroid", in_channels=1, tanh=True)
47
+ fn = os.path.join(S2F_ROOT, "ckp/spheroid/ckp_spheroid_FN.pth")
48
+ if os.path.exists(fn):
49
+ g, _ = create_s2f_model(in_channels=1, model_type="s2f_spheroid")
50
+ g.load_state_dict(torch.load(fn, map_location="cpu", weights_only=False)["model_state_dict"], strict=True)
51
+ g.set_output_mode(use_tanh=False)
52
+ out["supervised_spheroid_FN"] = dict(model=g.to(device).eval(), kind="spheroid", in_channels=1, tanh=False)
53
+ return out
54
+
55
+
56
+ def stage1_model(path, device):
57
+ g, _ = create_s2f_model(in_channels=1, model_type="s2f")
58
+ sd = torch.load(path, map_location="cpu", weights_only=False)["generator_state_dict"]
59
+ g.load_state_dict({k.replace("denoise_module", "initial_conv"): v for k, v in sd.items()}, strict=True)
60
+ return dict(model=g.to(device).eval(), kind="singlecell", in_channels=1, tanh=True)
61
+
62
+
63
+ def run_models(device, pattern):
64
+ out = {}
65
+ for p in sorted(glob.glob(pattern)):
66
+ ck = torch.load(p, map_location="cpu", weights_only=False)
67
+ cfg = ck["config"]
68
+ g = build_generator(**cfg); g.load_state_dict(ck["generator_state_dict"], strict=True)
69
+ name = os.path.basename(os.path.dirname(p))
70
+ out[name] = dict(model=g.to(device).eval(), kind="singlecell", in_channels=cfg["in_channels"], tanh=True,
71
+ substrate_mode=cfg["substrate_mode"], input_mode=cfg["input_mode"],
72
+ epoch=ck.get("epoch"), val_loss=ck.get("val_loss"))
73
+ return out
74
+
75
+
76
+ def mean_map(manifest, dataset_root):
77
+ acc, n = None, 0
78
+ for b in make_loader(manifest, dataset_root, batch_size=8, substrate_mode="none"):
79
+ acc = b[1].sum(0, keepdim=True) if acc is None else acc + b[1].sum(0, keepdim=True); n += b[1].size(0)
80
+ return acc / n
81
+
82
+
83
+ def evaluate_one(name, spec, manifest, dataset_root, device, mw, norm, substrate_mode, batch, mean_pred=None):
84
+ input_mode = spec.get("input_mode", "bf")
85
+ use_settings = spec.get("in_channels", 1) == 3
86
+ sm = substrate_mode if use_settings else "none"
87
+ forced = None
88
+ if sm and sm.startswith("force:"):
89
+ forced, sm = sm.split(":", 1)[1], "reference"
90
+ try:
91
+ loader = make_loader(manifest, dataset_root, batch_size=batch, substrate_mode=sm, input_mode=input_mode)
92
+ except FileNotFoundError as e:
93
+ print(f" [skip] {name} on {manifest}: {e}"); return []
94
+ rows_meta, rec, out, k = loader.dataset.rows, PerImageWFMRMELoss(), [], 0
95
+ with torch.no_grad():
96
+ for b in loader:
97
+ x, y = b[0].to(device), b[1].to(device)
98
+ t0 = time.time()
99
+ if mean_pred is not None:
100
+ p = mean_pred.to(device).expand_as(y)
101
+ else:
102
+ if use_settings:
103
+ meta = b[4]
104
+ if forced:
105
+ meta = dict(meta, substrate=[forced] * x.size(0))
106
+ x = torch.cat([x, create_settings_channels(meta, norm, device, x.shape, config_path=CONFIG_PATH)], 1)
107
+ p = spec["model"](x)
108
+ p = (p + 1) / 2 if spec.get("tanh", True) else p
109
+ dt = (time.time() - t0) / x.size(0)
110
+ for i in range(y.size(0)):
111
+ r = rows_meta[k]; k += 1
112
+ t, q = y[i:i + 1], p[i:i + 1]
113
+ yt = float(r.get("ythreshold_Pa") or (200 if "spheroid" in manifest else 500))
114
+ out.append({"model": name, "manifest": manifest, "substrate_mode": substrate_mode if use_settings else "n/a",
115
+ "sample_dir": r["sample_dir"], "set": r.get("set", ""), "condition": r.get("condition", ""),
116
+ "mse": mw.compute_mse(t, q), "ms_ssim": mw.compute_ms_ssim(t, q),
117
+ "pixel_r": float(np.corrcoef(t.flatten().cpu().numpy(), q.flatten().cpu().numpy())[0, 1]),
118
+ "rme": float(rec(q, t)), "mean_gt": float(t.mean()), "mean_pred": float(q.mean()),
119
+ "mean_gt_Pa": float(t.mean()) * yt, "mean_pred_Pa": float(q.mean()) * yt,
120
+ "gt_max": float(t.max()), "gt_frac_saturated": float((t >= 0.98).float().mean()),
121
+ "seconds_per_image": dt})
122
+ return out
123
+
124
+
125
+ def summarise(df):
126
+ g = df.groupby(["model", "manifest", "substrate_mode"])
127
+ s = g.agg(n=("mse", "size"), **{f"{m}_{f}": (m, f) for m in ["mse", "ms_ssim", "pixel_r", "rme", "mean_gt_Pa",
128
+ "mean_pred_Pa", "gt_frac_saturated"] for f in ["mean", "std"]},
129
+ seconds_per_image=("seconds_per_image", "mean")).reset_index()
130
+ s["pred_over_gt"] = s["mean_pred_Pa_mean"] / s["mean_gt_Pa_mean"]
131
+ return s
132
+
133
+
134
+ def main():
135
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
136
+ ap.add_argument("--released", action="store_true", help="released single-cell + spheroid checkpoints")
137
+ ap.add_argument("--sweep", action="store_true", help="released single-cell model with 1 / 6 / 10 kPa settings")
138
+ ap.add_argument("--meanmap", action="store_true", help="input-free baseline (mean training map)")
139
+ ap.add_argument("--stage1", default=None, help="path to last_checkpoint_final.pth (image-only stage 1)")
140
+ ap.add_argument("--runs", nargs="?", const=os.path.join(RUNS_DIR, "*", "best_checkpoint.pth"), default=None,
141
+ help="glob of trained run checkpoints (default experiments/runs/*/best_checkpoint.pth)")
142
+ ap.add_argument("--manifests", default="", help="comma-separated manifest names (default: all in experiments/splits)")
143
+ ap.add_argument("--dataset-root", default=DEFAULT_DATASET_ROOT)
144
+ ap.add_argument("--out", default=os.path.join(RESULTS_DIR, "eval"))
145
+ ap.add_argument("--batch", type=int, default=2)
146
+ ap.add_argument("--device", default=None)
147
+ a = ap.parse_args()
148
+ device = torch.device(a.device or ("cuda" if torch.cuda.is_available() else "cpu"))
149
+ mans = [m for m in (a.manifests.split(",") if a.manifests else list_manifests()) if m]
150
+ sc_mans = [m for m in mans if not m.startswith("spheroid") and m != "alltune_train" and m != "zenodo_v1_train"]
151
+ sph_mans = [m for m in mans if m.startswith("spheroid") and m != "spheroid_train"]
152
+ print(f"device {device}; single-cell manifests {sc_mans}; spheroid manifests {sph_mans}")
153
+ mw, norm = TorchMetricsWrapper(device=device), compute_settings_normalization(config_path=CONFIG_PATH)
154
+
155
+ models = {}
156
+ if a.released:
157
+ models.update(released_models(device))
158
+ if a.stage1:
159
+ models["stage1_image_only"] = stage1_model(a.stage1, device)
160
+ if a.runs:
161
+ models.update(run_models(device, a.runs))
162
+ rows, params = [], []
163
+ for name, spec in models.items():
164
+ params.append({"model": name, "n_params_M": n_params(spec["model"]) / 1e6, "epoch": spec.get("epoch"),
165
+ "val_loss": spec.get("val_loss")})
166
+ targets = sc_mans if spec["kind"] == "singlecell" else sph_mans
167
+ modes = [spec.get("substrate_mode", "legacy")]
168
+ if name == "released_singlecell":
169
+ modes = ["legacy", "nominal"] + ([f"force:{k}" for k in SWEEP_KEYS] if a.sweep else [])
170
+ for m in targets:
171
+ for sm in modes:
172
+ print(f" {name:28s} {m:28s} {sm}", flush=True)
173
+ rows += evaluate_one(name, spec, m, a.dataset_root, device, mw, norm, sm, a.batch)
174
+ if a.meanmap:
175
+ for train_m, targets, name in [("alltune_train", sc_mans, "baseline_mean_map_singlecell"),
176
+ ("spheroid_train", sph_mans, "baseline_mean_map_spheroid")]:
177
+ if train_m not in list_manifests():
178
+ continue
179
+ M = mean_map(train_m, a.dataset_root)
180
+ for m in targets:
181
+ print(f" {name:28s} {m}", flush=True)
182
+ rows += evaluate_one(name, {"in_channels": 1}, m, a.dataset_root, device, mw, norm, "n/a", a.batch, mean_pred=M)
183
+ os.makedirs(a.out, exist_ok=True)
184
+ new = pd.DataFrame(rows)
185
+ pi = os.path.join(a.out, "per_image.csv")
186
+ if os.path.exists(pi) and len(new): # keep rows of models not re-evaluated now
187
+ old = pd.read_csv(pi)
188
+ new = pd.concat([old[~old.model.isin(new.model.unique())], new], ignore_index=True)
189
+ if len(new):
190
+ new.to_csv(pi, index=False)
191
+ summarise(new).to_csv(os.path.join(a.out, "summary.csv"), index=False)
192
+ pp = os.path.join(a.out, "model_params.csv")
193
+ if params:
194
+ P = pd.DataFrame(params)
195
+ if os.path.exists(pp):
196
+ old = pd.read_csv(pp); P = pd.concat([old[~old.model.isin(P.model)], P], ignore_index=True)
197
+ P.to_csv(pp, index=False)
198
+ print(f"wrote {pi}, summary.csv, model_params.csv in {a.out}")
199
+
200
+
201
+ if __name__ == "__main__":
202
+ main()
experiments/layout.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dataset layout, per-set metadata and manifest helpers for the S2F experiments.
3
+
4
+ Two dataset trees are involved:
5
+
6
+ * the **repository dataset** ``S2F/dataset/`` (git-ignored; same content as the Zenodo deposit):
7
+ singlecell/{train,test}/<sample>/ 236 / 46 fibroblasts on 6 kPa collagen gels
8
+ spheroid/{train,test}/<sample>/ 129 / 28 LS174T spheroids
9
+ ``experiments/prepare_dataset.py --write`` adds the remaining sets used in the paper:
10
+ singlecell_extra/<set>/<sample>/ training/validation images of the released model
11
+ that are not in singlecell/ (1 and 10 kPa)
12
+ singlecell_external/<set>/<sample>/ independent test sets (drugs, fibronectin, ...)
13
+
14
+ * the **legacy tree** used to develop the model (``cellforce/dataset`` on the workstation), whose
15
+ ``single_cell/all_tune/{train,test}`` folder (280 / 81 pairs) is the split of the released
16
+ single-cell checkpoint.
17
+
18
+ Splits are defined by manifest CSV files in ``experiments/splits/`` (tracked in git), one row per
19
+ sample, so that every experiment is reproducible from the repository dataset alone.
20
+ """
21
+ import csv
22
+ import hashlib
23
+ import os
24
+
25
+ S2F_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
26
+ # Override with environment variables if needed (e.g. dataset on another disk)
27
+ DEFAULT_DATASET_ROOT = os.environ.get("S2F_DATASET_ROOT", os.path.join(S2F_ROOT, "dataset"))
28
+ SPLITS_DIR = os.environ.get("S2F_SPLITS_DIR", os.path.join(S2F_ROOT, "experiments", "splits"))
29
+ RUNS_DIR = os.environ.get("S2F_RUNS_DIR", os.path.join(S2F_ROOT, "experiments", "runs"))
30
+ RESULTS_DIR = os.environ.get("S2F_RESULTS_DIR", os.path.join(S2F_ROOT, "experiments", "results"))
31
+
32
+ BF_SUFFIX = "BF_001.tif"
33
+ HM_SUFFIX = "_gray.jpg"
34
+ MASK_NAMES = ("cell_mask.tif", "cell_mask.png", "mask.tif", "mask.png") # optional, for the mask-only baseline
35
+
36
+ # TFM reconstruction settings (from the MATLAB settings files of each set).
37
+ # ythreshold_Pa = upper limit of the colour scale used to export the heatmaps -> grey 1.0 == ythreshold_Pa.
38
+ # tfm_pixelsize = value of Settings.PixelSize used in the TFM reconstruction.
39
+ FIBRO_6KPA = dict(cell_type="HFF", coating="collagen I", young_Pa=6000, tfm_pixelsize=3.0769, ythreshold_Pa=500)
40
+
41
+ # Legacy sets: key -> (relative path under legacy root, role, clean name, metadata)
42
+ # role: 'alltune' split of the released single-cell model (train 280 / val 81)
43
+ # 'published' already in S2F/dataset
44
+ # 'source' pool from which all_tune was assembled (1 / 10 kPa)
45
+ # 'external' independent test set
46
+ # 'union' duplicate collection, audited only
47
+ LEGACY_SETS = {
48
+ "all_tune/train": ("single_cell/all_tune/train", "alltune", None, {}),
49
+ "all_tune/test": ("single_cell/all_tune/test", "alltune", None, {}),
50
+ "fibro_split/train": ("single_cell/fibro_split/train", "published", "fibro_6kPa_train", dict(FIBRO_6KPA, condition="6 kPa collagen")),
51
+ "fibro_split/test": ("single_cell/fibro_split/test", "published", "fibro_6kPa_setVI", dict(FIBRO_6KPA, condition="6 kPa collagen")),
52
+ "1kpa_new/train": ("single_cell/1kpa_new/train", "source", "fibro_1kPa_train", dict(FIBRO_6KPA, young_Pa=1000, condition="1 kPa collagen")),
53
+ "1kpa_new/test": ("single_cell/1kpa_new/test", "source", "fibro_1kPa_setI", dict(FIBRO_6KPA, young_Pa=1000, condition="1 kPa collagen")),
54
+ "10kpa_new/train": ("single_cell/10kpa_new/train", "source", "fibro_10kPa_train", dict(FIBRO_6KPA, young_Pa=10000, condition="10 kPa collagen")),
55
+ "10kpa_new/test": ("single_cell/10kpa_new/test", "source", "fibro_10kPa_setI", dict(FIBRO_6KPA, young_Pa=10000, condition="10 kPa collagen")),
56
+ "1kPa_SET_III": ("single_cell/last_new_1kpa_10kpa/Heatmaps_01kPa_SET_III_for_Kaveh", "external", "fibro_1kPa_setIII", dict(FIBRO_6KPA, young_Pa=1000, condition="1 kPa collagen")),
57
+ "DMSO_SET_II": ("single_cell/last_new_1kpa_10kpa/Heatmaps_DMSO_SET_II_for_Kaveh", "external", "fibro_DMSO_setII", dict(FIBRO_6KPA, condition="DMSO")),
58
+ "Y27632_SET_II": ("single_cell/last_new_1kpa_10kpa/Heatmaps_Y27632_SET_II_for_Kaveh", "external", "fibro_Y27632_setII", dict(FIBRO_6KPA, condition="Y-27632")),
59
+ "Control_SET_I": ("single_cell/Drugs/Fibros_Control_SET_I_for_Kaveh", "external", "fibro_DMSO_setI", dict(FIBRO_6KPA, condition="DMSO")),
60
+ "Fibronectin_SET_I": ("single_cell/Drugs/Fibros_Fibronectin_SET_I_for_Kaveh", "external", "fibro_fibronectin_setI", dict(FIBRO_6KPA, coating="fibronectin", condition="fibronectin")),
61
+ "TGFB_SET_I": ("single_cell/Drugs/Fibros_TGFB_SETI_for_Kaveh", "external", "fibro_TGFb_setI", dict(FIBRO_6KPA, condition="TGF-beta")),
62
+ "Y27632_SET_I": ("single_cell/Drugs/Fibros_Y27632_SET_I_for_Kaveh", "external", "fibro_Y27632_setI", dict(FIBRO_6KPA, condition="Y-27632")),
63
+ "U2OS/test": ("single_cell/u2os/test", "external", "u2os_6kPa", dict(cell_type="U2OS", coating="collagen I", young_Pa=6000, tfm_pixelsize=6.1538, ythreshold_Pa=500, condition="U2OS")),
64
+ "Drugs/all": ("single_cell/Drugs/all", "union", None, {}),
65
+ "spheroid/train": ("spheroid/train", "published", "spheroid_train", dict(cell_type="LS174T spheroid", coating="collagen I", ythreshold_Pa=200, condition="spheroid")),
66
+ "spheroid/test": ("spheroid/test", "published", "spheroid_val", dict(cell_type="LS174T spheroid", coating="collagen I", ythreshold_Pa=200, condition="spheroid")),
67
+ }
68
+
69
+ # Published repository dataset (S2F/dataset), relative paths
70
+ PUBLISHED_SETS = {
71
+ "singlecell/train": "singlecell/train",
72
+ "singlecell/test": "singlecell/test",
73
+ "spheroid/train": "spheroid/train",
74
+ "spheroid/test": "spheroid/test",
75
+ }
76
+
77
+ MANIFEST_FIELDS = ["sample_dir", "split", "set", "condition", "cell_type", "coating", "young_Pa",
78
+ "tfm_pixelsize", "ythreshold_Pa", "legacy_folder", "legacy_substrate_code",
79
+ "substrate_legacy", "substrate_nominal", "bf_md5"]
80
+
81
+
82
+ def legacy_substrate_code(folder_name):
83
+ """Substrate code exactly as the original training code derived it (folder_name[:4]; 'kpan*' -> [:5])."""
84
+ code = folder_name[:4]
85
+ return folder_name[:5] if code.startswith("kpan") else code
86
+
87
+
88
+ def legacy_code_to_config_key(code):
89
+ """S2F config key that encodes the same (pixel size, Young's modulus) as the original code's substrate code."""
90
+ return {"kpa0": "PDMS_1kPa", "kpa1": "PDMS_10kPa", "kpan0": "PDMS_1kPa_3um",
91
+ "kpan1": "PDMS_10kPa_3um", "u2os": "U2OS_PDMS"}.get(code, "fibroblasts_PDMS")
92
+
93
+
94
+ def nominal_config_key(young_Pa, tfm_pixelsize, cell_type="HFF"):
95
+ """S2F config key for the nominal Young's modulus and TFM pixel-size setting of a sample."""
96
+ if cell_type == "U2OS":
97
+ return "U2OS_PDMS"
98
+ if young_Pa in (None, ""):
99
+ return None
100
+ young_Pa, px = float(young_Pa), float(tfm_pixelsize or 3.0769)
101
+ if young_Pa == 6000:
102
+ return "fibroblasts_PDMS"
103
+ suffix = "_3um" if abs(px - 3.0769) < 1e-3 else ""
104
+ return {1000: "PDMS_1kPa", 10000: "PDMS_10kPa"}.get(int(young_Pa), "fibroblasts_PDMS") + suffix
105
+
106
+
107
+ def sample_folders(root):
108
+ """Yield (folder_name, bf_path, hm_path) for every direct sub-folder holding a BF image and a gray heatmap."""
109
+ if not os.path.isdir(root):
110
+ return
111
+ for sub in sorted(os.listdir(root)):
112
+ p = os.path.join(root, sub)
113
+ if not os.path.isdir(p):
114
+ continue
115
+ files = os.listdir(p)
116
+ bf = [f for f in files if f.endswith(BF_SUFFIX)]
117
+ hm = [f for f in files if f.endswith(HM_SUFFIX)]
118
+ if bf and hm:
119
+ yield sub, os.path.join(p, bf[0]), os.path.join(p, hm[0])
120
+
121
+
122
+ def md5(path, chunk=1 << 20):
123
+ h = hashlib.md5()
124
+ with open(path, "rb") as f:
125
+ for b in iter(lambda: f.read(chunk), b""):
126
+ h.update(b)
127
+ return h.hexdigest()
128
+
129
+
130
+ def read_manifest(path):
131
+ with open(path, newline="") as f:
132
+ return list(csv.DictReader(f))
133
+
134
+
135
+ def write_manifest(path, rows):
136
+ os.makedirs(os.path.dirname(path), exist_ok=True)
137
+ with open(path, "w", newline="") as f:
138
+ w = csv.DictWriter(f, fieldnames=MANIFEST_FIELDS, extrasaction="ignore")
139
+ w.writeheader()
140
+ w.writerows(rows)
141
+
142
+
143
+ def manifest_path(name):
144
+ return os.path.join(SPLITS_DIR, f"{name}.csv")
145
+
146
+
147
+ def list_manifests():
148
+ if not os.path.isdir(SPLITS_DIR):
149
+ return []
150
+ return sorted(os.path.splitext(f)[0] for f in os.listdir(SPLITS_DIR) if f.endswith(".csv"))
experiments/losses.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reconstruction loss used for the released single-cell model.
3
+
4
+ Per-image weighted force-magnitude relative error (WFM-RME), as in the manuscript and as in the
5
+ loss that trained the released checkpoint (Archive/src/utils/losses.WFMRelativeMagnitudeLoss):
6
+ the weight of each pixel is its ground-truth magnitude divided by the mean ground-truth magnitude
7
+ *of the same image* (eps = 1e-6). Note: utils.metrics.WFMRMELoss normalises by the mean over the
8
+ whole batch instead; the two coincide only for batch size 1.
9
+ """
10
+ import torch
11
+ import torch.nn as nn
12
+
13
+
14
+ class PerImageWFMRMELoss(nn.Module):
15
+ def __init__(self, eps=1e-6):
16
+ super().__init__()
17
+ self.eps = eps
18
+
19
+ def forward(self, pred, target):
20
+ t, p = target[:, 0], pred[:, 0] # (B, H, W) magnitudes
21
+ rel = (p - t).abs() / (t + self.eps)
22
+ w = t / (t.mean(dim=(1, 2), keepdim=True) + self.eps)
23
+ return (rel * w).mean()
experiments/manifest_data.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Manifest-based data loading. Uses exactly the preprocessing of the S2F package
3
+ (``data.cell_dataset.load_image`` -> grayscale, resize to 1024, /255; ``ImageDataset``;
4
+ ``AdvancedAugmentations``), so results are comparable with the released models.
5
+
6
+ substrate_mode (single-cell only) decides which substrate setting builds the two substrate channels:
7
+ 'legacy' what the original training code fed (derived from the all_tune folder name) - default,
8
+ reproduces the conditions under which the released model was trained
9
+ 'nominal' the true Young's modulus / TFM pixel-size setting of each sample
10
+ 'reference' the 6 kPa reference setting (fibroblasts_PDMS) for every sample
11
+ 'none' no metadata (models without substrate channels)
12
+ input_mode:
13
+ 'bf' bright-field image (default)
14
+ 'mask' binary cell mask (file cell_mask.tif/png in the sample folder) - mask-only baseline
15
+ """
16
+ import os
17
+
18
+ import numpy as np
19
+ import torch
20
+ from torch.utils.data import DataLoader
21
+
22
+ from data.cell_dataset import ImageDataset, load_image
23
+ from experiments.layout import DEFAULT_DATASET_ROOT, MASK_NAMES, manifest_path, read_manifest
24
+
25
+
26
+ def _find(folder, suffix=None, names=None):
27
+ for f in sorted(os.listdir(folder)):
28
+ if (suffix and f.endswith(suffix)) or (names and f in names):
29
+ return os.path.join(folder, f)
30
+ return None
31
+
32
+
33
+ def load_manifest_pairs(manifest, dataset_root=DEFAULT_DATASET_ROOT, img_size=1024,
34
+ substrate_mode="legacy", input_mode="bf"):
35
+ rows = read_manifest(manifest if os.path.isfile(manifest) else manifest_path(manifest))
36
+ pairs = []
37
+ for r in rows:
38
+ d = os.path.join(dataset_root, r["sample_dir"])
39
+ bf = _find(d, suffix="BF_001.tif")
40
+ hm = _find(d, suffix="_gray.jpg")
41
+ if bf is None or hm is None:
42
+ raise FileNotFoundError(f"missing BF/heatmap in {d}")
43
+ if input_mode == "mask":
44
+ mp = _find(d, names=MASK_NAMES)
45
+ if mp is None:
46
+ raise FileNotFoundError(f"input_mode='mask' but no {MASK_NAMES} in {d}")
47
+ x = (load_image(mp, (img_size, img_size)) > 0.5).astype(np.float32)
48
+ else:
49
+ x = load_image(bf, (img_size, img_size))
50
+ y = load_image(hm, (img_size, img_size))
51
+ if substrate_mode == "none":
52
+ pairs.append((x, y, (0, 0)))
53
+ continue
54
+ sub = {"legacy": r.get("substrate_legacy"), "nominal": r.get("substrate_nominal"),
55
+ "reference": "fibroblasts_PDMS"}[substrate_mode] or "fibroblasts_PDMS"
56
+ pairs.append((x, y, (0, 0), {"folder_name": os.path.basename(d), "substrate": sub,
57
+ "root_folder": os.path.dirname(r["sample_dir"]), "set": r.get("set", "")}))
58
+ return pairs, rows
59
+
60
+
61
+ def _worker_init(worker_id):
62
+ """Distinct, reproducible numpy seed per worker (augmentations use np.random). Module-level for Windows spawn."""
63
+ np.random.seed(torch.initial_seed() % 2 ** 32)
64
+
65
+
66
+ class _MaskDataset(ImageDataset):
67
+ """Re-binarise the input after augmentation (intensity augmentations are meaningless for masks)."""
68
+ def __getitem__(self, idx):
69
+ out = list(super().__getitem__(idx))
70
+ out[0] = (out[0] > 0.5).float()
71
+ return tuple(out)
72
+
73
+
74
+ def make_loader(manifest, dataset_root=DEFAULT_DATASET_ROOT, batch_size=2, img_size=1024, augment=False,
75
+ shuffle=False, substrate_mode="legacy", input_mode="bf", seed=None, num_workers=0):
76
+ pairs, rows = load_manifest_pairs(manifest, dataset_root, img_size, substrate_mode, input_mode)
77
+ transform = None
78
+ if augment:
79
+ from data.augmentations import AdvancedAugmentations
80
+ transform = AdvancedAugmentations((img_size, img_size))
81
+ cls = _MaskDataset if input_mode == "mask" else ImageDataset
82
+ ds = cls(pairs, transform=transform, channel_first=True, threshold=0.0,
83
+ return_metadata=substrate_mode != "none")
84
+ ds.rows = rows
85
+ g = None
86
+ if seed is not None:
87
+ g = torch.Generator(); g.manual_seed(seed)
88
+ if num_workers > 0: # workers run the augmentations so the GPU is not left waiting
89
+ return DataLoader(ds, batch_size=batch_size, shuffle=shuffle, generator=g, num_workers=num_workers,
90
+ persistent_workers=True, pin_memory=torch.cuda.is_available(),
91
+ worker_init_fn=_worker_init)
92
+ return DataLoader(ds, batch_size=batch_size, shuffle=shuffle, generator=g, num_workers=0)
experiments/prepare_dataset.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Audit the legacy dataset tree against the repository dataset and (optionally) complete the
3
+ repository dataset with every image used in the paper, plus split manifests.
4
+
5
+ Step 1 - audit only (nothing is copied):
6
+ python -m experiments.prepare_dataset --legacy-root C:/Users/Kaveh/Documents/cellforce/dataset
7
+
8
+ Step 2 - copy missing images into S2F/dataset and write the manifests:
9
+ python -m experiments.prepare_dataset --legacy-root C:/Users/Kaveh/Documents/cellforce/dataset --write
10
+
11
+ What the audit answers (written to experiments/results/dataset_audit/):
12
+ counts.csv number of samples per legacy and repository set
13
+ published_vs_legacy.csv is S2F/dataset/singlecell (236/46) identical to legacy fibro_split? spheroids?
14
+ alltune_composition.csv where every image of all_tune/train (280) and all_tune/test (81) comes from
15
+ missing_from_published.csv all_tune images that are NOT in S2F/dataset (i.e. not in the Zenodo v1 deposit)
16
+ overlap_matrix.csv shared bright-field images between every pair of sets (leakage check)
17
+ substrate_codes.csv substrate code the original code derived from each all_tune folder name
18
+ report.txt human-readable summary
19
+ Images are matched by the MD5 of the bright-field file, so renamed/copied folders are still recognised.
20
+
21
+ With --write:
22
+ dataset/singlecell_extra/<set>/<folder>/ all_tune images missing from dataset/singlecell
23
+ dataset/singlecell_external/<set>/<folder>/ independent test sets
24
+ experiments/splits/*.csv manifests (commit these to git):
25
+ alltune_train.csv, alltune_val.csv split of the released single-cell model (280 / 81)
26
+ zenodo_v1_train.csv, zenodo_v1_test.csv published 6 kPa split (236 / 46)
27
+ spheroid_train.csv, spheroid_val.csv spheroid split (129 / 28)
28
+ test_<set>.csv one per independent test set
29
+ """
30
+ import argparse
31
+ import collections
32
+ import csv
33
+ import os
34
+ import shutil
35
+
36
+ from experiments.layout import (DEFAULT_DATASET_ROOT, LEGACY_SETS, PUBLISHED_SETS, RESULTS_DIR,
37
+ legacy_code_to_config_key, legacy_substrate_code, manifest_path,
38
+ md5, nominal_config_key, sample_folders, write_manifest)
39
+
40
+
41
+ def index_tree(root, rel):
42
+ """List samples of one set: [{folder, bf, hm, dir, md5}]."""
43
+ out = []
44
+ for folder, bf, hm in sample_folders(os.path.join(root, rel)):
45
+ out.append({"folder": folder, "bf": bf, "hm": hm, "dir": os.path.dirname(bf), "md5": md5(bf)})
46
+ return out
47
+
48
+
49
+ def discover_unlisted(root, listed_rel, max_depth=4):
50
+ """Directories under root that contain sample folders but are not in LEGACY_SETS (so nothing is missed)."""
51
+ found = []
52
+ listed = {os.path.normpath(os.path.join(root, r)) for r in listed_rel}
53
+ base_depth = os.path.normpath(root).count(os.sep)
54
+ for dp, dn, _ in os.walk(root):
55
+ if os.path.normpath(dp).count(os.sep) - base_depth >= max_depth:
56
+ dn[:] = []
57
+ continue
58
+ if os.path.normpath(dp) in listed:
59
+ dn[:] = []
60
+ continue
61
+ n = sum(1 for _ in sample_folders(dp))
62
+ if n:
63
+ found.append((os.path.relpath(dp, root), n))
64
+ dn[:] = []
65
+ return found
66
+
67
+
68
+ def row_for(sample_dir, split, set_name, meta, legacy_folder, bf_md5):
69
+ code = legacy_substrate_code(legacy_folder) if legacy_folder else ""
70
+ return {"sample_dir": sample_dir.replace(os.sep, "/"), "split": split, "set": set_name,
71
+ "condition": meta.get("condition", ""), "cell_type": meta.get("cell_type", ""),
72
+ "coating": meta.get("coating", ""), "young_Pa": meta.get("young_Pa", ""),
73
+ "tfm_pixelsize": meta.get("tfm_pixelsize", ""), "ythreshold_Pa": meta.get("ythreshold_Pa", ""),
74
+ "legacy_folder": legacy_folder, "legacy_substrate_code": code,
75
+ "substrate_legacy": legacy_code_to_config_key(code) if meta.get("cell_type") != "LS174T spheroid" else "",
76
+ "substrate_nominal": (nominal_config_key(meta.get("young_Pa"), meta.get("tfm_pixelsize"), meta.get("cell_type"))
77
+ if meta.get("cell_type") != "LS174T spheroid" else "") or "",
78
+ "bf_md5": bf_md5}
79
+
80
+
81
+ def copy_sample(src_dir, dst_dir, expected_md5):
82
+ if os.path.isdir(dst_dir):
83
+ bf = [f for f in os.listdir(dst_dir) if f.endswith("BF_001.tif")]
84
+ if bf and md5(os.path.join(dst_dir, bf[0])) == expected_md5:
85
+ return "exists"
86
+ raise RuntimeError(f"{dst_dir} exists with different content; refusing to overwrite")
87
+ os.makedirs(dst_dir)
88
+ for f in os.listdir(src_dir):
89
+ if os.path.isfile(os.path.join(src_dir, f)):
90
+ shutil.copy2(os.path.join(src_dir, f), os.path.join(dst_dir, f))
91
+ return "copied"
92
+
93
+
94
+ def main():
95
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
96
+ ap.add_argument("--legacy-root", required=True, help="old dataset folder (contains single_cell/ and spheroid/)")
97
+ ap.add_argument("--dataset-root", default=DEFAULT_DATASET_ROOT, help="repository dataset (default: S2F/dataset)")
98
+ ap.add_argument("--write", action="store_true", help="copy missing images and write split manifests")
99
+ ap.add_argument("--out", default=os.path.join(RESULTS_DIR, "dataset_audit"))
100
+ a = ap.parse_args()
101
+ os.makedirs(a.out, exist_ok=True)
102
+ rep = []
103
+
104
+ def say(s=""):
105
+ print(s); rep.append(s)
106
+
107
+ # ---------------- index ----------------
108
+ legacy = {k: index_tree(a.legacy_root, v[0]) for k, v in LEGACY_SETS.items()}
109
+ pub = {k: index_tree(a.dataset_root, v) for k, v in PUBLISHED_SETS.items()}
110
+ say(f"legacy root : {a.legacy_root}\ndataset root: {a.dataset_root}\n")
111
+ with open(os.path.join(a.out, "counts.csv"), "w", newline="") as f:
112
+ w = csv.writer(f); w.writerow(["tree", "set", "path", "n_samples", "n_unique_bf"])
113
+ for k, v in legacy.items():
114
+ w.writerow(["legacy", k, LEGACY_SETS[k][0], len(v), len({s['md5'] for s in v})])
115
+ say(f" legacy {k:20s} n={len(v):4d}")
116
+ for k, v in pub.items():
117
+ w.writerow(["repository", k, PUBLISHED_SETS[k], len(v), len({s['md5'] for s in v})])
118
+ say(f" repository {k:19s} n={len(v):4d}")
119
+ unlisted = discover_unlisted(a.legacy_root, [v[0] for v in LEGACY_SETS.values()])
120
+ if unlisted:
121
+ say("\nFolders with samples in the legacy tree that are NOT listed in experiments/layout.py:")
122
+ for rel, n in unlisted:
123
+ say(f" {rel} ({n} samples)")
124
+ if not legacy["all_tune/train"]:
125
+ say("\nERROR: single_cell/all_tune/train not found under --legacy-root."); open(os.path.join(a.out, "report.txt"), "w").write("\n".join(rep)); return
126
+
127
+ # ---------------- published vs legacy ----------------
128
+ say("\nRepository dataset vs legacy:")
129
+ rows = []
130
+ for p, l in [("singlecell/train", "fibro_split/train"), ("singlecell/test", "fibro_split/test"),
131
+ ("spheroid/train", "spheroid/train"), ("spheroid/test", "spheroid/test")]:
132
+ P, Lg = {s["md5"] for s in pub[p]}, {s["md5"] for s in legacy[l]}
133
+ r = {"repository_set": p, "legacy_set": l, "n_repository": len(P), "n_legacy": len(Lg),
134
+ "shared": len(P & Lg), "only_repository": len(P - Lg), "only_legacy": len(Lg - P)}
135
+ rows.append(r); say(f" {p:18s} vs {l:18s}: shared {r['shared']}, only repo {r['only_repository']}, only legacy {r['only_legacy']}")
136
+ with open(os.path.join(a.out, "published_vs_legacy.csv"), "w", newline="") as f:
137
+ w = csv.DictWriter(f, fieldnames=list(rows[0])); w.writeheader(); w.writerows(rows)
138
+
139
+ # ---------------- all_tune composition ----------------
140
+ by_md5_pub = {s["md5"]: (k, s) for k, v in pub.items() for s in v}
141
+ src_keys = [k for k, v in LEGACY_SETS.items() if v[1] in ("published", "source", "external")]
142
+ by_md5_leg = collections.defaultdict(list)
143
+ for k in src_keys:
144
+ for s in legacy[k]:
145
+ by_md5_leg[s["md5"]].append((k, s))
146
+ comp, missing, codes = [], [], collections.Counter()
147
+ for split_key, split in [("all_tune/train", "train"), ("all_tune/test", "val")]:
148
+ for s in legacy[split_key]:
149
+ in_pub = by_md5_pub.get(s["md5"])
150
+ srcs = [k for k, _ in by_md5_leg.get(s["md5"], [])]
151
+ src = next((k for k in srcs if LEGACY_SETS[k][1] != "external"), srcs[0] if srcs else "UNMATCHED")
152
+ code = legacy_substrate_code(s["folder"])
153
+ codes[(split, code)] += 1
154
+ r = {"alltune_split": split, "alltune_folder": s["folder"], "source_legacy_set": src,
155
+ "all_matching_sets": ";".join(srcs), "in_repository_dataset": in_pub[0] if in_pub else "",
156
+ "legacy_substrate_code": code, "bf_md5": s["md5"]}
157
+ comp.append(r)
158
+ if not in_pub:
159
+ missing.append(r)
160
+ for name, data in [("alltune_composition.csv", comp), ("missing_from_published.csv", missing)]:
161
+ with open(os.path.join(a.out, name), "w", newline="") as f:
162
+ w = csv.DictWriter(f, fieldnames=list(comp[0])); w.writeheader(); w.writerows(data)
163
+ with open(os.path.join(a.out, "substrate_codes.csv"), "w", newline="") as f:
164
+ w = csv.writer(f); w.writerow(["alltune_split", "legacy_substrate_code", "n", "config_key_fed_to_model"])
165
+ for (sp, c), n in sorted(codes.items()):
166
+ w.writerow([sp, c, n, legacy_code_to_config_key(c)])
167
+ say("\nall_tune composition (split x source set):")
168
+ cc = collections.Counter((r["alltune_split"], r["source_legacy_set"]) for r in comp)
169
+ for (sp, src), n in sorted(cc.items()):
170
+ say(f" {sp:5s} {src:20s} {n:4d}")
171
+ say(f"\nall_tune images NOT in the repository dataset (Zenodo v1): {len(missing)}")
172
+ for (sp, src), n in sorted(collections.Counter((r['alltune_split'], r['source_legacy_set']) for r in missing).items()):
173
+ say(f" {sp:5s} {src:20s} {n:4d}")
174
+ say("\nSubstrate code the original code fed for all_tune folders (folder_name[:4]):")
175
+ for (sp, c), n in sorted(codes.items()):
176
+ say(f" {sp:5s} '{c}' -> {legacy_code_to_config_key(c):18s} {n:4d}")
177
+
178
+ # ---------------- overlaps / leakage ----------------
179
+ names = list(legacy)
180
+ sets = {k: {s["md5"] for s in legacy[k]} for k in names}
181
+ with open(os.path.join(a.out, "overlap_matrix.csv"), "w", newline="") as f:
182
+ w = csv.writer(f); w.writerow(["set"] + names)
183
+ for x in names:
184
+ w.writerow([x] + [len(sets[x] & sets[y]) for y in names])
185
+ tv = sets["all_tune/train"] | sets["all_tune/test"]
186
+ say("\nLeakage check - independent test sets vs all_tune train+val (must be 0):")
187
+ for k, v in LEGACY_SETS.items():
188
+ if v[1] == "external":
189
+ say(f" {k:20s} {len(sets[k] & tv):3d} (of {len(sets[k])})")
190
+ say("Validation-set content - legacy 'test' folders inside all_tune/test (val):")
191
+ for k in ["fibro_split/test", "1kpa_new/test", "10kpa_new/test"]:
192
+ say(f" {k:20s} {len(sets[k] & sets['all_tune/test']):3d} of {len(sets[k])} in val; {len(sets[k] & sets['all_tune/train']):3d} in train")
193
+
194
+ # ---------------- write ----------------
195
+ if a.write:
196
+ say("\nCopying and writing manifests ...")
197
+ n_copied = collections.Counter()
198
+ alltune_rows = {"train": [], "val": []}
199
+ for r, s in zip(comp, legacy["all_tune/train"] + legacy["all_tune/test"]):
200
+ split, src = r["alltune_split"], r["source_legacy_set"]
201
+ meta = LEGACY_SETS[src][3] if src in LEGACY_SETS else {}
202
+ if r["in_repository_dataset"]:
203
+ pk, ps = by_md5_pub[s["md5"]]
204
+ sample_dir = os.path.join(PUBLISHED_SETS[pk], ps["folder"])
205
+ else:
206
+ clean = LEGACY_SETS[src][2] if src in LEGACY_SETS and LEGACY_SETS[src][2] else "unmatched"
207
+ sample_dir = os.path.join("singlecell_extra", clean, s["folder"])
208
+ n_copied[copy_sample(s["dir"], os.path.join(a.dataset_root, sample_dir), s["md5"])] += 1
209
+ alltune_rows[split].append(row_for(sample_dir, split, src, meta, s["folder"], s["md5"]))
210
+ write_manifest(manifest_path("alltune_train"), alltune_rows["train"])
211
+ write_manifest(manifest_path("alltune_val"), alltune_rows["val"])
212
+ for pk, man, lk in [("singlecell/train", "zenodo_v1_train", "fibro_split/train"),
213
+ ("singlecell/test", "zenodo_v1_test", "fibro_split/test"),
214
+ ("spheroid/train", "spheroid_train", "spheroid/train"),
215
+ ("spheroid/test", "spheroid_val", "spheroid/test")]:
216
+ meta = LEGACY_SETS[lk][3]
217
+ write_manifest(manifest_path(man), [row_for(os.path.join(PUBLISHED_SETS[pk], s["folder"]), man.split("_")[-1],
218
+ lk, meta, s["folder"], s["md5"]) for s in pub[pk]])
219
+ for k, (rel, role, clean, meta) in LEGACY_SETS.items():
220
+ if role != "external" or not legacy[k]:
221
+ continue
222
+ rows_ = []
223
+ for s in legacy[k]:
224
+ sample_dir = os.path.join("singlecell_external", clean, s["folder"])
225
+ n_copied[copy_sample(s["dir"], os.path.join(a.dataset_root, sample_dir), s["md5"])] += 1
226
+ rows_.append(row_for(sample_dir, "test", k, meta, s["folder"], s["md5"]))
227
+ write_manifest(manifest_path(f"test_{clean}"), rows_)
228
+ say(f" sample folders: {dict(n_copied)}")
229
+ say(f" manifests written to experiments/splits/ - commit them to git")
230
+ else:
231
+ say("\n(audit only - rerun with --write to copy the missing images and write experiments/splits/*.csv)")
232
+ open(os.path.join(a.out, "report.txt"), "w").write("\n".join(rep))
233
+ print(f"\nReport: {os.path.join(a.out, 'report.txt')}")
234
+
235
+
236
+ if __name__ == "__main__":
237
+ main()
experiments/run_all.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Run the revision experiments end to end (cross-platform; run from the S2F repository root).
3
+
4
+ python -m experiments.run_all --legacy-root C:/Users/Kaveh/Documents/cellforce/dataset --steps audit
5
+ python -m experiments.run_all --legacy-root ... --steps prepare,eval_released
6
+ python -m experiments.run_all --steps train,eval_runs --variants s2f_full,unet,s2f_no_gan
7
+ python -m experiments.run_all --legacy-root ... --stage1 C:/.../outputs/checkpoints/last/last_checkpoint_final.pth --steps eval_released
8
+
9
+ steps:
10
+ audit dataset audit only (no copying) -> experiments/results/dataset_audit/
11
+ prepare copy missing images into dataset/ + write experiments/splits/*.csv
12
+ eval_released released checkpoints (+ substrate sweep, mean-map baseline, optional stage-1)
13
+ -> experiments/results/eval/
14
+ train train the listed variants (default: configs.PRIORITY), skipping finished runs.
15
+ One job per GPU in parallel (--gpus 0,1); the next variant starts as soon as a GPU is free.
16
+ Logs: experiments/runs/<variant>_seed<seed>/train.log
17
+ eval_runs evaluate every experiments/runs/*/best_checkpoint.pth
18
+ """
19
+ import argparse
20
+ import json
21
+ import os
22
+ import subprocess
23
+ import sys
24
+ import time
25
+
26
+ from experiments.configs import PRIORITY, get_config
27
+ from experiments.layout import RUNS_DIR, S2F_ROOT
28
+
29
+
30
+ def sh(args):
31
+ print("\n$ " + " ".join(args), flush=True)
32
+ r = subprocess.run([sys.executable, "-m"] + args, cwd=S2F_ROOT)
33
+ if r.returncode:
34
+ sys.exit(r.returncode)
35
+
36
+
37
+ def finished(variant, seed):
38
+ h = os.path.join(RUNS_DIR, f"{variant}_seed{seed}", "history.json")
39
+ if not os.path.exists(h):
40
+ return False
41
+ return len(json.load(open(h))) >= get_config(variant)["epochs"]
42
+
43
+
44
+ def gpu_queue(queue, gpus, num_workers, extra=()):
45
+ """Run training jobs in parallel, one per GPU (each job sees only its GPU as cuda:0)."""
46
+ running, failed = {}, []
47
+ queue = list(queue)
48
+ while queue or running:
49
+ for gpu in gpus:
50
+ if gpu not in running and queue:
51
+ v, seed = queue.pop(0)
52
+ run_dir = os.path.join(RUNS_DIR, f"{v}_seed{seed}")
53
+ os.makedirs(run_dir, exist_ok=True)
54
+ log = open(os.path.join(run_dir, "train.log"), "a")
55
+ env = dict(os.environ, CUDA_VISIBLE_DEVICES=gpu, PYTHONUNBUFFERED="1")
56
+ cmd = [sys.executable, "-m", "experiments.train", "--variant", v, "--seed", seed, "--resume",
57
+ "--num-workers", num_workers] + list(extra)
58
+ running[gpu] = (subprocess.Popen(cmd, cwd=S2F_ROOT, env=env, stdout=log, stderr=subprocess.STDOUT), v, seed, log)
59
+ print(f"[{time.strftime('%H:%M')}] GPU {gpu}: started {v} seed {seed} (log {run_dir}/train.log)", flush=True)
60
+ time.sleep(10 if extra else 30)
61
+ for gpu, (proc, v, seed, log) in list(running.items()):
62
+ if proc.poll() is not None:
63
+ log.close(); del running[gpu]
64
+ status = "finished" if proc.returncode == 0 else f"FAILED (exit {proc.returncode}, see train.log)"
65
+ if proc.returncode:
66
+ failed.append((v, seed))
67
+ print(f"[{time.strftime('%H:%M')}] GPU {gpu}: {v} seed {seed} {status}", flush=True)
68
+ if failed:
69
+ print(f"failed jobs: {failed}")
70
+
71
+
72
+ def main():
73
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
74
+ ap.add_argument("--legacy-root", default=None)
75
+ ap.add_argument("--stage1", default=None, help="path to last_checkpoint_final.pth")
76
+ ap.add_argument("--steps", default="audit")
77
+ ap.add_argument("--variants", default=",".join(PRIORITY))
78
+ ap.add_argument("--seeds", default="0")
79
+ ap.add_argument("--gpus", default="0,1", help="GPU ids; one training job per GPU at a time")
80
+ ap.add_argument("--num-workers", default="2", help="DataLoader workers per training job")
81
+ ap.add_argument("--extra-train-args", default="", help="passed to experiments.train, e.g. '--epochs 1 --max-batches 2 --allow-cpu' for a smoke test")
82
+ a = ap.parse_args()
83
+ steps = a.steps.split(",")
84
+ if ("audit" in steps or "prepare" in steps) and not a.legacy_root:
85
+ sys.exit("--legacy-root is required for audit/prepare")
86
+ if "audit" in steps:
87
+ sh(["experiments.prepare_dataset", "--legacy-root", a.legacy_root])
88
+ if "prepare" in steps:
89
+ sh(["experiments.prepare_dataset", "--legacy-root", a.legacy_root, "--write"])
90
+ if "eval_released" in steps:
91
+ cmd = ["experiments.evaluate", "--released", "--sweep", "--meanmap"]
92
+ if a.stage1:
93
+ cmd += ["--stage1", a.stage1]
94
+ sh(cmd)
95
+ if "train" in steps:
96
+ queue = [(v, s) for v in a.variants.split(",") for s in a.seeds.split(",") if not finished(v, int(s))]
97
+ print(f"{len(queue)} training jobs on GPUs {a.gpus}: {queue}")
98
+ gpu_queue(queue, a.gpus.split(","), a.num_workers, a.extra_train_args.split())
99
+ if "eval_runs" in steps:
100
+ sh(["experiments.evaluate", "--runs"])
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
experiments/splits/.gitkeep ADDED
File without changes
experiments/train.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Train one baseline / ablation variant with the shared protocol (experiments/configs.py).
3
+
4
+ python -m experiments.train --variant s2f_full
5
+ python -m experiments.train --variant unet --seed 1
6
+ python -m experiments.train --variant s2f_no_gan --resume # continue from last_checkpoint.pth
7
+ python -m experiments.train --variant unet --epochs 1 --max-batches 5 # smoke test
8
+
9
+ Outputs in experiments/runs/<variant>_seed<seed>/:
10
+ config.json exact configuration used
11
+ history.json per-epoch losses (each generator loss term separately), lr, time
12
+ best_checkpoint.pth generator weights at the lowest validation reconstruction loss
13
+ last_checkpoint.pth full state for --resume
14
+ visualizations/ predictions on the first validation batch every 10 epochs
15
+ The training loop follows training/s2f_trainer.py (same losses, label convention and update order);
16
+ it additionally skips the discriminator when lambda_gan = 0, records every loss term, and seeds all RNGs.
17
+ """
18
+ import argparse
19
+ import json
20
+ import math
21
+ import os
22
+ import random
23
+ import time
24
+
25
+ import numpy as np
26
+ import torch
27
+ import torch.nn as nn
28
+ import torch.nn.functional as F
29
+
30
+ from experiments.ablation_models import build_discriminator, build_generator, n_params
31
+ from experiments.configs import get_config
32
+ from experiments.layout import DEFAULT_DATASET_ROOT, RUNS_DIR, S2F_ROOT
33
+ from experiments.losses import PerImageWFMRMELoss
34
+ from experiments.manifest_data import make_loader
35
+ from models.s2f_model import create_settings_channels
36
+ from utils.substrate_settings import compute_settings_normalization
37
+
38
+ CONFIG_PATH = os.path.join(S2F_ROOT, "config", "substrate_settings.json")
39
+
40
+
41
+ def seed_everything(seed):
42
+ random.seed(seed); np.random.seed(seed); torch.manual_seed(seed); torch.cuda.manual_seed_all(seed)
43
+
44
+
45
+ def cosine_with_warmup(optimizer, warmup_steps, total_steps):
46
+ """Same schedule as diffusers.optimization.get_cosine_schedule_with_warmup (num_cycles=0.5)."""
47
+ def f(step):
48
+ if step < warmup_steps:
49
+ return step / max(1, warmup_steps)
50
+ progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)
51
+ return max(0.0, 0.5 * (1.0 + math.cos(math.pi * progress)))
52
+ return torch.optim.lr_scheduler.LambdaLR(optimizer, f)
53
+
54
+
55
+ def add_settings(x, batch, cfg, norm, device):
56
+ if cfg["substrate_mode"] == "none":
57
+ return x
58
+ meta = batch[4]
59
+ return torch.cat([x, create_settings_channels(meta, norm, device, x.shape, config_path=CONFIG_PATH)], dim=1)
60
+
61
+
62
+ def save_vis(G, val_loader, cfg, norm, device, path):
63
+ import matplotlib
64
+ matplotlib.use("Agg")
65
+ import matplotlib.pyplot as plt
66
+ batch = next(iter(val_loader))
67
+ x = add_settings(batch[0].to(device), batch, cfg, norm, device)
68
+ with torch.no_grad():
69
+ p = (G(x) + 1) / 2
70
+ n = min(2, x.size(0))
71
+ fig, ax = plt.subplots(3, n, figsize=(4 * n, 12), squeeze=False)
72
+ for i in range(n):
73
+ ax[0, i].imshow(x[i, 0].cpu(), cmap="gray"); ax[1, i].imshow(p[i, 0].cpu(), cmap="jet", vmin=0, vmax=1)
74
+ ax[2, i].imshow(batch[1][i, 0], cmap="jet", vmin=0, vmax=1)
75
+ for r in range(3):
76
+ ax[r, i].axis("off")
77
+ fig.tight_layout(); fig.savefig(path, dpi=100); plt.close(fig)
78
+
79
+
80
+ def train(cfg, run_dir, dataset_root=DEFAULT_DATASET_ROOT, device=None, resume=False, max_batches=None,
81
+ num_workers=2, allow_cpu=False):
82
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
83
+ if str(device).startswith("cpu") and not allow_cpu:
84
+ raise SystemExit("CUDA is not available - refusing to train on CPU (pass --allow-cpu for a smoke test)")
85
+ if str(device).startswith("cuda"):
86
+ torch.backends.cudnn.benchmark = True
87
+ print(f"GPU: {torch.cuda.get_device_name(torch.device(device))} "
88
+ f"(CUDA_VISIBLE_DEVICES={os.environ.get('CUDA_VISIBLE_DEVICES', 'all')})")
89
+ os.makedirs(os.path.join(run_dir, "visualizations"), exist_ok=True)
90
+ seed_everything(cfg["seed"])
91
+ train_loader = make_loader(cfg["train_manifest"], dataset_root, cfg["batch_size"], cfg["img_size"],
92
+ augment=cfg["augment"], shuffle=True, substrate_mode=cfg["substrate_mode"],
93
+ input_mode=cfg["input_mode"], seed=cfg["seed"], num_workers=num_workers)
94
+ if num_workers > 0: # check that worker processes work on this machine
95
+ try:
96
+ next(iter(train_loader))
97
+ except RuntimeError as e:
98
+ print(f"DataLoader workers failed ({str(e).splitlines()[0][:120]}); falling back to num_workers=0")
99
+ train_loader = make_loader(cfg["train_manifest"], dataset_root, cfg["batch_size"], cfg["img_size"],
100
+ augment=cfg["augment"], shuffle=True, substrate_mode=cfg["substrate_mode"],
101
+ input_mode=cfg["input_mode"], seed=cfg["seed"], num_workers=0)
102
+ seed_everything(cfg["seed"])
103
+ val_loader = make_loader(cfg["val_manifest"], dataset_root, cfg["batch_size"], cfg["img_size"],
104
+ substrate_mode=cfg["substrate_mode"], input_mode=cfg["input_mode"])
105
+ norm = compute_settings_normalization(config_path=CONFIG_PATH)
106
+ use_gan = cfg["lambda_gan"] > 0
107
+ G = build_generator(**cfg).to(device)
108
+ D = build_discriminator(cfg["in_channels"]).to(device) if use_gan else None
109
+ cfg = dict(cfg, n_params_generator=n_params(G), n_params_discriminator=n_params(D) if D else 0,
110
+ n_train=len(train_loader.dataset), n_val=len(val_loader.dataset), device=str(device),
111
+ torch_version=torch.__version__)
112
+ json.dump(cfg, open(os.path.join(run_dir, "config.json"), "w"), indent=2)
113
+ print(f"[{cfg['variant']}] G {cfg['n_params_generator']/1e6:.2f} M params, D {cfg['n_params_discriminator']/1e6:.2f} M; "
114
+ f"train {cfg['n_train']}, val {cfg['n_val']}, device {device}")
115
+
116
+ g_opt = torch.optim.Adam(G.parameters(), lr=cfg["g_lr"], betas=(cfg["beta1"], cfg["beta2"]))
117
+ d_opt = torch.optim.Adam(D.parameters(), lr=cfg["d_lr"], betas=(cfg["beta1"], cfg["beta2"])) if use_gan else None
118
+ steps = len(train_loader) * cfg["epochs"]
119
+ g_sch = cosine_with_warmup(g_opt, int(steps * cfg["warmup_frac"]), steps)
120
+ d_sch = cosine_with_warmup(d_opt, int(steps * cfg["warmup_frac"]), steps) if use_gan else None
121
+ rec_loss, bce = PerImageWFMRMELoss(), nn.BCEWithLogitsLoss()
122
+ hist, best, start = [], float("inf"), 0
123
+
124
+ last_path = os.path.join(run_dir, "last_checkpoint.pth")
125
+ if resume and os.path.exists(last_path):
126
+ ck = torch.load(last_path, map_location="cpu", weights_only=False)
127
+ G.load_state_dict(ck["generator_state_dict"]); g_opt.load_state_dict(ck["g_optimizer"]); g_sch.load_state_dict(ck["g_scheduler"])
128
+ if use_gan:
129
+ D.load_state_dict(ck["discriminator_state_dict"]); d_opt.load_state_dict(ck["d_optimizer"]); d_sch.load_state_dict(ck["d_scheduler"])
130
+ hist, best, start = ck["history"], ck["best_val_loss"], ck["epoch"] + 1
131
+ print(f"resumed at epoch {start}")
132
+
133
+ for epoch in range(start, cfg["epochs"]):
134
+ t0 = time.time(); G.train(); D.train() if use_gan else None
135
+ acc = dict(g=0.0, rec=0.0, gan=0.0, force=0.0, d=0.0); nb = 0
136
+ for bi, batch in enumerate(train_loader):
137
+ if max_batches and bi >= max_batches:
138
+ break
139
+ x = add_settings(batch[0].to(device), batch, cfg, norm, device)
140
+ y = batch[1].to(device)
141
+ fake = G(x); p = (fake + 1) / 2
142
+ l_rec = rec_loss(p, y)
143
+ l_force = F.mse_loss(p.flatten(1).mean(1), y.flatten(1).mean(1))
144
+ l_gan = torch.zeros((), device=device)
145
+ if use_gan:
146
+ pf = D(torch.cat([x, fake], 1)); l_gan = bce(pf, torch.ones_like(pf))
147
+ l_g = cfg["lambda_rec"] * l_rec + cfg["lambda_gan"] * l_gan + cfg["lambda_force"] * l_force
148
+ g_opt.zero_grad(); l_g.backward(); g_opt.step()
149
+ l_d = torch.zeros(())
150
+ if use_gan:
151
+ pr = D(torch.cat([x, y * 2 - 1], 1)); pf = D(torch.cat([x, fake.detach()], 1))
152
+ l_d = 0.5 * (bce(pr, torch.ones_like(pr)) + bce(pf, torch.zeros_like(pf)))
153
+ d_opt.zero_grad(); l_d.backward(); d_opt.step(); d_sch.step()
154
+ g_sch.step()
155
+ for k, v in zip(acc, [l_g, l_rec, l_gan, l_force, l_d]):
156
+ acc[k] += float(v)
157
+ nb += 1
158
+ G.eval(); v_rec = v_mse = 0.0; nv = 0
159
+ with torch.no_grad():
160
+ for bi, batch in enumerate(val_loader):
161
+ if max_batches and bi >= max_batches:
162
+ break
163
+ x = add_settings(batch[0].to(device), batch, cfg, norm, device); y = batch[1].to(device)
164
+ p = (G(x) + 1) / 2
165
+ v_rec += float(rec_loss(p, y)); v_mse += float(F.mse_loss(p, y)); nv += 1
166
+ h = {"epoch": epoch, **{f"train_{k}": v / max(nb, 1) for k, v in acc.items()},
167
+ "val_rec": v_rec / max(nv, 1), "val_mse": v_mse / max(nv, 1),
168
+ "g_lr": g_opt.param_groups[0]["lr"], "epoch_seconds": time.time() - t0}
169
+ hist.append(h)
170
+ is_best = h["val_rec"] < best
171
+ best = min(best, h["val_rec"])
172
+ print(f"epoch {epoch:3d} G {h['train_g']:.4f} (rec {h['train_rec']:.4f} gan {h['train_gan']:.4f} "
173
+ f"force {h['train_force']:.5f}) D {h['train_d']:.4f} val_rec {h['val_rec']:.4f} "
174
+ f"{'*' if is_best else ' '} {h['epoch_seconds']:.0f}s", flush=True)
175
+ json.dump(hist, open(os.path.join(run_dir, "history.json"), "w"), indent=1)
176
+ if is_best:
177
+ torch.save({"epoch": epoch, "generator_state_dict": G.state_dict(), "config": cfg,
178
+ "val_loss": h["val_rec"], "history": hist}, os.path.join(run_dir, "best_checkpoint.pth"))
179
+ state = {"epoch": epoch, "generator_state_dict": G.state_dict(), "g_optimizer": g_opt.state_dict(),
180
+ "g_scheduler": g_sch.state_dict(), "history": hist, "best_val_loss": best, "config": cfg}
181
+ if use_gan:
182
+ state.update(discriminator_state_dict=D.state_dict(), d_optimizer=d_opt.state_dict(), d_scheduler=d_sch.state_dict())
183
+ torch.save(state, last_path)
184
+ if epoch % 10 == 0 or epoch == cfg["epochs"] - 1:
185
+ save_vis(G, val_loader, cfg, norm, device, os.path.join(run_dir, "visualizations", f"epoch_{epoch:03d}.png"))
186
+ print(f"done. best val_rec {best:.4f}; checkpoints in {run_dir}")
187
+ return run_dir
188
+
189
+
190
+ def main():
191
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
192
+ ap.add_argument("--variant", required=True)
193
+ ap.add_argument("--seed", type=int, default=None)
194
+ ap.add_argument("--epochs", type=int, default=None)
195
+ ap.add_argument("--g-lr", type=float, default=None); ap.add_argument("--d-lr", type=float, default=None)
196
+ ap.add_argument("--batch-size", type=int, default=None)
197
+ ap.add_argument("--dataset-root", default=DEFAULT_DATASET_ROOT)
198
+ ap.add_argument("--run-dir", default=None)
199
+ ap.add_argument("--device", default=None)
200
+ ap.add_argument("--resume", action="store_true")
201
+ ap.add_argument("--max-batches", type=int, default=None, help="limit batches per epoch (smoke test only)")
202
+ ap.add_argument("--num-workers", type=int, default=2, help="DataLoader worker processes for augmentation")
203
+ ap.add_argument("--allow-cpu", action="store_true", help="allow training without CUDA (smoke tests)")
204
+ a = ap.parse_args()
205
+ cfg = get_config(a.variant, seed=a.seed, epochs=a.epochs, g_lr=a.g_lr, d_lr=a.d_lr, batch_size=a.batch_size)
206
+ run_dir = a.run_dir or os.path.join(RUNS_DIR, f"{a.variant}_seed{cfg['seed']}")
207
+ train(cfg, run_dir, a.dataset_root, a.device, a.resume, a.max_batches, a.num_workers, a.allow_cpu)
208
+
209
+
210
+ if __name__ == "__main__":
211
+ main()