SAM3-LoRA-Breast-Lesion / scripts /evaluate_sam3_multiprompt_stability_qc.py
pengzeli's picture
Add files using upload-large-folder tool
590304f verified
Raw
History Blame Contribute Delete
7.63 kB
#!/usr/bin/env python3
"""Evaluate deployable multi-prompt mask stability as an offline QC signal."""
from __future__ import annotations
import argparse
import itertools
from pathlib import Path
import numpy as np
import pandas as pd
from PIL import Image
def parse_args():
p = argparse.ArgumentParser()
p.add_argument("--predictions_csv", default="outputs/sam3_multiprompt_lora_qc/multiprompt_lora_eval/all_prompt_predictions.csv")
p.add_argument("--index_csv", default="data/sam3_multiprompt_lora_qc/index/test_index.csv")
p.add_argument("--output_dir", default="outputs/sam3_multiprompt_lora_qc/stability_qc")
p.add_argument("--default_prompt", default="breast lesion")
p.add_argument("--overwrite", action="store_true")
return p.parse_args()
def read(path):
return np.asarray(Image.open(path).convert("L")) > 0
def align(a, shape):
return a if a.shape == shape else np.asarray(Image.fromarray(a.astype(np.uint8) * 255).resize((shape[1], shape[0]), Image.Resampling.NEAREST)) > 0
def overlap(a, b):
b = align(b, a.shape)
inter = int(np.logical_and(a, b).sum())
den = int(a.sum() + b.sum())
union = int(np.logical_or(a, b).sum())
return (2 * inter / den if den else 1.0, inter / union if union else 1.0)
def bbox(mask):
y, x = np.where(mask)
return None if not len(x) else (x.min(), y.min(), x.max() + 1, y.max() + 1)
def bbox_iou(a, b):
if a is None or b is None:
return 0.0
ix1, iy1, ix2, iy2 = max(a[0], b[0]), max(a[1], b[1]), min(a[2], b[2]), min(a[3], b[3])
inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
aa, bb = (a[2] - a[0]) * (a[3] - a[1]), (b[2] - b[0]) * (b[3] - b[1])
return inter / (aa + bb - inter) if aa + bb > inter else 0.0
def auc(y, score):
y = np.asarray(y, dtype=bool)
score = np.asarray(score, dtype=float)
keep = np.isfinite(score)
y, score = y[keep], score[keep]
positives, negatives = int(y.sum()), int((~y).sum())
if not positives or not negatives:
return np.nan
ranks = pd.Series(score).rank(method="average").to_numpy()
return float((ranks[y].sum() - positives * (positives + 1) / 2) / (positives * negatives))
def save_stability_overlay(group, output_path):
valid = group[group["valid_mask"].astype(bool)]
if valid.empty:
return
image = Image.open(valid.iloc[0]["original_image_path"]).convert("RGB")
masks = [align(read(path), (image.height, image.width)) for path in valid["pred_mask_path"]]
agreement = np.mean(np.stack(masks), axis=0)
base = np.asarray(image).copy()
high, uncertain = agreement >= 0.8, (agreement > 0.2) & (agreement < 0.8)
base[high] = (0.55 * base[high] + np.array([0, 220, 0]) * 0.45).astype(np.uint8)
base[uncertain] = (0.55 * base[uncertain] + np.array([255, 170, 0]) * 0.45).astype(np.uint8)
output_path.parent.mkdir(parents=True, exist_ok=True)
Image.fromarray(base).save(output_path)
def main():
args = parse_args()
out = Path(args.output_dir)
out.mkdir(parents=True, exist_ok=True)
predictions = pd.read_csv(args.predictions_csv)
rows = []
for sid, group in predictions.groupby("sample_id"):
valid = group[group["valid_mask"].astype(bool)]
masks = [(r["prompt"], read(r["pred_mask_path"])) for _, r in valid.iterrows()]
pair = [(overlap(a, b), bbox_iou(bbox(a), bbox(b))) for (_, a), (_, b) in itertools.combinations(masks, 2)]
areas = np.array([m.sum() for _, m in masks], dtype=float)
centers = np.array([[(b[0] + b[2]) / 2, (b[1] + b[3]) / 2] for _, m in masks if (b := bbox(m)) is not None])
selected = valid[valid["prompt"] == args.default_prompt]
selected = selected.iloc[0] if len(selected) else valid.iloc[0]
rows.append({
"sample_id": sid,
"selected_prompt": selected["prompt"],
"selected_mask_path": selected["pred_mask_path"],
"selected_mask_dice": selected["dice"],
"mean_pairwise_dice": np.mean([x[0][0] for x in pair]) if pair else np.nan,
"min_pairwise_dice": np.min([x[0][0] for x in pair]) if pair else np.nan,
"mean_pairwise_iou": np.mean([x[0][1] for x in pair]) if pair else np.nan,
"mask_area_mean": areas.mean() if len(areas) else np.nan,
"mask_area_std": areas.std() if len(areas) else np.nan,
"mask_area_cv": areas.std() / areas.mean() if len(areas) and areas.mean() else np.nan,
"mask_center_std": centers.std(axis=0).mean() if len(centers) else np.nan,
"mask_bbox_iou_mean": np.mean([x[1] for x in pair]) if pair else np.nan,
"mask_bbox_iou_min": np.min([x[1] for x in pair]) if pair else np.nan,
"valid_prompt_count": len(valid),
"failure_prompt_count": len(group) - len(valid),
})
features = pd.DataFrame(rows)
features["good_mask"] = features["selected_mask_dice"] >= 0.75
features["bad_mask"] = features["selected_mask_dice"] < 0.50
features["quality_label"] = np.where(features["good_mask"], "good", np.where(features["bad_mask"], "bad", "medium"))
features.to_csv(out / "stability_features.csv", index=False)
feature_cols = ["mean_pairwise_dice", "min_pairwise_dice", "mean_pairwise_iou", "mask_area_cv", "mask_center_std", "mask_bbox_iou_mean", "mask_bbox_iou_min"]
summary = []
for col in feature_cols:
sign = -1 if col in {"mask_area_cv", "mask_center_std"} else 1
summary.append({"feature": col, "dice_correlation": features[[col, "selected_mask_dice"]].corr().iloc[0, 1], "auc_good": auc(features["good_mask"], sign * features[col]), "auc_bad": auc(features["bad_mask"], -sign * features[col])})
pd.DataFrame(summary).to_csv(out / "qc_metrics_summary.csv", index=False)
auc_good = auc(features["good_mask"], features["mean_pairwise_dice"])
auc_bad = auc(features["bad_mask"], -features["mean_pairwise_dice"])
sweeps = []
for threshold in [0.7, 0.8, 0.9]:
passed = (features["mean_pairwise_dice"] >= threshold) & (features["mask_area_cv"] <= 0.3) & (features["failure_prompt_count"] == 0)
p, f = features[passed], features[~passed]
sweeps.append({"threshold": threshold, "pass_rate": passed.mean(), "pass_subset_mean_dice": p["selected_mask_dice"].mean(), "fail_subset_mean_dice": f["selected_mask_dice"].mean(), "bad_mask_rate_in_pass": p["bad_mask"].mean(), "good_mask_rate_in_pass": p["good_mask"].mean(), "auc_good": auc_good, "auc_bad": auc_bad, "bad_detection_sensitivity": ((~passed) & features["bad_mask"]).sum() / max(1, features["bad_mask"].sum()), "bad_detection_specificity": (passed & ~features["bad_mask"]).sum() / max(1, (~features["bad_mask"]).sum())})
pd.DataFrame(sweeps).to_csv(out / "qc_rule_threshold_sweep.csv", index=False)
features.sort_values("selected_mask_dice", ascending=False).head(30).to_csv(out / "pass_subset_examples.csv", index=False)
features.sort_values("selected_mask_dice").head(30).to_csv(out / "fail_subset_examples.csv", index=False)
features.sort_values("mean_pairwise_dice").head(30).to_csv(out / "worst_instability_examples.csv", index=False)
examples = pd.concat([features.nlargest(10, "mean_pairwise_dice"), features.nsmallest(10, "mean_pairwise_dice")]).drop_duplicates("sample_id")
for _, example in examples.iterrows():
group = predictions[predictions["sample_id"] == example["sample_id"]]
save_stability_overlay(group, out / "overlay_examples" / f"{example['sample_id']}.png")
print(pd.DataFrame(sweeps).to_string(index=False))
if __name__ == "__main__":
main()