File size: 23,846 Bytes
9a5439c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
"""STPath (Huang et al. 2025) zero-shot spatial gene-expression prediction from H&E GigaPath features.

Source   code    https://github.com/Graph-and-Geometric-Learning/STPath  (commit 3b10e6d, no LICENSE file)
         weights https://huggingface.co/tlhuang/STPath  stfm.pth  (no licence declared; not gated)
         local copy: ext/stpath/{repo,weights}   -- treated as untrusted: state dict loaded with weights_only=True,
         and only the pure-torch model modules (model.py, spatial_transformer.py, fa.py, nn_utils) are imported.
Model    STFM spatial transformer: per-spot input = Linear(GigaPath tile feature 1536) + masked gene token
         + organ token + tech token; attention over all spots of a slide with a 2D frame-averaged distance bias.
         Coordinates are shifted to 0 and rescaled to [0,100] per slide (official STPathInference._normalize_coords).
         Output: 38,984 Ensembl-gene vector per spot on the training scale log1p(raw UMI count) (NOT library-normalised;
         training used normalize_method='log1pv2' = sc.pp.log1p on raw counts).
Encoder  The released checkpoint was trained on prov-gigapath tile features ONLY (official pipeline: 224-px HEST patches
         @ 0.5 um/px, CenterCrop 224, ImageNet norm).  prov-gigapath is gated: --extract needs HF_TOKEN in the env
         (pass it as an env var only; this script never reads, prints or stores it).
         Our features = mean of GigaPath(fp16) over the centre + 4 corner 224-px crops at ~0.5 um/px
         (centre-only saved too as <set>_gigapath_center.npy):
           ORION  333-px tiles (512-px @ 0.325 um/px resized -> ~0.49 um/px), cropped at native size.
           TCGA   672-px PathGen patches = read_region level 0; visually 40x (RBC ~28-30 px -> ~0.25 um/px),
                  so resized 672 -> 336 px (~0.5 um/px) before cropping.  (20x-scanned slides, if any, would be off 2x.)

Sets
  int2_check  repo example (HEST INT2, CCRCC, full slide 2541 spots) -> reproduces README mean Pearson 0.1562;
              also INT2 in random 26-spot groups (sparse context, like our 1% HEST subsample) and single spots.
  hest_val    cache/hest (9484 Visium spots, 1% subsample of 421 HEST samples, precomputed GigaPath features),
              one STPath pass per sample (all its spots as context), organ = HEST organ, tech = Visium.
              Samples in HEST-bench were excluded from STPath pretraining -> reported separately as held-out.
  orion       ORION CRC tiles (common.load_all_tiles, 3187 tiles / 41 slides), one pass per slide with coords from
              the filename x_y (level-0 px), organ = Bowel, tech = Visium.  + protein sanity check
              (orion_protein_check.csv: predicted RNA vs <marker>_count/n_nuclei, tiles with >= 20 nuclei).
  tcga_text   cache/allimg_meta.csv (6144 patches, 4088 WSIs -> mostly 1 patch = no spatial context), one pass per
              WSI, coords from key x_y; organ from the TCGA project (public GDC files API, cached in
              tcga_text_projects.csv) via PROJECT2ORGAN, unmapped -> 'Others'; tech = Visium.

Outputs cache/stpath/<set>_pred.npy (N,G) float16, native STPath scale log1p(raw count); <set>_genes.csv; <set>_meta.csv
        hest_val additionally: hest_val_metrics.csv (per-gene Pearson/Spearman), hest_val_within_sample.csv.
Run     .venv/bin/python 26_stpath_infer.py --set int2_check|hest_val
        HF_TOKEN=... .venv/bin/python 26_stpath_infer.py --set orion|tcga_text --extract   # GigaPath features
        .venv/bin/python 26_stpath_infer.py --set orion|tcga_text [--feats F.npy]          # STPath prediction
"""
import argparse
import json
import os
import pickle
import re
import sys
import weakref

import numpy as np
import pandas as pd
import torch

