| |
| """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() |
|
|