Instructions to use Aditya2162/ivus-segmentation with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Keras
How to use Aditya2162/ivus-segmentation with Keras:
# Available backend options are: "jax", "torch", "tensorflow". import os os.environ["KERAS_BACKEND"] = "jax" import keras model = keras.saving.load_model("hf://Aditya2162/ivus-segmentation") - Notebooks
- Google Colab
- Kaggle
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +43 -0
- .gitignore +3 -0
- DeepIVUS.py +11 -0
- README.md +138 -0
- data/bifurcation/BS.dcm +3 -0
- data/bifurcation/FILE0000.dcm +3 -0
- data/bifurcation/FILE0001.dcm +3 -0
- data/bifurcation/FILE0002.dcm +3 -0
- data/bifurcation/FILE0003.dcm +3 -0
- data/bifurcation/FILE0004.dcm +3 -0
- data/bifurcation/FILE0005.dcm +3 -0
- data/bifurcation/FILE0006.dcm +3 -0
- data/bifurcation/FILE0007.dcm +3 -0
- data/bifurcation/FILE0008.dcm +3 -0
- data/bifurcation/FILE0009.dcm +3 -0
- data/bifurcation/FILE0010.dcm +3 -0
- data/bifurcation/FILE0011.dcm +3 -0
- data/paul/FILE0000.dcm +3 -0
- data/paul/FILE0001.dcm +3 -0
- data/paul/FILE0002.dcm +3 -0
- data/paul/FILE0003.dcm +3 -0
- data/paul/FILE0004.dcm +3 -0
- data/paul/FILE0005.dcm +3 -0
- data/paul/FILE0006.dcm +3 -0
- data/paul/FILE0007.dcm +3 -0
- data/paul/FILE0008.dcm +3 -0
- data/paul/FILE0009.dcm +3 -0
- data/roboflow/frame_01_0001_001_png.rf.19fb74b147f4e2ea7aeeeba9a8f9bb60.jpg +0 -0
- data/roboflow/frame_01_0001_002_png.rf.6c1848781a860346790ff8fa5af515e5.jpg +0 -0
- data/roboflow/frame_01_0001_003_png.rf.b166d416312c040666953184f32f971e.jpg +0 -0
- data/roboflow/frame_01_0001_004_png.rf.cceea12169d174c43a10fc0841339655.jpg +0 -0
- data/roboflow/frame_01_0002_001_png.rf.bc6e576fb56cf3fa16ca2705540e7462.jpg +0 -0
- data/roboflow/frame_01_0002_002_png.rf.f8d2da6f60c17ec862cd25ce27054ea6.jpg +0 -0
- data/roboflow/frame_01_0002_003_png.rf.0891613389e1956264481167c3da76c5.jpg +0 -0
- data/roboflow/frame_01_0002_004_png.rf.d856eaad3d0e66d83526cdb1c6ca906b.jpg +0 -0
- data/roboflow/frame_01_0002_005_png.rf.ad0e0cdd7d17a39e3760906c14d4b7f8.jpg +0 -0
- data/roboflow/frame_01_0003_001_png.rf.7a05cfb79c71e0dd658fb8a0c320a661.jpg +0 -0
- data/roboflow/frame_01_0003_002_png.rf.1ad13255bfe8e3029d8ed34eaa4c1fc8.jpg +0 -0
- data/roboflow/frame_01_0003_003_png.rf.1ca9655e2ac1ace359fb46e3c276d6b0.jpg +0 -0
- data/roboflow/frame_01_0003_004_png.rf.8aa5d3eac529b567e6093ec251259bd0.jpg +0 -0
- data/roboflow/frame_01_0003_005_png.rf.60bcc346261a1c5698ae5a0ed861b611.jpg +0 -0
- data/roboflow/frame_01_0004_002_png.rf.c98839b8200c486c8f5e4dd04478ce3f.jpg +0 -0
- data/roboflow/frame_01_0004_004_png.rf.99e69a1356cdb3550481ac6e4e3bc985.jpg +0 -0
- deepivus/__init__.py +5 -0
- deepivus/analysis/__init__.py +13 -0
- deepivus/analysis/scoring.py +162 -0
- deepivus/cli.py +138 -0
- deepivus/config.py +95 -0
- deepivus/gui/__init__.py +5 -0
- deepivus/gui/annotation_editor.py +428 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,46 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
data/bifurcation/BS.dcm filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
data/bifurcation/FILE0000.dcm filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
data/bifurcation/FILE0001.dcm filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
data/bifurcation/FILE0002.dcm filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
data/bifurcation/FILE0003.dcm filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
data/bifurcation/FILE0004.dcm filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
data/bifurcation/FILE0005.dcm filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
data/bifurcation/FILE0006.dcm filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
data/bifurcation/FILE0007.dcm filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
data/bifurcation/FILE0008.dcm filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
data/bifurcation/FILE0009.dcm filter=lfs diff=lfs merge=lfs -text
|
| 47 |
+
data/bifurcation/FILE0010.dcm filter=lfs diff=lfs merge=lfs -text
|
| 48 |
+
data/bifurcation/FILE0011.dcm filter=lfs diff=lfs merge=lfs -text
|
| 49 |
+
data/paul/FILE0000.dcm filter=lfs diff=lfs merge=lfs -text
|
| 50 |
+
data/paul/FILE0001.dcm filter=lfs diff=lfs merge=lfs -text
|
| 51 |
+
data/paul/FILE0002.dcm filter=lfs diff=lfs merge=lfs -text
|
| 52 |
+
data/paul/FILE0003.dcm filter=lfs diff=lfs merge=lfs -text
|
| 53 |
+
data/paul/FILE0004.dcm filter=lfs diff=lfs merge=lfs -text
|
| 54 |
+
data/paul/FILE0005.dcm filter=lfs diff=lfs merge=lfs -text
|
| 55 |
+
data/paul/FILE0006.dcm filter=lfs diff=lfs merge=lfs -text
|
| 56 |
+
data/paul/FILE0007.dcm filter=lfs diff=lfs merge=lfs -text
|
| 57 |
+
data/paul/FILE0008.dcm filter=lfs diff=lfs merge=lfs -text
|
| 58 |
+
data/paul/FILE0009.dcm filter=lfs diff=lfs merge=lfs -text
|
| 59 |
+
docs/memo_assets/lumen_finetune_dynamics.png filter=lfs diff=lfs merge=lfs -text
|
| 60 |
+
docs/memo_assets/multitask_pipeline_diagram.png filter=lfs diff=lfs merge=lfs -text
|
| 61 |
+
docs/memo_assets/multitask_training_dynamics.png filter=lfs diff=lfs merge=lfs -text
|
| 62 |
+
docs/memo_assets/standalone_reliability_diagram.png filter=lfs diff=lfs merge=lfs -text
|
| 63 |
+
docs/memo_assets/standalone_threshold_sweep.png filter=lfs diff=lfs merge=lfs -text
|
| 64 |
+
docs/multitask_finetuning_comprehensive_memo.pdf filter=lfs diff=lfs merge=lfs -text
|
| 65 |
+
models/deepIVUS/variables/variables.data-00001-of-00002 filter=lfs diff=lfs merge=lfs -text
|
| 66 |
+
models/multitask/checkpoints/ckpt-13.data-00000-of-00001 filter=lfs diff=lfs merge=lfs -text
|
| 67 |
+
models/multitask/lumen_multitask_base/variables/variables.data-00000-of-00001 filter=lfs diff=lfs merge=lfs -text
|
| 68 |
+
models/standalone/bifurcation/best_bifurcation_classifier.keras filter=lfs diff=lfs merge=lfs -text
|
| 69 |
+
models/standalone/lumen/variables/variables.data-00000-of-00001 filter=lfs diff=lfs merge=lfs -text
|
| 70 |
+
output/20260223_232201/FILE0005_overlay.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 71 |
+
output/20260223_232201/FILE0005_overlay_bifurcation.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 72 |
+
output/augment_preview/augment_preview_4x4.png filter=lfs diff=lfs merge=lfs -text
|
| 73 |
+
output/augment_preview/frame_bank_FILE0001_frame0_aug4x4.png filter=lfs diff=lfs merge=lfs -text
|
| 74 |
+
output/data_scale_vs_accuracy_projected_points.png filter=lfs diff=lfs merge=lfs -text
|
| 75 |
+
output/roboflow_inference/roboflow_4x4_lumen_bifurcation_grid.png filter=lfs diff=lfs merge=lfs -text
|
| 76 |
+
output/tensorboard/multitask_20260220_044431/augment_preview_4x4.png filter=lfs diff=lfs merge=lfs -text
|
| 77 |
+
output/three_stage_bifurcation_frame_FILE00005.png filter=lfs diff=lfs merge=lfs -text
|
| 78 |
+
output/training_outputs/single_inference/FILE0005_overlay.mp4 filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__
|
| 2 |
+
build
|
| 3 |
+
DeepIVUS.egg-info
|
DeepIVUS.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compatibility entrypoint for the DeepIVUS CLI.
|
| 2 |
+
|
| 3 |
+
Implementation has been moved into the ``deepivus`` package to keep modules
|
| 4 |
+
small and easier to maintain.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from deepivus.cli import cli
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
if __name__ == "__main__":
|
| 11 |
+
cli()
|
README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# IVUS-Segmentation
|
| 2 |
+
|
| 3 |
+
DeepIVUS pipeline for lumen segmentation and bifurcation frame classification on IVUS DICOMs.
|
| 4 |
+
|
| 5 |
+
## What This Repo Contains
|
| 6 |
+
|
| 7 |
+
- Runtime package: `deepivus/`
|
| 8 |
+
- CLI entrypoints and pipeline orchestration
|
| 9 |
+
- Lumen segmentation inference
|
| 10 |
+
- Bifurcation classifier inference
|
| 11 |
+
- Video/JSON/XML export utilities
|
| 12 |
+
- Data and annotations:
|
| 13 |
+
- `data/`: source DICOM folders (`data/bifurcation`, `data/paul`)
|
| 14 |
+
- `evals/frame_bank_merged/`: canonical annotation bank
|
| 15 |
+
- `evals/splits/ivus_split_merged_600.json`: canonical train/val/test split
|
| 16 |
+
- Models:
|
| 17 |
+
- `models/standalone/lumen/`: standalone lumen TF SavedModel
|
| 18 |
+
- `models/standalone/bifurcation/best_bifurcation_classifier.keras`: standalone bifurcation classifier
|
| 19 |
+
- `models/standalone/bifurcation/threshold.json`: selected inference threshold (from validation sweep)
|
| 20 |
+
- Training and evaluation scripts:
|
| 21 |
+
- `scripts/finetune/bifurcation/`
|
| 22 |
+
- `scripts/finetune/lumen/`
|
| 23 |
+
- shared helpers: `scripts/finetune/shared/common.py`
|
| 24 |
+
- data tooling: `scripts/data/frame_bank.py`
|
| 25 |
+
|
| 26 |
+
## Main Runtime Workflow
|
| 27 |
+
|
| 28 |
+
### 1) Segment a DICOM and classify branching
|
| 29 |
+
|
| 30 |
+
```bash
|
| 31 |
+
python DeepIVUS.py segment data/paul/FILE00005.dcm
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
Outputs are written under `output/<timestamp>/` and include:
|
| 35 |
+
|
| 36 |
+
- contours XML and JSONL
|
| 37 |
+
- top-confidence JSONL
|
| 38 |
+
- lumen overlay video
|
| 39 |
+
- bifurcation predictions JSONL + summary JSON
|
| 40 |
+
- overlay video with branch/non-branch flag
|
| 41 |
+
|
| 42 |
+
Notes:
|
| 43 |
+
|
| 44 |
+
- `--bifurcation-threshold` is optional.
|
| 45 |
+
- If omitted, threshold is loaded from `threshold.json` beside the selected bifurcation model.
|
| 46 |
+
|
| 47 |
+
### 2) Edit contour annotations in GUI
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
python DeepIVUS.py edit-annotations data/paul/FILE00005.dcm
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
## Bifurcation Model Workflow
|
| 54 |
+
|
| 55 |
+
### 1) Sample new frames for manual labeling
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
python -u scripts/finetune/bifurcation/sample_new_bifurcation_frames.py
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
### 2) Label sampled frames in GUI
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
+
python -u scripts/finetune/bifurcation/annotate_bifurcation_samples.py
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
### 3) Merge labels into canonical frame bank
|
| 68 |
+
|
| 69 |
+
```bash
|
| 70 |
+
python -u scripts/finetune/bifurcation/merge_bifurcation_annotations.py
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
### 4) Create/update canonical split
|
| 74 |
+
|
| 75 |
+
```bash
|
| 76 |
+
python -u scripts/finetune/bifurcation/create_bifurcation_splits.py
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
### 5) Train bifurcation classifier
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
python -u scripts/finetune/bifurcation/train_bifurcation_classifier.py
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
### 6) Evaluate on test and select threshold from validation
|
| 86 |
+
|
| 87 |
+
```bash
|
| 88 |
+
python -u scripts/finetune/bifurcation/run_bifurcation_test_inference.py
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
This writes test metrics and persists selected threshold to:
|
| 92 |
+
|
| 93 |
+
- `threshold.json` beside the evaluated classifier model file.
|
| 94 |
+
|
| 95 |
+
## Lumen Model Workflow
|
| 96 |
+
|
| 97 |
+
### 1) Identify lumen class index in SavedModel logits
|
| 98 |
+
|
| 99 |
+
```bash
|
| 100 |
+
python -u scripts/finetune/lumen/identify_lumen_class.py
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
### 2) Fine-tune lumen model
|
| 104 |
+
|
| 105 |
+
```bash
|
| 106 |
+
python -u scripts/finetune/lumen/finetune_lumen_from_saved_model.py \
|
| 107 |
+
--output-model-dir models/standalone/lumen
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
### 3) Evaluate lumen model on test split
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
python -u scripts/finetune/lumen/run_test_inference.py \
|
| 114 |
+
--model-dir models/standalone/lumen
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
### 4) Run single-DICOM lumen + bifurcation overlay inference
|
| 118 |
+
|
| 119 |
+
```bash
|
| 120 |
+
python -u scripts/finetune/lumen/run_single_dicom_inference.py \
|
| 121 |
+
--dicom-path data/paul/FILE00005.dcm
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
## Repository Structure
|
| 125 |
+
|
| 126 |
+
- `DeepIVUS.py`: top-level CLI launcher
|
| 127 |
+
- `deepivus/`: runtime package
|
| 128 |
+
- `models/`: runtime models and threshold
|
| 129 |
+
- `evals/`: canonical annotation bank and split
|
| 130 |
+
- `scripts/`: training/eval/data utilities
|
| 131 |
+
- `output/`: generated artifacts (runs, metrics, videos)
|
| 132 |
+
|
| 133 |
+
## Environment
|
| 134 |
+
|
| 135 |
+
- Python project config: `pyproject.toml`
|
| 136 |
+
- Conda env file: `environment.yml`
|
| 137 |
+
|
| 138 |
+
Install dependencies with your preferred toolchain (`poetry`, `pip`, or `conda`) using those files.
|
data/bifurcation/BS.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9344074612f7936d84732d8e831bc37c6e68ce200592a45028b6765931659d11
|
| 3 |
+
size 32770958
|
data/bifurcation/FILE0000.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ff3a63757f3574f8aa138e6ddf0b69c3d0bd6b7acade47636b3d49ee224f9584
|
| 3 |
+
size 30254548
|
data/bifurcation/FILE0001.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3860565d1bd2daeca152dd740e492815ba87bfd6f18a521a3e2f4844be2d4212
|
| 3 |
+
size 253280
|
data/bifurcation/FILE0002.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f426d507eda872079a3bc6c3ff324b6ffd134b171aa3b4027155fb4bd4c763bf
|
| 3 |
+
size 253278
|
data/bifurcation/FILE0003.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f714c5b35ca43fb06daecb5c3247ed6c8068da38946890a92fad97382677123c
|
| 3 |
+
size 34254644
|
data/bifurcation/FILE0004.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7494a4ae42c498ac7ade0bfc021042fbedbaf54c46f5885c32e24a4478fb0c31
|
| 3 |
+
size 28254496
|
data/bifurcation/FILE0005.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:01107332ec0e8f5e9ecac762e7f96917b52b2fc64b82acbd1f280b86db060e82
|
| 3 |
+
size 21754318
|
data/bifurcation/FILE0006.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fbaf49c4ef9d64992f656b14dba55b088b14a05c76d0cd56543b496934b57217
|
| 3 |
+
size 108756594
|
data/bifurcation/FILE0007.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:59fbe148fab19df26bb8d718d93901a79ac894f1e08d77db7185341820b07110
|
| 3 |
+
size 16004168
|
data/bifurcation/FILE0008.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a2f110098e932e85e28e3257c77633bd560f3cab30f46b2803f0d40e04ab3a72
|
| 3 |
+
size 71255616
|
data/bifurcation/FILE0009.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f5e4fe16bbbd1defa4e88e739a325be632d460818a05b5d3a9853ad67d686035
|
| 3 |
+
size 13004092
|
data/bifurcation/FILE0010.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2f608ec149cea275e98122d5b7795f9b00e492081c5971217f2eb9ad02275c8c
|
| 3 |
+
size 58255278
|
data/bifurcation/FILE0011.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d7dff95c57f6ab3ee7b0058226b71f9bc74bc57aff5f868826adfe215a565c7c
|
| 3 |
+
size 35004668
|
data/paul/FILE0000.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1a1f11f0dfea1ef086da41d1386849761513c96393ab8b72c75be471f7887b48
|
| 3 |
+
size 30566174
|
data/paul/FILE0001.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fae476f3c82dbf9b241807715d20f4dc1e625268304dca405297cf6d3afe1416
|
| 3 |
+
size 9167550
|
data/paul/FILE0002.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:bd67e698ffdad8756801285a4c5a3cb10afa711b1b53a665c069c5d2c8e797f4
|
| 3 |
+
size 10649688
|
data/paul/FILE0003.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ca807cb0b4d65dedf5d426103d9e478a82b96401931d517d306ee3a4181435e0
|
| 3 |
+
size 11845072
|
data/paul/FILE0004.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f2b0c0be3fb1ff6920f59f31c25adbb239efbf91f221c383f7d13cf805895337
|
| 3 |
+
size 19811870
|
data/paul/FILE0005.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:13fa61c40771c6867cb6fa2e4889e0044f99357448a682ff44483f338622b2b3
|
| 3 |
+
size 28187200
|
data/paul/FILE0006.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:349a5c710fcf9b281e98df610c2231630b63260e1a81af0dbcae641796fad827
|
| 3 |
+
size 34090280
|
data/paul/FILE0007.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:029d28d17cd97a38d63a17ef6cb7c9b1bdcfa1e2653b5cc31a0ba0601017d805
|
| 3 |
+
size 25494378
|
data/paul/FILE0008.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3cbe7a3a036addc6be29435b0d2364dfa7b7a16b5107a8ec85b2edf78af65cbe
|
| 3 |
+
size 23930322
|
data/paul/FILE0009.dcm
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:84db9e7f2df31266b9b46c03c95746aadf4086ecb8df34a4d8f42c85bc63ec1a
|
| 3 |
+
size 28718644
|
data/roboflow/frame_01_0001_001_png.rf.19fb74b147f4e2ea7aeeeba9a8f9bb60.jpg
ADDED
|
data/roboflow/frame_01_0001_002_png.rf.6c1848781a860346790ff8fa5af515e5.jpg
ADDED
|
data/roboflow/frame_01_0001_003_png.rf.b166d416312c040666953184f32f971e.jpg
ADDED
|
data/roboflow/frame_01_0001_004_png.rf.cceea12169d174c43a10fc0841339655.jpg
ADDED
|
data/roboflow/frame_01_0002_001_png.rf.bc6e576fb56cf3fa16ca2705540e7462.jpg
ADDED
|
data/roboflow/frame_01_0002_002_png.rf.f8d2da6f60c17ec862cd25ce27054ea6.jpg
ADDED
|
data/roboflow/frame_01_0002_003_png.rf.0891613389e1956264481167c3da76c5.jpg
ADDED
|
data/roboflow/frame_01_0002_004_png.rf.d856eaad3d0e66d83526cdb1c6ca906b.jpg
ADDED
|
data/roboflow/frame_01_0002_005_png.rf.ad0e0cdd7d17a39e3760906c14d4b7f8.jpg
ADDED
|
data/roboflow/frame_01_0003_001_png.rf.7a05cfb79c71e0dd658fb8a0c320a661.jpg
ADDED
|
data/roboflow/frame_01_0003_002_png.rf.1ad13255bfe8e3029d8ed34eaa4c1fc8.jpg
ADDED
|
data/roboflow/frame_01_0003_003_png.rf.1ca9655e2ac1ace359fb46e3c276d6b0.jpg
ADDED
|
data/roboflow/frame_01_0003_004_png.rf.8aa5d3eac529b567e6093ec251259bd0.jpg
ADDED
|
data/roboflow/frame_01_0003_005_png.rf.60bcc346261a1c5698ae5a0ed861b611.jpg
ADDED
|
data/roboflow/frame_01_0004_002_png.rf.c98839b8200c486c8f5e4dd04478ce3f.jpg
ADDED
|
data/roboflow/frame_01_0004_004_png.rf.99e69a1356cdb3550481ac6e4e3bc985.jpg
ADDED
|
deepivus/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DeepIVUS modular package."""
|
| 2 |
+
|
| 3 |
+
__all__ = [
|
| 4 |
+
"cli",
|
| 5 |
+
]
|
deepivus/analysis/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Analysis subpackage."""
|
| 2 |
+
|
| 3 |
+
from .scoring import (
|
| 4 |
+
compute_oblongness_scores,
|
| 5 |
+
detect_online_bifurcation_signal,
|
| 6 |
+
detect_sustained_bifurcation_signal,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
"compute_oblongness_scores",
|
| 11 |
+
"detect_sustained_bifurcation_signal",
|
| 12 |
+
"detect_online_bifurcation_signal",
|
| 13 |
+
]
|
deepivus/analysis/scoring.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scoring and event-detection utilities for lumen shape stability."""
|
| 2 |
+
|
| 3 |
+
from typing import Tuple
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def compute_oblongness_scores(lumen_masks: np.ndarray) -> np.ndarray:
|
| 9 |
+
"""Compute per-frame oblongness score in [0, 1] from binary lumen masks."""
|
| 10 |
+
scores = np.zeros((lumen_masks.shape[0],), dtype=np.float32)
|
| 11 |
+
for idx in range(lumen_masks.shape[0]):
|
| 12 |
+
ys, xs = np.where(lumen_masks[idx] > 0)
|
| 13 |
+
if ys.size < 20:
|
| 14 |
+
scores[idx] = np.float32(0.0)
|
| 15 |
+
continue
|
| 16 |
+
|
| 17 |
+
x = xs.astype(np.float32)
|
| 18 |
+
y = ys.astype(np.float32)
|
| 19 |
+
x -= float(np.mean(x))
|
| 20 |
+
y -= float(np.mean(y))
|
| 21 |
+
cov = np.cov(np.vstack((x, y)))
|
| 22 |
+
eigvals = np.linalg.eigvalsh(cov)
|
| 23 |
+
eigvals = np.maximum(eigvals, 1e-6)
|
| 24 |
+
axis_ratio = float(np.sqrt(eigvals[-1] / eigvals[0]))
|
| 25 |
+
scores[idx] = np.float32(np.clip((axis_ratio - 1.0) / (axis_ratio + 1.0), 0.0, 1.0))
|
| 26 |
+
return scores
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def moving_average(x: np.ndarray, window: int) -> np.ndarray:
|
| 30 |
+
"""Centered moving-average smoothing."""
|
| 31 |
+
if window <= 1:
|
| 32 |
+
return x.copy()
|
| 33 |
+
kernel = np.ones((window,), dtype=np.float32) / float(window)
|
| 34 |
+
pad = window // 2
|
| 35 |
+
x_pad = np.pad(x.astype(np.float32), (pad, pad), mode="edge")
|
| 36 |
+
return np.convolve(x_pad, kernel, mode="valid")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def causal_moving_average(x: np.ndarray, window: int) -> np.ndarray:
|
| 40 |
+
"""Past-only moving average for online detection."""
|
| 41 |
+
x = x.astype(np.float32)
|
| 42 |
+
if window <= 1:
|
| 43 |
+
return x.copy()
|
| 44 |
+
out = np.zeros_like(x, dtype=np.float32)
|
| 45 |
+
csum = np.cumsum(x, dtype=np.float32)
|
| 46 |
+
for idx in range(x.shape[0]):
|
| 47 |
+
start = max(0, idx - window + 1)
|
| 48 |
+
total = csum[idx] - (csum[start - 1] if start > 0 else 0.0)
|
| 49 |
+
out[idx] = total / float(idx - start + 1)
|
| 50 |
+
return out
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def keep_sustained_runs(flags: np.ndarray, min_run: int) -> np.ndarray:
|
| 54 |
+
"""Keep only True runs with length >= ``min_run``."""
|
| 55 |
+
kept = np.zeros_like(flags, dtype=bool)
|
| 56 |
+
start = None
|
| 57 |
+
for idx, val in enumerate(flags):
|
| 58 |
+
if val and start is None:
|
| 59 |
+
start = idx
|
| 60 |
+
if (not val or idx == len(flags) - 1) and start is not None:
|
| 61 |
+
end = idx if not val else idx + 1
|
| 62 |
+
if (end - start) >= min_run:
|
| 63 |
+
kept[start:end] = True
|
| 64 |
+
start = None
|
| 65 |
+
return kept
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def detect_sustained_bifurcation_signal(oblong_scores: np.ndarray, fps: float) -> Tuple[np.ndarray, np.ndarray]:
|
| 69 |
+
"""Detect sustained high-oblongness intervals and return (smoothed_scores, flags)."""
|
| 70 |
+
smooth_window = max(5, int(round(max(fps, 1.0) * 0.5)))
|
| 71 |
+
if smooth_window % 2 == 0:
|
| 72 |
+
smooth_window += 1
|
| 73 |
+
smoothed = moving_average(oblong_scores, smooth_window)
|
| 74 |
+
|
| 75 |
+
median = float(np.median(smoothed))
|
| 76 |
+
mad = float(np.median(np.abs(smoothed - median)))
|
| 77 |
+
robust_scale = max(1e-6, 1.4826 * mad)
|
| 78 |
+
robust_z = (smoothed - median) / robust_scale
|
| 79 |
+
|
| 80 |
+
candidate = (smoothed > 0.35) & (robust_z > 2.5)
|
| 81 |
+
min_run = max(5, int(round(max(fps, 1.0) * 0.4)))
|
| 82 |
+
sustained = keep_sustained_runs(candidate.astype(bool), min_run=min_run)
|
| 83 |
+
return smoothed.astype(np.float32), sustained
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def detect_online_bifurcation_signal(oblong_scores: np.ndarray, fps: float) -> Tuple[np.ndarray, np.ndarray]:
|
| 87 |
+
"""Online bifurcation detector using only past information.
|
| 88 |
+
|
| 89 |
+
Algorithm:
|
| 90 |
+
- Causal smoothing (past-only moving average).
|
| 91 |
+
- Running robust baseline from a fixed history window.
|
| 92 |
+
- Incremental sustained-run confirmation (no future lookahead).
|
| 93 |
+
"""
|
| 94 |
+
x = np.asarray(oblong_scores, dtype=np.float32)
|
| 95 |
+
if x.size == 0:
|
| 96 |
+
return x.copy(), np.zeros((0,), dtype=bool)
|
| 97 |
+
|
| 98 |
+
smooth_window = max(5, int(round(max(fps, 1.0) * 0.35)))
|
| 99 |
+
smoothed = causal_moving_average(x, smooth_window)
|
| 100 |
+
|
| 101 |
+
baseline_window = max(24, int(round(max(fps, 1.0) * 8.0)))
|
| 102 |
+
warmup = max(12, int(round(max(fps, 1.0) * 1.0)))
|
| 103 |
+
min_run = max(6, int(round(max(fps, 1.0) * 0.4)))
|
| 104 |
+
|
| 105 |
+
sustained = np.zeros((x.shape[0],), dtype=bool)
|
| 106 |
+
run_len = 0
|
| 107 |
+
misses_left = 0
|
| 108 |
+
active = False
|
| 109 |
+
|
| 110 |
+
# Causal slope signal helps detect onset before level becomes very high.
|
| 111 |
+
slope = np.zeros_like(smoothed, dtype=np.float32)
|
| 112 |
+
slope[1:] = smoothed[1:] - smoothed[:-1]
|
| 113 |
+
|
| 114 |
+
for idx in range(x.shape[0]):
|
| 115 |
+
hist_start = max(0, idx - baseline_window)
|
| 116 |
+
history = smoothed[hist_start:idx] # past only
|
| 117 |
+
if history.size < warmup:
|
| 118 |
+
run_len = 0
|
| 119 |
+
misses_left = 0
|
| 120 |
+
continue
|
| 121 |
+
|
| 122 |
+
median = float(np.median(history))
|
| 123 |
+
mad = float(np.median(np.abs(history - median)))
|
| 124 |
+
robust_scale = max(1e-6, 1.4826 * mad)
|
| 125 |
+
robust_z = (float(smoothed[idx]) - median) / robust_scale
|
| 126 |
+
|
| 127 |
+
q90 = float(np.quantile(history, 0.9))
|
| 128 |
+
dyn_level = max(0.31, q90 + 0.05)
|
| 129 |
+
|
| 130 |
+
slope_hist = slope[hist_start:idx]
|
| 131 |
+
slope_median = float(np.median(slope_hist))
|
| 132 |
+
slope_mad = float(np.median(np.abs(slope_hist - slope_median)))
|
| 133 |
+
slope_scale = max(1e-6, 1.4826 * slope_mad)
|
| 134 |
+
slope_z = (float(slope[idx]) - slope_median) / slope_scale
|
| 135 |
+
|
| 136 |
+
level = float(smoothed[idx])
|
| 137 |
+
enter_candidate = (
|
| 138 |
+
(level >= dyn_level and robust_z > 1.95)
|
| 139 |
+
or (level >= 0.29 and robust_z > 1.3 and slope_z > 2.6)
|
| 140 |
+
)
|
| 141 |
+
continue_candidate = (
|
| 142 |
+
(level >= max(0.27, dyn_level - 0.02) and robust_z > 1.2)
|
| 143 |
+
or (level >= 0.25 and robust_z > 1.0 and slope_z > 1.8)
|
| 144 |
+
)
|
| 145 |
+
is_candidate = continue_candidate if active else enter_candidate
|
| 146 |
+
|
| 147 |
+
if is_candidate:
|
| 148 |
+
run_len += 1
|
| 149 |
+
misses_left = 1
|
| 150 |
+
active = True
|
| 151 |
+
if run_len >= min_run:
|
| 152 |
+
# Confirm sustained event using only history seen so far.
|
| 153 |
+
sustained[idx - min_run + 1 : idx + 1] = True
|
| 154 |
+
else:
|
| 155 |
+
# Allow a single miss so short dips do not break a sustained run.
|
| 156 |
+
if misses_left > 0 and run_len > 0:
|
| 157 |
+
misses_left -= 1
|
| 158 |
+
else:
|
| 159 |
+
run_len = 0
|
| 160 |
+
active = False
|
| 161 |
+
|
| 162 |
+
return smoothed.astype(np.float32), sustained
|
deepivus/cli.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLI entrypoints for DeepIVUS."""
|
| 2 |
+
|
| 3 |
+
import datetime
|
| 4 |
+
import os
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
import click
|
| 8 |
+
from deepivus.config import resolve_bifurcation_threshold
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@click.group()
|
| 12 |
+
def cli() -> None:
|
| 13 |
+
"""DeepIVUS CLI."""
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@cli.command("segment")
|
| 17 |
+
@click.argument("dicom_path", type=click.Path(exists=True, dir_okay=False))
|
| 18 |
+
@click.option(
|
| 19 |
+
"--output-prefix",
|
| 20 |
+
"-o",
|
| 21 |
+
default=None,
|
| 22 |
+
type=str,
|
| 23 |
+
help="Output path prefix (without extension). Defaults to output/<timestamp>/<input filename>.",
|
| 24 |
+
)
|
| 25 |
+
@click.option(
|
| 26 |
+
"--fps",
|
| 27 |
+
default=None,
|
| 28 |
+
type=float,
|
| 29 |
+
help="Overlay video FPS. Defaults to DICOM CineRate or 30.",
|
| 30 |
+
)
|
| 31 |
+
@click.option(
|
| 32 |
+
"--bifurcation-threshold",
|
| 33 |
+
default=None,
|
| 34 |
+
show_default=False,
|
| 35 |
+
type=click.FloatRange(min=0.0, max=1.0),
|
| 36 |
+
help="Threshold for bifurcation classifier labels. Defaults to threshold.json beside the selected bifurcation model.",
|
| 37 |
+
)
|
| 38 |
+
@click.option(
|
| 39 |
+
"--framewise/--no-framewise",
|
| 40 |
+
default=False,
|
| 41 |
+
show_default=True,
|
| 42 |
+
help="Run inference frame-by-frame (batch_size=1) to simulate realtime processing.",
|
| 43 |
+
)
|
| 44 |
+
def segment_cmd(
|
| 45 |
+
dicom_path: str,
|
| 46 |
+
output_prefix: Optional[str],
|
| 47 |
+
fps: Optional[float],
|
| 48 |
+
bifurcation_threshold: Optional[float],
|
| 49 |
+
framewise: bool,
|
| 50 |
+
) -> None:
|
| 51 |
+
"""Segment lumen and classify bifurcation for all frames."""
|
| 52 |
+
from .pipeline import segment_and_export
|
| 53 |
+
|
| 54 |
+
if output_prefix is None:
|
| 55 |
+
stem = os.path.splitext(os.path.basename(dicom_path))[0]
|
| 56 |
+
output_root = os.path.join(os.getcwd(), "output")
|
| 57 |
+
os.makedirs(output_root, exist_ok=True)
|
| 58 |
+
|
| 59 |
+
run_folder = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 60 |
+
output_dir = os.path.join(output_root, run_folder)
|
| 61 |
+
suffix = 1
|
| 62 |
+
while os.path.exists(output_dir):
|
| 63 |
+
output_dir = os.path.join(output_root, f"{run_folder}_{suffix}")
|
| 64 |
+
suffix += 1
|
| 65 |
+
|
| 66 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 67 |
+
output_prefix = os.path.join(output_dir, stem)
|
| 68 |
+
else:
|
| 69 |
+
output_dir = os.path.dirname(os.path.abspath(output_prefix))
|
| 70 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 71 |
+
|
| 72 |
+
if bifurcation_threshold is None:
|
| 73 |
+
bifurcation_threshold = resolve_bifurcation_threshold(default=0.5)
|
| 74 |
+
|
| 75 |
+
(
|
| 76 |
+
xml_path,
|
| 77 |
+
json_path,
|
| 78 |
+
top_conf_json_path,
|
| 79 |
+
video_path,
|
| 80 |
+
bif_overlay_video_path,
|
| 81 |
+
bif_json_path,
|
| 82 |
+
bif_summary_path,
|
| 83 |
+
) = segment_and_export(
|
| 84 |
+
dicom_path,
|
| 85 |
+
output_prefix,
|
| 86 |
+
fps,
|
| 87 |
+
bifurcation_threshold,
|
| 88 |
+
framewise=framewise,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
click.echo(f"Contours XML: {xml_path}")
|
| 92 |
+
click.echo(f"Contours JSONL: {json_path}")
|
| 93 |
+
click.echo(f"Top confidence JSONL: {top_conf_json_path}")
|
| 94 |
+
click.echo(f"Overlay video: {video_path}")
|
| 95 |
+
click.echo(f"Overlay video (with bifurcation flags): {bif_overlay_video_path}")
|
| 96 |
+
click.echo(f"Bifurcation predictions JSONL: {bif_json_path}")
|
| 97 |
+
click.echo(f"Bifurcation summary JSON: {bif_summary_path}")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@cli.command("edit-annotations")
|
| 101 |
+
@click.argument("dicom_path", type=click.Path(exists=True, dir_okay=False))
|
| 102 |
+
@click.option(
|
| 103 |
+
"--annotations-path",
|
| 104 |
+
default=None,
|
| 105 |
+
type=click.Path(exists=True, dir_okay=False),
|
| 106 |
+
help=(
|
| 107 |
+
"Base contour JSONL to edit. "
|
| 108 |
+
"Defaults to latest output/<timestamp>/<dicom_stem>_contours.jsonl."
|
| 109 |
+
),
|
| 110 |
+
)
|
| 111 |
+
@click.option(
|
| 112 |
+
"--edits-path",
|
| 113 |
+
default=None,
|
| 114 |
+
type=click.Path(dir_okay=False),
|
| 115 |
+
help="Path for edited annotations JSONL. Defaults beside base file as *_edited_annotations.jsonl.",
|
| 116 |
+
)
|
| 117 |
+
@click.option(
|
| 118 |
+
"--output-root",
|
| 119 |
+
default="output",
|
| 120 |
+
show_default=True,
|
| 121 |
+
type=click.Path(file_okay=False),
|
| 122 |
+
help="Root folder containing pipeline outputs used for default annotation discovery.",
|
| 123 |
+
)
|
| 124 |
+
def edit_annotations_cmd(
|
| 125 |
+
dicom_path: str,
|
| 126 |
+
annotations_path: Optional[str],
|
| 127 |
+
edits_path: Optional[str],
|
| 128 |
+
output_root: str,
|
| 129 |
+
) -> None:
|
| 130 |
+
"""Open GUI editor to review and adjust contour coordinates frame-by-frame."""
|
| 131 |
+
from .gui import launch_annotation_editor
|
| 132 |
+
|
| 133 |
+
launch_annotation_editor(
|
| 134 |
+
dicom_path=dicom_path,
|
| 135 |
+
annotations_path=annotations_path,
|
| 136 |
+
edits_path=edits_path,
|
| 137 |
+
output_root=output_root,
|
| 138 |
+
)
|
deepivus/config.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Runtime configuration and model path resolution."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
ROOT_DIR = Path(__file__).resolve().parents[1]
|
| 10 |
+
MODELS_DIR = ROOT_DIR / "models"
|
| 11 |
+
STANDALONE_MODELS_DIR = MODELS_DIR / "standalone"
|
| 12 |
+
|
| 13 |
+
MULTITASK_DIR = MODELS_DIR / "multitask"
|
| 14 |
+
MULTITASK_LUMEN_MODEL_DIR = MULTITASK_DIR / "lumen_multitask_base"
|
| 15 |
+
MULTITASK_BIFURCATION_HEAD_PATH = MULTITASK_DIR / "bifurcation_head.keras"
|
| 16 |
+
|
| 17 |
+
LUMEN_MODEL_DIR = STANDALONE_MODELS_DIR / "lumen"
|
| 18 |
+
BIFURCATION_MODEL_PATH = STANDALONE_MODELS_DIR / "bifurcation" / "best_bifurcation_classifier.keras"
|
| 19 |
+
BIFURCATION_THRESHOLD_PATH = STANDALONE_MODELS_DIR / "bifurcation" / "threshold.json"
|
| 20 |
+
|
| 21 |
+
LEGACY_LUMEN_MODEL_DIRS = [
|
| 22 |
+
MODELS_DIR / "lumen",
|
| 23 |
+
ROOT_DIR / "model_finetuned_lumen",
|
| 24 |
+
ROOT_DIR / "model",
|
| 25 |
+
]
|
| 26 |
+
LEGACY_BIFURCATION_MODEL_PATHS = [
|
| 27 |
+
MODELS_DIR / "bifurcation" / "best_bifurcation_classifier.keras",
|
| 28 |
+
ROOT_DIR / "output" / "bifurcation_classifier" / "best_bifurcation_classifier.keras",
|
| 29 |
+
]
|
| 30 |
+
LEGACY_BIFURCATION_THRESHOLD_PATHS = [
|
| 31 |
+
MODELS_DIR / "bifurcation" / "threshold.json",
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def resolve_lumen_model_dir() -> Path:
|
| 36 |
+
env_path = os.getenv("DEEPIVUS_LUMEN_MODEL_DIR")
|
| 37 |
+
if env_path:
|
| 38 |
+
return Path(env_path)
|
| 39 |
+
# Prefer multitask lumen base if available.
|
| 40 |
+
if MULTITASK_LUMEN_MODEL_DIR.exists():
|
| 41 |
+
return MULTITASK_LUMEN_MODEL_DIR
|
| 42 |
+
if LUMEN_MODEL_DIR.exists():
|
| 43 |
+
return LUMEN_MODEL_DIR
|
| 44 |
+
for path in LEGACY_LUMEN_MODEL_DIRS:
|
| 45 |
+
if path.exists():
|
| 46 |
+
return path
|
| 47 |
+
return LUMEN_MODEL_DIR
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def resolve_bifurcation_model_path() -> Path:
|
| 51 |
+
env_path = os.getenv("DEEPIVUS_BIF_MODEL_PATH")
|
| 52 |
+
if env_path:
|
| 53 |
+
return Path(env_path)
|
| 54 |
+
# Prefer multitask bifurcation head if available.
|
| 55 |
+
if MULTITASK_BIFURCATION_HEAD_PATH.exists():
|
| 56 |
+
return MULTITASK_BIFURCATION_HEAD_PATH
|
| 57 |
+
if BIFURCATION_MODEL_PATH.exists():
|
| 58 |
+
return BIFURCATION_MODEL_PATH
|
| 59 |
+
for path in LEGACY_BIFURCATION_MODEL_PATHS:
|
| 60 |
+
if path.exists():
|
| 61 |
+
return path
|
| 62 |
+
return BIFURCATION_MODEL_PATH
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def resolve_bifurcation_threshold_path(model_path: Path | None = None) -> Path:
|
| 66 |
+
env_path = os.getenv("DEEPIVUS_BIF_THRESHOLD_PATH")
|
| 67 |
+
if env_path:
|
| 68 |
+
return Path(env_path)
|
| 69 |
+
if model_path is None:
|
| 70 |
+
model_path = resolve_bifurcation_model_path()
|
| 71 |
+
return Path(model_path).parent / "threshold.json"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def resolve_bifurcation_threshold(default: float = 0.5, model_path: Path | None = None) -> float:
|
| 75 |
+
primary = resolve_bifurcation_threshold_path(model_path=model_path)
|
| 76 |
+
candidate_paths = [primary, BIFURCATION_THRESHOLD_PATH, *LEGACY_BIFURCATION_THRESHOLD_PATHS]
|
| 77 |
+
seen: set[Path] = set()
|
| 78 |
+
for path in candidate_paths:
|
| 79 |
+
path = Path(path)
|
| 80 |
+
if path in seen:
|
| 81 |
+
continue
|
| 82 |
+
seen.add(path)
|
| 83 |
+
if not path.exists():
|
| 84 |
+
continue
|
| 85 |
+
try:
|
| 86 |
+
import json
|
| 87 |
+
|
| 88 |
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
| 89 |
+
value = payload.get("selected_threshold", payload.get("threshold", default))
|
| 90 |
+
value = float(value)
|
| 91 |
+
if 0.0 <= value <= 1.0:
|
| 92 |
+
return value
|
| 93 |
+
except Exception:
|
| 94 |
+
continue
|
| 95 |
+
return float(default)
|
deepivus/gui/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GUI helpers for annotation inspection and editing."""
|
| 2 |
+
|
| 3 |
+
from .annotation_editor import launch_annotation_editor
|
| 4 |
+
|
| 5 |
+
__all__ = ["launch_annotation_editor"]
|
deepivus/gui/annotation_editor.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Interactive GUI for editing per-frame IVUS contour annotations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from glob import glob
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import matplotlib.pyplot as plt
|
| 13 |
+
import numpy as np
|
| 14 |
+
from matplotlib.lines import Line2D
|
| 15 |
+
from matplotlib.widgets import Button, Slider
|
| 16 |
+
|
| 17 |
+
from ..io.dicom import read_dicom
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class FrameState:
|
| 22 |
+
"""In-memory annotation state for one frame."""
|
| 23 |
+
|
| 24 |
+
frame: int
|
| 25 |
+
lumen_x: list[float]
|
| 26 |
+
lumen_y: list[float]
|
| 27 |
+
plaque_x: list[float]
|
| 28 |
+
plaque_y: list[float]
|
| 29 |
+
lumen_confidence: float | None
|
| 30 |
+
plaque_confidence: float | None
|
| 31 |
+
bifurcation: bool = False
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class AnnotationEditor:
|
| 35 |
+
"""Matplotlib-based editor for contour annotations."""
|
| 36 |
+
|
| 37 |
+
def __init__(
|
| 38 |
+
self,
|
| 39 |
+
dicom_path: str,
|
| 40 |
+
annotations_path: str,
|
| 41 |
+
edits_path: str,
|
| 42 |
+
) -> None:
|
| 43 |
+
self.dicom_path = dicom_path
|
| 44 |
+
self.annotations_path = annotations_path
|
| 45 |
+
self.edits_path = edits_path
|
| 46 |
+
|
| 47 |
+
_, images = read_dicom(dicom_path)
|
| 48 |
+
self.images = images
|
| 49 |
+
|
| 50 |
+
self.frame_states = self._load_base_annotations(annotations_path)
|
| 51 |
+
if len(self.frame_states) != images.shape[0]:
|
| 52 |
+
raise ValueError(
|
| 53 |
+
"Frame count mismatch between DICOM and annotations: "
|
| 54 |
+
f"{images.shape[0]} vs {len(self.frame_states)}"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
self._load_existing_edits(edits_path)
|
| 58 |
+
self._ensure_edits_file_header()
|
| 59 |
+
|
| 60 |
+
self.current_frame = 0
|
| 61 |
+
self.drag_target: tuple[str, int] | None = None
|
| 62 |
+
self.drag_threshold_px = 10.0
|
| 63 |
+
self.frame_dirty = False
|
| 64 |
+
|
| 65 |
+
self.fig = None
|
| 66 |
+
self.ax_image = None
|
| 67 |
+
self.image_artist = None
|
| 68 |
+
self.lumen_line: Line2D | None = None
|
| 69 |
+
self.plaque_line: Line2D | None = None
|
| 70 |
+
self.lumen_points = None
|
| 71 |
+
self.plaque_points = None
|
| 72 |
+
|
| 73 |
+
self.slider: Slider | None = None
|
| 74 |
+
self.status_text = None
|
| 75 |
+
self.is_updating_slider = False
|
| 76 |
+
|
| 77 |
+
@staticmethod
|
| 78 |
+
def _safe_float(value: Any) -> float | None:
|
| 79 |
+
if value is None:
|
| 80 |
+
return None
|
| 81 |
+
try:
|
| 82 |
+
out = float(value)
|
| 83 |
+
except (TypeError, ValueError):
|
| 84 |
+
return None
|
| 85 |
+
if np.isnan(out):
|
| 86 |
+
return None
|
| 87 |
+
return out
|
| 88 |
+
|
| 89 |
+
def _load_base_annotations(self, path: str) -> list[FrameState]:
|
| 90 |
+
states: list[FrameState] = []
|
| 91 |
+
with open(path, "r", encoding="utf-8") as fp:
|
| 92 |
+
for raw in fp:
|
| 93 |
+
line = raw.strip()
|
| 94 |
+
if not line:
|
| 95 |
+
continue
|
| 96 |
+
rec = json.loads(line)
|
| 97 |
+
if rec.get("record_type") != "frame":
|
| 98 |
+
continue
|
| 99 |
+
states.append(
|
| 100 |
+
FrameState(
|
| 101 |
+
frame=int(rec["frame"]),
|
| 102 |
+
lumen_x=[float(v) for v in rec.get("lumen", {}).get("x", [])],
|
| 103 |
+
lumen_y=[float(v) for v in rec.get("lumen", {}).get("y", [])],
|
| 104 |
+
plaque_x=[float(v) for v in rec.get("plaque", {}).get("x", [])],
|
| 105 |
+
plaque_y=[float(v) for v in rec.get("plaque", {}).get("y", [])],
|
| 106 |
+
lumen_confidence=self._safe_float(rec.get("lumen_confidence")),
|
| 107 |
+
plaque_confidence=self._safe_float(rec.get("plaque_confidence")),
|
| 108 |
+
bifurcation=bool(rec.get("bifurcation", False)),
|
| 109 |
+
)
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
states.sort(key=lambda s: s.frame)
|
| 113 |
+
expected = list(range(len(states)))
|
| 114 |
+
actual = [s.frame for s in states]
|
| 115 |
+
if actual != expected:
|
| 116 |
+
raise ValueError(
|
| 117 |
+
"Base annotations must contain contiguous frame records in order "
|
| 118 |
+
f"(got first frames: {actual[:10]})"
|
| 119 |
+
)
|
| 120 |
+
return states
|
| 121 |
+
|
| 122 |
+
def _load_existing_edits(self, path: str) -> None:
|
| 123 |
+
if not os.path.exists(path):
|
| 124 |
+
return
|
| 125 |
+
|
| 126 |
+
latest: dict[int, dict[str, Any]] = {}
|
| 127 |
+
with open(path, "r", encoding="utf-8") as fp:
|
| 128 |
+
for raw in fp:
|
| 129 |
+
line = raw.strip()
|
| 130 |
+
if not line:
|
| 131 |
+
continue
|
| 132 |
+
rec = json.loads(line)
|
| 133 |
+
if rec.get("record_type") != "frame_edit":
|
| 134 |
+
continue
|
| 135 |
+
frame_idx = int(rec["frame"])
|
| 136 |
+
latest[frame_idx] = rec
|
| 137 |
+
|
| 138 |
+
for frame_idx, rec in latest.items():
|
| 139 |
+
if frame_idx < 0 or frame_idx >= len(self.frame_states):
|
| 140 |
+
continue
|
| 141 |
+
state = self.frame_states[frame_idx]
|
| 142 |
+
state.lumen_x = [float(v) for v in rec.get("lumen", {}).get("x", state.lumen_x)]
|
| 143 |
+
state.lumen_y = [float(v) for v in rec.get("lumen", {}).get("y", state.lumen_y)]
|
| 144 |
+
state.plaque_x = [float(v) for v in rec.get("plaque", {}).get("x", state.plaque_x)]
|
| 145 |
+
state.plaque_y = [float(v) for v in rec.get("plaque", {}).get("y", state.plaque_y)]
|
| 146 |
+
state.bifurcation = bool(rec.get("bifurcation", state.bifurcation))
|
| 147 |
+
|
| 148 |
+
def _ensure_edits_file_header(self) -> None:
|
| 149 |
+
os.makedirs(os.path.dirname(os.path.abspath(self.edits_path)), exist_ok=True)
|
| 150 |
+
if os.path.exists(self.edits_path):
|
| 151 |
+
return
|
| 152 |
+
meta = {
|
| 153 |
+
"record_type": "meta",
|
| 154 |
+
"created_at": datetime.now().isoformat(timespec="seconds"),
|
| 155 |
+
"dicom_path": os.path.abspath(self.dicom_path),
|
| 156 |
+
"base_annotations_path": os.path.abspath(self.annotations_path),
|
| 157 |
+
"format": "append_only_frame_edits",
|
| 158 |
+
}
|
| 159 |
+
with open(self.edits_path, "w", encoding="utf-8") as fp:
|
| 160 |
+
fp.write(json.dumps(meta) + "\n")
|
| 161 |
+
|
| 162 |
+
def _append_frame_edit(self, frame_idx: int, reason: str) -> None:
|
| 163 |
+
state = self.frame_states[frame_idx]
|
| 164 |
+
rec = {
|
| 165 |
+
"record_type": "frame_edit",
|
| 166 |
+
"saved_at": datetime.now().isoformat(timespec="seconds"),
|
| 167 |
+
"reason": reason,
|
| 168 |
+
"frame": frame_idx,
|
| 169 |
+
"bifurcation": state.bifurcation,
|
| 170 |
+
"lumen": {"x": state.lumen_x, "y": state.lumen_y},
|
| 171 |
+
"plaque": {"x": state.plaque_x, "y": state.plaque_y},
|
| 172 |
+
"lumen_confidence": state.lumen_confidence,
|
| 173 |
+
"plaque_confidence": state.plaque_confidence,
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
with open(self.edits_path, "a", encoding="utf-8") as fp:
|
| 177 |
+
fp.write(json.dumps(rec) + "\n")
|
| 178 |
+
fp.flush()
|
| 179 |
+
os.fsync(fp.fileno())
|
| 180 |
+
|
| 181 |
+
self.frame_dirty = False
|
| 182 |
+
|
| 183 |
+
def _build_ui(self) -> None:
|
| 184 |
+
self.fig = plt.figure(figsize=(13, 9))
|
| 185 |
+
self.fig.canvas.manager.set_window_title("DeepIVUS Annotation Editor")
|
| 186 |
+
|
| 187 |
+
self.ax_image = self.fig.add_axes([0.05, 0.22, 0.9, 0.74])
|
| 188 |
+
self.ax_image.set_title("DeepIVUS Annotation Editor", fontsize=14, weight="bold")
|
| 189 |
+
self.ax_image.set_axis_off()
|
| 190 |
+
|
| 191 |
+
slider_ax = self.fig.add_axes([0.12, 0.13, 0.76, 0.035])
|
| 192 |
+
self.slider = Slider(
|
| 193 |
+
ax=slider_ax,
|
| 194 |
+
label="Frame",
|
| 195 |
+
valmin=0,
|
| 196 |
+
valmax=len(self.frame_states) - 1,
|
| 197 |
+
valinit=0,
|
| 198 |
+
valstep=1,
|
| 199 |
+
color="#1f77b4",
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
prev_ax = self.fig.add_axes([0.12, 0.05, 0.1, 0.055])
|
| 203 |
+
next_ax = self.fig.add_axes([0.24, 0.05, 0.1, 0.055])
|
| 204 |
+
save_ax = self.fig.add_axes([0.42, 0.05, 0.14, 0.055])
|
| 205 |
+
bif_ax = self.fig.add_axes([0.72, 0.05, 0.16, 0.055])
|
| 206 |
+
|
| 207 |
+
self.prev_button = Button(prev_ax, "Prev Frame", color="#E0E0E0", hovercolor="#D0D0D0")
|
| 208 |
+
self.next_button = Button(next_ax, "Next Frame", color="#E0E0E0", hovercolor="#D0D0D0")
|
| 209 |
+
self.save_button = Button(save_ax, "Save Frame", color="#D6F5D6", hovercolor="#BFF0BF")
|
| 210 |
+
self.bif_button = Button(bif_ax, "Bifurcation: No", color="#F5D6D6", hovercolor="#F0BFBF")
|
| 211 |
+
|
| 212 |
+
self.status_text = self.fig.text(0.05, 0.18, "", fontsize=10)
|
| 213 |
+
|
| 214 |
+
self.slider.on_changed(self._on_slider)
|
| 215 |
+
self.prev_button.on_clicked(self._on_prev)
|
| 216 |
+
self.next_button.on_clicked(self._on_next)
|
| 217 |
+
self.save_button.on_clicked(self._on_save)
|
| 218 |
+
self.bif_button.on_clicked(self._on_toggle_bifurcation)
|
| 219 |
+
|
| 220 |
+
self.fig.canvas.mpl_connect("button_press_event", self._on_press)
|
| 221 |
+
self.fig.canvas.mpl_connect("motion_notify_event", self._on_motion)
|
| 222 |
+
self.fig.canvas.mpl_connect("button_release_event", self._on_release)
|
| 223 |
+
self.fig.canvas.mpl_connect("key_press_event", self._on_key)
|
| 224 |
+
self.fig.canvas.mpl_connect("close_event", self._on_close)
|
| 225 |
+
|
| 226 |
+
def _state(self) -> FrameState:
|
| 227 |
+
return self.frame_states[self.current_frame]
|
| 228 |
+
|
| 229 |
+
def _render_frame(self) -> None:
|
| 230 |
+
state = self._state()
|
| 231 |
+
image = self.images[self.current_frame]
|
| 232 |
+
|
| 233 |
+
if self.image_artist is None:
|
| 234 |
+
self.image_artist = self.ax_image.imshow(image, cmap="gray")
|
| 235 |
+
self.lumen_line = self.ax_image.plot([], [], color="#1db954", lw=2)[0]
|
| 236 |
+
self.plaque_line = self.ax_image.plot([], [], color="#ff5a5a", lw=2)[0]
|
| 237 |
+
self.lumen_points = self.ax_image.scatter([], [], c="#1db954", s=28, edgecolors="black", linewidths=0.4)
|
| 238 |
+
self.plaque_points = self.ax_image.scatter([], [], c="#ff5a5a", s=28, edgecolors="black", linewidths=0.4)
|
| 239 |
+
else:
|
| 240 |
+
self.image_artist.set_data(image)
|
| 241 |
+
|
| 242 |
+
lumen_x, lumen_y = state.lumen_x, state.lumen_y
|
| 243 |
+
plaque_x, plaque_y = state.plaque_x, state.plaque_y
|
| 244 |
+
|
| 245 |
+
self.lumen_line.set_data(lumen_x + lumen_x[:1], lumen_y + lumen_y[:1])
|
| 246 |
+
self.plaque_line.set_data(plaque_x + plaque_x[:1], plaque_y + plaque_y[:1])
|
| 247 |
+
|
| 248 |
+
lumen_offsets = np.c_[lumen_x, lumen_y] if lumen_x and lumen_y else np.empty((0, 2))
|
| 249 |
+
plaque_offsets = np.c_[plaque_x, plaque_y] if plaque_x and plaque_y else np.empty((0, 2))
|
| 250 |
+
self.lumen_points.set_offsets(lumen_offsets)
|
| 251 |
+
self.plaque_points.set_offsets(plaque_offsets)
|
| 252 |
+
|
| 253 |
+
bif_text = "Yes" if state.bifurcation else "No"
|
| 254 |
+
bif_color = "#D6F5D6" if state.bifurcation else "#F5D6D6"
|
| 255 |
+
self.bif_button.label.set_text(f"Bifurcation: {bif_text}")
|
| 256 |
+
self.bif_button.ax.set_facecolor(bif_color)
|
| 257 |
+
|
| 258 |
+
self.status_text.set_text(
|
| 259 |
+
f"Frame {self.current_frame + 1}/{len(self.frame_states)} "
|
| 260 |
+
f"Lumen pts: {len(lumen_x)} Plaque pts: {len(plaque_x)} "
|
| 261 |
+
f"Autosave file: {os.path.basename(self.edits_path)}"
|
| 262 |
+
)
|
| 263 |
+
self.fig.canvas.draw_idle()
|
| 264 |
+
|
| 265 |
+
def _set_frame(self, frame_idx: int) -> None:
|
| 266 |
+
frame_idx = int(np.clip(frame_idx, 0, len(self.frame_states) - 1))
|
| 267 |
+
if frame_idx == self.current_frame:
|
| 268 |
+
return
|
| 269 |
+
|
| 270 |
+
if self.frame_dirty:
|
| 271 |
+
self._append_frame_edit(self.current_frame, reason="frame_change")
|
| 272 |
+
|
| 273 |
+
self.current_frame = frame_idx
|
| 274 |
+
self.is_updating_slider = True
|
| 275 |
+
self.slider.set_val(frame_idx)
|
| 276 |
+
self.is_updating_slider = False
|
| 277 |
+
self._render_frame()
|
| 278 |
+
|
| 279 |
+
def _nearest_point(self, x: float, y: float) -> tuple[str, int] | None:
|
| 280 |
+
state = self._state()
|
| 281 |
+
|
| 282 |
+
def best_idx(xs: list[float], ys: list[float]) -> tuple[int, float] | None:
|
| 283 |
+
if not xs:
|
| 284 |
+
return None
|
| 285 |
+
pts = np.column_stack((np.asarray(xs), np.asarray(ys)))
|
| 286 |
+
dist = np.linalg.norm(pts - np.asarray([x, y]), axis=1)
|
| 287 |
+
idx = int(np.argmin(dist))
|
| 288 |
+
return idx, float(dist[idx])
|
| 289 |
+
|
| 290 |
+
lumen = best_idx(state.lumen_x, state.lumen_y)
|
| 291 |
+
plaque = best_idx(state.plaque_x, state.plaque_y)
|
| 292 |
+
|
| 293 |
+
choice: tuple[str, int, float] | None = None
|
| 294 |
+
if lumen is not None:
|
| 295 |
+
choice = ("lumen", lumen[0], lumen[1])
|
| 296 |
+
if plaque is not None and (choice is None or plaque[1] < choice[2]):
|
| 297 |
+
choice = ("plaque", plaque[0], plaque[1])
|
| 298 |
+
|
| 299 |
+
if choice is None or choice[2] > self.drag_threshold_px:
|
| 300 |
+
return None
|
| 301 |
+
return choice[0], choice[1]
|
| 302 |
+
|
| 303 |
+
def _on_slider(self, val: float) -> None:
|
| 304 |
+
if self.is_updating_slider:
|
| 305 |
+
return
|
| 306 |
+
self._set_frame(int(val))
|
| 307 |
+
|
| 308 |
+
def _on_prev(self, _event: Any) -> None:
|
| 309 |
+
self._set_frame(self.current_frame - 1)
|
| 310 |
+
|
| 311 |
+
def _on_next(self, _event: Any) -> None:
|
| 312 |
+
self._set_frame(self.current_frame + 1)
|
| 313 |
+
|
| 314 |
+
def _on_save(self, _event: Any) -> None:
|
| 315 |
+
self._append_frame_edit(self.current_frame, reason="manual_save")
|
| 316 |
+
self._render_frame()
|
| 317 |
+
|
| 318 |
+
def _on_toggle_bifurcation(self, _event: Any) -> None:
|
| 319 |
+
state = self._state()
|
| 320 |
+
state.bifurcation = not state.bifurcation
|
| 321 |
+
self.frame_dirty = True
|
| 322 |
+
self._append_frame_edit(self.current_frame, reason="bifurcation_toggle")
|
| 323 |
+
self._render_frame()
|
| 324 |
+
|
| 325 |
+
def _on_key(self, event: Any) -> None:
|
| 326 |
+
if event.key in {"left", "a"}:
|
| 327 |
+
self._set_frame(self.current_frame - 1)
|
| 328 |
+
elif event.key in {"right", "d"}:
|
| 329 |
+
self._set_frame(self.current_frame + 1)
|
| 330 |
+
elif event.key == "s":
|
| 331 |
+
self._on_save(event)
|
| 332 |
+
elif event.key == "b":
|
| 333 |
+
self._on_toggle_bifurcation(event)
|
| 334 |
+
|
| 335 |
+
def _on_press(self, event: Any) -> None:
|
| 336 |
+
if event.inaxes != self.ax_image or event.button != 1:
|
| 337 |
+
return
|
| 338 |
+
if event.xdata is None or event.ydata is None:
|
| 339 |
+
return
|
| 340 |
+
|
| 341 |
+
hit = self._nearest_point(float(event.xdata), float(event.ydata))
|
| 342 |
+
self.drag_target = hit
|
| 343 |
+
|
| 344 |
+
def _on_motion(self, event: Any) -> None:
|
| 345 |
+
if self.drag_target is None:
|
| 346 |
+
return
|
| 347 |
+
if event.inaxes != self.ax_image or event.xdata is None or event.ydata is None:
|
| 348 |
+
return
|
| 349 |
+
|
| 350 |
+
state = self._state()
|
| 351 |
+
contour_name, idx = self.drag_target
|
| 352 |
+
|
| 353 |
+
h, w = self.images[self.current_frame].shape
|
| 354 |
+
x = float(np.clip(event.xdata, 0, w - 1))
|
| 355 |
+
y = float(np.clip(event.ydata, 0, h - 1))
|
| 356 |
+
|
| 357 |
+
if contour_name == "lumen":
|
| 358 |
+
state.lumen_x[idx] = x
|
| 359 |
+
state.lumen_y[idx] = y
|
| 360 |
+
else:
|
| 361 |
+
state.plaque_x[idx] = x
|
| 362 |
+
state.plaque_y[idx] = y
|
| 363 |
+
|
| 364 |
+
self.frame_dirty = True
|
| 365 |
+
self._render_frame()
|
| 366 |
+
|
| 367 |
+
def _on_release(self, _event: Any) -> None:
|
| 368 |
+
if self.drag_target is None:
|
| 369 |
+
return
|
| 370 |
+
self.drag_target = None
|
| 371 |
+
if self.frame_dirty:
|
| 372 |
+
self._append_frame_edit(self.current_frame, reason="point_drag")
|
| 373 |
+
self._render_frame()
|
| 374 |
+
|
| 375 |
+
def _on_close(self, _event: Any) -> None:
|
| 376 |
+
if self.frame_dirty:
|
| 377 |
+
self._append_frame_edit(self.current_frame, reason="window_close")
|
| 378 |
+
|
| 379 |
+
def show(self) -> None:
|
| 380 |
+
self._build_ui()
|
| 381 |
+
self._render_frame()
|
| 382 |
+
plt.show()
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def _default_annotations_path(dicom_path: str, output_root: str) -> str:
|
| 386 |
+
stem = os.path.splitext(os.path.basename(dicom_path))[0]
|
| 387 |
+
pattern = os.path.join(output_root, "*", f"{stem}_contours.jsonl")
|
| 388 |
+
matches = [p for p in glob(pattern) if os.path.isfile(p)]
|
| 389 |
+
if matches:
|
| 390 |
+
matches.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
| 391 |
+
return matches[0]
|
| 392 |
+
|
| 393 |
+
raise FileNotFoundError(
|
| 394 |
+
"No pipeline contour JSONL found for "
|
| 395 |
+
f"'{stem}'. Expected files like: {pattern}"
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def _default_edits_path(annotations_path: str) -> str:
|
| 400 |
+
if annotations_path.endswith("_contours.jsonl"):
|
| 401 |
+
return annotations_path.replace("_contours.jsonl", "_edited_annotations.jsonl")
|
| 402 |
+
root, _ = os.path.splitext(annotations_path)
|
| 403 |
+
return root + "_edited_annotations.jsonl"
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def launch_annotation_editor(
|
| 407 |
+
dicom_path: str,
|
| 408 |
+
annotations_path: str | None = None,
|
| 409 |
+
edits_path: str | None = None,
|
| 410 |
+
output_root: str = "output",
|
| 411 |
+
) -> None:
|
| 412 |
+
"""Launch interactive annotation GUI.
|
| 413 |
+
|
| 414 |
+
If ``annotations_path`` is not provided, the latest contours JSONL is
|
| 415 |
+
selected from ``output/<timestamp>/``.
|
| 416 |
+
"""
|
| 417 |
+
if annotations_path is None:
|
| 418 |
+
annotations_path = _default_annotations_path(dicom_path, output_root)
|
| 419 |
+
|
| 420 |
+
if edits_path is None:
|
| 421 |
+
edits_path = _default_edits_path(annotations_path)
|
| 422 |
+
|
| 423 |
+
editor = AnnotationEditor(
|
| 424 |
+
dicom_path=dicom_path,
|
| 425 |
+
annotations_path=annotations_path,
|
| 426 |
+
edits_path=edits_path,
|
| 427 |
+
)
|
| 428 |
+
editor.show()
|