ROOT = os.path.dirname(os.path.abspath(__file__))
EXT = os.path.join(ROOT, "ext", "stpath")
REPO = os.path.join(EXT, "repo")
WEIGHTS = os.path.join(EXT, "weights", "stfm.pth")
VOC = os.path.join(REPO, "utils_data", "symbol2ensembl.json")
CACHE = os.path.join(ROOT, "cache")
OUT = os.path.join(CACHE, "stpath")
HEST = os.path.join(ROOT, "..", "HEST")
MARKERS = ["PTPRC", "EPCAM", "KRT8", "ACTA2", "MKI67", "PECAM1", "CD68", "CD3E", "CDH1", "COL1A1"]
HEST_BENCH = {  # MahmoodLab/hest-bench adata ids; STPath excluded these from pretraining (train.py)
    "INT%d" % i for i in range(1, 25)} | {"TENX111", "TENX147", "TENX148", "TENX149", "NCBI642", "NCBI643",
    "NCBI783", "NCBI785", "TENX95", "TENX99", "TENX118", "TENX141", "NCBI681", "NCBI682", "NCBI683", "NCBI684",
    "TENX116", "TENX126", "TENX140", "ZEN36", "ZEN40", "ZEN48", "ZEN49", "TENX115", "TENX117"} | {
    "MEND%d" % i for i in list(range(139, 155)) + list(range(156, 163))}


# ----------------------------------------------------------------------------------------------- model
class STPath:
    def __init__(self, device):
        sys.path.insert(0, REPO)
        from stpath.model.model import STFM
        from stpath.model.nn_utils.config import ModelConfig
        import stpath.utils.constants as C

        sym2ens = json.load(open(VOC))
        ens = sorted(set(sym2ens.values()))
        self.gene2id = {g: i + 2 for i, g in enumerate(ens)}  # 0 pad, 1 mask (GeneExpTokenizer)
        self.sym2ens = sym2ens
        self.n_genes = max(self.gene2id.values()) + 1
        self.C = C
        cfg = ModelConfig.get_default_config()
        cfg.feature_dim, cfg.activation, cfg.backbone = 1536, "gelu", "spatial_transformer"
        cfg.n_genes, cfg.n_tech, cfg.n_species, cfg.n_organs = self.n_genes, len(C.tech_voc), len(C.specie_voc), len(C.organ_voc)
        self.model = STFM(cfg)
        self.model.load_state_dict(torch.load(WEIGHTS, map_location="cpu", weights_only=True))
        self.model.eval().to(device)
        self.device = device

    def organ_id(self, organ):  # IDTokenizer.encode(align_first=True); None -> "Others" as in STPathInference
        organ = "Others" if organ is None or (isinstance(organ, float) and np.isnan(organ)) else organ
        return self.C.organ_voc.index(self.C.organ_align_mapping.get(organ, "<pad>"))

    def tech_id(self, tech):
        return 0 if tech is None else self.C.tech_voc.index(self.C.tech_align_mapping.get(tech, "<pad>"))

    def gene_cols(self, symbols):
        """token column for each symbol (-1 if not in the STPath vocabulary)"""
        return np.array([self.gene2id.get(self.sym2ens.get(s), -1) for s in symbols])

    @torch.no_grad()
    def predict(self, feats, coords, organ, tech, cols):
        """feats (n,1536), coords (n,2) one slide -> (n, len(cols)) log1p(count)-scale, NaN for cols==-1"""
        n = len(feats)
        c = torch.as_tensor(np.asarray(coords, np.float64), dtype=torch.float32)
        c = c - c.min(0).values
        rng = c.max(0).values
        c = torch.where(rng > 0, c / rng.clamp(min=1e-8) * 100, torch.zeros_like(c))  # rescale_coords; guard 1-spot/collinear
        x = torch.as_tensor(np.asarray(feats, np.float32), device=self.device)
        ge = torch.zeros(n, self.n_genes, device=self.device)
        ge[:, 1] = 1  # all spots masked (pure image -> expression)
        p = self.model.prediction_head(
            img_tokens=x, coords=c.to(self.device), ge_tokens=ge,
            batch_idx=torch.zeros(n, dtype=torch.long, device=self.device),
            tech_tokens=torch.full((n,), self.tech_id(tech), dtype=torch.long, device=self.device),
            organ_tokens=torch.full((n,), self.organ_id(organ), dtype=torch.long, device=self.device))
        out = p[:, torch.as_tensor(np.clip(cols, 0, None), device=self.device)].float().cpu().numpy()
        out[:, cols < 0] = np.nan
        # library size of the predicted profile (all vocab genes) for optional CP10k re-normalisation
        lib = torch.expm1(p[:, 2:].clamp(min=0)).sum(1).cpu().numpy()
        return out, lib


