Spaces:
Sleeping
Sleeping
Deploy AngioAI Pro: YOLOv8-seg stenosis ensemble, corrected metrics & SYNTAX
Browse files- README.md +15 -43
- app.py +389 -476
- requirements.txt +1 -4
- stenosis_ensemble.py +115 -0
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: 🫀
|
| 4 |
colorFrom: red
|
| 5 |
colorTo: blue
|
|
@@ -7,52 +7,24 @@ sdk: gradio
|
|
| 7 |
sdk_version: 4.44.1
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
-
license:
|
| 11 |
---
|
| 12 |
|
| 13 |
-
#
|
| 14 |
|
| 15 |
-
|
| 16 |
-
**Supervisor: Dr. Arif Mehmood**
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
| 25 |
-
|
|
| 26 |
-
| 1 | Keyframe extraction | Best diagnostic frame (contrast × sharpness score) |
|
| 27 |
-
| 2 | Mask2Former | Stenosis detection — bounding boxes + instance masks |
|
| 28 |
-
| 3a | ResUNet | Binary vessel mask (Dice 0.8015 on ARCADE) |
|
| 29 |
-
| 3b | YOLOv8m-seg | 26-class coronary anatomy segmentation |
|
| 30 |
-
| 4 | FFR Pipeline v4 | Quantitative Flow Ratio (QFR) estimation |
|
| 31 |
-
| 5 | SYNTAX Score | Lesion complexity scoring |
|
| 32 |
-
|
| 33 |
-
---
|
| 34 |
-
|
| 35 |
-
## Models
|
| 36 |
|
| 37 |
-
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|------|-------------|------|------------|
|
| 41 |
-
| `mask2former_best.pth` | Mask2Former Swin-Base | Stenosis detection | — |
|
| 42 |
-
| `binary_best.pth` | ResUNet (16→256 ch) | Binary vessel segmentation | Dice 0.8015 |
|
| 43 |
-
| `best.pt` | YOLOv8m-seg (nc=26) | 26-class coronary anatomy | — |
|
| 44 |
-
|
| 45 |
-
Dataset: [ARCADE Challenge](https://arcade.grand-challenge.org/) (syntax + stenosis splits)
|
| 46 |
-
|
| 47 |
-
---
|
| 48 |
-
|
| 49 |
-
## Notes
|
| 50 |
-
|
| 51 |
-
- **Hardware**: CPU-only (HF free tier) — inference takes ~30–60 seconds per video
|
| 52 |
-
- **Input**: MP4 / AVI coronary angiography video, ideally ≥5 seconds
|
| 53 |
-
- **Scale**: Default 3.75 px/mm (ARCADE hardware). Adjust slider for non-ARCADE data.
|
| 54 |
-
- **FFR formula**: QFR = 1 − (0.33·DS + 0.60·DS²) — Tu et al. JACC 2016 / FAVOR II
|
| 55 |
-
|
| 56 |
-
---
|
| 57 |
|
| 58 |
-
*
|
|
|
|
| 1 |
---
|
| 2 |
+
title: AngioAI Pro
|
| 3 |
emoji: 🫀
|
| 4 |
colorFrom: red
|
| 5 |
colorTo: blue
|
|
|
|
| 7 |
sdk_version: 4.44.1
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: other
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# AngioAI Pro — Coronary Angiography Analysis
|
| 14 |
|
| 15 |
+
Upload a coronary X-ray angiography (XCA) video. The pipeline:
|
|
|
|
| 16 |
|
| 17 |
+
| Step | Module | Notes |
|
| 18 |
+
|---|---|---|
|
| 19 |
+
| 1 | Keyframe extraction | contrast × sharpness score |
|
| 20 |
+
| 2 | Stenosis detection | 3 × YOLOv8-seg ensemble with weighted mask fusion |
|
| 21 |
+
| 3a | Binary vessel mask | ResUNet |
|
| 22 |
+
| 3b | Coronary segments | 25 SYNTAX segments (YOLOv8-seg) |
|
| 23 |
+
| 4 | QCA + FFR estimate | diameter stenosis on the measured branch; QFR = 1 − (0.33·DS + 0.60·DS²) |
|
| 24 |
+
| 5 | SYNTAX (anatomical) | segment weights × lesion multiplier, lesions ≥ 50 % DS |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
**Held-out ARCADE stenosis test set (300 images):** precision 0.55, recall 0.76, F1 0.64 (mask IoU ≥ 0.3), 0.80 false positives per image.
|
| 27 |
|
| 28 |
+
Checkpoints are loaded from the private model repo `MuhammadAdil63/angio-ai-checkpoints` (Space secret `HF_TOKEN`).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
+
> **Research use only.** Not a medical device and not for clinical diagnosis. Not validated on non-ARCADE data or on normal angiograms.
|
app.py
CHANGED
|
@@ -7,7 +7,8 @@
|
|
| 7 |
# Checkpoints are loaded from HF Hub: MuhammadAdil63/angio-ai-checkpoints
|
| 8 |
# Set HF_TOKEN as a Space Secret (Settings → Secrets) for private repo access.
|
| 9 |
# Files expected in that repo:
|
| 10 |
-
#
|
|
|
|
| 11 |
# binary_best.pth (ResUNet binary vessel segmentation)
|
| 12 |
# best.pt (YOLOv8m-seg 26-class ARCADE syntax)
|
| 13 |
#
|
|
@@ -112,7 +113,6 @@ def _hf_ckpt(filename: str) -> str:
|
|
| 112 |
CONFIG = {
|
| 113 |
# ── Checkpoint paths (resolved lazily via HF Hub) ─────────────────────────
|
| 114 |
# These callables are invoked once inside each model's _load_*() function.
|
| 115 |
-
"MASK2FORMER_CKPT" : lambda: _hf_ckpt("mask2former_best.pth"),
|
| 116 |
"RESUNET_CKPT" : lambda: _hf_ckpt("binary_best.pth"),
|
| 117 |
"YOLO_CKPT" : lambda: _hf_ckpt("best.pt"),
|
| 118 |
|
|
@@ -171,292 +171,196 @@ LOGO_ITU = _encode_logo(str(Path(__file__).parent / "ITULog.png"))
|
|
| 171 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 172 |
|
| 173 |
CSS = """
|
| 174 |
-
|
| 175 |
-
|
| 176 |
:root {
|
| 177 |
-
--
|
| 178 |
-
--
|
| 179 |
-
--
|
| 180 |
-
--
|
| 181 |
-
--
|
| 182 |
-
--
|
| 183 |
-
--
|
| 184 |
-
--
|
| 185 |
-
--
|
| 186 |
-
--
|
| 187 |
-
--
|
| 188 |
-
--
|
| 189 |
-
--
|
| 190 |
-
--w30: rgba(240,249,255,0.30);
|
| 191 |
-
--w10: rgba(240,249,255,0.08);
|
| 192 |
-
--border: rgba(34,211,238,0.18);
|
| 193 |
-
--border-s: rgba(240,249,255,0.09);
|
| 194 |
-
--shadow: 0 4px 24px rgba(0,0,0,0.40);
|
| 195 |
-
--radius: 12px;
|
| 196 |
-
--radius-s: 8px;
|
| 197 |
-
--font: 'Inter', system-ui, sans-serif;
|
| 198 |
-
--mono: 'JetBrains Mono', 'Courier New', monospace;
|
| 199 |
}
|
| 200 |
-
*, *::before, *::after { box-sizing: border-box; }
|
| 201 |
|
| 202 |
body, .gradio-container {
|
| 203 |
-
background: var(--
|
| 204 |
-
font-family:
|
| 205 |
-
color: var(--
|
| 206 |
-
min-height: 100vh;
|
| 207 |
}
|
| 208 |
-
.gradio-container { padding: 0 !important; max-width: 100% !important; }
|
| 209 |
-
footer { display: none !important; }
|
| 210 |
-
.gap { gap: 16px !important; }
|
| 211 |
|
| 212 |
-
/* ── Header ──
|
| 213 |
#header {
|
| 214 |
-
background: var(--
|
| 215 |
-
border-bottom:
|
| 216 |
-
padding:
|
| 217 |
display: flex;
|
| 218 |
align-items: center;
|
| 219 |
justify-content: space-between;
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
overflow: hidden;
|
| 223 |
-
}
|
| 224 |
-
#header::after {
|
| 225 |
-
content: '';
|
| 226 |
-
position: absolute;
|
| 227 |
-
bottom: 0; left: 0; right: 0; height: 2px;
|
| 228 |
-
background: linear-gradient(90deg,
|
| 229 |
-
transparent 0%, var(--cyan-400) 15%, var(--cyan-400) 17%,
|
| 230 |
-
transparent 19%, transparent 24%,
|
| 231 |
-
var(--cyan-400) 26%, #fff 28%, var(--cyan-400) 30%,
|
| 232 |
-
transparent 32%, transparent 38%,
|
| 233 |
-
var(--cyan-400) 40%, var(--cyan-400) 42%,
|
| 234 |
-
transparent 44%, transparent 100%);
|
| 235 |
-
opacity: 0.7;
|
| 236 |
-
}
|
| 237 |
-
#logo-angio .name {
|
| 238 |
-
font-size: 22px; font-weight: 700;
|
| 239 |
-
color: var(--cyan-400) !important;
|
| 240 |
-
letter-spacing: 3px; text-transform: uppercase;
|
| 241 |
-
font-family: var(--mono) !important;
|
| 242 |
}
|
| 243 |
-
#logo-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
| 246 |
}
|
| 247 |
-
#logo-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
|
|
|
| 252 |
}
|
| 253 |
-
.header-meta b { color: var(--cyan-300) !important; font-weight: 600; }
|
| 254 |
|
| 255 |
-
/* ──
|
| 256 |
-
.tab-nav {
|
| 257 |
-
background: var(--navy-900) !important;
|
| 258 |
-
border-bottom: 1px solid var(--border-s) !important;
|
| 259 |
-
padding: 0 24px !important;
|
| 260 |
-
}
|
| 261 |
.tab-nav button {
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
padding:
|
| 267 |
-
|
| 268 |
}
|
| 269 |
-
.tab-nav button:hover { color: var(--white) !important; }
|
| 270 |
.tab-nav button.selected {
|
| 271 |
-
color: var(--
|
| 272 |
-
border-bottom-color: var(--
|
| 273 |
-
font-weight: 600 !important;
|
| 274 |
}
|
| 275 |
|
| 276 |
-
/* ── Cards ──
|
| 277 |
.card {
|
| 278 |
-
background: var(--
|
| 279 |
-
border: 1px solid var(--border
|
| 280 |
-
border-radius: var(--radius)
|
| 281 |
-
padding:
|
| 282 |
-
box-shadow: var(--shadow)
|
| 283 |
-
margin-bottom: 16px
|
| 284 |
-
position: relative; overflow: hidden;
|
| 285 |
-
}
|
| 286 |
-
.card::before {
|
| 287 |
-
content: ''; position: absolute;
|
| 288 |
-
top: 0; left: 0; right: 0; height: 1px;
|
| 289 |
-
background: linear-gradient(90deg, transparent, var(--cyan-400), transparent);
|
| 290 |
-
opacity: 0.35;
|
| 291 |
}
|
| 292 |
.card-title {
|
| 293 |
-
font-size:
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
| 298 |
}
|
| 299 |
|
| 300 |
-
/* ── Upload zone ──
|
| 301 |
-
#upload-zone
|
| 302 |
-
border:
|
| 303 |
border-radius: var(--radius) !important;
|
| 304 |
-
background: var(--
|
| 305 |
-
min-height:
|
| 306 |
-
transition: all 0.2s !important;
|
| 307 |
-
}
|
| 308 |
-
#upload-zone:hover {
|
| 309 |
-
border-color: var(--cyan-300) !important;
|
| 310 |
-
box-shadow: 0 0 20px var(--cyan-glow) !important;
|
| 311 |
-
}
|
| 312 |
-
#upload-zone label, #upload-zone span, #upload-zone p {
|
| 313 |
-
color: var(--w60) !important; font-size: 13px !important;
|
| 314 |
}
|
| 315 |
-
#upload-zone svg { stroke: var(--cyan-400) !important; opacity: 0.6; }
|
| 316 |
|
| 317 |
-
/* ── Sliders ──
|
| 318 |
-
input[type=range] { accent-color: var(--
|
| 319 |
-
.gradio-slider label
|
| 320 |
-
color: var(--w60) !important; font-size: 12px !important;
|
| 321 |
-
font-family: var(--mono) !important;
|
| 322 |
-
}
|
| 323 |
-
.gradio-slider .info { color: var(--w30) !important; font-size: 11px !important; }
|
| 324 |
|
| 325 |
-
/* ── Buttons ──
|
| 326 |
#btn-analyse {
|
| 327 |
-
background:
|
| 328 |
-
color:
|
| 329 |
-
border
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
#btn-analyse:hover {
|
| 338 |
-
box-shadow: 0 0 36px rgba(34,211,238,0.50) !important;
|
| 339 |
-
transform: translateY(-1px) !important;
|
| 340 |
}
|
|
|
|
| 341 |
#btn-reset {
|
| 342 |
-
background: transparent !important;
|
| 343 |
-
|
| 344 |
-
border-radius: var(--radius-s) !important;
|
| 345 |
-
font-size: 11px !important; font-family: var(--mono) !important;
|
| 346 |
-
letter-spacing: 1px !important; text-transform: uppercase !important;
|
| 347 |
-
padding: 10px 20px !important; cursor: pointer !important;
|
| 348 |
-
width: 100% !important; transition: all 0.2s !important;
|
| 349 |
-
}
|
| 350 |
-
#btn-reset:hover { border-color: var(--w30) !important; color: var(--white) !important; }
|
| 351 |
-
|
| 352 |
-
button.sm.secondary, button.lg.secondary {
|
| 353 |
-
background: transparent !important; color: var(--cyan-400) !important;
|
| 354 |
border: 1px solid var(--border) !important;
|
| 355 |
-
border-radius: var(--radius
|
| 356 |
-
font-size:
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
}
|
| 361 |
-
button.sm.secondary:hover, button.lg.secondary:hover {
|
| 362 |
-
background: var(--cyan-glow) !important;
|
| 363 |
-
box-shadow: 0 0 16px var(--cyan-glow) !important;
|
| 364 |
}
|
| 365 |
|
| 366 |
-
/* ── Metric badges ──
|
| 367 |
-
.metric-row {
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
text-align: center !important; position: relative; overflow: hidden;
|
| 373 |
}
|
| 374 |
-
.metric-badge
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
background: var(--
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
}
|
| 379 |
.metric-badge .val {
|
| 380 |
-
font-size: 22px
|
| 381 |
-
|
|
|
|
| 382 |
}
|
| 383 |
-
.metric-badge .val.ischemic
|
| 384 |
-
.metric-badge .val.borderline{ color: var(--
|
| 385 |
-
.metric-badge .val.ok
|
| 386 |
.metric-badge .lbl {
|
| 387 |
-
font-size:
|
| 388 |
-
|
| 389 |
-
|
|
|
|
| 390 |
}
|
| 391 |
|
| 392 |
-
/* ──
|
| 393 |
-
#
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
border: 1px solid var(--border) !important;
|
| 397 |
-
background: var(--cyan-glow) !important;
|
| 398 |
-
color: var(--cyan-300) !important;
|
| 399 |
-
font-family: var(--mono) !important; letter-spacing: 0.5px;
|
| 400 |
}
|
| 401 |
|
| 402 |
-
/* ──
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
border:
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
background: var(--navy-950) !important;
|
| 412 |
-
border: 1px solid var(--border-s) !important;
|
| 413 |
-
font-family: var(--mono) !important; font-size: 12px !important;
|
| 414 |
}
|
| 415 |
|
| 416 |
-
/* ── Footer ──
|
| 417 |
#footer {
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
#footer b { color: var(--cyan-400) !important; }
|
| 425 |
-
|
| 426 |
-
/* ── Gradio overrides ─────────────────────────────────────────────── */
|
| 427 |
-
.gradio-container .prose, .gradio-container h1, .gradio-container h2,
|
| 428 |
-
.gradio-container h3, .gradio-container p, .gradio-container label,
|
| 429 |
-
.gradio-container span { color: var(--white) !important; }
|
| 430 |
-
.gradio-container input, .gradio-container textarea, .gradio-container select {
|
| 431 |
-
background: var(--navy-800) !important;
|
| 432 |
-
border-color: var(--border-s) !important; color: var(--white) !important;
|
| 433 |
}
|
| 434 |
-
::-webkit-scrollbar { width: 5px; height: 5px; }
|
| 435 |
-
::-webkit-scrollbar-track { background: var(--navy-950); }
|
| 436 |
-
::-webkit-scrollbar-thumb { background: var(--navy-600); border-radius: 3px; }
|
| 437 |
-
::-webkit-scrollbar-thumb:hover { background: var(--cyan-400); }
|
| 438 |
"""
|
| 439 |
|
| 440 |
-
|
| 441 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 442 |
# HEADER HTML
|
| 443 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 444 |
|
| 445 |
def _header_html() -> str:
|
| 446 |
-
|
| 447 |
-
if LOGO_ITU else
|
| 448 |
-
'<span style="font-size:11px;color:rgba(240,249,255,0.4);font-family:JetBrains Mono,monospace">ITU LAHORE</span>')
|
| 449 |
-
return f"""
|
| 450 |
<div id="header">
|
| 451 |
<div id="logo-angio">
|
| 452 |
-
|
| 453 |
-
<
|
| 454 |
</div>
|
| 455 |
-
<div class="header-meta">
|
| 456 |
-
<b>Muhammad Adil</b> · MS Data Science<br>
|
| 457 |
-
Supervisor: Dr. Arif Mehmood · ITU Lahore
|
| 458 |
-
</div>
|
| 459 |
-
<div id="logo-itu">{itu_tag}</div>
|
| 460 |
</div>"""
|
| 461 |
|
| 462 |
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -504,63 +408,24 @@ def extract_best_frame(video_path: str, n: int = 1) -> tuple:
|
|
| 504 |
return rgb, best_idx, best_score, total
|
| 505 |
|
| 506 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 507 |
-
# MODULE 2 —
|
| 508 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 509 |
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
def _load_mask2former():
|
| 514 |
-
global _m2f_model, _m2f_proc
|
| 515 |
-
if _m2f_model is not None:
|
| 516 |
-
return
|
| 517 |
-
from transformers import (AutoImageProcessor,
|
| 518 |
-
Mask2FormerForUniversalSegmentation)
|
| 519 |
-
ckpt = CONFIG["MASK2FORMER_CKPT"]() # resolves via HF Hub download
|
| 520 |
-
if not os.path.exists(ckpt):
|
| 521 |
-
raise FileNotFoundError(f"Mask2Former checkpoint not found:\n{ckpt}")
|
| 522 |
-
|
| 523 |
-
_m2f_proc = AutoImageProcessor.from_pretrained(
|
| 524 |
-
"facebook/mask2former-swin-base-coco-instance")
|
| 525 |
-
_m2f_model = Mask2FormerForUniversalSegmentation.from_pretrained(
|
| 526 |
-
"facebook/mask2former-swin-base-coco-instance",
|
| 527 |
-
num_labels=2, ignore_mismatched_sizes=True)
|
| 528 |
-
|
| 529 |
-
state = torch.load(ckpt, map_location="cpu", weights_only=False)
|
| 530 |
-
weights = state.get("model_state", state)
|
| 531 |
-
_m2f_model.load_state_dict(weights, strict=False)
|
| 532 |
-
_m2f_model.to(CONFIG["DEVICE"]).eval()
|
| 533 |
|
| 534 |
|
| 535 |
-
def
|
| 536 |
"""
|
| 537 |
-
Returns list of dicts: [{bbox:[x1,y1,x2,y2], score:float, mask:np.ndarray}, ...]
|
| 538 |
"""
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
with torch.no_grad():
|
| 546 |
-
outputs = _m2f_model(**inputs)
|
| 547 |
|
| 548 |
-
results = _m2f_proc.post_process_instance_segmentation(
|
| 549 |
-
outputs, target_sizes=[(512, 512)])[0]
|
| 550 |
-
|
| 551 |
-
detections = []
|
| 552 |
-
for seg_info in results["segments_info"]:
|
| 553 |
-
score = float(seg_info["score"])
|
| 554 |
-
if score < conf_thr:
|
| 555 |
-
continue
|
| 556 |
-
mask = (results["segmentation"] == seg_info["id"]).cpu().numpy().astype(np.uint8)
|
| 557 |
-
ys, xs = np.where(mask)
|
| 558 |
-
if len(ys) == 0:
|
| 559 |
-
continue
|
| 560 |
-
x1, y1, x2, y2 = int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
|
| 561 |
-
detections.append({"bbox": [x1, y1, x2, y2], "score": score, "mask": mask})
|
| 562 |
-
|
| 563 |
-
return detections
|
| 564 |
|
| 565 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 566 |
# MODULE 3 — RESUNET BINARY SEGMENTATION
|
|
@@ -815,34 +680,20 @@ def run_resunet(frame_rgb: np.ndarray):
|
|
| 815 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 816 |
|
| 817 |
# ARCADE 26-class name list (index = YOLO cls_id + 1)
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
"
|
| 823 |
-
"LAD
|
| 824 |
-
"D1",
|
| 825 |
-
"
|
| 826 |
-
"
|
| 827 |
-
"
|
| 828 |
-
"
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
"OM1", # 13
|
| 833 |
-
"OM2", # 14
|
| 834 |
-
"OM3", # 15
|
| 835 |
-
"LCX var", # 16
|
| 836 |
-
"RCA prox", # 17
|
| 837 |
-
"RCA mid", # 18
|
| 838 |
-
"RCA dist", # 19
|
| 839 |
-
"PDA", # 20
|
| 840 |
-
"PLV", # 21
|
| 841 |
-
"AM", # 22
|
| 842 |
-
"Conus", # 23
|
| 843 |
-
"SAN", # 24
|
| 844 |
-
"Other", # 25
|
| 845 |
-
]
|
| 846 |
|
| 847 |
_yolo_model = None # singleton -- loaded once on first call, reused every frame
|
| 848 |
|
|
@@ -930,15 +781,17 @@ def run_yolo_seg(frame_rgb: np.ndarray, seg_conf: float) -> np.ndarray:
|
|
| 930 |
n_written = 0
|
| 931 |
for i in order:
|
| 932 |
cls_id = int(cls_ids[i])
|
|
|
|
|
|
|
| 933 |
mask_resized = cv2.resize(
|
| 934 |
masks[i], (w, h), interpolation=cv2.INTER_LINEAR
|
| 935 |
)
|
| 936 |
binary = mask_resized > 0.5
|
| 937 |
-
label_val =
|
| 938 |
seg_map[binary] = label_val
|
| 939 |
n_written += int(binary.sum())
|
| 940 |
|
| 941 |
-
detected_cls = [_YOLO_CLASS_NAMES[
|
| 942 |
print(f"[SEG] Detections: {len(cls_ids)} | classes: {detected_cls} | "
|
| 943 |
f"vessel px: {n_written} ({100 * n_written / (h * w):.1f}%)")
|
| 944 |
print(f"[SEG] seg_map unique labels: {np.unique(seg_map)}")
|
|
@@ -975,6 +828,7 @@ FFR_CFG = {
|
|
| 975 |
# If auto-computed ref exceeds this, clamp it
|
| 976 |
"ref_diam_max_mm" : 5.0,
|
| 977 |
"ref_diam_min_mm" : 1.0,
|
|
|
|
| 978 |
"px_per_mm" : 3.75,
|
| 979 |
}
|
| 980 |
|
|
@@ -1157,6 +1011,7 @@ def run_ffr(frame_rgb, binary_mask, detections):
|
|
| 1157 |
centers_arr = np.array(centers)
|
| 1158 |
print(f"[FFR] After noise filter: {len(diams_sm)} measurements kept")
|
| 1159 |
|
|
|
|
| 1160 |
stenoses=[]; unloc=[]
|
| 1161 |
|
| 1162 |
for det in detections:
|
|
@@ -1169,17 +1024,25 @@ def run_ffr(frame_rgb, binary_mask, detections):
|
|
| 1169 |
x1,y1,x2,y2=bbox
|
| 1170 |
inside=np.where((centers_arr[:,0]>=y1)&(centers_arr[:,0]<=y2)&
|
| 1171 |
(centers_arr[:,1]>=x1)&(centers_arr[:,1]<=x2))[0]
|
| 1172 |
-
|
| 1173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1174 |
if idx>=len(diams_sm): continue
|
| 1175 |
d_sten=float(diams_sm[idx]); ds=max(0.,1.-d_sten/ref_px)
|
| 1176 |
if ds < FFR_CFG["min_ds_fraction"]:
|
| 1177 |
-
unloc.append({**det,"reason":"no
|
| 1178 |
ffr_est=_ds_to_ffr(ds)
|
| 1179 |
stenoses.append({"index":idx,"position_mm":float(arc_pos[idx]/px_mm),
|
| 1180 |
"d_mm":float(d_sten/px_mm),"ref_mm":float(ref_mm),
|
| 1181 |
"pct_ds":float(ds*100),"ffr":ffr_est,
|
| 1182 |
-
"score":score,"significant":ffr_est<=0.80
|
|
|
|
|
|
|
| 1183 |
|
| 1184 |
global_ffr=1.
|
| 1185 |
for s in stenoses: global_ffr*=s["ffr"]
|
|
@@ -1195,7 +1058,7 @@ def run_ffr(frame_rgb, binary_mask, detections):
|
|
| 1195 |
b=det.get("bbox",[]); sc=det.get("score",0)
|
| 1196 |
if len(b)<4: continue
|
| 1197 |
x1,y1,x2,y2=b
|
| 1198 |
-
col="red" if sc>=0.5 else "orange"
|
| 1199 |
axes[0].add_patch(mpatches.Rectangle(
|
| 1200 |
(x1,y1),x2-x1,y2-y1,fill=False,edgecolor=col,linewidth=2))
|
| 1201 |
axes[0].text(x1,y1-5,f"{sc:.2f}",color=col,fontsize=8,fontweight="bold")
|
|
@@ -1240,9 +1103,14 @@ def run_ffr(frame_rgb, binary_mask, detections):
|
|
| 1240 |
axes[2].legend(fontsize=8); axes[2].grid(alpha=0.3); axes[2].set_ylim(0)
|
| 1241 |
axes[2].set_title("Lumen diameter profile", fontsize=11, color="#1a6fa8")
|
| 1242 |
|
| 1243 |
-
|
| 1244 |
-
|
| 1245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1246 |
fontsize=12,fontweight="bold",color="#1a2533",y=1.01)
|
| 1247 |
plt.tight_layout()
|
| 1248 |
|
|
@@ -1267,6 +1135,10 @@ def run_ffr(frame_rgb, binary_mask, detections):
|
|
| 1267 |
"ref_diam_mm" : round(ref_mm,2),
|
| 1268 |
"vessel_len_mm": round(float(arc_pos[-1]/px_mm),1) if len(arc_pos)>0 else 0,
|
| 1269 |
"n_unlocalized": len(unloc),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1270 |
}
|
| 1271 |
return result, buf
|
| 1272 |
|
|
@@ -1307,16 +1179,7 @@ ARCADE_COLOURS = {
|
|
| 1307 |
25: (180, 180, 180), # Other — silver grey
|
| 1308 |
}
|
| 1309 |
|
| 1310 |
-
ARCADE_LABELS =
|
| 1311 |
-
1:"LMCA", 2:"LAD prox", 3:"LAD mid", 4:"LAD dist",
|
| 1312 |
-
5:"D1", 6:"D2", 7:"D3", 8:"Septal",
|
| 1313 |
-
9:"LAD var", 10:"LCX prox", 11:"LCX mid", 12:"LCX dist",
|
| 1314 |
-
13:"OM1", 14:"OM2", 15:"OM3", 16:"LCX var",
|
| 1315 |
-
17:"RCA prox",18:"RCA mid", 19:"RCA dist", 20:"PDA",
|
| 1316 |
-
21:"PLV", 22:"AM", 23:"Conus", 24:"SAN",
|
| 1317 |
-
25:"Other",
|
| 1318 |
-
}
|
| 1319 |
-
|
| 1320 |
|
| 1321 |
def render_nnunet_overlay(frame_rgb, seg_map, alpha=0.55):
|
| 1322 |
"""
|
|
@@ -1383,36 +1246,51 @@ def render_nnunet_overlay(frame_rgb, seg_map, alpha=0.55):
|
|
| 1383 |
# MODULE 7 — SYNTAX SCORE (simplified anatomical estimate)
|
| 1384 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 1385 |
|
| 1386 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1387 |
"""
|
| 1388 |
-
|
| 1389 |
-
|
| 1390 |
-
|
| 1391 |
-
|
| 1392 |
-
|
| 1393 |
-
an automated surrogate for display purposes.
|
| 1394 |
"""
|
| 1395 |
-
if
|
| 1396 |
-
|
| 1397 |
-
|
| 1398 |
-
base_score = 0.
|
| 1399 |
for st in stenoses:
|
| 1400 |
-
|
| 1401 |
-
|
| 1402 |
-
|
| 1403 |
-
|
| 1404 |
-
|
| 1405 |
-
|
| 1406 |
-
|
| 1407 |
-
|
| 1408 |
-
|
| 1409 |
-
|
| 1410 |
-
|
| 1411 |
-
score = round(base_score, 1)
|
| 1412 |
-
if score < 23: tier = "Low (<23)"
|
| 1413 |
-
elif score < 33: tier = "Intermediate (23–32)"
|
| 1414 |
-
else: tier = "High (≥33)"
|
| 1415 |
-
return score, tier
|
| 1416 |
|
| 1417 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 1418 |
# JSON ENCODER — handles numpy float32/int64 etc.
|
|
@@ -1435,7 +1313,7 @@ def _to_json(obj) -> str:
|
|
| 1435 |
# MAIN PIPELINE FUNCTION
|
| 1436 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 1437 |
|
| 1438 |
-
def analyse(video_path, sten_conf, seg_conf, px_per_mm_override, progress=gr.Progress()):
|
| 1439 |
"""
|
| 1440 |
Full pipeline. Called by Gradio on button click.
|
| 1441 |
Returns: (status_html, frame_img, seg_img, ffr_img, metrics_html, json_str)
|
|
@@ -1451,17 +1329,8 @@ def analyse(video_path, sten_conf, seg_conf, px_per_mm_override, progress=gr.Pro
|
|
| 1451 |
frame_rgb, frame_idx, frame_score, total_frames = extract_best_frame(video_path)
|
| 1452 |
|
| 1453 |
# Step 2 — stenosis detection
|
| 1454 |
-
progress(0.30, desc="
|
| 1455 |
-
detections =
|
| 1456 |
-
|
| 1457 |
-
# Compute vessel overlap for each detection
|
| 1458 |
-
binary_mask_for_overlap = np.ones((512,512),dtype=np.uint8) # placeholder until ResUNet
|
| 1459 |
-
for det in detections:
|
| 1460 |
-
b=det.get("bbox",[]);
|
| 1461 |
-
if len(b)<4: det["overlap"]=0.; continue
|
| 1462 |
-
x1,y1,x2,y2=[int(v) for v in b]
|
| 1463 |
-
area=(x2-x1)*(y2-y1)
|
| 1464 |
-
det["overlap"]=min(float(binary_mask_for_overlap[y1:y2,x1:x2].sum())/max(area,1),1.)
|
| 1465 |
|
| 1466 |
# Step 3a — binary segmentation (ResUNet)
|
| 1467 |
progress(0.50, desc="Running ResUNet…")
|
|
@@ -1469,7 +1338,7 @@ def analyse(video_path, sten_conf, seg_conf, px_per_mm_override, progress=gr.Pro
|
|
| 1469 |
print(f"[ResUNet] binary_mask sum={binary_mask.sum()} "
|
| 1470 |
f"unique={np.unique(binary_mask)} "
|
| 1471 |
f"vessel%={100*binary_mask.mean():.1f}")
|
| 1472 |
-
#
|
| 1473 |
for det in detections:
|
| 1474 |
b=det.get("bbox",[])
|
| 1475 |
if len(b)<4: continue
|
|
@@ -1481,26 +1350,37 @@ def analyse(video_path, sten_conf, seg_conf, px_per_mm_override, progress=gr.Pro
|
|
| 1481 |
progress(0.65, desc="Running seg…")
|
| 1482 |
seg_map = run_yolo_seg(frame_rgb, seg_conf)
|
| 1483 |
seg_overlay = render_nnunet_overlay(frame_rgb, seg_map)
|
|
|
|
|
|
|
| 1484 |
|
| 1485 |
# Step 4 — FFR
|
| 1486 |
progress(0.80, desc="Computing FFR…")
|
| 1487 |
ffr_result, ffr_fig = run_ffr(frame_rgb, binary_mask, detections)
|
| 1488 |
|
| 1489 |
if "error" in ffr_result:
|
| 1490 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1491 |
frame_rgb, binary_overlay, seg_overlay, None,
|
| 1492 |
_metrics_html(ffr_result, {}, frame_idx, total_frames),
|
| 1493 |
_to_json(ffr_result))
|
| 1494 |
|
| 1495 |
# Step 5 — SYNTAX score
|
| 1496 |
progress(0.92, desc="Computing SYNTAX score…")
|
| 1497 |
-
syntax_score, syntax_tier = compute_syntax_score(ffr_result["stenoses"],
|
| 1498 |
ffr_result["syntax_score"] = syntax_score
|
| 1499 |
ffr_result["syntax_tier"] = syntax_tier
|
|
|
|
|
|
|
|
|
|
| 1500 |
|
| 1501 |
progress(1.00, desc="Done.")
|
|
|
|
| 1502 |
status = _status("ok",
|
| 1503 |
-
|
|
|
|
| 1504 |
f"(quality score {frame_score:.1f})")
|
| 1505 |
metrics = _metrics_html(ffr_result, {"score":frame_score,"idx":frame_idx},
|
| 1506 |
frame_idx, total_frames)
|
|
@@ -1533,85 +1413,128 @@ def _status(kind: str, msg: str) -> str:
|
|
| 1533 |
f'{ico} {msg}</div>')
|
| 1534 |
|
| 1535 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1536 |
def _metrics_html(ffr_result, frame_info, frame_idx, total) -> str:
|
| 1537 |
-
if not ffr_result
|
| 1538 |
return '<div class="card"><div class="card-title">Metrics</div><p style="color:#5a7a96">Run analysis to see results.</p></div>'
|
| 1539 |
|
| 1540 |
-
|
| 1541 |
-
|
| 1542 |
-
|
| 1543 |
-
|
| 1544 |
-
|
| 1545 |
-
|
| 1546 |
-
|
| 1547 |
-
|
| 1548 |
-
|
| 1549 |
-
|
| 1550 |
-
|
| 1551 |
-
|
| 1552 |
-
|
| 1553 |
-
|
| 1554 |
-
|
| 1555 |
-
|
| 1556 |
-
|
| 1557 |
-
|
| 1558 |
-
|
| 1559 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1560 |
sig_col = "#d64040" if st.get("significant") else "#0d9e6e"
|
| 1561 |
-
|
| 1562 |
-
|
| 1563 |
-
|
| 1564 |
-
|
| 1565 |
-
|
| 1566 |
-
|
| 1567 |
-
|
| 1568 |
-
|
| 1569 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1570 |
|
| 1571 |
return f"""
|
| 1572 |
<div class="card">
|
| 1573 |
<div class="card-title">Key metrics</div>
|
| 1574 |
<div class="metric-row">
|
| 1575 |
-
|
| 1576 |
-
|
| 1577 |
-
|
| 1578 |
-
|
| 1579 |
-
|
| 1580 |
-
|
| 1581 |
-
|
| 1582 |
-
</div>
|
| 1583 |
-
<div class="metric-badge">
|
| 1584 |
-
<div class="val">{ns}</div>
|
| 1585 |
-
<div class="lbl">Stenoses</div>
|
| 1586 |
-
</div>
|
| 1587 |
-
<div class="metric-badge">
|
| 1588 |
-
<div class="val {syn_cls}">{syn}</div>
|
| 1589 |
-
<div class="lbl">SYNTAX score</div>
|
| 1590 |
-
</div>
|
| 1591 |
-
<div class="metric-badge">
|
| 1592 |
-
<div class="val">{ref} mm</div>
|
| 1593 |
-
<div class="lbl">Ref diameter</div>
|
| 1594 |
-
</div>
|
| 1595 |
-
<div class="metric-badge">
|
| 1596 |
-
<div class="val">{vlen} mm</div>
|
| 1597 |
-
<div class="lbl">Vessel length</div>
|
| 1598 |
-
</div>
|
| 1599 |
</div>
|
| 1600 |
<div style="font-size:12px;color:#5a7a96;margin-top:4px">
|
| 1601 |
-
|
| 1602 |
-
|
| 1603 |
-
|
|
|
|
| 1604 |
</div>
|
| 1605 |
</div>
|
| 1606 |
-
|
| 1607 |
-
|
|
|
|
| 1608 |
"""
|
| 1609 |
|
| 1610 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 1611 |
# GRADIO APP
|
| 1612 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 1613 |
|
| 1614 |
-
with gr.Blocks(css=CSS, title="
|
| 1615 |
|
| 1616 |
# Header
|
| 1617 |
gr.HTML(_header_html())
|
|
@@ -1637,8 +1560,8 @@ with gr.Blocks(css=CSS, title="Angio AI") as demo:
|
|
| 1637 |
|
| 1638 |
gr.HTML('<div class="card"><div class="card-title">Model controls</div>')
|
| 1639 |
sten_conf = gr.Slider(
|
| 1640 |
-
minimum=0.05, maximum=0.95, value=0.
|
| 1641 |
-
label="Stenosis confidence threshold (
|
| 1642 |
info="Detections below this confidence are discarded",
|
| 1643 |
)
|
| 1644 |
seg_conf = gr.Slider(
|
|
@@ -1651,50 +1574,41 @@ with gr.Blocks(css=CSS, title="Angio AI") as demo:
|
|
| 1651 |
label="Scale (px / mm)",
|
| 1652 |
info="ARCADE default = 3.75 px/mm (0.267 mm/px). Adjust if using non-ARCADE data.",
|
| 1653 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1654 |
gr.HTML('</div>')
|
| 1655 |
|
| 1656 |
# Right — info card
|
| 1657 |
with gr.Column(scale=1):
|
| 1658 |
gr.HTML("""
|
| 1659 |
<div class="card">
|
| 1660 |
-
<div class="card-title">
|
| 1661 |
-
<
|
| 1662 |
-
<
|
| 1663 |
-
|
| 1664 |
-
|
| 1665 |
-
</
|
| 1666 |
-
<
|
| 1667 |
-
|
| 1668 |
-
|
| 1669 |
-
|
| 1670 |
-
|
| 1671 |
-
|
| 1672 |
-
|
| 1673 |
-
</
|
| 1674 |
-
<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid rgba(240,249,255,0.07)">
|
| 1675 |
-
<span style="font-family:JetBrains Mono,monospace;font-size:10px;color:#22d3ee;min-width:18px">04</span>
|
| 1676 |
-
<span style="font-size:12px;color:rgba(240,249,255,0.7)">Binary vessel segmentation</span>
|
| 1677 |
-
</div>
|
| 1678 |
-
<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid rgba(240,249,255,0.07)">
|
| 1679 |
-
<span style="font-family:JetBrains Mono,monospace;font-size:10px;color:#22d3ee;min-width:18px">05</span>
|
| 1680 |
-
<span style="font-size:12px;color:rgba(240,249,255,0.7)">FFR estimation — QFR v4</span>
|
| 1681 |
-
</div>
|
| 1682 |
-
<div style="display:flex;align-items:center;gap:10px;padding:8px 0">
|
| 1683 |
-
<span style="font-family:JetBrains Mono,monospace;font-size:10px;color:#22d3ee;min-width:18px">06</span>
|
| 1684 |
-
<span style="font-size:12px;color:rgba(240,249,255,0.7)">SYNTAX score computation</span>
|
| 1685 |
-
</div>
|
| 1686 |
-
</div>
|
| 1687 |
-
<div style="margin-top:14px;padding-top:12px;border-top:1px solid rgba(240,249,255,0.07);font-size:11px;color:rgba(240,249,255,0.35);font-family:JetBrains Mono,monospace;line-height:1.8">
|
| 1688 |
-
FFR ≤ 0.80 → ischemic | 1−(0.33·DS+0.60·DS²)<br>
|
| 1689 |
-
Tu et al. JACC 2016 · FAVOR II
|
| 1690 |
</div>
|
| 1691 |
</div>
|
| 1692 |
<div class="card">
|
| 1693 |
-
<div class="card-title">
|
| 1694 |
-
<div style="font-
|
| 1695 |
-
|
| 1696 |
-
<
|
| 1697 |
-
|
|
|
|
|
|
|
|
|
|
| 1698 |
</div>
|
| 1699 |
</div>
|
| 1700 |
""")
|
|
@@ -1740,15 +1654,14 @@ with gr.Blocks(css=CSS, title="Angio AI") as demo:
|
|
| 1740 |
# Footer
|
| 1741 |
gr.HTML("""
|
| 1742 |
<div id="footer">
|
| 1743 |
-
|
| 1744 |
-
|
| 1745 |
-
Image-based QFR — Tu et al. JACC 2016 · FAVOR II trial
|
| 1746 |
</div>""")
|
| 1747 |
|
| 1748 |
# ── WIRING ────────────────────────────────────────────────────────────
|
| 1749 |
|
| 1750 |
-
def run_and_switch(video, sc, sg, px):
|
| 1751 |
-
return analyse(video, sc, sg, px)
|
| 1752 |
|
| 1753 |
def load_demo1():
|
| 1754 |
return _get_demo_video(DEMO_VIDEO_1_NAME)
|
|
@@ -1757,7 +1670,7 @@ with gr.Blocks(css=CSS, title="Angio AI") as demo:
|
|
| 1757 |
|
| 1758 |
btn_analyse.click(
|
| 1759 |
fn=run_and_switch,
|
| 1760 |
-
inputs=[video_input, sten_conf, seg_conf, px_per_mm],
|
| 1761 |
outputs=[status_out, frame_out, binary_out, seg_out, ffr_out, metrics_out, json_out],
|
| 1762 |
).then(
|
| 1763 |
# Copy status HTML to results page status box
|
|
@@ -1793,5 +1706,5 @@ if __name__ == "__main__":
|
|
| 1793 |
demo.launch(
|
| 1794 |
server_name="0.0.0.0",
|
| 1795 |
server_port=7860,
|
| 1796 |
-
share=
|
| 1797 |
)
|
|
|
|
| 7 |
# Checkpoints are loaded from HF Hub: MuhammadAdil63/angio-ai-checkpoints
|
| 8 |
# Set HF_TOKEN as a Space Secret (Settings → Secrets) for private repo access.
|
| 9 |
# Files expected in that repo:
|
| 10 |
+
# stenosis/yolov8x_seg.pt, stenosis/yolov8l_sgd.pt, stenosis/yolov8l_adamw.pt (stenosis ensemble;
|
| 11 |
+
# loaded from ./weights/stenosis first, HF Hub as fallback)
|
| 12 |
# binary_best.pth (ResUNet binary vessel segmentation)
|
| 13 |
# best.pt (YOLOv8m-seg 26-class ARCADE syntax)
|
| 14 |
#
|
|
|
|
| 113 |
CONFIG = {
|
| 114 |
# ── Checkpoint paths (resolved lazily via HF Hub) ─────────────────────────
|
| 115 |
# These callables are invoked once inside each model's _load_*() function.
|
|
|
|
| 116 |
"RESUNET_CKPT" : lambda: _hf_ckpt("binary_best.pth"),
|
| 117 |
"YOLO_CKPT" : lambda: _hf_ckpt("best.pt"),
|
| 118 |
|
|
|
|
| 171 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 172 |
|
| 173 |
CSS = """
|
| 174 |
+
/* ── Base ── */
|
|
|
|
| 175 |
:root {
|
| 176 |
+
--primary: #1a6fa8;
|
| 177 |
+
--primary-lt: #e8f3fb;
|
| 178 |
+
--primary-mid: #4a9fd4;
|
| 179 |
+
--accent: #0d9e6e; /* Angio AI green */
|
| 180 |
+
--bg: #f7fafd;
|
| 181 |
+
--surface: #ffffff;
|
| 182 |
+
--border: #cce0f0;
|
| 183 |
+
--text: #1a2533;
|
| 184 |
+
--text-sec: #5a7a96;
|
| 185 |
+
--danger: #d64040;
|
| 186 |
+
--warn: #d98b00;
|
| 187 |
+
--radius: 10px;
|
| 188 |
+
--shadow: 0 2px 12px rgba(26,111,168,0.10);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
}
|
|
|
|
| 190 |
|
| 191 |
body, .gradio-container {
|
| 192 |
+
background: var(--bg) !important;
|
| 193 |
+
font-family: 'Segoe UI', system-ui, sans-serif !important;
|
| 194 |
+
color: var(--text) !important;
|
|
|
|
| 195 |
}
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
+
/* ── Header bar ── */
|
| 198 |
#header {
|
| 199 |
+
background: var(--surface);
|
| 200 |
+
border-bottom: 2px solid var(--border);
|
| 201 |
+
padding: 14px 28px;
|
| 202 |
display: flex;
|
| 203 |
align-items: center;
|
| 204 |
justify-content: space-between;
|
| 205 |
+
box-shadow: var(--shadow);
|
| 206 |
+
margin-bottom: 0 !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
}
|
| 208 |
+
#logo-itu img { height: 48px; }
|
| 209 |
+
#logo-angio {
|
| 210 |
+
font-size: 26px;
|
| 211 |
+
font-weight: 700;
|
| 212 |
+
color: var(--accent);
|
| 213 |
+
letter-spacing: 1px;
|
| 214 |
}
|
| 215 |
+
#logo-angio span {
|
| 216 |
+
font-size: 13px;
|
| 217 |
+
color: var(--text-sec);
|
| 218 |
+
font-weight: 400;
|
| 219 |
+
display: block;
|
| 220 |
+
letter-spacing: 0;
|
| 221 |
}
|
|
|
|
| 222 |
|
| 223 |
+
/* ── Tab bar ── */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
.tab-nav button {
|
| 225 |
+
color: var(--text-sec) !important;
|
| 226 |
+
border-bottom: 3px solid transparent !important;
|
| 227 |
+
font-size: 15px !important;
|
| 228 |
+
font-weight: 500 !important;
|
| 229 |
+
padding: 10px 24px !important;
|
| 230 |
+
background: transparent !important;
|
| 231 |
}
|
|
|
|
| 232 |
.tab-nav button.selected {
|
| 233 |
+
color: var(--primary) !important;
|
| 234 |
+
border-bottom-color: var(--primary) !important;
|
|
|
|
| 235 |
}
|
| 236 |
|
| 237 |
+
/* ── Cards ── */
|
| 238 |
.card {
|
| 239 |
+
background: var(--surface);
|
| 240 |
+
border: 1px solid var(--border);
|
| 241 |
+
border-radius: var(--radius);
|
| 242 |
+
padding: 22px;
|
| 243 |
+
box-shadow: var(--shadow);
|
| 244 |
+
margin-bottom: 16px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
}
|
| 246 |
.card-title {
|
| 247 |
+
font-size: 14px;
|
| 248 |
+
font-weight: 600;
|
| 249 |
+
color: var(--primary);
|
| 250 |
+
text-transform: uppercase;
|
| 251 |
+
letter-spacing: 0.5px;
|
| 252 |
+
margin-bottom: 12px;
|
| 253 |
+
padding-bottom: 8px;
|
| 254 |
+
border-bottom: 1px solid var(--primary-lt);
|
| 255 |
}
|
| 256 |
|
| 257 |
+
/* ── Upload zone ── */
|
| 258 |
+
#upload-zone {
|
| 259 |
+
border: 2px dashed var(--primary-mid) !important;
|
| 260 |
border-radius: var(--radius) !important;
|
| 261 |
+
background: var(--primary-lt) !important;
|
| 262 |
+
min-height: 200px !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
}
|
|
|
|
| 264 |
|
| 265 |
+
/* ── Sliders ── */
|
| 266 |
+
input[type=range] { accent-color: var(--primary); }
|
| 267 |
+
.gradio-slider label { color: var(--text-sec) !important; font-size: 13px !important; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
+
/* ── Buttons ── */
|
| 270 |
#btn-analyse {
|
| 271 |
+
background: var(--primary) !important;
|
| 272 |
+
color: white !important;
|
| 273 |
+
border: none !important;
|
| 274 |
+
border-radius: var(--radius) !important;
|
| 275 |
+
font-size: 15px !important;
|
| 276 |
+
font-weight: 600 !important;
|
| 277 |
+
padding: 12px 32px !important;
|
| 278 |
+
cursor: pointer !important;
|
| 279 |
+
width: 100% !important;
|
| 280 |
+
transition: background 0.2s !important;
|
|
|
|
|
|
|
|
|
|
| 281 |
}
|
| 282 |
+
#btn-analyse:hover { background: #155d90 !important; }
|
| 283 |
#btn-reset {
|
| 284 |
+
background: transparent !important;
|
| 285 |
+
color: var(--text-sec) !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
border: 1px solid var(--border) !important;
|
| 287 |
+
border-radius: var(--radius) !important;
|
| 288 |
+
font-size: 13px !important;
|
| 289 |
+
padding: 8px 20px !important;
|
| 290 |
+
cursor: pointer !important;
|
| 291 |
+
width: 100% !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
}
|
| 293 |
|
| 294 |
+
/* ── Metric badges ── */
|
| 295 |
+
.metric-row {
|
| 296 |
+
display: flex;
|
| 297 |
+
gap: 12px;
|
| 298 |
+
flex-wrap: wrap;
|
| 299 |
+
margin-bottom: 12px;
|
|
|
|
| 300 |
}
|
| 301 |
+
.metric-badge {
|
| 302 |
+
flex: 1;
|
| 303 |
+
min-width: 110px;
|
| 304 |
+
background: var(--primary-lt);
|
| 305 |
+
border: 1px solid var(--border);
|
| 306 |
+
border-radius: 8px;
|
| 307 |
+
padding: 10px 14px;
|
| 308 |
+
text-align: center;
|
| 309 |
}
|
| 310 |
.metric-badge .val {
|
| 311 |
+
font-size: 22px;
|
| 312 |
+
font-weight: 700;
|
| 313 |
+
color: var(--primary);
|
| 314 |
}
|
| 315 |
+
.metric-badge .val.ischemic { color: var(--danger); }
|
| 316 |
+
.metric-badge .val.borderline { color: var(--warn); }
|
| 317 |
+
.metric-badge .val.ok { color: var(--accent); }
|
| 318 |
.metric-badge .lbl {
|
| 319 |
+
font-size: 11px;
|
| 320 |
+
color: var(--text-sec);
|
| 321 |
+
text-transform: uppercase;
|
| 322 |
+
letter-spacing: 0.4px;
|
| 323 |
}
|
| 324 |
|
| 325 |
+
/* ── Result images ── */
|
| 326 |
+
#result-images img {
|
| 327 |
+
border-radius: var(--radius);
|
| 328 |
+
border: 1px solid var(--border);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
}
|
| 330 |
|
| 331 |
+
/* ── Status ── */
|
| 332 |
+
#status-box {
|
| 333 |
+
padding: 10px 16px;
|
| 334 |
+
border-radius: 8px;
|
| 335 |
+
font-size: 13px;
|
| 336 |
+
font-weight: 500;
|
| 337 |
+
border: 1px solid var(--border);
|
| 338 |
+
background: var(--primary-lt);
|
| 339 |
+
color: var(--primary);
|
|
|
|
|
|
|
|
|
|
| 340 |
}
|
| 341 |
|
| 342 |
+
/* ── Footer ── */
|
| 343 |
#footer {
|
| 344 |
+
text-align: center;
|
| 345 |
+
font-size: 12px;
|
| 346 |
+
color: var(--text-sec);
|
| 347 |
+
padding: 20px;
|
| 348 |
+
border-top: 1px solid var(--border);
|
| 349 |
+
margin-top: 24px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
"""
|
| 352 |
|
|
|
|
| 353 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 354 |
# HEADER HTML
|
| 355 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 356 |
|
| 357 |
def _header_html() -> str:
|
| 358 |
+
return """
|
|
|
|
|
|
|
|
|
|
| 359 |
<div id="header">
|
| 360 |
<div id="logo-angio">
|
| 361 |
+
AngioAI Pro
|
| 362 |
+
<span>Coronary Angiography Analysis · Research use only</span>
|
| 363 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
</div>"""
|
| 365 |
|
| 366 |
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
| 408 |
return rgb, best_idx, best_score, total
|
| 409 |
|
| 410 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 411 |
+
# MODULE 2 — STENOSIS DETECTION (YOLOv8-seg ensemble)
|
| 412 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 413 |
|
| 414 |
+
# Stenosis detection: 3 x YOLOv8-seg ensemble (see stenosis_ensemble.py).
|
| 415 |
+
# Replaces Mask2Former, which scored worst on the held-out ARCADE test set (F1@0.3 0.485 vs 0.637).
|
| 416 |
+
_sten_ens = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
|
| 418 |
|
| 419 |
+
def run_stenosis(frame_rgb: np.ndarray, conf_thr: float) -> list:
|
| 420 |
"""
|
| 421 |
+
Returns list of dicts: [{bbox:[x1,y1,x2,y2], score:float, mask:np.ndarray, n_models:int}, ...]
|
| 422 |
"""
|
| 423 |
+
global _sten_ens
|
| 424 |
+
if _sten_ens is None:
|
| 425 |
+
from stenosis_ensemble import StenosisEnsemble
|
| 426 |
+
_sten_ens = StenosisEnsemble()
|
| 427 |
+
return _sten_ens.predict(frame_rgb, score_thr=conf_thr)
|
|
|
|
|
|
|
|
|
|
| 428 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
|
| 430 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 431 |
# MODULE 3 — RESUNET BINARY SEGMENTATION
|
|
|
|
| 680 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 681 |
|
| 682 |
# ARCADE 26-class name list (index = YOLO cls_id + 1)
|
| 683 |
+
# YOLO cls_id + 1 == ARCADE category id == SYNTAX segment (verified from best.pt names:
|
| 684 |
+
# '1'..'9','9a','10','10a','11','12','12a','13','14','14a','15','16','16a','16b','16c','12b','14b','stenosis').
|
| 685 |
+
# cls_id 25 ('stenosis') is not an anatomical segment and is ignored in the seg map.
|
| 686 |
+
SYNTAX_SEGMENTS = {
|
| 687 |
+
1:"1 RCA prox", 2:"2 RCA mid", 3:"3 RCA dist", 4:"4 R-PDA",
|
| 688 |
+
5:"5 Left main", 6:"6 LAD prox", 7:"7 LAD mid", 8:"8 LAD apical",
|
| 689 |
+
9:"9 D1", 10:"9a D1a", 11:"10 D2", 12:"10a D2a",
|
| 690 |
+
13:"11 LCx prox", 14:"12 Intermediate", 15:"12a OM1", 16:"13 LCx dist",
|
| 691 |
+
17:"14 L-PL", 18:"14a L-PL a", 19:"15 L-PDA", 20:"16 R-PL",
|
| 692 |
+
21:"16a R-PL a", 22:"16b R-PL b", 23:"16c R-PL c", 24:"12b OM2",
|
| 693 |
+
25:"14b L-PL b",
|
| 694 |
+
}
|
| 695 |
+
_YOLO_CLASS_NAMES = ["background"] + [SYNTAX_SEGMENTS[i] for i in range(1, 26)]
|
| 696 |
+
_YOLO_STENOSIS_CLS = 25
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 697 |
|
| 698 |
_yolo_model = None # singleton -- loaded once on first call, reused every frame
|
| 699 |
|
|
|
|
| 781 |
n_written = 0
|
| 782 |
for i in order:
|
| 783 |
cls_id = int(cls_ids[i])
|
| 784 |
+
if cls_id >= _YOLO_STENOSIS_CLS: # 'stenosis' class -> not a segment
|
| 785 |
+
continue
|
| 786 |
mask_resized = cv2.resize(
|
| 787 |
masks[i], (w, h), interpolation=cv2.INTER_LINEAR
|
| 788 |
)
|
| 789 |
binary = mask_resized > 0.5
|
| 790 |
+
label_val = cls_id + 1
|
| 791 |
seg_map[binary] = label_val
|
| 792 |
n_written += int(binary.sum())
|
| 793 |
|
| 794 |
+
detected_cls = [_YOLO_CLASS_NAMES[int(c) + 1] for c in cls_ids if int(c) < _YOLO_STENOSIS_CLS]
|
| 795 |
print(f"[SEG] Detections: {len(cls_ids)} | classes: {detected_cls} | "
|
| 796 |
f"vessel px: {n_written} ({100 * n_written / (h * w):.1f}%)")
|
| 797 |
print(f"[SEG] seg_map unique labels: {np.unique(seg_map)}")
|
|
|
|
| 828 |
# If auto-computed ref exceeds this, clamp it
|
| 829 |
"ref_diam_max_mm" : 5.0,
|
| 830 |
"ref_diam_min_mm" : 1.0,
|
| 831 |
+
"max_offline_px" : 12, # lesion farther than this from the measured centreline is not quantified
|
| 832 |
"px_per_mm" : 3.75,
|
| 833 |
}
|
| 834 |
|
|
|
|
| 1011 |
centers_arr = np.array(centers)
|
| 1012 |
print(f"[FFR] After noise filter: {len(diams_sm)} measurements kept")
|
| 1013 |
|
| 1014 |
+
centers_arr = np.array(centers)
|
| 1015 |
stenoses=[]; unloc=[]
|
| 1016 |
|
| 1017 |
for det in detections:
|
|
|
|
| 1024 |
x1,y1,x2,y2=bbox
|
| 1025 |
inside=np.where((centers_arr[:,0]>=y1)&(centers_arr[:,0]<=y2)&
|
| 1026 |
(centers_arr[:,1]>=x1)&(centers_arr[:,1]<=x2))[0]
|
| 1027 |
+
if len(inside)>0:
|
| 1028 |
+
idx = int(inside[np.argmin(diams_sm[inside])])
|
| 1029 |
+
else:
|
| 1030 |
+
dist = np.linalg.norm(centers_arr-np.array([(y1+y2)/2,(x1+x2)/2]),axis=1)
|
| 1031 |
+
idx = int(np.argmin(dist))
|
| 1032 |
+
# lesion is not on the measured (dominant) branch -> do not measure it there
|
| 1033 |
+
if dist[idx] > FFR_CFG["max_offline_px"]:
|
| 1034 |
+
unloc.append({**det,"reason":"not on measured branch"}); continue
|
| 1035 |
if idx>=len(diams_sm): continue
|
| 1036 |
d_sten=float(diams_sm[idx]); ds=max(0.,1.-d_sten/ref_px)
|
| 1037 |
if ds < FFR_CFG["min_ds_fraction"]:
|
| 1038 |
+
unloc.append({**det,"reason":"no measurable narrowing"}); continue
|
| 1039 |
ffr_est=_ds_to_ffr(ds)
|
| 1040 |
stenoses.append({"index":idx,"position_mm":float(arc_pos[idx]/px_mm),
|
| 1041 |
"d_mm":float(d_sten/px_mm),"ref_mm":float(ref_mm),
|
| 1042 |
"pct_ds":float(ds*100),"ffr":ffr_est,
|
| 1043 |
+
"score":score,"significant":ffr_est<=0.80,
|
| 1044 |
+
"segment":det.get("segment","—"),"segment_id":det.get("segment_id",0),
|
| 1045 |
+
"bbox":[int(v) for v in bbox],"n_models":det.get("n_models",0)})
|
| 1046 |
|
| 1047 |
global_ffr=1.
|
| 1048 |
for s in stenoses: global_ffr*=s["ffr"]
|
|
|
|
| 1058 |
b=det.get("bbox",[]); sc=det.get("score",0)
|
| 1059 |
if len(b)<4: continue
|
| 1060 |
x1,y1,x2,y2=b
|
| 1061 |
+
col="red" if sc>=0.5 else "orange" # fused ensemble score
|
| 1062 |
axes[0].add_patch(mpatches.Rectangle(
|
| 1063 |
(x1,y1),x2-x1,y2-y1,fill=False,edgecolor=col,linewidth=2))
|
| 1064 |
axes[0].text(x1,y1-5,f"{sc:.2f}",color=col,fontsize=8,fontweight="bold")
|
|
|
|
| 1103 |
axes[2].legend(fontsize=8); axes[2].grid(alpha=0.3); axes[2].set_ylim(0)
|
| 1104 |
axes[2].set_title("Lumen diameter profile", fontsize=11, color="#1a6fa8")
|
| 1105 |
|
| 1106 |
+
if not detections:
|
| 1107 |
+
ffr_title = "No stenosis detected"
|
| 1108 |
+
elif not stenoses:
|
| 1109 |
+
ffr_title = f"{len(detections)} detected, none quantifiable on the measured branch"
|
| 1110 |
+
else:
|
| 1111 |
+
ffr_label = ("⚠ ISCHEMIC" if global_ffr<=0.80 else "✓ Non-ischemic")
|
| 1112 |
+
ffr_title = f"Branch FFR: {global_ffr:.3f} {ffr_label} | Quantified: {len(stenoses)}/{len(detections)}"
|
| 1113 |
+
fig.suptitle(f"FFR Analysis — {ffr_title} | Ref: {ref_mm:.2f} mm",
|
| 1114 |
fontsize=12,fontweight="bold",color="#1a2533",y=1.01)
|
| 1115 |
plt.tight_layout()
|
| 1116 |
|
|
|
|
| 1135 |
"ref_diam_mm" : round(ref_mm,2),
|
| 1136 |
"vessel_len_mm": round(float(arc_pos[-1]/px_mm),1) if len(arc_pos)>0 else 0,
|
| 1137 |
"n_unlocalized": len(unloc),
|
| 1138 |
+
"n_detected" : len(detections),
|
| 1139 |
+
"unlocalized" : [{"segment":u.get("segment","—"),"score":round(float(u.get("score",0)),3),
|
| 1140 |
+
"bbox":[int(v) for v in u.get("bbox",[])],"reason":u.get("reason","")} for u in unloc],
|
| 1141 |
+
"n_significant": sum(1 for s_ in stenoses if s_["pct_ds"]>=50),
|
| 1142 |
}
|
| 1143 |
return result, buf
|
| 1144 |
|
|
|
|
| 1179 |
25: (180, 180, 180), # Other — silver grey
|
| 1180 |
}
|
| 1181 |
|
| 1182 |
+
ARCADE_LABELS = dict(SYNTAX_SEGMENTS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1183 |
|
| 1184 |
def render_nnunet_overlay(frame_rgb, seg_map, alpha=0.55):
|
| 1185 |
"""
|
|
|
|
| 1246 |
# MODULE 7 — SYNTAX SCORE (simplified anatomical estimate)
|
| 1247 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 1248 |
|
| 1249 |
+
# SYNTAX segment weighting factors (Sianos et al., EuroIntervention 2005, Table 1).
|
| 1250 |
+
# Keys = ARCADE label ids (see SYNTAX_SEGMENTS). Verify against syntaxscore.org before clinical use.
|
| 1251 |
+
_SYNTAX_W_RIGHT = {1:1, 2:1, 3:1, 4:1, 5:5, 6:3.5, 7:2.5, 8:1, 9:1, 10:1, 11:0.5, 12:0.5,
|
| 1252 |
+
13:1.5, 14:1, 15:1, 16:0.5, 17:0.5, 18:0.5, 19:0, 20:0.5, 21:0.5, 22:0.5, 23:0.5,
|
| 1253 |
+
24:1, 25:0.5}
|
| 1254 |
+
_SYNTAX_W_LEFT = {1:0, 2:0, 3:0, 4:0, 5:6, 6:3.5, 7:2.5, 8:1, 9:1, 10:1, 11:0.5, 12:0.5,
|
| 1255 |
+
13:2.5, 14:1, 15:1, 16:1.5, 17:1, 18:1, 19:1, 20:0, 21:0, 22:0, 23:0,
|
| 1256 |
+
24:1, 25:1}
|
| 1257 |
+
|
| 1258 |
+
|
| 1259 |
+
def segment_for_detection(det, seg_map, dilate_px=7):
|
| 1260 |
+
"""Majority anatomical label under the (slightly dilated) stenosis mask."""
|
| 1261 |
+
m = det.get("mask")
|
| 1262 |
+
if m is None:
|
| 1263 |
+
x1,y1,x2,y2 = [int(v) for v in det["bbox"]]
|
| 1264 |
+
m = np.zeros_like(seg_map, dtype=np.uint8); m[y1:y2+1, x1:x2+1] = 1
|
| 1265 |
+
m = cv2.dilate(m.astype(np.uint8), np.ones((dilate_px, dilate_px), np.uint8)) > 0
|
| 1266 |
+
labs = seg_map[m]; labs = labs[labs > 0]
|
| 1267 |
+
if labs.size == 0:
|
| 1268 |
+
return 0, "unassigned"
|
| 1269 |
+
sid = int(np.bincount(labs).argmax())
|
| 1270 |
+
return sid, SYNTAX_SEGMENTS.get(sid, "unassigned")
|
| 1271 |
+
|
| 1272 |
+
|
| 1273 |
+
def compute_syntax_score(stenoses, dominance="right"):
|
| 1274 |
"""
|
| 1275 |
+
Anatomical SYNTAX estimate: sum over lesions with DS >= 50% of
|
| 1276 |
+
segment weight x 2 (non-occlusive) or x 5 (occlusion, DS >= 99%).
|
| 1277 |
+
NOT scored yet: adverse lesion characteristics (bifurcation, ostial, length > 20 mm,
|
| 1278 |
+
tortuosity, calcification, thrombus, diffuse disease) -> true SYNTAX is >= this value.
|
| 1279 |
+
Lesions without an assigned segment are listed but not scored.
|
|
|
|
| 1280 |
"""
|
| 1281 |
+
W = _SYNTAX_W_LEFT if dominance == "left" else _SYNTAX_W_RIGHT
|
| 1282 |
+
score, scored, unassigned = 0.0, 0, 0
|
|
|
|
|
|
|
| 1283 |
for st in stenoses:
|
| 1284 |
+
if st["pct_ds"] < 50:
|
| 1285 |
+
st["syntax_points"] = 0.0; continue
|
| 1286 |
+
sid = st.get("segment_id", 0)
|
| 1287 |
+
if not sid:
|
| 1288 |
+
st["syntax_points"] = None; unassigned += 1; continue
|
| 1289 |
+
pts = W.get(sid, 0) * (5 if st["pct_ds"] >= 99 else 2)
|
| 1290 |
+
st["syntax_points"] = pts; score += pts; scored += 1
|
| 1291 |
+
score = round(score, 1)
|
| 1292 |
+
tier = "Low (≤22)" if score <= 22 else ("Intermediate (23–32)" if score <= 32 else "High (≥33)")
|
| 1293 |
+
return score, tier, scored, unassigned
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1294 |
|
| 1295 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 1296 |
# JSON ENCODER — handles numpy float32/int64 etc.
|
|
|
|
| 1313 |
# MAIN PIPELINE FUNCTION
|
| 1314 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 1315 |
|
| 1316 |
+
def analyse(video_path, sten_conf, seg_conf, px_per_mm_override, dominance="right", progress=gr.Progress()):
|
| 1317 |
"""
|
| 1318 |
Full pipeline. Called by Gradio on button click.
|
| 1319 |
Returns: (status_html, frame_img, seg_img, ffr_img, metrics_html, json_str)
|
|
|
|
| 1329 |
frame_rgb, frame_idx, frame_score, total_frames = extract_best_frame(video_path)
|
| 1330 |
|
| 1331 |
# Step 2 — stenosis detection
|
| 1332 |
+
progress(0.30, desc="Detecting stenoses (YOLOv8-seg ensemble)…")
|
| 1333 |
+
detections = run_stenosis(frame_rgb, sten_conf)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1334 |
|
| 1335 |
# Step 3a — binary segmentation (ResUNet)
|
| 1336 |
progress(0.50, desc="Running ResUNet…")
|
|
|
|
| 1338 |
print(f"[ResUNet] binary_mask sum={binary_mask.sum()} "
|
| 1339 |
f"unique={np.unique(binary_mask)} "
|
| 1340 |
f"vessel%={100*binary_mask.mean():.1f}")
|
| 1341 |
+
# Vessel overlap of each detection (used by FFR to drop off-vessel detections)
|
| 1342 |
for det in detections:
|
| 1343 |
b=det.get("bbox",[])
|
| 1344 |
if len(b)<4: continue
|
|
|
|
| 1350 |
progress(0.65, desc="Running seg…")
|
| 1351 |
seg_map = run_yolo_seg(frame_rgb, seg_conf)
|
| 1352 |
seg_overlay = render_nnunet_overlay(frame_rgb, seg_map)
|
| 1353 |
+
for det in detections:
|
| 1354 |
+
det["segment_id"], det["segment"] = segment_for_detection(det, seg_map)
|
| 1355 |
|
| 1356 |
# Step 4 — FFR
|
| 1357 |
progress(0.80, desc="Computing FFR…")
|
| 1358 |
ffr_result, ffr_fig = run_ffr(frame_rgb, binary_mask, detections)
|
| 1359 |
|
| 1360 |
if "error" in ffr_result:
|
| 1361 |
+
ffr_result["n_detected"] = len(detections)
|
| 1362 |
+
ffr_result["detections"] = [{"segment":d.get("segment","—"),"score":round(d["score"],3),
|
| 1363 |
+
"bbox":d["bbox"]} for d in detections]
|
| 1364 |
+
return (_status("warn", "Vessel centreline could not be traced — FFR/QCA not available. "
|
| 1365 |
+
+ ffr_result["error"]),
|
| 1366 |
frame_rgb, binary_overlay, seg_overlay, None,
|
| 1367 |
_metrics_html(ffr_result, {}, frame_idx, total_frames),
|
| 1368 |
_to_json(ffr_result))
|
| 1369 |
|
| 1370 |
# Step 5 — SYNTAX score
|
| 1371 |
progress(0.92, desc="Computing SYNTAX score…")
|
| 1372 |
+
syntax_score, syntax_tier, n_scored, n_unassigned = compute_syntax_score(ffr_result["stenoses"], dominance)
|
| 1373 |
ffr_result["syntax_score"] = syntax_score
|
| 1374 |
ffr_result["syntax_tier"] = syntax_tier
|
| 1375 |
+
ffr_result["syntax_lesions_scored"] = n_scored
|
| 1376 |
+
ffr_result["syntax_lesions_unassigned"] = n_unassigned
|
| 1377 |
+
ffr_result["dominance"] = dominance
|
| 1378 |
|
| 1379 |
progress(1.00, desc="Done.")
|
| 1380 |
+
n_det = len(detections)
|
| 1381 |
status = _status("ok",
|
| 1382 |
+
("No stenosis detected — " if n_det == 0 else f"{n_det} stenosis candidate(s) detected — ")
|
| 1383 |
+
+ f"Frame {frame_idx}/{total_frames} "
|
| 1384 |
f"(quality score {frame_score:.1f})")
|
| 1385 |
metrics = _metrics_html(ffr_result, {"score":frame_score,"idx":frame_idx},
|
| 1386 |
frame_idx, total_frames)
|
|
|
|
| 1413 |
f'{ico} {msg}</div>')
|
| 1414 |
|
| 1415 |
|
| 1416 |
+
def _badge(val, lbl, cls="", small=False):
|
| 1417 |
+
st = ' style="font-size:14px"' if small else ""
|
| 1418 |
+
return (f'<div class="metric-badge"><div class="val {cls}"{st}>{val}</div>'
|
| 1419 |
+
f'<div class="lbl">{lbl}</div></div>')
|
| 1420 |
+
|
| 1421 |
+
|
| 1422 |
def _metrics_html(ffr_result, frame_info, frame_idx, total) -> str:
|
| 1423 |
+
if not ffr_result:
|
| 1424 |
return '<div class="card"><div class="card-title">Metrics</div><p style="color:#5a7a96">Run analysis to see results.</p></div>'
|
| 1425 |
|
| 1426 |
+
n_det = int(ffr_result.get("n_detected", 0))
|
| 1427 |
+
err = ffr_result.get("error")
|
| 1428 |
+
|
| 1429 |
+
# ── Case A: no stenosis detected ──────────────────────────────────────────
|
| 1430 |
+
if n_det == 0:
|
| 1431 |
+
extra = ("" if err else
|
| 1432 |
+
f'<div style="font-size:12px;color:#5a7a96;margin-top:6px">Measured branch: '
|
| 1433 |
+
f'ref diameter {ffr_result.get("ref_diam_mm","—")} mm, length {ffr_result.get("vessel_len_mm","—")} mm '
|
| 1434 |
+
f' | Frame {frame_idx}/{total} | Scale {FFR_CFG["px_per_mm"]} px/mm</div>')
|
| 1435 |
+
return f"""
|
| 1436 |
+
<div class="card">
|
| 1437 |
+
<div class="card-title">Key metrics</div>
|
| 1438 |
+
<div class="metric-row">
|
| 1439 |
+
{_badge("0", "Stenoses detected", "ok")}
|
| 1440 |
+
{_badge("✓ None", "Stenosis", "ok", small=True)}
|
| 1441 |
+
{_badge("N/A", "FFR (no lesion)")}
|
| 1442 |
+
{_badge("0", "SYNTAX (anatomical)", "ok")}
|
| 1443 |
+
</div>
|
| 1444 |
+
<div style="font-size:13px;color:#0d9e6e;margin-top:6px"><b>No stenosis detected</b> at the current
|
| 1445 |
+
confidence threshold. This is not a clinical exclusion of coronary artery disease.</div>
|
| 1446 |
+
{extra}
|
| 1447 |
+
</div>"""
|
| 1448 |
+
|
| 1449 |
+
# ── Case B: detections, but QCA/FFR failed (no centreline) ───────────────
|
| 1450 |
+
if err:
|
| 1451 |
+
rows = "".join(f'<tr><td style="padding:4px 8px">#{i+1}</td><td style="padding:4px 8px">{d["segment"]}</td>'
|
| 1452 |
+
f'<td style="padding:4px 8px">{d["score"]:.2f}</td></tr>'
|
| 1453 |
+
for i, d in enumerate(ffr_result.get("detections", [])))
|
| 1454 |
+
return f"""
|
| 1455 |
+
<div class="card">
|
| 1456 |
+
<div class="card-title">Key metrics</div>
|
| 1457 |
+
<div class="metric-row">
|
| 1458 |
+
{_badge(n_det, "Stenoses detected", "borderline")}
|
| 1459 |
+
{_badge("N/A", "FFR / %DS", "", small=True)}
|
| 1460 |
+
{_badge("N/A", "SYNTAX", "", small=True)}
|
| 1461 |
+
</div>
|
| 1462 |
+
<div style="font-size:12px;color:#d98b00;margin-top:4px">Vessel centreline could not be traced, so
|
| 1463 |
+
diameter stenosis, FFR and SYNTAX could not be computed.</div>
|
| 1464 |
+
</div>
|
| 1465 |
+
<div class="card"><div class="card-title">Detections</div><table style="width:100%;border-collapse:collapse;font-size:13px">
|
| 1466 |
+
<thead><tr style="background:#e8f3fb;color:#1a6fa8"><th style="padding:6px 8px;text-align:left">#</th>
|
| 1467 |
+
<th style="padding:6px 8px;text-align:left">Segment</th><th style="padding:6px 8px;text-align:left">Confidence</th></tr></thead>
|
| 1468 |
+
<tbody>{rows}</tbody></table></div>"""
|
| 1469 |
+
|
| 1470 |
+
# ── Case C: normal result ────────────────────────────────────────────────
|
| 1471 |
+
sts = ffr_result.get("stenoses", [])
|
| 1472 |
+
n_q = len(sts)
|
| 1473 |
+
n_sig = int(ffr_result.get("n_significant", 0))
|
| 1474 |
+
gffr = ffr_result.get("global_ffr")
|
| 1475 |
+
syn = ffr_result.get("syntax_score", 0.0)
|
| 1476 |
+
stier = ffr_result.get("syntax_tier", "—")
|
| 1477 |
+
|
| 1478 |
+
if n_q == 0:
|
| 1479 |
+
ffr_val, ffr_cls, isch_lbl, isch_cls = "N/A", "", "Not quantified", ""
|
| 1480 |
+
else:
|
| 1481 |
+
ffr_val = f"{gffr:.2f}"
|
| 1482 |
+
isch = ffr_result.get("ischemic")
|
| 1483 |
+
ffr_cls = isch_cls = "ischemic" if isch else "ok"
|
| 1484 |
+
isch_lbl = "⚠ ISCHEMIC" if isch else "✓ NON-ISCHEMIC"
|
| 1485 |
+
syn_cls = "ischemic" if syn >= 33 else ("borderline" if syn >= 23 else "ok")
|
| 1486 |
+
sig_cls = "ischemic" if n_sig else "ok"
|
| 1487 |
+
|
| 1488 |
+
rows = ""
|
| 1489 |
+
for i, st in enumerate(sts):
|
| 1490 |
sig_col = "#d64040" if st.get("significant") else "#0d9e6e"
|
| 1491 |
+
pts = st.get("syntax_points")
|
| 1492 |
+
pts_s = "—" if pts is None else (f"{pts:g}" if pts else "0")
|
| 1493 |
+
rows += (f'<tr><td style="padding:4px 8px">#{i+1}</td>'
|
| 1494 |
+
f'<td style="padding:4px 8px">{st.get("segment","—")}</td>'
|
| 1495 |
+
f'<td style="padding:4px 8px">{st["score"]:.2f}</td>'
|
| 1496 |
+
f'<td style="padding:4px 8px">{st["pct_ds"]:.0f}%</td>'
|
| 1497 |
+
f'<td style="padding:4px 8px">{st["d_mm"]:.2f} mm</td>'
|
| 1498 |
+
f'<td style="padding:4px 8px;color:{sig_col};font-weight:600">{st["ffr"]:.2f}</td>'
|
| 1499 |
+
f'<td style="padding:4px 8px">{pts_s}</td>'
|
| 1500 |
+
f'<td style="padding:4px 8px">quantified</td></tr>')
|
| 1501 |
+
for j, u in enumerate(ffr_result.get("unlocalized", [])):
|
| 1502 |
+
rows += (f'<tr style="color:#7a9ab6"><td style="padding:4px 8px">#{n_q+j+1}</td>'
|
| 1503 |
+
f'<td style="padding:4px 8px">{u["segment"]}</td><td style="padding:4px 8px">{u["score"]:.2f}</td>'
|
| 1504 |
+
f'<td style="padding:4px 8px">—</td><td style="padding:4px 8px">—</td><td style="padding:4px 8px">—</td>'
|
| 1505 |
+
f'<td style="padding:4px 8px">—</td><td style="padding:4px 8px">{u["reason"]}</td></tr>')
|
| 1506 |
+
th = "".join(f'<th style="padding:6px 8px;text-align:left">{h}</th>' for h in
|
| 1507 |
+
["#", "Segment", "Conf.", "DS%", "Min diam", "FFR", "SYNTAX pts", "Status"])
|
| 1508 |
|
| 1509 |
return f"""
|
| 1510 |
<div class="card">
|
| 1511 |
<div class="card-title">Key metrics</div>
|
| 1512 |
<div class="metric-row">
|
| 1513 |
+
{_badge(n_det, "Stenoses detected", "borderline")}
|
| 1514 |
+
{_badge(f"{n_sig}", "Significant (DS ≥ 50%)", sig_cls)}
|
| 1515 |
+
{_badge(ffr_val, "Branch FFR (QFR est.)", ffr_cls)}
|
| 1516 |
+
{_badge(isch_lbl, "Ischaemia", isch_cls, small=True)}
|
| 1517 |
+
{_badge(f"{syn:g}", "SYNTAX (anatomical)", syn_cls)}
|
| 1518 |
+
{_badge(f'{ffr_result.get("ref_diam_mm","—")} mm', "Ref diameter (measured branch)")}
|
| 1519 |
+
{_badge(f'{ffr_result.get("vessel_len_mm","—")} mm', "Measured branch length")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1520 |
</div>
|
| 1521 |
<div style="font-size:12px;color:#5a7a96;margin-top:4px">
|
| 1522 |
+
Quantified on measured branch: <b>{n_q}/{n_det}</b> |
|
| 1523 |
+
SYNTAX tier: <b>{stier}</b> ({ffr_result.get("dominance","right")} dominance; lesions ≥50% DS only;
|
| 1524 |
+
adverse lesion features not yet scored{"; " + str(ffr_result.get("syntax_lesions_unassigned")) + " lesion(s) without segment" if ffr_result.get("syntax_lesions_unassigned") else ""}) |
|
| 1525 |
+
Frame {frame_idx}/{total} | Scale: {FFR_CFG["px_per_mm"]} px/mm
|
| 1526 |
</div>
|
| 1527 |
</div>
|
| 1528 |
+
<div class="card"><div class="card-title">Stenosis table</div>
|
| 1529 |
+
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
| 1530 |
+
<thead><tr style="background:#e8f3fb;color:#1a6fa8">{th}</tr></thead><tbody>{rows}</tbody></table></div>
|
| 1531 |
"""
|
| 1532 |
|
| 1533 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 1534 |
# GRADIO APP
|
| 1535 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 1536 |
|
| 1537 |
+
with gr.Blocks(css=CSS, title="AngioAI Pro") as demo:
|
| 1538 |
|
| 1539 |
# Header
|
| 1540 |
gr.HTML(_header_html())
|
|
|
|
| 1560 |
|
| 1561 |
gr.HTML('<div class="card"><div class="card-title">Model controls</div>')
|
| 1562 |
sten_conf = gr.Slider(
|
| 1563 |
+
minimum=0.05, maximum=0.95, value=0.20, step=0.05,
|
| 1564 |
+
label="Stenosis confidence threshold (YOLOv8-seg ensemble)",
|
| 1565 |
info="Detections below this confidence are discarded",
|
| 1566 |
)
|
| 1567 |
seg_conf = gr.Slider(
|
|
|
|
| 1574 |
label="Scale (px / mm)",
|
| 1575 |
info="ARCADE default = 3.75 px/mm (0.267 mm/px). Adjust if using non-ARCADE data.",
|
| 1576 |
)
|
| 1577 |
+
dominance = gr.Radio(
|
| 1578 |
+
choices=["right", "left"], value="right",
|
| 1579 |
+
label="Coronary dominance (for SYNTAX weighting)",
|
| 1580 |
+
)
|
| 1581 |
gr.HTML('</div>')
|
| 1582 |
|
| 1583 |
# Right — info card
|
| 1584 |
with gr.Column(scale=1):
|
| 1585 |
gr.HTML("""
|
| 1586 |
<div class="card">
|
| 1587 |
+
<div class="card-title">Pipeline overview</div>
|
| 1588 |
+
<ol style="font-size:13px;color:#1a2533;line-height:1.9;padding-left:16px">
|
| 1589 |
+
<li>Best frame extracted from video</li>
|
| 1590 |
+
<li>Stenosis detection — YOLOv8-seg ensemble (3 models)</li>
|
| 1591 |
+
<li>Coronary segmentation — 26-class anatomy labelling</li>
|
| 1592 |
+
<li>Binary vessel mask — ResUNet</li>
|
| 1593 |
+
<li>FFR estimation — QFR v4</li>
|
| 1594 |
+
<li>SYNTAX score computation</li>
|
| 1595 |
+
</ol>
|
| 1596 |
+
<hr style="border-color:#cce0f0;margin:12px 0">
|
| 1597 |
+
<div style="font-size:12px;color:#5a7a96">
|
| 1598 |
+
<b>FFR threshold:</b> ≤ 0.80 → ischemic<br>
|
| 1599 |
+
<b>Formula:</b> 1 − (0.33·DS + 0.60·DS²)<br>
|
| 1600 |
+
<b>Reference:</b> Tu et al. JACC 2016
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1601 |
</div>
|
| 1602 |
</div>
|
| 1603 |
<div class="card">
|
| 1604 |
+
<div class="card-title">Model checkpoints</div>
|
| 1605 |
+
<div style="font-size:12px;color:#5a7a96;line-height:1.8">
|
| 1606 |
+
Stenosis: <code>weights/stenosis/</code><br>
|
| 1607 |
+
<code>yolov8x_seg.pt · yolov8l_sgd.pt · yolov8l_adamw.pt</code><br><br>
|
| 1608 |
+
Others auto-downloaded from<br>
|
| 1609 |
+
<code>MuhammadAdil63/angio-ai-checkpoints</code><br>
|
| 1610 |
+
<code>binary_best.pth</code><br>
|
| 1611 |
+
<code>best.pt</code>
|
| 1612 |
</div>
|
| 1613 |
</div>
|
| 1614 |
""")
|
|
|
|
| 1654 |
# Footer
|
| 1655 |
gr.HTML("""
|
| 1656 |
<div id="footer">
|
| 1657 |
+
AngioAI Pro · Research use only — not a medical device, not for clinical diagnosis |
|
| 1658 |
+
Image-based QFR — Tu et al. JACC 2016 · FAVOR II trial | SYNTAX weights — Sianos et al. 2005
|
|
|
|
| 1659 |
</div>""")
|
| 1660 |
|
| 1661 |
# ── WIRING ────────────────────────────────────────────────────────────
|
| 1662 |
|
| 1663 |
+
def run_and_switch(video, sc, sg, px, dom):
|
| 1664 |
+
return analyse(video, sc, sg, px, dom)
|
| 1665 |
|
| 1666 |
def load_demo1():
|
| 1667 |
return _get_demo_video(DEMO_VIDEO_1_NAME)
|
|
|
|
| 1670 |
|
| 1671 |
btn_analyse.click(
|
| 1672 |
fn=run_and_switch,
|
| 1673 |
+
inputs=[video_input, sten_conf, seg_conf, px_per_mm, dominance],
|
| 1674 |
outputs=[status_out, frame_out, binary_out, seg_out, ffr_out, metrics_out, json_out],
|
| 1675 |
).then(
|
| 1676 |
# Copy status HTML to results page status box
|
|
|
|
| 1706 |
demo.launch(
|
| 1707 |
server_name="0.0.0.0",
|
| 1708 |
server_port=7860,
|
| 1709 |
+
share=os.environ.get("SPACE_ID") is None, # public share link only when run locally
|
| 1710 |
)
|
requirements.txt
CHANGED
|
@@ -1,10 +1,7 @@
|
|
| 1 |
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 2 |
torch==2.7.0+cpu
|
| 3 |
torchvision==0.22.0+cpu
|
| 4 |
-
|
| 5 |
-
transformers==4.49.0
|
| 6 |
-
accelerate>=0.34.2
|
| 7 |
-
ultralytics==8.2.18
|
| 8 |
huggingface_hub>=0.26.0
|
| 9 |
opencv-python-headless==4.9.0.80
|
| 10 |
scikit-image==0.25.2
|
|
|
|
| 1 |
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 2 |
torch==2.7.0+cpu
|
| 3 |
torchvision==0.22.0+cpu
|
| 4 |
+
ultralytics==8.4.31
|
|
|
|
|
|
|
|
|
|
| 5 |
huggingface_hub>=0.26.0
|
| 6 |
opencv-python-headless==4.9.0.80
|
| 7 |
scikit-image==0.25.2
|
stenosis_ensemble.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AngioAI - stenosis detection: 3 x YOLOv8-seg ensemble with weighted mask fusion.
|
| 3 |
+
|
| 4 |
+
Validated on ARCADE stenosis (threshold/config chosen on VAL, reported on held-out TEST, 300 imgs):
|
| 5 |
+
F1@IoU0.3 = 0.637 precision 0.549 recall 0.759 AP30 0.707 F1@IoU0.5 = 0.467 0.80 FP/img
|
| 6 |
+
(see D:\\AngioAI - Commercial\\stenosis\\results\\ensemble\\)
|
| 7 |
+
|
| 8 |
+
Fusion (same as eval_ensemble_stenosis.py):
|
| 9 |
+
- predictions from all members clustered by box IoU >= CLUSTER_IOU (one prediction per model per cluster)
|
| 10 |
+
- cluster score = sum_m(w_m * s_m) / sum(all w) -> a lesion seen by only one model is down-weighted
|
| 11 |
+
- cluster mask = weighted pixel vote >= 0.5
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
ens = StenosisEnsemble() # loads weights from ./weights/stenosis or ANGIOAI_WEIGHTS
|
| 15 |
+
dets = ens.predict(frame_rgb, score_thr=0.20)
|
| 16 |
+
# -> [{"bbox":[x1,y1,x2,y2], "score":float, "mask":uint8[H,W], "n_models":int, "models":[...]}, ...]
|
| 17 |
+
"""
|
| 18 |
+
import os
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
WEIGHTS_DIR = Path(os.environ.get("ANGIOAI_WEIGHTS", Path(__file__).resolve().parent / "weights")) / "stenosis"
|
| 24 |
+
|
| 25 |
+
# name: (file, imgsz used in training, fusion weight = val AP@IoU0.3 of the single model)
|
| 26 |
+
MEMBERS = {
|
| 27 |
+
"yolov8x_seg": ("yolov8x_seg.pt", 640, 0.6407),
|
| 28 |
+
"yolov8l_sgd": ("yolov8l_sgd.pt", 512, 0.6173),
|
| 29 |
+
"yolov8l_adamw": ("yolov8l_adamw.pt", 512, 0.6107),
|
| 30 |
+
}
|
| 31 |
+
CLUSTER_IOU = 0.20
|
| 32 |
+
DEFAULT_SCORE_THR = 0.20 # chosen on ARCADE val (max F1@IoU0.3)
|
| 33 |
+
MEMBER_CONF = 0.01 # keep low-confidence member preds; the fused score does the filtering
|
| 34 |
+
MAX_DET = 30
|
| 35 |
+
HF_REPO = os.environ.get("ANGIOAI_HF_REPO", "MuhammadAdil63/angio-ai-checkpoints")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _resolve(fname: str) -> str:
|
| 39 |
+
local = WEIGHTS_DIR / fname
|
| 40 |
+
if local.exists():
|
| 41 |
+
return str(local)
|
| 42 |
+
from huggingface_hub import hf_hub_download # fallback for cloud deployment
|
| 43 |
+
return hf_hub_download(repo_id=HF_REPO, filename=f"stenosis/{fname}", repo_type="model",
|
| 44 |
+
token=os.environ.get("HF_TOKEN"))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _box_iou(a, b):
|
| 48 |
+
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]))
|
| 49 |
+
i = ix * iy
|
| 50 |
+
u = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - i
|
| 51 |
+
return i / u if u else 0.0
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _mask_box(m):
|
| 55 |
+
ys, xs = np.nonzero(m)
|
| 56 |
+
return (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1) if len(ys) else None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class StenosisEnsemble:
|
| 60 |
+
def __init__(self, device=None, members=None):
|
| 61 |
+
from ultralytics import YOLO
|
| 62 |
+
import torch
|
| 63 |
+
self.device = device if device is not None else (0 if torch.cuda.is_available() else "cpu")
|
| 64 |
+
self.members = members or MEMBERS
|
| 65 |
+
self.models = {n: YOLO(_resolve(f)) for n, (f, _, _) in self.members.items()}
|
| 66 |
+
self.W = sum(w for _, _, w in self.members.values())
|
| 67 |
+
|
| 68 |
+
def _member_preds(self, frame_rgb):
|
| 69 |
+
H, W = frame_rgb.shape[:2]
|
| 70 |
+
bgr = np.ascontiguousarray(frame_rgb[..., ::-1]) # ultralytics expects BGR numpy
|
| 71 |
+
out = {}
|
| 72 |
+
for n, model in self.models.items():
|
| 73 |
+
_, imgsz, _ = self.members[n]
|
| 74 |
+
r = model.predict(bgr, imgsz=imgsz, conf=MEMBER_CONF, max_det=MAX_DET,
|
| 75 |
+
retina_masks=True, verbose=False, device=self.device)[0]
|
| 76 |
+
preds = []
|
| 77 |
+
if r.masks is not None:
|
| 78 |
+
for m, s in zip(r.masks.data.cpu().numpy() > 0.5, r.boxes.conf.cpu().numpy()):
|
| 79 |
+
if m.shape != (H, W):
|
| 80 |
+
import cv2
|
| 81 |
+
m = cv2.resize(m.astype(np.uint8), (W, H), interpolation=cv2.INTER_NEAREST) > 0
|
| 82 |
+
b = _mask_box(m)
|
| 83 |
+
if b:
|
| 84 |
+
preds.append((float(s), b, m))
|
| 85 |
+
out[n] = preds
|
| 86 |
+
return out
|
| 87 |
+
|
| 88 |
+
def fuse(self, member_preds):
|
| 89 |
+
cand = sorted(((s, b, m, n) for n, ps in member_preds.items() for s, b, m in ps),
|
| 90 |
+
key=lambda x: -(x[0] * self.members[x[3]][2]))
|
| 91 |
+
clusters = []
|
| 92 |
+
for s, b, m, n in cand:
|
| 93 |
+
for c in clusters:
|
| 94 |
+
if n not in c["m"] and _box_iou(b, c["ref"]) >= CLUSTER_IOU:
|
| 95 |
+
c["m"][n] = (s, m); break
|
| 96 |
+
else:
|
| 97 |
+
clusters.append({"ref": b, "m": {n: (s, m)}})
|
| 98 |
+
dets = []
|
| 99 |
+
for c in clusters:
|
| 100 |
+
score = sum(self.members[n][2] * s for n, (s, _) in c["m"].items()) / self.W
|
| 101 |
+
acc = None; tot = 0.0
|
| 102 |
+
for n, (s, m) in c["m"].items():
|
| 103 |
+
w = self.members[n][2] * s
|
| 104 |
+
acc = m * w if acc is None else acc + m * w
|
| 105 |
+
tot += w
|
| 106 |
+
mask = acc >= 0.5 * tot
|
| 107 |
+
b = _mask_box(mask)
|
| 108 |
+
if b:
|
| 109 |
+
dets.append({"bbox": [b[0], b[1], b[2] - 1, b[3] - 1], "score": float(score),
|
| 110 |
+
"mask": mask.astype(np.uint8), "n_models": len(c["m"]),
|
| 111 |
+
"models": sorted(c["m"])})
|
| 112 |
+
return sorted(dets, key=lambda d: -d["score"])
|
| 113 |
+
|
| 114 |
+
def predict(self, frame_rgb, score_thr=DEFAULT_SCORE_THR):
|
| 115 |
+
return [d for d in self.fuse(self._member_preds(frame_rgb)) if d["score"] >= score_thr]
|