File size: 22,834 Bytes
c2ede1f | 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 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 | """Frozen spatial-proteomics foundation-model embeddings of every ORION multiplex-IF tile under the 8 dihedral transforms.
Usage: python 14_extract_if_fm.py virtues [--no_hoechst] [--qc_drop] [--bs 4] [--no_probe]
Output: cache/if_virtues.npy (N=3187, 8, 512) float16 (raw features; row i = tile_id i, axis 1 = common.dihedral d).
(--no_hoechst / --qc_drop write cache/if_virtues_nohoechst.npy / if_virtues_qcdrop.npy instead.)
VirTues (Wenckstern et al., Nature 2026; github.com/bunnelab/virtues, HF bunnelab/virtues, weights virtues-sp32)
---------------------------------------------------------------------------------------------------------------
Model: MultiplexVirtues with the released base_config (patch 8, dim 512, encoder 'hvhvhvhvhvhvhvhv', RoPE), ESM-2
marker priors. Only the encoder is used (no masking). Input = list of (C,H,W) standardised crops + marker indices.
Tile embedding (pooling): mean over the spatial grid of VirTues *patch summary tokens* (the per-8x8-patch summary
token that attends across all markers; `encoder.forward_list(...).patch_summary_tokens`, shape (H/8, W/8, 512)).
VirTues has no global/CLS token; its own tile/tissue-level tooling (spora-bench SporaVirTuesWrapper.embed_tile /
embed_tissue) returns exactly these patch summary tokens as a bag (then ABMIL), and cell/niche tokens are
area-weighted averages of them (virtues.utils.cell_tokens). A uniform mean over the whole tile = the niche token of
the full 166 um tile. Encoding runs in fp16 autocast, like the spora-bench wrapper.
Marker -> UniProt (ESM-2 esm2_t30_150M_UR50D, mean over residues of layer 30, computed with fair-esm exactly as
virtues/utils/compute_esm_embeddings.py; FASTA from rest.uniprot.org; saved as ext/marker_embeddings/esm2_t30_150M_UR50D/[ID].pt):
Hoechst P68431 (Histone H3.1) -- spora flags DNA stains `is_nuclear_marker` with no UniProt ID, so VirTues'
`uniprot_filtered` loading DROPS them (spora-bench VirTues config for lin2023highsubset
= the ORION cohort). spora-io's `replace_nuclear_uniprot_ids=True` instead maps them to
Histone H3 P68431 (used for lin2023high in spora-bench). Default here: keep Hoechst as
P68431 (H3 IMC channels are in VirTues' pretraining vocabulary); --no_hoechst drops it.
CD31 P16284 (PECAM1) | CD45 P08575 (PTPRC canonical, as in spora examples) | CD68 P34810 | CD4 P01730 | FOXP3 Q9BZS1
CD8a P01732 | CD45RO P08575-4 (UniProt PTPRC isoform 2 = "CD45R0/CD45RO"; spora uses isoform IDs, e.g. CD45RA P08575-8)
CD20 P11836 (MS4A1) | PD-L1 Q9NZQ7 (CD274) | CD3e P07766 | CD163 Q86VB7 | E-cadherin P12830 (CDH1) | PD-1 Q15116
Ki67 P46013 | Pan-CK P02533 (KRT14; the keratin spora/VirTues assign to pan-cytokeratin channels, e.g. cords2024cancer
"Pan Cytokeratin + Keratin Epithelial") | SMA P62736 (ACTA2)
Preprocessing (mirrors spora-bench configs/datasets/virtues/lin2023highsubset.yaml:
standardization quantile_clipping_log1p/uq_0.99_image, resolution 1.0 um/px, filter gaussian_blur, use_mean_std,
disable_quantile_mask=False):
1. resolution: ORION tiles are 512 px crops of the 0.325 um/px Orion scan resized to 333 px (0.4997 um/px, 166.4 um).
Resampled to 168x168 px (0.991 um/px; 168 = 21 patches of 8) with antialiased bilinear interpolation.
2. per-"image" upper quantile: for every slide (spora tissue = one ORION slide) and channel, the 0.99 quantile of all
resampled pixels of that slide's tiles; x <- log1p(clip(x, 0, uq)).
3. dataset-level per-channel mean/std of the clipped log1p values over all ORION tiles; x <- (x - mean) / (std + 1e-8).
4. 3x3 Gaussian blur (sigma 1, reflect padding; torchvision v2.GaussianBlur as in spora-io FilterFactory).
5. quantile mask: spora drops a (slide, channel) whose upper quantile is 0. On these 8-bit tiles that happens for
52/697 (slide, channel) pairs (FOXP3 in 25/41 slides, Ki67 6, CD20/CD8a/PD-L1 5, CD31/CD45RO 3) purely because of
8-bit quantisation of sparse markers, and would give slide-dependent channel sets (a slide/batch confound). Default
here: uq is floored at 1 grey level and all channels are kept for every tile; --qc_drop reproduces spora's drop.
6. dihedral d (common.dihedral on the (C,H,W) tensor) applied to the standardised 168 px tile; all 8 encoded.
The whole 168 px tile is encoded in one pass (21x21 patch grid; RoPE positions so non-128 sizes are supported).
Deviations from the VirTues/spora preprocessing (documented):
* Input intensities are the 8-bit (0-255) ORION tile exports, not the raw 16-bit Orion counts spora stores; because
log1p is scale-dependent the clipped-log values are compressed differently (z-scoring per channel partly absorbs it).
* Quantiles / means / stds come from the 3187 ORION tiles (all pixels, no tissue mask), not whole-slide tissue pixels.
* VirTues was pretrained on 128x128 crops; tiles here are 168x168 (whole tile, no sliding window).
* uq floored at 1 grey level instead of spora's per-slide channel drop (step 5).
* PD-1 in these exports is a near-saturated, almost constant channel (log1p mean 4.65, std 0.16; PD-1_prop ~ 1 for
most tiles); the per-channel z-scoring therefore amplifies its background texture. Kept as-is (faithful pipeline).
* Hoechst is kept as Histone H3 (P68431) by default; VirTues' own ORION config drops DNA stains (see --no_hoechst).
* flash_attn is not installed for this torch build: flash_attn_varlen_qkvpacked_func is replaced by an exact
PyTorch SDPA implementation over the same variable-length sequences (same math, softmax scale 1/sqrt(d)).
"""
import os
import sys
import time
import types
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") # less fragmentation on the shared GPU
import numpy as np
import tifffile
import torch
import torch.nn.functional as F
from common import CACHE, ORION, ROOT, dihedral, load_all_tiles
assert sys.argv[1] == "virtues", "usage: python 14_extract_if_fm.py virtues [--no_hoechst] [--qc_drop] [--bs N] [--no_probe]"
NO_HOECHST = "--no_hoechst" in sys.argv
QC_DROP = "--qc_drop" in sys.argv # spora behaviour: drop (slide, channel) with 0.99-quantile == 0 (default: floor at 1)
BS = int(sys.argv[sys.argv.index("--bs") + 1]) if "--bs" in sys.argv else 4 # tiles per forward (x8 dihedrals = 32 crops, ~4.7 GiB peak)
dev = "cuda"
VIRTUES_REPO = os.path.join(ROOT, "ext", "virtues")
EMB_ROOT = os.path.join(ROOT, "ext", "marker_embeddings")
ESM_NAME = "esm2_t30_150M_UR50D"
EMB_DIR = os.path.join(EMB_ROOT, ESM_NAME)
WEIGHTS = ("virtues-sp32/model.safetensors", "virtues-sp31/model.safetensors")
SIZE, MPP_IN = 168, 0.325 * 512 / 333 # 333 px tile = 512 px @ 0.325 um/px -> 0.4997 um/px; 168 px ~ 0.991 um/px
# channel order of the ORION IF tiles (= the *_prop columns of the dataframes)
MARKERS = [("Hoechst", "P68431"), ("CD31", "P16284"), ("CD45", "P08575"), ("CD68", "P34810"), ("CD4", "P01730"),
("FOXP3", "Q9BZS1"), ("CD8a", "P01732"), ("CD45RO", "P08575-4"), ("CD20", "P11836"), ("PD-L1", "Q9NZQ7"),
("CD3e", "P07766"), ("CD163", "Q86VB7"), ("E-cadherin", "P12830"), ("PD-1", "Q15116"), ("Ki67", "P46013"),
("Pan-CK", "P02533"), ("SMA", "P62736")]
# ----------------------------------------------------------------------------------------------- marker embeddings
def ensure_marker_embeddings(ids):
"""ESM-2 mean-pooled embeddings, identical to virtues/utils/compute_esm_embeddings.py (fair-esm, layer 30)."""
import requests
fasta_dir = os.path.join(EMB_ROOT, "fasta")
os.makedirs(fasta_dir, exist_ok=True)
os.makedirs(EMB_DIR, exist_ok=True)
todo = [u for u in ids if not os.path.exists(os.path.join(EMB_DIR, f"{u}.pt"))]
if not todo:
return
import esm
_load = torch.load # official fair-esm checkpoints pickle argparse.Namespace -> need weights_only=False
torch.load = lambda *a, **k: _load(*a, **{**k, "weights_only": False})
try:
model, alphabet = esm.pretrained.load_model_and_alphabet(ESM_NAME)
finally:
torch.load = _load
# small one-off job: GPU if the shared card has room, else CPU (the repo recommends CPU for long sequences)
edev = "cuda" if torch.cuda.is_available() and torch.cuda.mem_get_info()[0] > 4 * 2**30 else "cpu"
model.eval().to(edev)
conv = alphabet.get_batch_converter()
for u in todo:
fp = os.path.join(fasta_dir, f"{u}.fasta")
if not os.path.exists(fp):
r = requests.get(f"https://rest.uniprot.org/uniprotkb/{u}.fasta", timeout=60)
r.raise_for_status()
open(fp, "w").write(r.text)
seq = "".join(l.strip() for l in open(fp) if not l.startswith(">"))
_, _, tok = conv([(u, seq)])
n = int((tok != alphabet.padding_idx).sum())
with torch.no_grad():
# return_contacts only adds the contact head (repo passes True); representations are identical, and
# stacking all attention maps for Ki67 (3256 aa) would need 25 GB
rep = model(tok.to(edev), repr_layers=[30], return_contacts=False)["representations"][30]
emb = rep[0, 1:n - 1].mean(0).cpu()
torch.save(emb, os.path.join(EMB_DIR, f"{u}.pt"))
print(f" ESM-2 {u}: len {len(seq)} -> {tuple(emb.shape)}", flush=True)
del model
torch.cuda.empty_cache()
def check_against_repo_embeddings(ids):
"""The repo ships ESM-2 embeddings for its example dataset; compare on shared UniProt IDs."""
ref = os.path.join(VIRTUES_REPO, "assets", "example_dataset", "marker_embeddings")
shared = [u for u in ids if os.path.exists(os.path.join(ref, f"{u}.pt"))]
if shared:
cs = [F.cosine_similarity(torch.load(os.path.join(EMB_DIR, f"{u}.pt")).float(),
torch.load(os.path.join(ref, f"{u}.pt")).float(), 0).item() for u in shared]
mx = max((torch.load(os.path.join(EMB_DIR, f"{u}.pt")) - torch.load(os.path.join(ref, f"{u}.pt"))).abs().max().item()
for u in shared)
print(f"marker embeddings vs repo example set ({len(shared)} shared IDs): min cos {min(cs):.6f}, max|diff| {mx:.2e}")
# ----------------------------------------------------------------------------------------------- flash-attn shim
def install_flash_attn_shim():
"""Exact SDPA replacement for flash_attn_varlen_qkvpacked_func (qkv: (total, 3, H, D), cu_seqlens: (n+1,))."""
def varlen_qkvpacked(qkv, cu_seqlens, max_seqlen, dropout_p=0.0, softmax_scale=None, causal=False, **_):
total, _, H, D = qkv.shape
cu = cu_seqlens.tolist()
lens = np.diff(cu)
out = torch.empty(total, H, D, dtype=qkv.dtype, device=qkv.device)
for L in np.unique(lens):
if L == 0:
continue
starts = np.asarray(cu[:-1])[lens == L]
if len(starts) == len(lens): # all sequences same length -> contiguous reshape
q = qkv.view(len(lens), L, 3, H, D)
idx = None
else:
idx = (torch.as_tensor(starts, device=qkv.device)[:, None] + torch.arange(L, device=qkv.device)).reshape(-1)
q = qkv[idx].view(len(starts), L, 3, H, D)
q = q.permute(2, 0, 3, 1, 4) # 3, n, H, L, D
o = F.scaled_dot_product_attention(q[0], q[1], q[2], dropout_p=dropout_p, is_causal=causal,
scale=softmax_scale) # n, H, L, D
o = o.transpose(1, 2).reshape(-1, H, D)
if idx is None:
out = o.contiguous()
else:
out[idx] = o
return out
pkg = types.ModuleType("flash_attn")
pkg.__path__ = []
sub = types.ModuleType("flash_attn.flash_attn_interface")
sub.flash_attn_varlen_qkvpacked_func = varlen_qkvpacked
pkg.flash_attn_interface = sub
sys.modules.setdefault("flash_attn", pkg)
sys.modules.setdefault("flash_attn.flash_attn_interface", sub)
def load_virtues(marker_ids):
install_flash_attn_shim()
if VIRTUES_REPO not in sys.path:
sys.path.insert(0, VIRTUES_REPO)
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
from virtues.modules.multiplex_virtues import MultiplexVirtues
from virtues.utils.utils import load_marker_embedding_dict, load_marker_embeddings
emb = load_marker_embeddings(EMB_DIR)
emb_dict = load_marker_embedding_dict(EMB_DIR)
model = MultiplexVirtues(prior_bias_embeddings=emb) # defaults == configs/base_config.yaml (released model)
for w in WEIGHTS:
try:
sd = load_file(hf_hub_download("bunnelab/virtues", w))
model.load_state_dict(sd, strict=True)
print("loaded", w)
break
except Exception as e: # fall back to sp31
print("could not load", w, repr(e)[:200])
else:
raise RuntimeError("no VirTues weights could be loaded")
model.eval().to(dev)
midx = torch.tensor([emb_dict[u] for u in marker_ids], dtype=torch.long)
return model, midx
# ----------------------------------------------------------------------------------------------- data
def load_resampled(df):
"""Raw uint8 tiles -> (N,17,SIZE,SIZE) float16 at ~1 um/px, plus raw per-channel mean intensity (baseline)."""
N = len(df)
X = np.empty((N, 17, SIZE, SIZE), np.float16)
raw_mean = np.empty((N, 17), np.float32)
paths = [os.path.join(ORION, p) for p in df.target_path]
chunk = 64
for s in range(0, N, chunk):
t = np.stack([tifffile.imread(p) for p in paths[s:s + chunk]]) # n,333,333,17 uint8
x = torch.from_numpy(t).to(dev).permute(0, 3, 1, 2).float()
raw_mean[s:s + chunk] = x.mean((2, 3)).cpu().numpy()
X[s:s + chunk] = F.interpolate(x, size=(SIZE, SIZE), mode="bilinear", antialias=True,
align_corners=False).clamp(min=0).half().cpu().numpy()
if s % 640 == 0:
print(f" read {s}/{N}", flush=True)
return X, raw_mean
def standardize(X, slide):
"""quantile_clipping_log1p / uq_0.99_image + global mean/std + gaussian blur (in place, float16)."""
from torchvision.transforms.v2.functional import gaussian_blur
N, C = X.shape[:2]
slides = np.unique(slide)
uq = np.zeros((len(slides), C), np.float32)
n_zero = np.zeros((len(slides), C), bool)
s1, s2, cnt = np.zeros(C), np.zeros(C), np.zeros(C)
for k, sl in enumerate(slides):
idx = np.where(slide == sl)[0]
x = torch.from_numpy(X[idx]).to(dev).float().transpose(0, 1).reshape(C, -1) # C, n*H*W
# torch.quantile is limited to 2**24 elements: very large slides use a fixed random pixel subsample
qs = x if x.shape[1] <= 2 ** 24 else x[:, torch.randperm(x.shape[1], generator=torch.Generator().manual_seed(0))[: 2 ** 24].to(x.device)]
uq[k] = np.array([torch.quantile(qs[c], 0.99).item() for c in range(C)])
n_zero[k] = uq[k] <= 0
if not QC_DROP: # 8-bit tiles: sparse markers (FOXP3, ...) often have uq == 0 -> floor at one grey level
uq[k] = np.maximum(uq[k], 1.0)
u = torch.from_numpy(uq[k]).to(dev)[:, None]
v = torch.log1p(torch.minimum(x, u))
ok = (uq[k] > 0)
s1[ok] += v.sum(1).double().cpu().numpy()[ok]
s2[ok] += (v.double() ** 2).sum(1).cpu().numpy()[ok]
cnt[ok] += v.shape[1]
mean = s1 / cnt
std = np.sqrt(s2 / cnt - mean ** 2)
valid = uq > 0 # spora quantile mask: drop (slide, channel) with zero upper quantile
slide_k = {sl: k for k, sl in enumerate(slides)}
m_t = torch.tensor(mean, dtype=torch.float32, device=dev).view(1, C, 1, 1)
s_t = torch.tensor(std, dtype=torch.float32, device=dev).view(1, C, 1, 1)
for s in range(0, N, 128):
ks = [slide_k[sl] for sl in slide[s:s + 128]]
u = torch.from_numpy(uq[ks]).to(dev)[:, :, None, None]
x = torch.from_numpy(X[s:s + 128]).to(dev).float()
x = (torch.log1p(torch.minimum(x, u)) - m_t) / (s_t + 1e-8)
x = gaussian_blur(x, kernel_size=[3, 3], sigma=[1.0, 1.0])
X[s:s + 128] = x.half().cpu().numpy()
return X, uq, mean, std, valid, slide_k, n_zero
# ----------------------------------------------------------------------------------------------- sanity checks
def sanity(out, raw_mean, markers, groups):
from scipy.stats import spearmanr
from sklearn.linear_model import RidgeCV
from sklearn.model_selection import GroupKFold, KFold, cross_val_predict
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
o = out.astype(np.float32)
print("\n=== sanity checks ===")
print("finite:", bool(np.isfinite(o).all()), "| shape", out.shape, out.dtype)
z = o[:, 0]
zn = z / np.linalg.norm(z, axis=1, keepdims=True)
rng = np.random.default_rng(0)
s = rng.choice(len(z), min(1000, len(z)), replace=False)
cos = zn[s] @ zn[s].T
off = cos[~np.eye(len(s), dtype=bool)]
zc = z - z.mean(0)
zcn = zc / np.linalg.norm(zc, axis=1, keepdims=True)
offc = (zcn[s] @ zcn[s].T)[~np.eye(len(s), dtype=bool)]
sd = z.std(0)
ev = np.linalg.eigvalsh(np.cov(z.T))[::-1]
print(f"mean pairwise cosine (raw) {off.mean():.4f} (min {off.min():.4f}) | after centering {offc.mean():.4f}")
print(f"per-dim std: min {sd.min():.4f} median {np.median(sd):.4f} max {sd.max():.4f}; "
f"dims with std<1e-3: {(sd < 1e-3).sum()}; PCA participation ratio {ev.sum() ** 2 / (ev ** 2).sum():.1f}; "
f"top-10 PCs explain {ev[:10].sum() / ev.sum():.3f}")
on = o / np.linalg.norm(o, axis=2, keepdims=True)
same = np.mean([(on[:, 0] * on[:, d]).sum(1).mean() for d in range(1, 8)])
samec = np.mean([(zcn * ((o[:, d] - z.mean(0)) / np.linalg.norm(o[:, d] - z.mean(0), axis=1, keepdims=True))).sum(1).mean()
for d in range(1, 8)])
print(f"dihedral consistency: mean cos(d0, d1..7) same tile {same:.4f} (centered {samec:.4f}) "
f"vs random tile pairs {off.mean():.4f} (centered {offc.mean():.4f})")
Y = markers[:, :17]
names = [m for m, _ in MARKERS]
def probe(Xf, cv, grp=None):
pipe = make_pipeline(StandardScaler(), RidgeCV(alphas=np.logspace(-2, 5, 15)))
P = cross_val_predict(pipe, Xf, Y, cv=cv, groups=grp)
r = np.array([spearmanr(P[:, j], Y[:, j])[0] for j in range(Y.shape[1])])
return np.nan_to_num(r)
feats = {"VirTues (d0)": z, "VirTues (mean 8 dihedral)": o.mean(1), "baseline: IF mean intensity": raw_mean,
"baseline: log1p IF mean intensity": np.log1p(raw_mean)}
res = {}
for cvname, cv, grp in [("5-fold tiles", KFold(5, shuffle=True, random_state=0), None),
("5-fold patient-grouped", GroupKFold(5), groups)]:
for fn, Xf in feats.items():
r = probe(Xf, cv, grp)
res[(cvname, fn)] = r
print(f"ridge probe [{cvname:22s}] {fn:34s} mean Spearman over 17 *_prop = {r.mean():.4f}")
print("per-marker Spearman (5-fold tiles): VirTues d0 | IF mean intensity")
a, b = res[("5-fold tiles", "VirTues (d0)")], res[("5-fold tiles", "baseline: IF mean intensity")]
print(" " + " ".join(f"{n}:{x:.2f}|{y:.2f}" for n, x, y in zip(names, a, b)))
return res
# ----------------------------------------------------------------------------------------------- main
if __name__ == "__main__":
t0 = time.time()
df = load_all_tiles()
N = len(df)
keep_ch = [c for c in range(17) if not (NO_HOECHST and MARKERS[c][0] == "Hoechst")]
ids = [MARKERS[c][1] for c in range(17)]
print("marker -> UniProt:", ", ".join(f"{m}={u}" for m, u in MARKERS), "| using channels:", len(keep_ch))
ensure_marker_embeddings(sorted(set(ids)))
check_against_repo_embeddings(sorted(set(ids)))
model, midx_all = load_virtues(ids)
print(f"[{time.time() - t0:.0f}s] model ready; reading tiles (input {MPP_IN:.4f} um/px -> {SIZE}px = "
f"{MPP_IN * 333 / SIZE:.3f} um/px)", flush=True)
X, raw_mean = load_resampled(df)
slide = df.in_slide_name.values
X, uq, mean, std, valid, slide_k, n_zero = standardize(X, slide)
print(f"[{time.time() - t0:.0f}s] standardized. global log1p mean {np.round(mean, 2).tolist()}\n std {np.round(std, 2).tolist()}")
print(f" (slide, channel) pairs with 0.99-quantile == 0: {n_zero.sum()} of {n_zero.size} ("
+ ", ".join(f"{MARKERS[c][0]}x{n_zero[:, c].sum()}" for c in range(17) if n_zero[:, c].any())
+ (") -> dropped (spora quantile mask, --qc_drop)" if QC_DROP else ") -> uq floored at 1 grey level, channel kept"))
# per-tile channel set (quantile mask x optional Hoechst removal) -> batch tiles with identical channel sets
tile_valid = valid[[slide_k[s] for s in slide]]
tile_valid[:, [c for c in range(17) if c not in keep_ch]] = False
sig = [tuple(np.where(v)[0]) for v in tile_valid]
groups = {}
for i, g in enumerate(sig):
groups.setdefault(g, []).append(i)
out = np.zeros((N, 8, 512), np.float16)
torch.cuda.reset_peak_memory_stats()
done = 0
with torch.inference_mode(), torch.autocast("cuda", dtype=torch.float16):
for g, idxs in groups.items():
ch = torch.tensor(g, dtype=torch.long)
midx = midx_all[ch].to(dev)
for s in range(0, len(idxs), BS):
b = idxs[s:s + BS]
x = torch.from_numpy(X[b]).to(dev).float()[:, ch] # B,C,H,W
xd = [dihedral(x, d) for d in range(8)]
crops = [xd[d][i] for d in range(8) for i in range(len(b))] # order: d-major
ps = model.encoder.forward_list(crops, [midx] * len(crops)).patch_summary_tokens # list of (21,21,512)
e = torch.stack([p.float().mean((0, 1)) for p in ps]).view(8, len(b), -1).transpose(0, 1)
out[b] = e.cpu().numpy().astype(np.float16)
done += len(b)
if (done // BS) % 50 == 0:
print(f" encoded {done}/{N} [{time.time() - t0:.0f}s] peak GPU "
f"{torch.cuda.max_memory_allocated() / 2**30:.2f} GiB", flush=True)
fn = os.path.join(CACHE, "if_virtues" + ("_nohoechst" if NO_HOECHST else "") + ("_qcdrop" if QC_DROP else "") + ".npy")
np.save(fn, out)
print(f"saved {fn} {out.shape} {out.dtype} finite: {np.isfinite(out).all()} | channel-set groups: {len(groups)} | "
f"peak GPU alloc {torch.cuda.max_memory_allocated() / 2**30:.2f} GiB | encode+prep time {time.time() - t0:.0f}s")
if "--no_probe" not in sys.argv:
sanity(out, raw_mean, np.load(os.path.join(CACHE, "markers.npy")), df.patient.values)
print(f"total {time.time() - t0:.0f}s")
|