# ----------------------------------------------------------------------------------------------- metrics
def corr_cols(a, b):
    a = a - a.mean(0); b = b - b.mean(0)
    return (a * b).sum(0) / np.sqrt((a * a).sum(0) * (b * b).sum(0) + 1e-20)


def rank_cols(a):
    from scipy.stats import rankdata
    return np.apply_along_axis(rankdata, 0, a)


def gene_metrics(pred, truth):
    return corr_cols(pred, truth), corr_cols(rank_cols(pred), rank_cols(truth))


# ----------------------------------------------------------------------------------------------- sets
def run_int2(m):
    import anndata as ad
    import h5py
    ex = os.path.join(REPO, "example_data")
    hvg = json.load(open(os.path.join(ex, "var_50genes.json")))["genes"]
    with h5py.File(os.path.join(ex, "INT2.h5")) as f:
        E, Cc, bc = f["embeddings"][:], f["coords"][:], f["barcodes"][:].flatten().astype(str)
    a = ad.read_h5ad(os.path.join(ex, "INT2.h5ad"))[bc, :]
    gt = np.log1p(a[:, hvg].X.toarray())
    cols = m.gene_cols(hvg)
    p, _ = m.predict(E, Cc, "Kidney", "Visium", cols)
    r = corr_cols(p, gt)
    print(f"INT2 full slide ({len(E)} spots): mean Pearson 50 HVG = {np.nanmean(r):.4f} (README: 0.1562)")
    rs = []
    for seed in range(5):  # sparse context like our 1% HEST subsample: 26 random spots per pass
        idx = np.random.default_rng(seed).permutation(len(E))
        pp = np.zeros_like(p)
        for ch in np.array_split(idx, len(E) // 26):
            pp[ch] = m.predict(E[ch], Cc[ch], "Kidney", "Visium", cols)[0]
        rs.append(np.nanmean(corr_cols(pp, gt)))
    print(f"INT2 sparse context (random 26-spot groups, 5 seeds): mean Pearson = {np.mean(rs):.4f} +- {np.std(rs):.4f}")
    p1 = np.concatenate([m.predict(E[i:i + 1], Cc[i:i + 1], "Kidney", "Visium", cols)[0] for i in range(len(E))])
    print(f"INT2 no context (each spot alone): mean Pearson = {np.nanmean(corr_cols(p1, gt)):.4f}")


def hest_coords():
    from anndata._core import file_backing

    def _ss(self, state):  # same unpickle patch as 21_hest_prep.py
        self.__dict__ = state.copy()
        ref = state.get("_adata_ref", state.get("_adata"))
        self.__dict__["_adata_ref"] = weakref.ref(ref) if ref is not None else (lambda: None)

    file_backing.AnnDataFileManager.__setstate__ = _ss
    A = pickle.load(open(os.path.join(HEST, "visium_humandisease_adata.pickle"), "rb"))
    H = pd.read_csv(os.path.join(HEST, "HEST_v1_1_0.csv"))
    H = H[(H.st_technology == "Visium") & (H.species == "Homo sapiens")].reset_index(drop=True)
    assert len(H) == len(A)
    xy = {}
    for a, sid in zip(A, H.id):
        for b, c in zip(a.obs_names, np.asarray(a.obsm["spatial"], np.float64)):
            xy[(sid, b)] = c
    return xy


def run_hest(m):
    import scipy.sparse as sp
    M = pd.read_csv(os.path.join(CACHE, "hest", "meta.csv"))
    genes = pd.read_csv(os.path.join(CACHE, "hest", "genes.csv")).gene.tolist()
    X = sp.load_npz(os.path.join(CACHE, "hest", "counts.npz")).tocsr()
    F = np.load(os.path.join(CACHE, "hest", "he_gigapath.npy"))[:, 0].astype(np.float32)
    xy = hest_coords()
    coords = np.stack([xy[(s, b)] for s, b in zip(M["sample"], M.spot)])
    cols = m.gene_cols(genes)
    keep = cols >= 0
    print(f"HEST genes {len(genes)}, in STPath vocab {keep.sum()}")
    genes_k, cols_k = [g for g, k in zip(genes, keep) if k], cols[keep]
    pred = np.zeros((len(M), len(cols_k)), np.float32)
    lib = np.zeros(len(M), np.float32)
    for sid, idx in M.groupby("sample").indices.items():
        pred[idx], lib[idx] = m.predict(F[idx], coords[idx], M.organ.iloc[idx[0]], "Visium", cols_k)
    os.makedirs(OUT, exist_ok=True)
    np.save(os.path.join(OUT, "hest_val_pred.npy"), pred.astype(np.float16))
    pd.Series(genes_k).to_csv(os.path.join(OUT, "hest_val_genes.csv"), index=False, header=["gene"])
    mo = M[["idx", "sample", "spot", "organ", "oncotree"]].copy()
    mo["x"], mo["y"] = coords[:, 0], coords[:, 1]
    mo["hest_bench_heldout"] = mo["sample"].isin(HEST_BENCH)
    mo["pred_lib_expm1"] = lib
    mo["status"] = "ok"
    mo.to_csv(os.path.join(OUT, "hest_val_meta.csv"), index=False)

    # ---- evaluation: truth = log1p(CP10k) of measured counts (and log1p raw = STPath's training target)
    Xk = X[:, np.where(keep)[0]]
    tot = np.asarray(X.sum(1)).ravel()
    L = sp.diags(1e4 / tot) @ Xk
    L.data = np.log1p(L.data)
    frac = np.asarray((Xk > 0).mean(0)).ravel()
    mu = np.asarray(L.mean(0)).ravel()
    var = np.asarray(L.multiply(L).mean(0)).ravel() - mu ** 2
    var[frac < 0.01] = -1
    top50 = list(np.argsort(-var)[:50])
    gi = {g: i for i, g in enumerate(genes_k)}
    mk = [gi[g] for g in MARKERS if g in gi]
    sel = top50 + [i for i in mk if i not in top50]
    T_cp = L[:, sel].toarray()
    T_raw = np.log1p(Xk[:, sel].toarray())
    P = pred[:, sel]
    P_cp = np.log1p(np.expm1(np.clip(P, 0, None)) / lib[:, None] * 1e4)  # predicted profile re-normalised to CP10k
    held = mo.hest_bench_heldout.values
    rows = []
    for subset, msk in [("all", np.ones(len(M), bool)), ("heldout_hestbench", held), ("seen_pretrain", ~held)]:
        for tname, T, PP in [("log1p_cp10k", T_cp, P), ("log1p_raw", T_raw, P), ("log1p_cp10k_vs_predcp10k", T_cp, P_cp)]:
            pr, sr = gene_metrics(PP[msk], T[msk])
            for j, g in enumerate(sel):
                rows.append({"subset": subset, "truth": tname, "gene": genes_k[g], "is_top50_hvg": g in top50,
                             "is_marker": genes_k[g] in MARKERS, "pearson": pr[j], "spearman": sr[j],
                             "n_spots": int(msk.sum())})
    R = pd.DataFrame(rows)
    R.to_csv(os.path.join(OUT, "hest_val_metrics.csv"), index=False)
    # within-sample correlations (spatial-variation within a slide, the HEST-bench style), samples with >=30 spots
    ws = []
    for sid, idx in M.groupby("sample").indices.items():
        if len(idx) >= 30:
            pr, _ = gene_metrics(P[idx][:, :50], T_cp[idx][:, :50])
            ws.append({"sample": sid, "heldout": sid in HEST_BENCH, "n": len(idx), "mean_pearson_top50": np.nanmean(pr)})
    W = pd.DataFrame(ws)
    W.to_csv(os.path.join(OUT, "hest_val_within_sample.csv"), index=False)
    print(f"\nspots {len(M)}, samples {M['sample'].nunique()}; held-out (HEST-bench) spots {held.sum()} "
          f"in {M[held]['sample'].nunique()} samples")
    s = R.groupby(["subset", "truth", "is_top50_hvg"])[["pearson", "spearman"]].agg(["mean", "median"])
    print(s.round(3).to_string())
    print("\nmarkers, truth log1p CP10k (pooled across spots):")
    print(R[R.is_marker & (R.truth == "log1p_cp10k")].pivot(index="gene", columns="subset", values=["pearson", "spearman"]).round(3).to_string())
    print("\nwithin-sample mean Pearson top-50 HVG (samples >=30 spots):")
    print(W.groupby("heldout").mean_pearson_top50.describe().round(3).to_string())


def parse_xy(s):
    mm = re.search(r"_(\d+)_(\d+)(?:_0_512_512)?(?:\.\w+)?$", s)
    return (float(mm.group(1)), float(mm.group(2))) if mm else (np.nan, np.nan)


def target_paths(name):
    if name == "orion":
        sys.path.insert(0, ROOT)
        from common import load_all_tiles, ORION
        return [os.path.join(ORION, p) for p in load_all_tiles().image_path], 333   # ~0.49 um/px already
    d = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
    return d.image_path.tolist(), 336  # PathGen read_region level 0 = 40x (~0.25 um/px): 672 -> 336 = ~0.5 um/px


def five_crops(img, size=224):
    W, H = img.size
    offs = [((W - size) // 2, (H - size) // 2), (0, 0), (W - size, 0), (0, H - size), (W - size, H - size)]
    return [img.crop((x, y, x + size, y + size)) for x, y in offs]  # centre first


def extract_gigapath(name, device, bs=32):
    """GigaPath tile features (fp16) = mean over centre + 4 corner 224-px crops at ~0.5 um/px; centre-only also saved.
    Needs HF_TOKEN in the environment (prov-gigapath is gated); the token is never read/stored by this script."""
    import timm
    from PIL import Image, PngImagePlugin
    from torchvision import transforms
    PngImagePlugin.MAX_TEXT_CHUNK = 64 * 1024 * 1024
    enc = timm.create_model("hf_hub:prov-gigapath/prov-gigapath", pretrained=True).half().eval().to(device)
    tf = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225))])
    paths, side = target_paths(name)
    feats = np.full((len(paths), 5, 1536), np.nan, np.float32)
    status = ["ok"] * len(paths)
    buf, rows = [], []

    def flush():
        if not buf:
            return
        with torch.inference_mode():
            f = enc(torch.stack(buf).half().to(device)).float().cpu().numpy()
        feats[np.array(rows)] = f.reshape(-1, 5, 1536)
        buf.clear(); rows.clear()

    for i, p in enumerate(paths):
        try:
            im = Image.open(p).convert("RGB")
            if im.size != (side, side):
                im = im.resize((side, side), Image.BICUBIC)
            buf.extend(tf(c) for c in five_crops(im)); rows.append(i)
        except Exception as e:
            status[i] = f"unreadable:{type(e).__name__}"
            print(f"[{name}] skip {p}: {e}", flush=True)
        if len(rows) * 5 >= bs:
            flush()
        if i % 500 == 0:
            print(f"[{name}] {i}/{len(paths)}", flush=True)
    flush()
    os.makedirs(OUT, exist_ok=True)
    np.save(os.path.join(OUT, f"{name}_gigapath.npy"), feats.mean(1))
    np.save(os.path.join(OUT, f"{name}_gigapath_center.npy"), feats[:, 0])
    pd.DataFrame({"path": paths, "feat_status": status}).to_csv(os.path.join(OUT, f"{name}_gigapath_status.csv"), index_label="row")
    print(f"[{name}] features {feats.shape[0]} rows, failed {sum(s != 'ok' for s in status)}; "
          f"non-finite rows {int((~np.isfinite(feats.mean(1)).all(1)).sum())}")


