AngioAI-Pro / stenosis_ensemble.py
MuhammadAdil63's picture
Deploy AngioAI Pro: YOLOv8-seg stenosis ensemble, corrected metrics & SYNTAX
f0d9c8b verified
Raw History Blame Contribute Delete
5.13 kB
"""
AngioAI - stenosis detection: 3 x YOLOv8-seg ensemble with weighted mask fusion.
Validated on ARCADE stenosis (threshold/config chosen on VAL, reported on held-out TEST, 300 imgs):
F1@IoU0.3 = 0.637 precision 0.549 recall 0.759 AP30 0.707 F1@IoU0.5 = 0.467 0.80 FP/img
(see D:\\AngioAI - Commercial\\stenosis\\results\\ensemble\\)
Fusion (same as eval_ensemble_stenosis.py):
- predictions from all members clustered by box IoU >= CLUSTER_IOU (one prediction per model per cluster)
- cluster score = sum_m(w_m * s_m) / sum(all w) -> a lesion seen by only one model is down-weighted
- cluster mask = weighted pixel vote >= 0.5
Usage:
ens = StenosisEnsemble() # loads weights from ./weights/stenosis or ANGIOAI_WEIGHTS
dets = ens.predict(frame_rgb, score_thr=0.20)
# -> [{"bbox":[x1,y1,x2,y2], "score":float, "mask":uint8[H,W], "n_models":int, "models":[...]}, ...]
"""
import os
from pathlib import Path
import numpy as np
WEIGHTS_DIR = Path(os.environ.get("ANGIOAI_WEIGHTS", Path(__file__).resolve().parent / "weights")) / "stenosis"
# name: (file, imgsz used in training, fusion weight = val AP@IoU0.3 of the single model)
MEMBERS = {
"yolov8x_seg": ("yolov8x_seg.pt", 640, 0.6407),
"yolov8l_sgd": ("yolov8l_sgd.pt", 512, 0.6173),
"yolov8l_adamw": ("yolov8l_adamw.pt", 512, 0.6107),
}
CLUSTER_IOU = 0.20
DEFAULT_SCORE_THR = 0.20 # chosen on ARCADE val (max F1@IoU0.3)
MEMBER_CONF = 0.01 # keep low-confidence member preds; the fused score does the filtering
MAX_DET = 30
HF_REPO = os.environ.get("ANGIOAI_HF_REPO", "MuhammadAdil63/angio-ai-checkpoints")
def _resolve(fname: str) -> str:
local = WEIGHTS_DIR / fname
if local.exists():
return str(local)
from huggingface_hub import hf_hub_download # fallback for cloud deployment
return hf_hub_download(repo_id=HF_REPO, filename=f"stenosis/{fname}", repo_type="model",
token=os.environ.get("HF_TOKEN"))
def _box_iou(a, b):
ix = max(0, min(a[2], b[2]) - max(a[0], b[0])); iy = max(0, min(a[3], b[3]) - max(a[1], b[1]))
i = ix * iy
u = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - i
return i / u if u else 0.0
def _mask_box(m):
ys, xs = np.nonzero(m)
return (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1) if len(ys) else None
class StenosisEnsemble:
def __init__(self, device=None, members=None):
from ultralytics import YOLO
import torch
self.device = device if device is not None else (0 if torch.cuda.is_available() else "cpu")
self.members = members or MEMBERS
self.models = {n: YOLO(_resolve(f)) for n, (f, _, _) in self.members.items()}
self.W = sum(w for _, _, w in self.members.values())
def _member_preds(self, frame_rgb):
H, W = frame_rgb.shape[:2]
bgr = np.ascontiguousarray(frame_rgb[..., ::-1]) # ultralytics expects BGR numpy
out = {}
for n, model in self.models.items():
_, imgsz, _ = self.members[n]
r = model.predict(bgr, imgsz=imgsz, conf=MEMBER_CONF, max_det=MAX_DET,
retina_masks=True, verbose=False, device=self.device)[0]
preds = []
if r.masks is not None:
for m, s in zip(r.masks.data.cpu().numpy() > 0.5, r.boxes.conf.cpu().numpy()):
if m.shape != (H, W):
import cv2
m = cv2.resize(m.astype(np.uint8), (W, H), interpolation=cv2.INTER_NEAREST) > 0
b = _mask_box(m)
if b:
preds.append((float(s), b, m))
out[n] = preds
return out
def fuse(self, member_preds):
cand = sorted(((s, b, m, n) for n, ps in member_preds.items() for s, b, m in ps),
key=lambda x: -(x[0] * self.members[x[3]][2]))
clusters = []
for s, b, m, n in cand:
for c in clusters:
if n not in c["m"] and _box_iou(b, c["ref"]) >= CLUSTER_IOU:
c["m"][n] = (s, m); break
else:
clusters.append({"ref": b, "m": {n: (s, m)}})
dets = []
for c in clusters:
score = sum(self.members[n][2] * s for n, (s, _) in c["m"].items()) / self.W
acc = None; tot = 0.0
for n, (s, m) in c["m"].items():
w = self.members[n][2] * s
acc = m * w if acc is None else acc + m * w
tot += w
mask = acc >= 0.5 * tot
b = _mask_box(mask)
if b:
dets.append({"bbox": [b[0], b[1], b[2] - 1, b[3] - 1], "score": float(score),
"mask": mask.astype(np.uint8), "n_models": len(c["m"]),
"models": sorted(c["m"])})
return sorted(dets, key=lambda d: -d["score"])
def predict(self, frame_rgb, score_thr=DEFAULT_SCORE_THR):
return [d for d in self.fuse(self._member_preds(frame_rgb)) if d["score"] >= score_thr]