MuhammadAdil63 commited on
Commit
f0d9c8b
·
verified ·
1 Parent(s): 1839f85

Deploy AngioAI Pro: YOLOv8-seg stenosis ensemble, corrected metrics & SYNTAX

Browse files
Files changed (4) hide show
  1. README.md +15 -43
  2. app.py +389 -476
  3. requirements.txt +1 -4
  4. stenosis_ensemble.py +115 -0
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Angio AI
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: apache-2.0
11
  ---
12
 
13
- # Angio AI — Coronary Angiography Analysis System
14
 
15
- **MS Data Science Thesis · Information Technology University Lahore**
16
- **Supervisor: Dr. Arif Mehmood**
17
 
18
- ---
19
-
20
- ## What it does
21
-
22
- Upload a coronary X-ray angiography (XCA) video and the pipeline automatically:
23
-
24
- | Step | Module | Output |
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
- All checkpoints are stored in [`MuhammadAdil63/angio-ai-checkpoints`](https://huggingface.co/MuhammadAdil63/angio-ai-checkpoints) and downloaded automatically on first run.
38
 
39
- | File | Architecture | Task | Performance |
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
- *This tool is for research purposes only and is not intended for clinical use.*
 
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
- # mask2former_best.pth (Mask2Former Swin-Base fine-tuned)
 
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
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap');
175
-
176
  :root {
177
- --navy-950: #040d1a;
178
- --navy-900: #071224;
179
- --navy-800: #0d1e38;
180
- --navy-700: #112a50;
181
- --navy-600: #1a3a68;
182
- --cyan-400: #22d3ee;
183
- --cyan-300: #67e8f9;
184
- --cyan-glow: rgba(34,211,238,0.12);
185
- --green-400: #34d399;
186
- --red-400: #f87171;
187
- --amber-400: #fbbf24;
188
- --white: #f0f9ff;
189
- --w60: rgba(240,249,255,0.60);
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(--navy-950) !important;
204
- font-family: var(--font) !important;
205
- color: var(--white) !important;
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(--navy-900);
215
- border-bottom: 1px solid var(--border-s);
216
- padding: 0 32px;
217
  display: flex;
218
  align-items: center;
219
  justify-content: space-between;
220
- min-height: 72px;
221
- position: relative;
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-angio .sub {
244
- font-size: 10px; color: var(--w60) !important;
245
- letter-spacing: 1.5px; text-transform: uppercase; margin-top: 3px;
 
 
 
246
  }
247
- #logo-itu img { height: 44px; filter: brightness(0) invert(1) opacity(0.65); }
248
- .header-meta {
249
- text-align: right; font-size: 11px;
250
- color: var(--w60) !important; line-height: 1.7;
251
- font-family: var(--mono) !important;
 
252
  }
253
- .header-meta b { color: var(--cyan-300) !important; font-weight: 600; }
254
 
255
- /* ── Tabs ─────────────────────────────────────────────────────────── */
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
- font-family: var(--font) !important; font-size: 13px !important;
263
- font-weight: 500 !important; color: var(--w60) !important;
264
- background: transparent !important; border: none !important;
265
- border-bottom: 2px solid transparent !important;
266
- padding: 14px 20px !important;
267
- transition: color 0.2s, border-color 0.2s !important;
268
  }
269
- .tab-nav button:hover { color: var(--white) !important; }
270
  .tab-nav button.selected {
271
- color: var(--cyan-400) !important;
272
- border-bottom-color: var(--cyan-400) !important;
273
- font-weight: 600 !important;
274
  }
275
 
276
- /* ── Cards ────────────────────────────────────────────────────────── */
277
  .card {
278
- background: var(--navy-800) !important;
279
- border: 1px solid var(--border-s) !important;
280
- border-radius: var(--radius) !important;
281
- padding: 20px 22px !important;
282
- box-shadow: var(--shadow) !important;
283
- margin-bottom: 16px !important;
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: 10px !important; font-weight: 600 !important;
294
- color: var(--cyan-400) !important; text-transform: uppercase !important;
295
- letter-spacing: 2px !important; margin-bottom: 14px !important;
296
- padding-bottom: 10px !important; border-bottom: 1px solid var(--border-s) !important;
297
- font-family: var(--mono) !important;
 
 
 
298
  }
299
 
300
- /* ── Upload zone ──────────────────────────────────────────────────── */
301
- #upload-zone, #upload-zone > div {
302
- border: 1px dashed var(--cyan-400) !important;
303
  border-radius: var(--radius) !important;
304
- background: var(--cyan-glow) !important;
305
- min-height: 160px !important;
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(--cyan-400) !important; }
319
- .gradio-slider label, .gradio-slider .label-wrap span {
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: linear-gradient(135deg, #0ea5e9, var(--cyan-400)) !important;
328
- color: var(--navy-950) !important; border: none !important;
329
- border-radius: var(--radius-s) !important;
330
- font-size: 12px !important; font-weight: 700 !important;
331
- padding: 13px 32px !important; letter-spacing: 2px !important;
332
- text-transform: uppercase !important; font-family: var(--mono) !important;
333
- cursor: pointer !important; width: 100% !important;
334
- box-shadow: 0 0 24px rgba(34,211,238,0.30) !important;
335
- transition: all 0.2s !important;
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; color: var(--w60) !important;
343
- border: 1px solid var(--border-s) !important;
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-s) !important;
356
- font-size: 11px !important; font-family: var(--mono) !important;
357
- letter-spacing: 1.5px !important; text-transform: uppercase !important;
358
- width: 100% !important; padding: 9px 12px !important;
359
- margin-top: 8px !important; transition: all 0.2s !important;
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 { display:flex !important; gap:10px !important; flex-wrap:wrap !important; margin-bottom:16px !important; }
368
- .metric-badge {
369
- flex: 1 !important; min-width: 100px !important;
370
- background: var(--navy-900) !important; border: 1px solid var(--border-s) !important;
371
- border-radius: var(--radius-s) !important; padding: 14px 10px !important;
372
- text-align: center !important; position: relative; overflow: hidden;
373
  }
374
- .metric-badge::before {
375
- content: ''; position: absolute;
376
- top: 0; left: 0; right: 0; height: 1px;
377
- background: var(--cyan-400); opacity: 0.3;
 
 
 
 
378
  }
379
  .metric-badge .val {
380
- font-size: 22px !important; font-weight: 700 !important;
381
- color: var(--cyan-400) !important; font-family: var(--mono) !important; line-height: 1.2;
 
382
  }
383
- .metric-badge .val.ischemic { color: var(--red-400) !important; text-shadow: 0 0 12px rgba(248,113,113,0.4); }
384
- .metric-badge .val.borderline{ color: var(--amber-400) !important; }
385
- .metric-badge .val.ok { color: var(--green-400) !important; text-shadow: 0 0 12px rgba(52,211,153,0.4); }
386
  .metric-badge .lbl {
387
- font-size: 9px !important; color: var(--w30) !important;
388
- text-transform: uppercase !important; letter-spacing: 1.5px !important;
389
- margin-top: 4px !important; font-family: var(--mono) !important;
 
390
  }
391
 
392
- /* ── Status box ───────────────────────────────────────────────────── */
393
- #status-box {
394
- padding: 12px 18px !important; border-radius: var(--radius-s) !important;
395
- font-size: 12px !important; font-weight: 500 !important;
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
- /* ── Images ───────────────────────────────────────────────────────── */
403
- .gradio-image img {
404
- border-radius: var(--radius-s) !important;
405
- border: 1px solid var(--border-s) !important;
406
- }
407
- .gradio-image .label-wrap { display: none !important; }
408
-
409
- /* ── Code (JSON) ──────────────────────────────────────────────────── */
410
- .gradio-code, .cm-editor, .cm-scroller {
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
- background: var(--navy-950) !important;
419
- border-top: 1px solid var(--border-s) !important;
420
- padding: 14px 32px !important; text-align: center !important;
421
- font-size: 11px !important; color: var(--w30) !important;
422
- font-family: var(--mono) !important; letter-spacing: 0.5px;
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
- itu_tag = (f'<img src="{LOGO_ITU}" alt="ITU">'
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
- <div class="name">Angio&thinsp;AI</div>
453
- <div class="sub">Coronary Angiography Analysis System</div>
454
  </div>
455
- <div class="header-meta">
456
- <b>Muhammad Adil</b> &nbsp;&middot;&nbsp; MS Data Science<br>
457
- Supervisor: Dr. Arif Mehmood &nbsp;&middot;&nbsp; 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 — MASK2FORMER INFERENCE
508
  # ─────────────────────────────────────────────────────────────────────────────
509
 
510
- _m2f_model = None
511
- _m2f_proc = None
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 run_mask2former(frame_rgb: np.ndarray, conf_thr: float) -> list:
536
  """
537
- Returns list of dicts: [{bbox:[x1,y1,x2,y2], score:float, mask:np.ndarray}, ...]
538
  """
539
- _load_mask2former()
540
- from PIL import Image
541
- pil = Image.fromarray(frame_rgb)
542
- inputs = _m2f_proc(images=pil, return_tensors="pt")
543
- inputs = {k: v.to(CONFIG["DEVICE"]) for k, v in inputs.items()}
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
- _YOLO_CLASS_NAMES = [
819
- "background", # 0 (background, not predicted by YOLO)
820
- "LMCA", # 1
821
- "LAD prox", # 2
822
- "LAD mid", # 3
823
- "LAD dist", # 4
824
- "D1", # 5
825
- "D2", # 6
826
- "D3", # 7
827
- "Septal", # 8
828
- "LAD var", # 9
829
- "LCX prox", # 10
830
- "LCX mid", # 11
831
- "LCX dist", # 12
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 = min(cls_id + 1, 25) # clamp to valid 1-25 range
938
  seg_map[binary] = label_val
939
  n_written += int(binary.sum())
940
 
941
- detected_cls = [_YOLO_CLASS_NAMES[min(int(c) + 1, 25)] for c in cls_ids]
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
- idx = int(inside[np.argmin(diams_sm[inside])]) if len(inside)>0 else \
1173
- int(np.argmin(np.linalg.norm(centers_arr-np.array([(y1+y2)/2,(x1+x2)/2]),axis=1)))
 
 
 
 
 
 
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-narrowing"}); continue
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
- ffr_label = ("⚠ ISCHEMIC" if global_ffr<=0.80 else "✓ Non-ischemic")
1244
- fig.suptitle(f"FFR Analysis — Global FFR: {global_ffr:.3f} {ffr_label} | "
1245
- f"Stenoses: {len(stenoses)} | Ref: {ref_mm:.2f} mm",
 
 
 
 
 
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
- def compute_syntax_score(stenoses, seg_map):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1387
  """
1388
- Simplified SYNTAX estimate:
1389
- Each stenosis contributes a weight based on DS% and
1390
- anatomical territory (estimated from nnUNet class ID).
1391
- Reference: Sianos et al. EuroIntervention 2005.
1392
- Note: true SYNTAX requires angiographic expertise; this is
1393
- an automated surrogate for display purposes.
1394
  """
1395
- if not stenoses:
1396
- return 0.0, "Low (0)"
1397
-
1398
- base_score = 0.
1399
  for st in stenoses:
1400
- ds = st["pct_ds"] / 100
1401
- # Weight by position: proximal vessels score higher
1402
- pos_w = max(0.5, 1.5 - st["position_mm"] / 100)
1403
- # Occlusion multiplier
1404
- occ_w = 5.0 if ds >= 0.99 else (2.5 if ds >= 0.70 else 1.5 if ds >= 0.50 else 1.0)
1405
- base_score += pos_w * occ_w * (1 + ds)
1406
-
1407
- # Add segment count from nnUNet
1408
- n_segs = len(np.unique(seg_map)) - 1 # exclude background
1409
- base_score += n_segs * 0.3
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="Running Mask2Former…")
1455
- detections = run_mask2former(frame_rgb, sten_conf)
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
- # Recompute overlap with real mask
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
- return (_status("warn", ffr_result["error"]),
 
 
 
 
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"], seg_map)
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
- f"Analysis complete — Frame {frame_idx}/{total_frames} "
 
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 or "error" in 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
- gffr = ffr_result.get("global_ffr", "—")
1541
- isch = ffr_result.get("ischemic", None)
1542
- ns = ffr_result.get("n_stenoses", 0)
1543
- ref = ffr_result.get("ref_diam_mm","—")
1544
- vlen = ffr_result.get("vessel_len_mm","—")
1545
- syn = ffr_result.get("syntax_score","—")
1546
- stier = ffr_result.get("syntax_tier","—")
1547
-
1548
- if isch is True: ffr_cls,ffr_lbl = "ischemic","⚠ ISCHEMIC"
1549
- elif isch is False: ffr_cls,ffr_lbl = "ok","✓ NON-ISCHEMIC"
1550
- else: ffr_cls,ffr_lbl = "","—"
1551
-
1552
- gffr=0.82
1553
- ns=1
1554
- syn = 0.4
1555
- syn_val = float(syn) if isinstance(syn,float) else 0
1556
- syn_cls = "ischemic" if syn_val>=33 else ("borderline" if syn_val>=23 else "ok")
1557
-
1558
- stenosis_rows = ""
1559
- for i,st in enumerate(ffr_result.get("stenoses",[])):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1560
  sig_col = "#d64040" if st.get("significant") else "#0d9e6e"
1561
- stenosis_rows += (
1562
- f'<tr>'
1563
- f'<td style="padding:4px 8px">#{i+1}</td>'
1564
- f'<td style="padding:4px 8px">{st["position_mm"]:.1f} mm</td>'
1565
- f'<td style="padding:4px 8px">{st["pct_ds"]:.1f}%</td>'
1566
- f'<td style="padding:4px 8px">{st["d_mm"]:.2f} mm</td>'
1567
- f'<td style="padding:4px 8px;color:{sig_col};font-weight:600">{st["ffr"]:.3f}</td>'
1568
- f'</tr>'
1569
- )
 
 
 
 
 
 
 
 
1570
 
1571
  return f"""
1572
  <div class="card">
1573
  <div class="card-title">Key metrics</div>
1574
  <div class="metric-row">
1575
- <div class="metric-badge">
1576
- <div class="val {ffr_cls}">{gffr if gffr!="—" else "—"}</div>
1577
- <div class="lbl">Global FFR</div>
1578
- </div>
1579
- <div class="metric-badge">
1580
- <div class="val {ffr_cls}" style="font-size:14px">{ffr_lbl}</div>
1581
- <div class="lbl">Ischaemia</div>
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
- SYNTAX tier: <b>{stier}</b> &nbsp;|&nbsp;
1602
- Frame {frame_idx}/{total} &nbsp;|&nbsp;
1603
- Scale: {FFR_CFG["px_per_mm"]} px/mm
 
1604
  </div>
1605
  </div>
1606
- {'<div class="card"><div class="card-title">Stenosis table</div><table style="width:100%;border-collapse:collapse;font-size:13px"><thead><tr style="background:#e8f3fb;color:#1a6fa8"><th style="padding:6px 8px;text-align:left">#</th><th style="padding:6px 8px;text-align:left">Position</th><th style="padding:6px 8px;text-align:left">DS%</th><th style="padding:6px 8px;text-align:left">Min diam</th><th style="padding:6px 8px;text-align:left">FFR</th></tr></thead><tbody>' + stenosis_rows + '</tbody></table></div>'
1607
- if stenosis_rows else ""}
 
1608
  """
1609
 
1610
  # ─────────────────────────────────────────────────────────────────────────────
1611
  # GRADIO APP
1612
  # ─────────────────────────────────────────────────────────────────────────────
1613
 
1614
- with gr.Blocks(css=CSS, title="Angio AI") as demo:
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.30, step=0.05,
1641
- label="Stenosis confidence threshold (Mask2Former)",
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">Analysis Pipeline</div>
1661
- <div style="display:flex;flex-direction:column;gap:0">
1662
- <div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid rgba(240,249,255,0.07)">
1663
- <span style="font-family:JetBrains Mono,monospace;font-size:10px;color:#22d3ee;min-width:18px">01</span>
1664
- <span style="font-size:12px;color:rgba(240,249,255,0.7)">Keyframe extraction</span>
1665
- </div>
1666
- <div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid rgba(240,249,255,0.07)">
1667
- <span style="font-family:JetBrains Mono,monospace;font-size:10px;color:#22d3ee;min-width:18px">02</span>
1668
- <span style="font-size:12px;color:rgba(240,249,255,0.7)">Stenosis detection</span>
1669
- </div>
1670
- <div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid rgba(240,249,255,0.07)">
1671
- <span style="font-family:JetBrains Mono,monospace;font-size:10px;color:#22d3ee;min-width:18px">03</span>
1672
- <span style="font-size:12px;color:rgba(240,249,255,0.7)">26-class coronary segmentation</span>
1673
- </div>
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 &le; 0.80 &rarr; ischemic &nbsp;|&nbsp; 1&minus;(0.33&middot;DS+0.60&middot;DS&sup2;)<br>
1689
- Tu et al. JACC 2016 &middot; FAVOR II
1690
  </div>
1691
  </div>
1692
  <div class="card">
1693
- <div class="card-title">Checkpoints</div>
1694
- <div style="font-family:JetBrains Mono,monospace;font-size:11px;color:rgba(240,249,255,0.45);line-height:2.2">
1695
- <span style="color:#22d3ee">&#9658;</span> mask2former_best.pth<br>
1696
- <span style="color:#22d3ee">&#9658;</span> binary_best.pth<br>
1697
- <span style="color:#22d3ee">&#9658;</span> best.pt
 
 
 
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
- Angio AI · MS Data Science · Information Technology University Lahore ·
1744
- Supervisor: Dr. Arif Mehmood &nbsp;|&nbsp;
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=True,
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'&nbsp;|&nbsp; Frame {frame_idx}/{total} &nbsp;|&nbsp; 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> &nbsp;|&nbsp;
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 ""}) &nbsp;|&nbsp;
1525
+ Frame {frame_idx}/{total} &nbsp;|&nbsp; 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 &nbsp;|&nbsp;
1658
+ Image-based QFR — Tu et al. JACC 2016 · FAVOR II trial &nbsp;|&nbsp; 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
- tokenizers==0.21.1
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]