def tcga_projects(meta):
    """TCGA project per WSI from the public GDC files API (file_name = <wsi>.svs); cached in cache/stpath."""
    fp = os.path.join(OUT, "tcga_text_projects.csv")
    if not os.path.exists(fp):
        import requests
        names = sorted(set(meta.slide + ".svs"))
        rows = []
        for i in range(0, len(names), 500):
            q = {"filters": {"op": "in", "content": {"field": "file_name", "value": names[i:i + 500]}},
                 "fields": "file_name,cases.project.project_id", "format": "JSON", "size": 1000}
            r = requests.post("https://api.gdc.cancer.gov/files", json=q, timeout=120).json()
            for h in r["data"]["hits"]:
                rows.append({"slide": h["file_name"][:-4], "project": h["cases"][0]["project"]["project_id"]})
        pd.DataFrame(rows).drop_duplicates("slide").to_csv(fp, index=False)
    return dict(pd.read_csv(fp).values)


# TCGA project -> STPath organ name (keys of constants.organ_align_mapping; lower-case ones are the STimage-1K4M
# spellings that align to their own tokens, e.g. 'ovary'->'Ovary'); unlisted projects -> 'Others'
PROJECT2ORGAN = {"BRCA": "Breast", "COAD": "Bowel", "READ": "Bowel", "LUAD": "Lung", "LUSC": "Lung",
                 "KIRC": "Kidney", "KIRP": "Kidney", "KICH": "Kidney", "LIHC": "Liver", "PAAD": "Pancreas",
                 "PRAD": "Prostate", "SKCM": "Skin", "UCEC": "Uterus", "UCS": "Uterus", "OV": "ovary",
                 "GBM": "glioblastoma", "LGG": "glioma", "STAD": "stomach", "HNSC": "mouth", "UVM": "Eye"}


