#!/usr/bin/env python3 """Evaluate one SAM3 LoRA checkpoint across equivalent lesion prompts.""" from __future__ import annotations import argparse import itertools import json import os import re import subprocess import sys from pathlib import Path import numpy as np import pandas as pd from PIL import Image PROMPTS = ["breast lesion", "breast tumor", "breast mass", "breast nodule", "ultrasound breast lesion", "breast ultrasound mass", "hypoechoic breast lesion"] SCRIPT_DIR = Path(__file__).resolve().parent BUNDLE_ROOT = SCRIPT_DIR.parent def slug(text): return re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_").lower() def args_parser(): p = argparse.ArgumentParser() p.add_argument("--index_csv", default="data/sam3_multiprompt_lora_qc/index/test_index.csv") p.add_argument("--sam3_base_model_path", default=str(Path(os.environ.get("SAM3_CHECKPOINT", BUNDLE_ROOT / "model" / "sam3_base.pt")))) p.add_argument("--sam3_lora_path", default=str(BUNDLE_ROOT / "model" / "best_model.pt")) p.add_argument("--output_dir", default="outputs/sam3_multiprompt_lora_qc/single_prompt_lora_multiprompt_eval") p.add_argument("--prompts", nargs="+", default=PROMPTS) p.add_argument("--max_samples", type=int) p.add_argument("--overwrite", action="store_true") p.add_argument("--resume", action="store_true") return p.parse_args() def mask(path): return np.asarray(Image.open(path).convert("L")) > 0 def dice(a, b): if a.shape != b.shape: b = np.asarray(Image.fromarray(b.astype(np.uint8) * 255).resize((a.shape[1], a.shape[0]), Image.Resampling.NEAREST)) > 0 den = int(a.sum() + b.sum()) return 2 * int(np.logical_and(a, b).sum()) / den if den else 1.0 def main(): args = args_parser() out = Path(args.output_dir) out.mkdir(parents=True, exist_ok=True) lora = Path(args.sam3_lora_path) checkpoint = lora / "best_model.pt" if lora.is_dir() else lora if not checkpoint.exists(): raise FileNotFoundError(checkpoint) command = [ sys.executable, "-u", str(SCRIPT_DIR / "evaluate_sam3_test_text_prompt_segmentation.py"), "--index_csv", args.index_csv, "--sam3_model_path", args.sam3_base_model_path, "--checkpoint_path", str(checkpoint), "--output_dir", str(out), "--encoder_trainable", "lora", "--lora_rank", "8", "--lora_alpha", "16", "--prompts", *args.prompts, ] if args.max_samples: command += ["--max_samples", str(args.max_samples)] if args.overwrite: command.append("--overwrite") if args.resume: command.append("--resume") subprocess.run(command, check=True) frames = [] for prompt in args.prompts: frame = pd.read_csv(out / f"predictions_{slug(prompt)}.csv") frame["prompt"] = prompt frames.append(frame) predictions = pd.concat(frames, ignore_index=True) predictions.to_csv(out / "all_prompt_predictions.csv", index=False) per_sample = [] for sid, group in predictions.groupby("sample_id"): valid = group[group["valid_mask"].astype(bool)] masks = [(row["prompt"], mask(row["pred_mask_path"])) for _, row in valid.iterrows()] pairs = [dice(a, b) for (_, a), (_, b) in itertools.combinations(masks, 2)] areas = valid["pred_area"].astype(float).to_numpy() per_sample.append({ "sample_id": sid, "prompt_dice_std": float(valid["dice"].std(ddof=0)), "mask_area_cv": float(areas.std() / areas.mean()) if len(areas) and areas.mean() else np.nan, "pairwise_mask_dice_mean": float(np.mean(pairs)) if pairs else np.nan, "pairwise_mask_dice_min": float(np.min(pairs)) if pairs else np.nan, "valid_prompt_count": len(valid), }) sample_summary = pd.DataFrame(per_sample) sample_summary.to_csv(out / "per_sample_prompt_sensitivity.csv", index=False) metrics = pd.read_csv(out / "all_prompt_metrics.csv") best = metrics.loc[metrics["mean Dice"].idxmax()] worst = metrics.loc[metrics["mean Dice"].idxmin()] training = metrics[metrics["prompt"] == "breast lesion"].iloc[0] robustness = { "checkpoint_path": str(checkpoint.resolve()), "best_prompt_by_mean_dice": best["prompt"], "best_mean_dice": best["mean Dice"], "worst_prompt_by_mean_dice": worst["prompt"], "worst_mean_dice": worst["mean Dice"], "mean_dice_range": best["mean Dice"] - worst["mean Dice"], "std_mean_dice_across_prompts": metrics["mean Dice"].std(ddof=0), "mean_per_sample_prompt_dice_std": sample_summary["prompt_dice_std"].mean(), "mean_per_sample_area_cv": sample_summary["mask_area_cv"].mean(), "mean_pairwise_mask_dice": sample_summary["pairwise_mask_dice_mean"].mean(), "fraction_pairwise_mean_below_0p8": (sample_summary["pairwise_mask_dice_mean"] < 0.8).mean(), "max_nontraining_prompt_drop_vs_breast_lesion": training["mean Dice"] - metrics[metrics["prompt"] != "breast lesion"]["mean Dice"].min(), } robustness["large_prompt_sensitivity"] = bool( robustness["mean_dice_range"] >= 0.05 or robustness["fraction_pairwise_mean_below_0p8"] >= 0.1 or robustness["max_nontraining_prompt_drop_vs_breast_lesion"] > 0.05 ) pd.DataFrame([robustness]).to_csv(out / "prompt_sensitivity_summary.csv", index=False) with open(out / "prompt_sensitivity_summary.json", "w") as handle: json.dump(robustness, handle, indent=2) print(pd.DataFrame([robustness]).to_string(index=False)) if __name__ == "__main__": main()