HMCLIP / scripts /image_traversals_compare.py
SteveZeyuZhang's picture
Add source files and streaming backup restore instructions
b91f38a
Raw History Blame Contribute Delete
39.6 kB
#!/usr/bin/env python3
"""
MERU-style image traversals comparing RadIR/RadIR (hyperbolic) vs RadIR_compare (Euclidean).
For each query CXR:
1. Encode the image with both models.
2. Walk along the geodesic / ray from the image embedding toward the origin [ROOT].
3. At each step, retrieve the nearest neighbor from a shared multi-granularity concept pool.
Hyperbolic path optionally keeps only texts that entail the current point
(MERU-style cone filter), then takes NN in that subset.
4. Render a MERU Figure-5 style side-by-side panel.
Usage (from RadIR/RadIR):
python scripts/image_traversals_compare.py \\
--images path1.jpg path2.jpg \\
--out outputs/traversals_compare.png
# Auto-pick diverse disease cases from valid.jsonl + disease labels:
python scripts/image_traversals_compare.py --auto_pick 4 --out outputs/traversals_compare.png
# Disable entailment filter (pure hyperbolic NN):
python scripts/image_traversals_compare.py --auto_pick 4 --no_entailment
"""
from __future__ import annotations
import argparse
import csv
import gc
import json
import os
import re
import sys
import textwrap
from pathlib import Path
from typing import Sequence
import matplotlib.pyplot as plt
import numpy as np
import torch
import torch.nn.functional as F
from matplotlib.patches import FancyBboxPatch
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
# Workspace layout: HMClip/RadIR/RadIR and HMClip/RadIR_compare
_hmclip = ROOT.parent.parent
if (_hmclip / "RadIR_compare").is_dir():
COMPARE_ROOT = _hmclip / "RadIR_compare"
elif (ROOT.parent / "RadIR_compare").is_dir():
COMPARE_ROOT = ROOT.parent / "RadIR_compare"
else:
COMPARE_ROOT = _hmclip / "RadIR_compare"
ROOT_LABEL = "[ROOT]"
# ---------------------------------------------------------------------------
# Concept pool
# ---------------------------------------------------------------------------
def load_concept_pool(
concepts_file: str | None,
extra_captions: int = 0,
caption_jsonl: str | None = None,
seed: int = 42,
) -> list[str]:
texts: list[str] = []
seen: set[str] = set()
def _add(s: str) -> None:
s = " ".join(s.strip().split())
if not s:
return
key = s.lower()
if key in seen or key == ROOT_LABEL.lower():
return
seen.add(key)
texts.append(s)
path = concepts_file or str(ROOT / "scripts" / "traversal_concepts.txt")
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
_add(line)
if extra_captions > 0 and caption_jsonl and os.path.isfile(caption_jsonl):
rng = np.random.default_rng(seed)
rows = []
with open(caption_jsonl, encoding="utf-8") as f:
for line in f:
item = json.loads(line)
text = str(item.get("text", "")).strip()
if not text:
continue
first = text.split(".")[0].strip()
if 8 <= len(first) <= 80:
rows.append(first)
if rows:
idx = rng.choice(len(rows), size=min(extra_captions, len(rows)), replace=False)
for i in idx:
_add(rows[int(i)])
texts.append(ROOT_LABEL)
return texts
# ---------------------------------------------------------------------------
# Image I/O
# ---------------------------------------------------------------------------
def resolve_image_path(path: str, dataset_root: str) -> str:
candidates = [
path,
os.path.join(dataset_root, path),
os.path.join(dataset_root, path.replace("dataset/", "", 1)),
os.path.join(str(ROOT), path),
os.path.join(str(ROOT), path.replace("dataset/", "dataset/", 1)),
]
for c in candidates:
if os.path.isfile(c):
return os.path.abspath(c)
raise FileNotFoundError(f"Image not found: {path}")
def load_image_tensor(path: str, imsize: int, with_depth: bool) -> torch.Tensor:
"""
Grayscale -> [-1, 1].
with_depth=False -> [1, H, W] (RadIR/RadIR hyp)
with_depth=True -> [1, 1, H, W] (RadIR_compare, C×D×H×W)
"""
img = Image.open(path).convert("L")
arr = np.array(img, dtype=np.float32) / 255.0
t = torch.from_numpy(arr).unsqueeze(0) # [1, H, W]
t = F.interpolate(
t.unsqueeze(0), size=(imsize, imsize), mode="bilinear", align_corners=False
).squeeze(0)
t = t * 2.0 - 1.0
if with_depth:
t = t.unsqueeze(0) # [1, 1, H, W]
return t
def preprocess_hyp_batch(image: torch.Tensor) -> torch.Tensor:
if image.ndim == 3:
image = image.unsqueeze(0)
if image.ndim == 4:
if image.shape[1] != 1:
image = image.mean(dim=1, keepdim=True)
elif image.ndim == 5:
if image.shape[1] != 1:
image = image.mean(dim=1, keepdim=True)
else:
raise ValueError(f"Unexpected image shape {tuple(image.shape)}")
return image
# ---------------------------------------------------------------------------
# Auto-pick demo images
# ---------------------------------------------------------------------------
def auto_pick_images(
dataset_root: str,
labels_csv: str,
jsonl: str,
n: int,
seed: int = 42,
) -> list[str]:
labels = {}
with open(labels_csv, encoding="utf-8") as f:
for row in csv.DictReader(f):
labels[row["dicom_id"]] = row
wanted = [
"normal_only",
"Pneumonia_pos",
"Effusion_pos",
"Cardiomegaly_pos",
"Edema_pos",
"Atelectasis_pos",
"Pneumothorax_pos",
]
found: dict[str, str] = {}
with open(jsonl, encoding="utf-8") as f:
for line in f:
item = json.loads(line)
did = os.path.splitext(os.path.basename(item["img_path"]))[0]
lab = labels.get(did)
if lab is None:
continue
for k in wanted:
if k in found or lab.get(k) != "1":
continue
if k != "normal_only":
others = sum(
int(lab[x])
for x in wanted
if x not in ("normal_only", k)
)
if others > 0:
continue
try:
full = resolve_image_path(item["img_path"], dataset_root)
except FileNotFoundError:
continue
found[k] = full
break
if len(found) >= n:
break
paths = [found[k] for k in wanted if k in found][:n]
if len(paths) < n:
with open(jsonl, encoding="utf-8") as f:
for line in f:
item = json.loads(line)
try:
full = resolve_image_path(item["img_path"], dataset_root)
except FileNotFoundError:
continue
if full not in paths:
paths.append(full)
if len(paths) >= n:
break
return paths
# ---------------------------------------------------------------------------
# Module path helpers (avoid radir / CTViT collisions between trees)
# ---------------------------------------------------------------------------
def _purge_modules(prefixes: Sequence[str]) -> None:
doomed = [
k
for k in list(sys.modules)
if any(k == p or k.startswith(p + ".") for p in prefixes)
]
for k in doomed:
del sys.modules[k]
def _push_paths(paths: Sequence[str]) -> list[str]:
old = list(sys.path)
for p in reversed(paths):
if p in sys.path:
sys.path.remove(p)
sys.path.insert(0, p)
return old
# ---------------------------------------------------------------------------
# Hyperbolic (RadIR/RadIR) encode + traverse
# ---------------------------------------------------------------------------
@torch.no_grad()
def run_hyperbolic(
image_paths: list[str],
concepts: list[str],
ckpt: str,
model_path: str,
device: str,
imsize: int = 224,
steps: int = 50,
text_batch: int = 64,
use_entailment: bool = True,
aperture_thresh: float = 1.2,
) -> tuple[list[list[str]], float]:
_purge_modules(["radir", "transformer_maskgit", "Rad_IR"])
rad_ir_root = str(ROOT / "Rad_IR")
# Inner package lives at ROOT/transformer_maskgit/transformer_maskgit/
old = _push_paths(
[str(ROOT), rad_ir_root, str(ROOT / "transformer_maskgit")]
)
try:
from transformers import AutoModel, AutoTokenizer
from radir import lorentz as L
from radir.RadIR import RADIR, l2norm
from transformer_maskgit.ctvit import CTViT
tokenizer = AutoTokenizer.from_pretrained(
model_path, trust_remote_code=True, local_files_only=True
)
text_model = AutoModel.from_pretrained(
model_path, trust_remote_code=True, local_files_only=True
)
image_encoder = CTViT(
dim=768,
codebook_size=8192,
image_size=imsize,
patch_size=16,
temporal_patch_size=10,
spatial_depth=8,
temporal_depth=6,
cls_depth=4,
dim_head=32,
heads=8,
channels=1,
)
model = RADIR(
tokenizer=tokenizer,
image_encoder=image_encoder,
text_encoder=text_model,
dim_text=768,
dim_image=512,
dim_latent=512,
use_mlm=False,
use_all_token_embeds=False,
)
model.load(ckpt)
model.to(device)
model.eval()
curv = model.get_curv().detach()
txt_feats = []
for i in range(0, len(concepts), text_batch):
batch = concepts[i : i + text_batch]
real = [t for t in batch if t != ROOT_LABEL]
feats_map: dict[str, torch.Tensor] = {}
if real:
tok = tokenizer(
real,
return_tensors="pt",
padding=True,
truncation=True,
max_length=128,
)
tok = {k: v.to(device) for k, v in tok.items()}
if "token_type_ids" not in tok:
tok["token_type_ids"] = torch.zeros_like(tok["input_ids"])
enc = model.text_transformer(
input_ids=tok["input_ids"],
token_type_ids=tok["token_type_ids"],
attention_mask=tok["attention_mask"],
)[0]
cls = enc[:, 0, :]
hyp = model.project_txt(cls)
for t, h in zip(real, hyp):
feats_map[t] = h
for t in batch:
if t == ROOT_LABEL:
dim = next(iter(feats_map.values())).shape[-1] if feats_map else 768
txt_feats.append(torch.zeros(dim, device=device))
else:
txt_feats.append(feats_map[t])
text_pool = torch.stack(txt_feats, dim=0).float()
root_idx = concepts.index(ROOT_LABEL) if ROOT_LABEL in concepts else -1
text_norms = torch.norm(text_pool, dim=-1)
text_aperture = None
if use_entailment:
text_aperture = L.half_aperture(text_pool, curv)
print(
f"Entailment filter ON (soft; aperture_thresh={aperture_thresh}, "
f"no hard radius gate — looser than training for traversal viz)"
)
all_paths: list[list[str]] = []
fallback_count = 0
total_steps = 0
for ip in image_paths:
img = load_image_tensor(ip, imsize=imsize, with_depth=False)
imgs = preprocess_hyp_batch(img.unsqueeze(0).to(device))
enc = model.visual_transformer(
imgs,
return_encoded_tokens=True,
modal_embedding=False,
modal_indexs=None,
is_condition=False,
)
if enc.dim() == 2:
image_global = l2norm(enc)
else:
image_global = l2norm(enc[:, 0, :])
z0 = model.project_img(image_global)
u0 = L.log_map0(z0.float(), curv)
labels: list[str] = []
ts = torch.linspace(1.0, 0.0, steps, device=device)
for t in ts:
u_t = (t * u0).float()
z_t = L.exp_map0(u_t, curv)
dist = L.pairwise_dist(z_t, text_pool, curv)[0]
total_steps += 1
if use_entailment and text_aperture is not None:
# MERU-style cone check, but softened for visualization:
# 1) wider aperture_thresh than training (default 1.2 vs 0.7)
# 2) soft penalty instead of hard mask (outside-cone texts can
# still win if much closer)
# 3) no hard text_norm <= z_norm gate (that collapsed mid-path
# to only [ROOT] in practice)
z_exp = z_t.expand_as(text_pool)
angle = L.oxy_angle(text_pool, z_exp, curv)
energy = torch.clamp(
angle - aperture_thresh * text_aperture, min=0.0
)
if root_idx >= 0:
energy[root_idx] = 0.0
# soft_weight ~1: energy (radians) added to hyperbolic dist
soft_weight = 1.0
scores = dist + soft_weight * energy
nn_idx = int(scores.argmin().item())
else:
nn_idx = int(dist.argmin().item())
labels.append(concepts[nn_idx])
all_paths.append(labels)
if use_entailment and total_steps > 0:
print(
f"Entailment: soft penalty (weight=1.0), "
f"aperture_thresh={aperture_thresh}"
)
curv_val = float(curv.item())
del model, text_model, image_encoder, text_pool
return all_paths, curv_val
finally:
sys.path[:] = old
_purge_modules(["radir", "transformer_maskgit", "Rad_IR"])
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
# ---------------------------------------------------------------------------
# Euclidean (RadIR_compare) encode + traverse
# ---------------------------------------------------------------------------
@torch.no_grad()
def run_euclidean(
image_paths: list[str],
concepts: list[str],
ckpt: str,
model_path: str,
device: str,
imsize: int = 480,
steps: int = 50,
text_batch: int = 32,
) -> list[list[str]]:
_purge_modules(["radir", "transformer_maskgit", "Rad_IR", "hf_model_paths"])
compare_scripts = str(COMPARE_ROOT / "scripts")
compare_rad = str(COMPARE_ROOT / "Rad_IR")
old = _push_paths(
[
str(COMPARE_ROOT),
compare_rad,
compare_scripts,
str(COMPARE_ROOT / "transformer_maskgit"),
]
)
try:
from transformers import BertModel, BertTokenizer
from radir.RadIR import RADIR, l2norm
from transformer_maskgit.ctvit import CTViT
tokenizer = BertTokenizer.from_pretrained(
model_path, local_files_only=True, do_lower_case=True
)
text_encoder = BertModel.from_pretrained(model_path, local_files_only=True)
image_encoder = CTViT(
dim=512,
codebook_size=8192,
image_size=imsize,
patch_size=20,
temporal_patch_size=10,
spatial_depth=8,
temporal_depth=6,
cls_depth=4,
dim_head=32,
heads=8,
)
model = RADIR(
image_encoder=image_encoder,
text_encoder=text_encoder,
tokenizer=tokenizer,
dim_text=768,
dim_image=512,
dim_latent=512,
use_mlm=False,
use_all_token_embeds=False,
)
model.load(ckpt)
model.to(device)
model.eval()
# Encode non-ROOT concepts only. On the unit sphere, ambient Euclidean
# distance to the origin always beats weakly-aligned texts, so ROOT must
# NOT compete via cdist — select it only when the query is near 0.
non_root = [t for t in concepts if t != ROOT_LABEL]
txt_feats = []
for i in range(0, len(non_root), text_batch):
batch = non_root[i : i + text_batch]
dummy = torch.zeros(len(batch), 1, 1, imsize, imsize, device=device)
tok = tokenizer(
batch,
return_tensors="pt",
padding="max_length",
truncation=True,
max_length=128,
).to(device)
text_latents, _, _, _ = model(
tok,
dummy,
return_latents=True,
device=device,
is_condition=False,
modal_embedding=False,
modal_indexs=None,
)
for h in text_latents:
txt_feats.append(h if h.ndim == 1 else h.squeeze(0))
text_pool = l2norm(torch.stack(txt_feats, dim=0))
# MERU-CLIP protocol: interpolate image → [ROOT] on the sphere.
# With root = 0, lerp+normalize keeps direction fixed for t>0, so NN
# is typically sticky (few unique concepts); last steps → [ROOT].
root_feat = torch.zeros(text_pool.shape[-1], device=device)
all_paths: list[list[str]] = []
for ip in image_paths:
img = load_image_tensor(ip, imsize=imsize, with_depth=True)
video = img.unsqueeze(0).to(device)
tok = tokenizer(
["chest radiograph"],
return_tensors="pt",
padding="max_length",
truncation=True,
max_length=128,
).to(device)
_, image_latents, _, _ = model(
tok,
video,
return_latents=True,
device=device,
is_condition=False,
modal_embedding=False,
modal_indexs=None,
)
z0 = image_latents
if z0.ndim == 1:
z0 = z0.unsqueeze(0)
z0 = l2norm(z0).squeeze(0)
# MERU interpolate(): lerp(root, image, w), w: 0→1, then flip
# so order is image-first → root-last.
weights = torch.linspace(0.0, 1.0, steps, device=device)
interp = torch.stack(
[torch.lerp(root_feat, z0, float(w.item())) for w in weights],
dim=0,
)
interp = F.normalize(interp, dim=-1).flip(0)
labels: list[str] = []
for i, z_t in enumerate(interp):
# Near the root end of the walk, emit [ROOT] (zero vector is
# unstable under normalize; matches CLIP figure ending at ROOT).
if i >= int(steps * 0.88):
labels.append(ROOT_LABEL)
continue
sims = (z_t.float() @ text_pool.float().T)
nn_idx = int(sims.argmax().item())
labels.append(non_root[nn_idx])
all_paths.append(labels)
del model, text_encoder, image_encoder, text_pool
return all_paths
finally:
sys.path[:] = old
_purge_modules(["radir", "transformer_maskgit", "Rad_IR", "hf_model_paths"])
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
# ---------------------------------------------------------------------------
# Path compression (MERU figure style) + min-depth helpers
# ---------------------------------------------------------------------------
def _norm_concept(s: str) -> str:
"""Normalize for near-duplicate detection (case / plural / spacing)."""
s = re.sub(r"\s+", " ", s.strip().lower())
toks = []
for t in s.split():
if len(t) > 4 and t.endswith("ies"):
t = t[:-3] + "y" # abnormalities -> abnormality
elif len(t) > 3 and t.endswith("s") and not t.endswith(
("ss", "ous", "us", "is", "as", "ies")
):
t = t[:-1]
toks.append(t)
return " ".join(toks)
def _pick_unused(
scores: torch.Tensor,
concepts: Sequence[str],
used: set[str],
root_idx: int = -1,
higher_is_better: bool = False,
) -> int | None:
"""Argmin/argmax among unused non-ROOT concepts."""
s = scores.clone()
ban = float("-inf") if higher_is_better else float("inf")
if root_idx >= 0:
s[root_idx] = ban
for i, c in enumerate(concepts):
if c == ROOT_LABEL or _norm_concept(c) in used:
s[i] = ban
if higher_is_better:
if not torch.isfinite(s).any():
return None
return int(s.argmax().item())
if not torch.isfinite(s).any():
return None
return int(s.argmin().item())
def unique_body_from_score_rows(
score_rows: torch.Tensor,
concepts: Sequence[str],
root_idx: int,
min_body: int,
higher_is_better: bool = False,
) -> list[str]:
"""
Build an ordered unique concept list along a traversal.
1) Greedy: at each time step take nearest unused concept.
2) If fewer than min_body, re-sample evenly in time (stratified) forcing
unused picks so the figure can show ≥ min_concepts levels.
"""
t_steps = int(score_rows.shape[0])
used: set[str] = set()
ordered: list[str] = []
for ti in range(t_steps):
idx = _pick_unused(
score_rows[ti],
concepts,
used,
root_idx=root_idx,
higher_is_better=higher_is_better,
)
if idx is None:
continue
lab = concepts[idx]
key = _norm_concept(lab)
if key in used:
continue
used.add(key)
ordered.append(lab)
if len(ordered) >= min_body:
return ordered
# Stratified fill: force one new concept per evenly spaced geodesic step.
used = set()
ordered = []
# Slightly more candidate slots than needed in case of ties / empties
n_slots = max(min_body, min(min_body + 2, t_steps))
slot_ids = np.linspace(0, t_steps - 1, n_slots)
slot_ids = sorted(set(int(round(i)) for i in slot_ids))
for ti in slot_ids:
idx = _pick_unused(
score_rows[ti],
concepts,
used,
root_idx=root_idx,
higher_is_better=higher_is_better,
)
if idx is None:
continue
lab = concepts[idx]
used.add(_norm_concept(lab))
ordered.append(lab)
if len(ordered) >= min_body:
break
# Last-resort: dump next-best at the first / mid / last rows
if len(ordered) < min_body:
for ti in (0, t_steps // 2, t_steps - 1):
idx = _pick_unused(
score_rows[ti],
concepts,
used,
root_idx=root_idx,
higher_is_better=higher_is_better,
)
if idx is None:
continue
lab = concepts[idx]
used.add(_norm_concept(lab))
ordered.append(lab)
if len(ordered) >= min_body:
break
return ordered
def synthesize_dense_labels(body: list[str], steps: int) -> list[str]:
"""Repeat each body label so compress_traversal can subsample evenly."""
if not body:
return [ROOT_LABEL] * max(steps, 1)
labels: list[str] = []
per = max(1, steps // len(body))
for lab in body:
labels.extend([lab] * per)
while len(labels) < steps:
labels.append(body[-1])
labels = labels[:steps]
# Reserve last few steps for ROOT (MERU ends at origin)
n_root = max(1, steps // 10)
labels[-n_root:] = [ROOT_LABEL] * n_root
return labels
def compress_traversal(
labels: list[str],
keep: int = 6,
) -> list[str]:
"""Dedup consecutive (near-)identical labels; keep up to `keep` steps + [ROOT]."""
dedup: list[str] = []
for lab in labels:
if lab == ROOT_LABEL:
continue
if not dedup:
dedup.append(lab)
continue
# exact or near-duplicate of previous (e.g. abnormality / abnormalities)
if lab == dedup[-1] or _norm_concept(lab) == _norm_concept(dedup[-1]):
continue
dedup.append(lab)
if not dedup:
return [ROOT_LABEL]
# keep includes [ROOT]; body slots = keep - 1
body_keep = max(1, keep - 1)
if len(dedup) > body_keep:
idx = np.linspace(0, len(dedup) - 1, body_keep)
idx = sorted(set(int(round(i)) for i in idx))
dedup = [dedup[i] for i in idx]
dedup.append(ROOT_LABEL)
return dedup
def align_two_paths(
left: list[str],
right: list[str],
) -> tuple[list[str], list[str]]:
def split(p: list[str]) -> tuple[list[str], str]:
if p and p[-1] == ROOT_LABEL:
return p[:-1], ROOT_LABEL
return list(p), ROOT_LABEL
l_body, _ = split(left)
r_body, _ = split(right)
n = max(len(l_body), len(r_body), 1)
def pad(body: list[str], n: int) -> list[str]:
if len(body) >= n:
return body[:n]
if not body:
return ["↓"] * n
return body + ["↓"] * (n - len(body))
return pad(l_body, n) + [ROOT_LABEL], pad(r_body, n) + [ROOT_LABEL]
# ---------------------------------------------------------------------------
# Visualization
# ---------------------------------------------------------------------------
from matplotlib.font_manager import FontProperties
# Liberation Serif ≈ Times New Roman (metric-compatible on Linux).
_HEADER_FONT = FontProperties(family="Liberation Serif", weight="bold", size=10)
_TITLE_FONT = FontProperties(family="Liberation Serif", weight="normal", size=12.5)
def _cell_color(row: int, n_rows: int) -> tuple[float, float, float]:
"""Single-hue soft slate → paper white (smooth, low-chroma)."""
if n_rows <= 1:
t = 1.0
else:
t = row / (n_rows - 1)
r = 0.788 + 0.164 * t
g = 0.839 + 0.124 * t
b = 0.886 + 0.086 * t
return (r, g, b)
def _wrap_label(lab: str, width: int = 24) -> str:
"""Prefer fewer, wider lines so text fills horizontal space and stays in-cell."""
if lab in ("↓", "▼", ROOT_LABEL) or len(lab) <= width:
return lab
lines = textwrap.wrap(
lab, width=width, break_long_words=False, break_on_hyphens=True
)
out = []
for line in lines:
if len(line) <= width + 2:
out.append(line)
else:
out.extend(
textwrap.wrap(
line, width=width, break_long_words=True, break_on_hyphens=True
)
)
return "\n".join(out)
def _label_font(lab: str, wrapped: str) -> FontProperties:
if lab == "↓":
# Compact arrow, not dominating the cell
return FontProperties(family="DejaVu Sans", weight="normal", size=6)
if lab == ROOT_LABEL:
return FontProperties(
family="Liberation Serif", style="normal", weight="normal", size=6.0
)
n_lines = wrapped.count("\n") + 1
longest = max(len(line) for line in wrapped.split("\n"))
if n_lines >= 3:
size = 5.6
elif n_lines == 2 or longest >= 22:
size = 6.2
else:
size = 6.8
return FontProperties(
family="Liberation Serif", style="italic", weight="normal", size=size
)
def plot_traversal_panel(
image_paths: list[str],
hyp_paths: list[list[str]],
euc_paths: list[list[str]],
out_path: str,
left_name: str = "HMClip",
right_name: str = "RadIR",
keep: int = 6,
keep_right: int | None = None,
dpi: int = 180,
) -> None:
n = len(image_paths)
fig_w = max(4.0 * n, 9.0)
keep_euc = keep if keep_right is None else keep_right
aligned = []
max_rows = 1
for h, e in zip(hyp_paths, euc_paths):
a, b = align_two_paths(
compress_traversal(h, keep=keep),
compress_traversal(e, keep=keep_euc),
)
aligned.append((a, b))
max_rows = max(max_rows, len(a))
row_h_in = 0.46 # taller cells so multi-line text has vertical room
header_h_in = 0.32
img_h_in = 1.85
fig_h = img_h_in + header_h_in + row_h_in * max_rows + 0.5
fig, axes = plt.subplots(1, n, figsize=(fig_w, fig_h))
if n == 1:
axes = [axes]
from mpl_toolkits.axes_grid1.inset_locator import inset_axes
for ax, ip, (left_labs, right_labs) in zip(axes, image_paths, aligned):
ax.set_axis_off()
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
img = Image.open(ip).convert("L")
img_frac = img_h_in / fig_h
img_ax = inset_axes(
ax,
width="92%",
height=f"{img_frac * 100 * 0.92:.1f}%",
loc="upper center",
borderpad=0.25,
)
img_ax.imshow(img, cmap="gray")
img_ax.set_xticks([])
img_ax.set_yticks([])
for spine in img_ax.spines.values():
spine.set_visible(True)
spine.set_linewidth(0.6)
spine.set_color("0.4")
n_rows = len(left_labs)
header_h = header_h_in / fig_h
row_h = row_h_in / fig_h
table_top = 1.0 - img_frac - 0.02
col_w = 0.47
left_x0, right_x0 = 0.025, 0.505
for x0, name in ((left_x0, left_name), (right_x0, right_name)):
ax.add_patch(
FancyBboxPatch(
(x0, table_top - header_h),
col_w,
header_h,
boxstyle="square,pad=0",
facecolor="#A8B4C0",
edgecolor="#3D4650",
linewidth=0.8,
transform=ax.transAxes,
clip_on=False,
)
)
ax.text(
x0 + col_w / 2,
table_top - header_h / 2,
name,
ha="center",
va="center",
fontproperties=_HEADER_FONT,
color="#1A1A1A",
transform=ax.transAxes,
)
for r, (ll, rl) in enumerate(zip(left_labs, right_labs)):
y = table_top - header_h - (r + 1) * row_h
color = _cell_color(r, n_rows)
for x0, lab in ((left_x0, ll), (right_x0, rl)):
ax.add_patch(
FancyBboxPatch(
(x0, y),
col_w,
row_h,
boxstyle="square,pad=0",
facecolor=color,
edgecolor="#6B7682",
linewidth=0.7,
transform=ax.transAxes,
clip_on=False,
)
)
# Use thin ↓ (not heavy ▼); wrap wide to use horizontal space
if lab == "↓":
wrapped = "↓"
else:
wrapped = _wrap_label(lab, width=24)
fp = _label_font(lab, wrapped)
# More vertical inset, less horizontal (use cell width)
inset_x = 0.012
inset_y = 0.035 * (row_h / (row_h_in / fig_h) if row_h > 0 else 1)
inset_y = max(0.018, min(0.028, row_h * 0.12))
txt = ax.text(
x0 + col_w / 2,
y + row_h / 2,
wrapped,
ha="center",
va="center",
fontproperties=fp,
color="#1F2933",
transform=ax.transAxes,
linespacing=0.98,
zorder=3,
)
clip = FancyBboxPatch(
(x0 + inset_x, y + inset_y),
col_w - 2 * inset_x,
row_h - 2 * inset_y,
boxstyle="square,pad=0",
transform=ax.transAxes,
facecolor="none",
edgecolor="none",
)
clip.set_visible(False)
ax.add_patch(clip)
txt.set_clip_path(clip)
fig.suptitle(
"Image traversals with HMClip (hyperbolic) and RadIR (Euclidean)",
fontproperties=_TITLE_FONT,
y=0.995,
)
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
fig.savefig(out_path, dpi=dpi, bbox_inches="tight", facecolor="white")
plt.close(fig)
print(f"Saved traversal panel to {out_path}")
def save_json_summary(
out_json: str,
image_paths: list[str],
hyp_raw: list[list[str]],
euc_raw: list[list[str]],
keep: int = 6,
keep_right: int | None = None,
) -> None:
keep_euc = keep if keep_right is None else keep_right
payload = []
for ip, h, e in zip(image_paths, hyp_raw, euc_raw):
hc = compress_traversal(h, keep=keep)
ec = compress_traversal(e, keep=keep_euc)
la, ra = align_two_paths(hc, ec)
payload.append(
{
"image": ip,
"radir_hyp": la,
"radir_compare": ra,
"hyp_unique_all": compress_traversal(h, keep=30),
"compare_unique_all": compress_traversal(e, keep=30),
}
)
os.makedirs(os.path.dirname(os.path.abspath(out_json)) or ".", exist_ok=True)
with open(out_json, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
print(f"Saved JSON summary to {out_json}")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args():
p = argparse.ArgumentParser(description="MERU-style HMClip vs RadIR_compare traversals")
p.add_argument("--images", nargs="*", default=None, help="Query image paths")
p.add_argument("--auto_pick", type=int, default=0, help="Auto-pick N diverse CXRs")
p.add_argument("--device", default="cuda:0")
p.add_argument("--hyp_ckpt", default=str(ROOT / "outputs" / "radir_best.pt"))
p.add_argument(
"--euc_ckpt",
default=str(
COMPARE_ROOT / "outputs" / "logs" / "CXRCT_UNI_stage1" / "Rad_IR.12000.pt"
),
)
p.add_argument(
"--hyp_bert",
default=str(
ROOT / "hf_models" / "microsoft" / "BiomedVLP-CXR-BERT-specialized"
),
)
p.add_argument(
"--euc_bert",
default=str(
COMPARE_ROOT / "hf_models" / "microsoft" / "BiomedVLP-CXR-BERT-specialized"
),
)
p.add_argument("--dataset_root", default=str(ROOT / "dataset"))
p.add_argument("--jsonl", default=str(ROOT / "dataset" / "valid.jsonl"))
p.add_argument(
"--labels_csv",
default=str(ROOT / "outputs" / "tsne_disease_labels.csv"),
)
p.add_argument(
"--concepts_file",
default=str(ROOT / "scripts" / "traversal_concepts.txt"),
)
p.add_argument(
"--extra_captions",
type=int,
default=0,
help="Optionally add N short first-sentences from valid.jsonl (0 = concepts file only)",
)
p.add_argument("--steps", type=int, default=50)
p.add_argument(
"--keep",
type=int,
default=6,
help="Max concepts shown per HMClip column including [ROOT] (subsample if longer)",
)
p.add_argument("--hyp_imsize", type=int, default=224)
p.add_argument("--euc_imsize", type=int, default=480)
p.add_argument(
"--entailment",
dest="entailment",
action="store_true",
default=True,
help="Hyperbolic: filter pool by entailment cone before NN (MERU-style, default on)",
)
p.add_argument(
"--no_entailment",
dest="entailment",
action="store_false",
help="Hyperbolic: pure distance NN (no cone filter)",
)
p.add_argument(
"--aperture_thresh",
type=float,
default=1.2,
help="Entailment aperture multiplier (training uses ~0.7; higher = weaker filter)",
)
p.add_argument("--out", default=str(ROOT / "outputs" / "traversals_compare.png"))
p.add_argument(
"--out_json",
default=str(ROOT / "outputs" / "traversals_compare.json"),
)
p.add_argument("--dpi", type=int, default=160)
p.add_argument("--seed", type=int, default=42)
return p.parse_args()
def main():
args = parse_args()
device = args.device
if device.startswith("cuda") and not torch.cuda.is_available():
print("CUDA not available, falling back to CPU")
device = "cpu"
print(f"COMPARE_ROOT = {COMPARE_ROOT}")
if args.images:
image_paths = [resolve_image_path(p, args.dataset_root) for p in args.images]
else:
n = args.auto_pick if args.auto_pick > 0 else 4
print(f"Auto-picking {n} diverse CXRs ...")
image_paths = auto_pick_images(
args.dataset_root,
args.labels_csv,
args.jsonl,
n=n,
seed=args.seed,
)
print("Query images:")
for p in image_paths:
print(f" - {p}")
concepts = load_concept_pool(
args.concepts_file,
extra_captions=args.extra_captions,
caption_jsonl=args.jsonl,
seed=args.seed,
)
print(f"Concept pool size: {len(concepts)} (includes {ROOT_LABEL})")
# Same keep for both sides (natural unique paths; ↓ only if one is shorter).
keep = args.keep
print("\n=== Hyperbolic RadIR/RadIR ===")
hyp_paths, curv = run_hyperbolic(
image_paths,
concepts,
ckpt=args.hyp_ckpt,
model_path=args.hyp_bert,
device=device,
imsize=args.hyp_imsize,
steps=args.steps,
use_entailment=args.entailment,
aperture_thresh=args.aperture_thresh,
)
print(f"curvature={curv:.6f}")
for ip, labs in zip(image_paths, hyp_paths):
print(
f" {os.path.basename(ip)}: "
f"{' → '.join(compress_traversal(labs, keep=keep))}"
)
print("\n=== Euclidean RadIR_compare ===")
euc_paths = run_euclidean(
image_paths,
concepts,
ckpt=args.euc_ckpt,
model_path=args.euc_bert,
device=device,
imsize=args.euc_imsize,
steps=args.steps,
)
for ip, labs in zip(image_paths, euc_paths):
print(
f" {os.path.basename(ip)}: "
f"{' → '.join(compress_traversal(labs, keep=keep))}"
)
plot_traversal_panel(
image_paths,
hyp_paths,
euc_paths,
out_path=args.out,
keep=keep,
dpi=args.dpi,
)
save_json_summary(
args.out_json,
image_paths,
hyp_paths,
euc_paths,
keep=keep,
)
if __name__ == "__main__":
main()