def run_target(m, name, feats_path):
    if name == "orion":
        sys.path.insert(0, ROOT)
        from common import load_all_tiles
        d = load_all_tiles()
        meta = pd.DataFrame({"key": d.image_path, "path": d.image_path, "slide": d.in_slide_name})
        meta["x"], meta["y"] = zip(*[parse_xy(os.path.basename(p)) for p in d.image_path])
        meta["organ"] = "Bowel"
    else:
        d = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
        meta = pd.DataFrame({"key": d.key, "path": d.image_path, "slide": d.wsi_id})
        meta["x"], meta["y"] = zip(*[parse_xy(k) for k in d.key])
        proj = tcga_projects(meta)  # TCGA project from GDC; organ token mapped, unknown -> 'Others'
        meta["project"] = meta.slide.map(proj)
        meta["organ"] = [PROJECT2ORGAN.get(str(p).replace("TCGA-", ""), "Others") for p in meta.project]
    feats_path = feats_path or os.path.join(OUT, f"{name}_gigapath.npy")
    if not os.path.exists(feats_path):
        sys.exit(f"[{name}] BLOCKED: STPath's released checkpoint needs prov-gigapath tile features (1536-d) and "
                 f"prov-gigapath is gated for this HF account. Provide --feats <GigaPath features (N={len(meta)},1536) "
                 f"aligned to {name} row order>.")
    F = np.load(feats_path).astype(np.float32)
    F = F[:, 0] if F.ndim == 3 else F
    assert F.shape == (len(meta), 1536), F.shape
    ok = np.isfinite(F).all(1)
    meta["status"] = np.where(ok, "ok", "no_feature")
    meta["n_context"] = meta.groupby("slide").slide.transform("size").where(ok, 0)
    vocab_genes = pd.read_csv(os.path.join(OUT, "hest_val_genes.csv")).gene.tolist()
    cols = m.gene_cols(vocab_genes)
    pred = np.full((len(meta), len(cols)), np.nan, np.float32)
    for sl, idx in meta[ok].groupby("slide").indices.items():
        idx = np.where(ok)[0][idx]
        pred[idx] = m.predict(F[idx], meta[["x", "y"]].values[idx], meta.organ.values[idx[0]], "Visium", cols)[0]
    np.save(os.path.join(OUT, f"{name}_pred.npy"), pred.astype(np.float16))
    pd.Series(vocab_genes).to_csv(os.path.join(OUT, f"{name}_genes.csv"), index=False, header=["gene"])
    meta.to_csv(os.path.join(OUT, f"{name}_meta.csv"), index_label="row")
    print(f"[{name}] predicted {ok.sum()} / {len(meta)}; {meta.slide.nunique()} slides")
    if name == "orion":
        orion_protein_check(pred, vocab_genes)


