File size: 10,878 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 | """Generate H&E descriptions with GPT-6.1 Sol on Azure OpenAI for images that have no real text (plan A:
one FIXED 3-shot prefix of real PathGen-1.6M captions -> prompt-cached; short caption; low reasoning effort).
Targets (images on disk without paired text):
ge_patches : PathGen-1.6M gene-expression patches (brain*, gliomas*, idc*, saromas*, skin*) 1640
slide_rep : PathGen-1.6M/slide_report/image_set/*.png 1
orion : ORION H&E tiles (he/*.jpeg) 6335
hemit : HEMIT H&E inputs (he/*/input/*) 55
Config (env): AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY, AZURE_OPENAI_DEPLOYMENT,
optional AZURE_OPENAI_API_VERSION (if unset the v1 endpoint <endpoint>/openai/v1/ is used)
Usage: python 27_azure_caption.py --dry # list targets + 3-shot prompt, no API call
python 27_azure_caption.py --pilot 20 # 20 images, prints measured cost / image
python 27_azure_caption.py --budget 40 # everything; stops when the USD budget is reached
Output: cache/gen_text/captions.jsonl (resumable; one line per image: set, key, path, caption, usage)
Generated text is SYNTHETIC (seen only the H&E) -> never use it as test text; tag ttype='synthetic'.
"""
import argparse
import base64
import concurrent.futures as cf
import glob
import io
import json
import os
import random
import threading
import time
import numpy as np
import pandas as pd
from PIL import Image, PngImagePlugin
from common import CACHE, ROOT
PngImagePlugin.MAX_TEXT_CHUNK = 64 * 1024 * 1024
P = os.path.join(ROOT, "..")
OUT = os.path.join(CACHE, "gen_text")
os.makedirs(OUT, exist_ok=True)
PRICE = {"in": 2.0, "cached": 0.10, "out": 10.0} # USD / 1M tokens, GPT-6.1 Sol standard
MAXPX = 512 # images are downscaled to <=512 px (one vision tile) to keep the token count low
ap = argparse.ArgumentParser()
ap.add_argument("--sets", nargs="+", default=["ge_patches", "slide_rep", "orion", "hemit"],
help="tcga_notext = TCGA images left without real text after 40_clean_text.py; dlpfc / idc = external test sets (all spots); tcga_calib = 1000 TCGA patches that HAVE real text (held-out patients of 31_), for calibrating synthetic vs real text")
ap.add_argument("--pilot", type=int, default=0)
ap.add_argument("--budget", type=float, default=40.0)
ap.add_argument("--workers", type=int, default=8)
ap.add_argument("--effort", default="low")
ap.add_argument("--dry", action="store_true")
args = ap.parse_args()
def targets():
rows = []
ge = ["brain_image", "brain_train_image", "brain_trainnew_image", "gliomas_test_image_new", "gliomas_train_image",
"idc_image", "idc_train_image", "saromas_test_image_new", "saromas_train_image_new", "skin_image", "skin_train_image"]
src = {"ge_patches": [f for d in ge for f in glob.glob(os.path.join(P, "PathGen-1.6M", d, "*"))],
"slide_rep": glob.glob(os.path.join(P, "PathGen-1.6M", "slide_report", "image_set", "*")),
"orion": glob.glob(os.path.join(P, "ORION", "ORIONCRC_dataset_tile_20x", "he", "*.jpeg")),
"hemit": glob.glob(os.path.join(P, "HEMIT", "he", "*", "input", "*"))}
if "tcga_calib" in args.sets: # same held-out image set as 31_virtual_validation.py (TCGA), random 1000
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
seen = set(pd.read_csv(os.path.join(CACHE, "pathgen_meta.csv")).query("split in ['train', 'val']").patient)
ev = sorted(set(T.img_idx) & set(I.index[~I.patient.isin(seen)]))
src["tcga_calib"] = list(I.image_path.values[np.random.default_rng(0).choice(ev, 1000, replace=False)])
if "tcga_notext" in args.sets: # TCGA images whose real texts are all junk (40_clean_text.py)
src["tcga_notext"] = list(pd.read_csv(os.path.join(CACHE, "notext_meta.csv")).image_path)
for s in ("dlpfc", "idc"): # external test sets (36_external_prep.py): the same 224-px patches as the anchor
if s in args.sets: # (0.5 um/px; = UNI2 / MIPHEI input) -> path = "npy:<set>:<row>"
n = len(np.load(os.path.join(CACHE, "external", EXT[s]), mmap_mode="r"))
rows += [{"set": s, "key": f"external/{s}/{i}", "path": f"npy:{s}:{i}"} for i in range(n)]
for s in args.sets:
if s in EXT:
continue
for f in sorted(src[s]):
if f.lower().endswith((".png", ".jpg", ".jpeg", ".tif", ".tiff")):
rows.append({"set": s, "key": os.path.relpath(f, P), "path": f})
return pd.DataFrame(rows)
EXT = {"dlpfc": "dlpfc_he_05um.npy", "idc": "idc_he.npy"}
def b64(path):
if path.startswith("npy:"):
_, st, i = path.split(":")
im = Image.fromarray(np.asarray(np.load(os.path.join(CACHE, "external", EXT[st]), mmap_mode="r")[int(i)]))
else:
im = Image.open(path).convert("RGB")
im.thumbnail((MAXPX, MAXPX))
buf = io.BytesIO()
im.save(buf, format="JPEG", quality=90)
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
def few_shot():
"""3 fixed real PathGen-1.6M captions (40-60 words), different slides; same for every request -> cacheable."""
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
T = T[(T.ttype == "caption") & T.source.str.contains("pathgen1.6m")]
w = T.caption.str.split().str.len()
T = T[(w >= 40) & (w <= 60)]
rng = random.Random(0)
picks, seen = [], set()
for i in rng.sample(list(T.index), len(T)):
wsi = I.wsi_id[T.img_idx[i]]
if wsi[:12] in seen:
continue
try:
img = b64(I.image_path[T.img_idx[i]])
except Exception:
continue
seen.add(wsi[:12]); picks.append((img, T.caption[i], T.key[i]))
if len(picks) == 3:
return picks
SYSTEM = ("You are a pathologist writing a short description of one H&E-stained histopathology image patch. "
"Describe only what is visible: tissue type and architecture, cell types and their density, nuclear "
"features, stroma, inflammation, vessels, necrosis or mitoses when present. Follow the style and length "
"of the examples (one paragraph, about 50 words). Do not mention the examples, staining quality, "
"magnification or patient information, and do not invent a diagnosis that the image does not support.")
def messages(shots, target_img):
m = [{"role": "system", "content": SYSTEM}]
for img, cap, _ in shots:
m.append({"role": "user", "content": [{"type": "text", "text": "Describe this H&E patch."},
{"type": "image_url", "image_url": {"url": img, "detail": "auto"}}]})
m.append({"role": "assistant", "content": cap})
m.append({"role": "user", "content": [{"type": "text", "text": "Describe this H&E patch."},
{"type": "image_url", "image_url": {"url": target_img, "detail": "auto"}}]})
return m
def client():
from openai import AzureOpenAI, OpenAI
ep, key = os.environ["AZURE_OPENAI_ENDPOINT"].rstrip("/"), os.environ["AZURE_OPENAI_API_KEY"]
if os.environ.get("AZURE_OPENAI_API_VERSION"):
return AzureOpenAI(azure_endpoint=ep, api_key=key, api_version=os.environ["AZURE_OPENAI_API_VERSION"])
return OpenAI(base_url=ep + "/openai/v1/", api_key=key)
def cost(u):
cached = (u.get("prompt_tokens_details") or {}).get("cached_tokens") or 0
return ((u["prompt_tokens"] - cached) * PRICE["in"] + cached * PRICE["cached"] + u["completion_tokens"] * PRICE["out"]) / 1e6
D = targets()
shots = few_shot()
print(f"targets: {len(D)} " + json.dumps(D.set.value_counts().to_dict()))
print("3-shot examples (PathGen-1.6M keys):", [k for _, _, k in shots])
fout = os.path.join(OUT, "captions.jsonl")
done = set()
spent = 0.0
if os.path.exists(fout):
for line in open(fout):
r = json.loads(line)
done.add(r["key"]); spent += r.get("usd", 0)
todo = D[~D.key.isin(done)]
if args.pilot:
todo = todo.groupby("set", group_keys=False).apply(lambda g: g.sample(min(len(g), max(1, args.pilot // D.set.nunique())), random_state=0))
print(f"already done {len(done)} (USD {spent:.2f}); to do now {len(todo)}")
json.dump({"system": SYSTEM, "shots": [{"key": k, "caption": c} for _, c, k in shots], "maxpx": MAXPX,
"effort": args.effort, "deployment": os.environ.get("AZURE_OPENAI_DEPLOYMENT")},
open(os.path.join(OUT, "prompt.json"), "w"), indent=1)
if args.dry:
for _, c, k in shots:
print(f"- {k}: {c}")
raise SystemExit
cl, dep = client(), os.environ["AZURE_OPENAI_DEPLOYMENT"]
lock = threading.Lock()
stop = threading.Event()
def run(row):
if stop.is_set():
return
for attempt in range(6):
try:
r = cl.chat.completions.create(model=dep, messages=messages(shots, b64(row.path)),
reasoning_effort=args.effort, max_completion_tokens=2000,
prompt_cache_key="he-caption-3shot-v1")
u = r.usage.model_dump()
rec = {"set": row.set, "key": row.key, "path": row.path, "caption": r.choices[0].message.content.strip(),
"finish": r.choices[0].finish_reason, "usage": u, "usd": cost(u), "ttype": "synthetic"}
break
except Exception as e: # rate limits / transient errors -> exponential backoff
err = repr(e)[:300]
if "content_filter" in err or attempt == 5:
rec = {"set": row.set, "key": row.key, "path": row.path, "caption": None, "error": err, "usd": 0}
break
time.sleep(2 ** attempt * 5)
global spent
with lock:
with open(fout, "a") as f:
f.write(json.dumps(rec) + "\n")
spent += rec["usd"]
if spent >= args.budget:
stop.set()
t0 = time.time()
with cf.ThreadPoolExecutor(args.workers) as ex:
for i, _ in enumerate(ex.map(run, [r for r in todo.itertuples()])):
if i % 200 == 0:
print(f"{i}/{len(todo)} USD {spent:.2f} {time.time() - t0:.0f}s", flush=True)
R = pd.read_json(fout, lines=True)
ok = R[R.caption.notna()]
print(f"done {len(ok)} ok / {len(R)} total; failures {R.caption.isna().sum()}; USD spent {R.usd.sum():.2f}; "
f"USD/image {ok.usd.mean():.4f} -> projected for all {len(D)}: {ok.usd.mean() * len(D):.0f}")
if len(ok):
u = pd.json_normalize(ok.usage)
print(u.filter(regex="prompt_tokens$|completion_tokens$|cached_tokens|reasoning_tokens").mean().round(0).to_string())
if stop.is_set():
print(f"STOPPED: budget {args.budget} USD reached")
|