#!/usr/bin/env python # -*- coding: utf-8 -*- """ Real vs. synthetic disease-mask comparison for AURAD. Nature / NPG (ggsci) style. Two improvements over the original two-panel t-SNE: 1) SINGLE shared embedding, plotted in ONE panel: color = disease marker = source (real = filled dot, syn = open ring) so you can directly see whether each disease's real & syn points co-localize. 2) Better disease separation, via (pick with EMBED_METHOD): "tsne" - PCA -> t-SNE (unsupervised) "umap" - PCA -> UMAP (unsupervised, tighter clusters) "umap_supervised" - PCA -> UMAP supervised on DISEASE labels only (source/real-syn is NEVER given to the embedding, so real<->syn overlap stays an emergent signal) Plus an optional Gaussian blur on the masks (BLUR_SIGMA) so that "nearby location" becomes a smooth similarity instead of 0/1 overlap. 3) Quantitative co-clustering evidence (COMPUTE_METRICS), per disease: - C2ST AUC : classifier two-sample test, ~0.5 = real/syn indistinguishable - MMD^2 (RBF) : distribution discrepancy, lower = closer - norm. centroid d : Mahalanobis-normalized real<->syn centroid distance - disease silhouette: how separable the diseases are (combined real+syn) Saved to _metrics.csv and printed. JSON record example (list of these, or one JSON per line): { "file_name": "chestdet/dataset/test/39796/39796.png", "mask": "chestdet/dataset/test/39796/mask_Consolidation_39796.png", "attn_list": [["Consolidation", "chestdet/dataset/test/39796/39796_Consolidation.png"]], ... } Real mask = REAL_ROOT / record["mask"] (REAL_MASK_SOURCE = "mask") Synth mask = SYN_ROOT / attn_list[i][1] (SYN_MASK_SOURCE = "attn") """ import os import json import argparse import numpy as np from PIL import Image from scipy.ndimage import gaussian_filter from scipy.spatial.distance import cdist from sklearn.decomposition import PCA from sklearn.manifold import TSNE from sklearn.metrics import silhouette_score from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline from sklearn.model_selection import cross_val_score, StratifiedKFold import matplotlib as mpl import matplotlib.pyplot as plt from matplotlib.lines import Line2D # ----------------------------- CONFIG ----------------------------------- JSON_PATH = "data.json" REAL_ROOT = "real_masks" SYN_ROOT = "syn_masks" # How to build the relative mask path (joined with the corresponding root): # "file_name" -> record["file_name"] (the raw CXR, NOT a mask) # "mask" -> record["mask"] (real disease mask) <-- real # "attn" -> attn_list[i][1] (syn disease mask, per disease) <-- syn REAL_MASK_SOURCE = "mask" SYN_MASK_SOURCE = "attn" TARGET_DISEASES = [ "Cardiomegaly", "Pneumothorax", "Consolidation", "Effusion", "Atelectasis", ] ATTN_KEY = "attn_list" # ---- feature extraction ---- MASK_SIZE = 64 # downsample masks to MASK_SIZE x MASK_SIZE BLUR_SIGMA = 2.0 # Gaussian blur sigma on the mask before flatten; 0 = off # ---- embedding ---- EMBED_METHOD = "umap" # "tsne" | "umap" | "umap_supervised" PCA_DIM = 50 TSNE_PERPLEXITY = 30 UMAP_NEIGHBORS = 30 UMAP_MIN_DIST = 0.40 # larger -> clusters spread into visible clouds (less dot-in-whitespace) UMAP_SPREAD = 1.30 # scales overall spacing; raise together with min_dist RANDOM_STATE = 42 # ---- plotting ---- SYN_MARKER = "x" # how to draw synthetic points: "x" | "plus" | "ring" # "x"/"plus" read clearly on top of filled real dots (best when dense) # "ring" = same-color open circle (cleaner when sparse) DRAW_KDE = False # overlay per-disease density contours (real solid / syn dashed) ALLOW_MULTI = False # if a record lists >1 target disease: False=first only, True=duplicate MAX_PER_GROUP = None # cap points per (disease, source); None = all # ---- metrics ---- COMPUTE_METRICS = True OUT_PREFIX = "real_vs_syn" # ------------------------------------------------------------------------ # NPG categorical palette — first 5 colors (matches the order of TARGET_DISEASES). NPG_PALETTE = ["#E64B35", "#4DBBD5", "#00A087", "#3C5488", "#F39B7F"] def set_nature_style(): mpl.rcParams.update({ "font.family": "sans-serif", "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans"], "font.size": 8, "axes.linewidth": 0.8, "axes.spines.top": False, "axes.spines.right": False, "axes.titlesize": 9, "axes.titleweight": "bold", "axes.labelsize": 8, "xtick.major.width": 0.8, "ytick.major.width": 0.8, "xtick.major.size": 3, "ytick.major.size": 3, "legend.frameon": False, "legend.fontsize": 7.5, "svg.fonttype": "none", "pdf.fonttype": 42, }) # --------------------------- data loading ------------------------------- def load_mask_vector(path, size, blur_sigma=0.0): """Load mask -> grayscale -> resize -> (optional blur) -> /255 -> flatten.""" with Image.open(path) as im: im = im.convert("L").resize((size, size), Image.BILINEAR) arr = np.asarray(im, dtype=np.float32) / 255.0 if blur_sigma and blur_sigma > 0: arr = gaussian_filter(arr, sigma=blur_sigma) return arr.reshape(-1) def parse_attn(attn_list): if not isinstance(attn_list, (list, tuple)): return for entry in attn_list: if isinstance(entry, (list, tuple)) and len(entry) >= 1: name = entry[0] path = entry[1] if len(entry) >= 2 else None else: name, path = entry, None yield name, path def record_items(rec, target_set, allow_multi, mask_source): """[(relative_mask_path, disease), ...] for one record per mask_source.""" diseases = [(n, p) for n, p in parse_attn(rec.get(ATTN_KEY, [])) if n in target_set] if not diseases: return [] if mask_source == "attn": return [(p, n) for n, p in diseases if p] rel = rec.get(mask_source) if not rel: return [] labels = [n for n, _ in diseases] labels = labels if allow_multi else labels[:1] return [(rel, n) for n in labels] def collect(records, root, source_tag, target_set, size, allow_multi, mask_source, blur_sigma): feats, diseases, missing, no_label = [], [], 0, 0 for rec in records: items = record_items(rec, target_set, allow_multi, mask_source) if not items: no_label += 1 continue for rel, dis in items: mpath = os.path.join(root, rel) if not os.path.isfile(mpath): missing += 1 continue try: vec = load_mask_vector(mpath, size, blur_sigma) except Exception as e: print(f" [warn] failed to read {mpath}: {e}") missing += 1 continue feats.append(vec) diseases.append(dis) print(f" [{source_tag}] loaded={len(feats)} missing_files={missing} " f"no_target_label={no_label}") return feats, diseases def subsample(feats, diseases, sources, max_per_group, seed): if not max_per_group: return feats, diseases, sources rng = np.random.default_rng(seed) keep, groups = [], {} for i, (d, s) in enumerate(zip(diseases, sources)): groups.setdefault((d, s), []).append(i) for idxs in groups.values(): if len(idxs) > max_per_group: idxs = list(rng.choice(idxs, size=max_per_group, replace=False)) keep.extend(idxs) keep.sort() return ([feats[i] for i in keep], [diseases[i] for i in keep], [sources[i] for i in keep]) def load_records(path): """Robust loader: whole-file JSON (array or dict) OR JSONL (one record/line).""" with open(path, "r") as f: text = f.read() # Try the whole file as a single JSON value first. try: data = json.loads(text) if isinstance(data, dict): return list(data.values()) if isinstance(data, list): return data return [data] except json.JSONDecodeError: pass # Fall back to line-delimited JSON (JSONL). records = [] for line in text.splitlines(): line = line.strip() if line: records.append(json.loads(line)) return records # ----------------------------- embedding -------------------------------- def embed(X, diseases, method, seed, n_neighbors=UMAP_NEIGHBORS, min_dist=UMAP_MIN_DIST, spread=UMAP_SPREAD): """PCA -> {t-SNE | UMAP | supervised UMAP}. Returns (N,2) array.""" n_comp = min(PCA_DIM, X.shape[0] - 1, X.shape[1]) X_red = PCA(n_components=n_comp, random_state=seed).fit_transform(X) if method == "tsne": perp = min(TSNE_PERPLEXITY, max(5, (X.shape[0] - 1) // 3)) print(f" t-SNE (perplexity={perp})") emb = TSNE(n_components=2, perplexity=perp, init="pca", learning_rate="auto", random_state=seed).fit_transform(X_red) return emb, X_red try: import umap except ImportError: raise SystemExit("UMAP not installed. Run: pip install umap-learn\n" "or set EMBED_METHOD = 'tsne'.") if method == "umap": print(f" UMAP (n_neighbors={n_neighbors}, min_dist={min_dist}, spread={spread})") reducer = umap.UMAP(n_neighbors=n_neighbors, min_dist=min_dist, spread=spread, random_state=seed) emb = reducer.fit_transform(X_red) elif method == "umap_supervised": print(f" UMAP supervised on DISEASE only " f"(n_neighbors={n_neighbors}, min_dist={min_dist}, spread={spread})") y = np.array([TARGET_DISEASES.index(d) for d in diseases]) reducer = umap.UMAP(n_neighbors=n_neighbors, min_dist=min_dist, spread=spread, random_state=seed) emb = reducer.fit_transform(X_red, y=y) # source NOT passed in else: raise SystemExit(f"Unknown EMBED_METHOD: {method}") return emb, X_red # ------------------------------ metrics --------------------------------- def rbf_mmd2(A, B): """Biased RBF-MMD^2 with the median-distance heuristic bandwidth.""" if len(A) < 2 or len(B) < 2: return float("nan") XX = cdist(A, A, "sqeuclidean") YY = cdist(B, B, "sqeuclidean") XY = cdist(A, B, "sqeuclidean") pooled = np.concatenate([XX[np.triu_indices_from(XX, 1)], YY[np.triu_indices_from(YY, 1)], XY.ravel()]) med = np.median(pooled) gamma = 1.0 / (med + 1e-12) k = lambda D: np.exp(-gamma * D) mmd2 = k(XX).mean() + k(YY).mean() - 2.0 * k(XY).mean() return float(max(mmd2, 0.0)) def c2st_auc(A, B, seed): """Classifier two-sample test: 5-fold CV AUC for real-vs-syn. ~0.5 = good.""" n = min(len(A), len(B)) if n < 5: return float("nan") Xc = np.vstack([A, B]) yc = np.concatenate([np.zeros(len(A)), np.ones(len(B))]).astype(int) folds = min(5, np.bincount(yc).min()) if folds < 2: return float("nan") clf = make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)) cv = StratifiedKFold(n_splits=folds, shuffle=True, random_state=seed) return float(cross_val_score(clf, Xc, yc, cv=cv, scoring="roc_auc").mean()) def norm_centroid_dist(A, B): """||mu_A - mu_B|| / sqrt(0.5*(tr cov_A + tr cov_B)). Scale-free.""" if len(A) < 2 or len(B) < 2: return float("nan") d = np.linalg.norm(A.mean(0) - B.mean(0)) spread = np.sqrt(0.5 * (A.var(0).sum() + B.var(0).sum())) + 1e-12 return float(d / spread) def compute_metrics(X_red, diseases, sources, seed): rows = [] for d in TARGET_DISEASES: m = diseases == d A = X_red[m & (sources == "real")] B = X_red[m & (sources == "syn")] rows.append({ "disease": d, "n_real": int(len(A)), "n_syn": int(len(B)), "c2st_auc": c2st_auc(A, B, seed), "mmd2": rbf_mmd2(A, B), "norm_centroid_dist": norm_centroid_dist(A, B), }) # disease separability (combined real+syn) in feature space dis_int = np.array([TARGET_DISEASES.index(d) for d in diseases]) sil = float(silhouette_score(X_red, dis_int)) if len(set(dis_int)) > 1 else float("nan") return rows, sil def print_metrics(rows, sil): print("\n" + "=" * 64) print(f"{'disease':14s} {'n_real':>6s} {'n_syn':>6s} " f"{'C2ST':>6s} {'MMD^2':>8s} {'cdist':>7s}") print("-" * 64) aucs = [] for r in rows: auc = r["c2st_auc"] if not np.isnan(auc): aucs.append(auc) print(f"{r['disease']:14s} {r['n_real']:6d} {r['n_syn']:6d} " f"{auc:6.3f} {r['mmd2']:8.4f} {r['norm_centroid_dist']:7.3f}") print("-" * 64) if aucs: print(f"mean C2ST AUC = {np.mean(aucs):.3f} (0.5 = real/syn indistinguishable)") print(f"disease silhouette (real+syn) = {sil:.3f}") print("=" * 64 + "\n") def save_metrics_csv(rows, sil, path): import csv with open(path, "w", newline="") as f: w = csv.DictWriter(f, fieldnames=list(rows[0].keys())) w.writeheader() for r in rows: w.writerow(r) f.write(f"# disease_silhouette_real_plus_syn,{sil:.6f}\n") print(f"Saved metrics: {path}") # ------------------------------ plotting -------------------------------- def add_kde_contours(ax, emb, diseases, sources, color_map): """Per-disease density contour: real solid, syn dashed (single level).""" from scipy.stats import gaussian_kde xmin, xmax = emb[:, 0].min(), emb[:, 0].max() ymin, ymax = emb[:, 1].min(), emb[:, 1].max() xx, yy = np.mgrid[xmin:xmax:120j, ymin:ymax:120j] grid = np.vstack([xx.ravel(), yy.ravel()]) for d in TARGET_DISEASES: for src, ls in [("real", "-"), ("syn", "--")]: m = (diseases == d) & (sources == src) if m.sum() < 8: continue try: kde = gaussian_kde(emb[m].T) z = kde(grid).reshape(xx.shape) ax.contour(xx, yy, z, levels=[z.max() * 0.30], colors=[color_map[d]], linewidths=0.7, linestyles=ls, alpha=0.55) except Exception: pass def plot_overlay(emb, diseases, sources, color_map, axis_label, subtitle, out_prefix, draw_kde=False, syn_marker="x"): fig, ax = plt.subplots(figsize=(5.2, 5.4), dpi=150) for d in TARGET_DISEASES: c = color_map[d] mr = (diseases == d) & (sources == "real") ms = (diseases == d) & (sources == "syn") # real = filled dot ax.scatter(emb[mr, 0], emb[mr, 1], s=15, c=c, marker="o", linewidths=0, alpha=0.55, rasterized=True, zorder=2) # syn = high-contrast marker drawn on top if syn_marker == "ring": ax.scatter(emb[ms, 0], emb[ms, 1], s=30, facecolors="none", edgecolors=c, marker="o", linewidths=0.9, alpha=0.9, rasterized=True, zorder=3) else: mk = "x" if syn_marker == "x" else "+" ax.scatter(emb[ms, 0], emb[ms, 1], s=20, c=c, marker=mk, linewidths=0.9, alpha=0.85, rasterized=True, zorder=3) if draw_kde: add_kde_contours(ax, emb, diseases, sources, color_map) # ---- tight, balanced canvas: equal aspect + small symmetric padding ---- ax.set_aspect("equal", adjustable="box") xr = emb[:, 0].max() - emb[:, 0].min() yr = emb[:, 1].max() - emb[:, 1].min() pad = 0.06 * max(xr, yr) ax.set_xlim(emb[:, 0].min() - pad, emb[:, 0].max() + pad) ax.set_ylim(emb[:, 1].min() - pad, emb[:, 1].max() + pad) ax.set_xticks([]); ax.set_yticks([]) ax.set_xlabel(f"{axis_label} 1") ax.set_ylabel(f"{axis_label} 2") if subtitle: ax.set_title(subtitle, fontsize=8, fontweight="normal", color="0.35") # ---- legends below the plot (no overlap with data) ---- dis_handles = [Line2D([0], [0], marker="o", linestyle="", markersize=5, markerfacecolor=color_map[d], markeredgecolor="none", label=d) for d in TARGET_DISEASES] if syn_marker == "ring": syn_h = Line2D([0], [0], marker="o", linestyle="", markersize=6, markerfacecolor="none", markeredgecolor="0.3", label="Synthetic") else: syn_h = Line2D([0], [0], marker=("x" if syn_marker == "x" else "+"), linestyle="", markersize=6, color="0.3", label="Synthetic") src_handles = [ Line2D([0], [0], marker="o", linestyle="", markersize=5, markerfacecolor="0.3", markeredgecolor="none", label="Real"), syn_h, ] leg1 = fig.legend(handles=dis_handles, loc="lower center", bbox_to_anchor=(0.5, 0.04), ncol=5, columnspacing=1.0, handletextpad=0.3) fig.add_artist(leg1) fig.legend(handles=src_handles, loc="lower center", bbox_to_anchor=(0.5, -0.01), ncol=2, columnspacing=1.5, handletextpad=0.3) fig.tight_layout(rect=[0, 0.10, 1, 1]) png, svg = f"{out_prefix}.png", f"{out_prefix}.svg" fig.savefig(png, dpi=300, bbox_inches="tight") fig.savefig(svg, bbox_inches="tight") print(f"Saved figure: {png} / {svg}") # -------------------------------- main ---------------------------------- def main(): ap = argparse.ArgumentParser() ap.add_argument("--json", default=JSON_PATH) ap.add_argument("--real_root", default=REAL_ROOT) ap.add_argument("--syn_root", default=SYN_ROOT) ap.add_argument("--out", default=OUT_PREFIX) ap.add_argument("--method", default=EMBED_METHOD, choices=["tsne", "umap", "umap_supervised"]) ap.add_argument("--blur", type=float, default=BLUR_SIGMA) ap.add_argument("--min_dist", type=float, default=UMAP_MIN_DIST) ap.add_argument("--spread", type=float, default=UMAP_SPREAD) ap.add_argument("--n_neighbors", type=int, default=UMAP_NEIGHBORS) ap.add_argument("--syn_marker", default=SYN_MARKER, choices=["x", "plus", "ring"]) ap.add_argument("--kde", action="store_true", default=DRAW_KDE) ap.add_argument("--no_metrics", action="store_true") args = ap.parse_args() set_nature_style() target_set = set(TARGET_DISEASES) records = load_records(args.json) print(f"Loaded {len(records)} records from {args.json} " f"(real={REAL_MASK_SOURCE}, syn={SYN_MASK_SOURCE}, " f"method={args.method}, blur={args.blur})") print("Reading masks...") real_f, real_d = collect(records, args.real_root, "real", target_set, MASK_SIZE, ALLOW_MULTI, REAL_MASK_SOURCE, args.blur) syn_f, syn_d = collect(records, args.syn_root, "syn", target_set, MASK_SIZE, ALLOW_MULTI, SYN_MASK_SOURCE, args.blur) feats = real_f + syn_f diseases = real_d + syn_d sources = ["real"] * len(real_f) + ["syn"] * len(syn_f) if len(feats) < 10: raise SystemExit("Too few masks loaded — check paths / MASK_SOURCE / disease names.") feats, diseases, sources = subsample(feats, diseases, sources, MAX_PER_GROUP, RANDOM_STATE) X = np.stack(feats).astype(np.float32) diseases = np.array(diseases) sources = np.array(sources) print(f"Feature matrix: {X.shape}") print("Embedding...") emb, X_red = embed(X, diseases, args.method, RANDOM_STATE, n_neighbors=args.n_neighbors, min_dist=args.min_dist, spread=args.spread) color_map = {d: NPG_PALETTE[i] for i, d in enumerate(TARGET_DISEASES)} axis_label = "t-SNE" if args.method == "tsne" else "UMAP" subtitle = None if COMPUTE_METRICS and not args.no_metrics: print("Computing metrics...") rows, sil = compute_metrics(X_red, diseases, sources, RANDOM_STATE) print_metrics(rows, sil) save_metrics_csv(rows, sil, f"{args.out}_metrics.csv") aucs = [r["c2st_auc"] for r in rows if not np.isnan(r["c2st_auc"])] if aucs: subtitle = f"mean C2ST AUC = {np.mean(aucs):.3f} silhouette = {sil:.3f}" plot_overlay(emb, diseases, sources, color_map, axis_label, subtitle, args.out, draw_kde=args.kde, syn_marker=args.syn_marker) if __name__ == "__main__": main() """ # recommended: UMAP, spread-out clusters, x-markers for syn, metrics python draw_cluster.py \ --json /home/jovyan/AURAD_infer/mask/det-test-cp9000/test_prompt_text2layout_single.json \ --syn_root /home/jovyan/AURAD_infer/mask/det-test-cp9000 \ --real_root /home/jovyan/AURAD_dataset \ --method umap --syn_marker x --min_dist 0.25 --spread 1.0 # if clusters still look too spread / too tight, tune these: python draw_cluster.py ... --min_dist 0.6 --spread 1.5 # looser, fills canvas more python draw_cluster.py ... --min_dist 0.25 --spread 1.0 # tighter clusters # other looks python draw_cluster.py ... --syn_marker ring # open-circle syn (sparse data) python draw_cluster.py ... --method umap_supervised --kde # diseases pushed apart + contours python draw_cluster.py ... --method tsne --blur 0 # original t-SNE style """