ORION_PAIRS = [("PTPRC", "CD45"), ("EPCAM", "Pan-CK"), ("KRT8", "Pan-CK"), ("CDH1", "E-cadherin"), ("ACTA2", "SMA"),
               ("MKI67", "Ki67"), ("PECAM1", "CD31"), ("CD68", "CD68"), ("CD3E", "CD3e"), ("CD8A", "CD8a"),
               ("MS4A1", "CD20"), ("CD163", "CD163")]


def orion_protein_check(pred, genes):
    """predicted RNA vs measured ORION protein (= <marker>_count / n_nuclei) per tile, tiles with >= 20 nuclei."""
    from scipy.stats import pearsonr, spearmanr
    sys.path.insert(0, ROOT)
    from common import load_all_tiles
    d = load_all_tiles()
    ts = pd.read_csv(os.path.join(ROOT, "results", "tile_stats.csv"))
    assert (ts.tile_id.values == d.tile_id.values).all()
    keep = (ts.n_nuclei.values >= 20) & np.isfinite(pred).all(1)
    gi = {g: i for i, g in enumerate(genes)}
    rows = []
    for g, p in ORION_PAIRS:
        x = pred[keep, gi[g]].astype(np.float64)
        y = (d[f"{p}_count"].values / np.maximum(ts.n_nuclei.values, 1))[keep]
        ws = [spearmanr(x[i], y[i])[0] for i in d[keep].groupby("in_slide_name").indices.values() if len(i) >= 20]
        rows.append({"gene": g, "protein": p, "n_tiles": int(keep.sum()), "pearson": pearsonr(x, y)[0],
                     "spearman": spearmanr(x, y)[0], "within_slide_spearman_median": np.nanmedian(ws),
                     "n_slides_within": len(ws)})
    R = pd.DataFrame(rows)
    R.to_csv(os.path.join(OUT, "orion_protein_check.csv"), index=False)
    print(R.round(3).to_string(index=False))


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--set", required=True, choices=["int2_check", "hest_val", "orion", "tcga_text"])
    ap.add_argument("--feats", default=None, help="GigaPath (N,1536) features for orion / tcga_text")
    ap.add_argument("--extract", action="store_true", help="compute GigaPath features for --set orion|tcga_text (needs HF_TOKEN env)")
    ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
    a = ap.parse_args()
    torch.manual_seed(0)
    if a.extract:
        extract_gigapath(a.set, a.device)
        sys.exit(0)
    model = STPath(a.device)
    {"int2_check": lambda: run_int2(model), "hest_val": lambda: run_hest(model)}.get(
        a.set, lambda: run_target(model, a.set, a.feats))()