Aditya2162 commited on
Commit
1d197a4
·
verified ·
1 Parent(s): 3a9790c

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +43 -0
  2. .gitignore +3 -0
  3. DeepIVUS.py +11 -0
  4. README.md +138 -0
  5. data/bifurcation/BS.dcm +3 -0
  6. data/bifurcation/FILE0000.dcm +3 -0
  7. data/bifurcation/FILE0001.dcm +3 -0
  8. data/bifurcation/FILE0002.dcm +3 -0
  9. data/bifurcation/FILE0003.dcm +3 -0
  10. data/bifurcation/FILE0004.dcm +3 -0
  11. data/bifurcation/FILE0005.dcm +3 -0
  12. data/bifurcation/FILE0006.dcm +3 -0
  13. data/bifurcation/FILE0007.dcm +3 -0
  14. data/bifurcation/FILE0008.dcm +3 -0
  15. data/bifurcation/FILE0009.dcm +3 -0
  16. data/bifurcation/FILE0010.dcm +3 -0
  17. data/bifurcation/FILE0011.dcm +3 -0
  18. data/paul/FILE0000.dcm +3 -0
  19. data/paul/FILE0001.dcm +3 -0
  20. data/paul/FILE0002.dcm +3 -0
  21. data/paul/FILE0003.dcm +3 -0
  22. data/paul/FILE0004.dcm +3 -0
  23. data/paul/FILE0005.dcm +3 -0
  24. data/paul/FILE0006.dcm +3 -0
  25. data/paul/FILE0007.dcm +3 -0
  26. data/paul/FILE0008.dcm +3 -0
  27. data/paul/FILE0009.dcm +3 -0
  28. data/roboflow/frame_01_0001_001_png.rf.19fb74b147f4e2ea7aeeeba9a8f9bb60.jpg +0 -0
  29. data/roboflow/frame_01_0001_002_png.rf.6c1848781a860346790ff8fa5af515e5.jpg +0 -0
  30. data/roboflow/frame_01_0001_003_png.rf.b166d416312c040666953184f32f971e.jpg +0 -0
  31. data/roboflow/frame_01_0001_004_png.rf.cceea12169d174c43a10fc0841339655.jpg +0 -0
  32. data/roboflow/frame_01_0002_001_png.rf.bc6e576fb56cf3fa16ca2705540e7462.jpg +0 -0
  33. data/roboflow/frame_01_0002_002_png.rf.f8d2da6f60c17ec862cd25ce27054ea6.jpg +0 -0
  34. data/roboflow/frame_01_0002_003_png.rf.0891613389e1956264481167c3da76c5.jpg +0 -0
  35. data/roboflow/frame_01_0002_004_png.rf.d856eaad3d0e66d83526cdb1c6ca906b.jpg +0 -0
  36. data/roboflow/frame_01_0002_005_png.rf.ad0e0cdd7d17a39e3760906c14d4b7f8.jpg +0 -0
  37. data/roboflow/frame_01_0003_001_png.rf.7a05cfb79c71e0dd658fb8a0c320a661.jpg +0 -0
  38. data/roboflow/frame_01_0003_002_png.rf.1ad13255bfe8e3029d8ed34eaa4c1fc8.jpg +0 -0
  39. data/roboflow/frame_01_0003_003_png.rf.1ca9655e2ac1ace359fb46e3c276d6b0.jpg +0 -0
  40. data/roboflow/frame_01_0003_004_png.rf.8aa5d3eac529b567e6093ec251259bd0.jpg +0 -0
  41. data/roboflow/frame_01_0003_005_png.rf.60bcc346261a1c5698ae5a0ed861b611.jpg +0 -0
  42. data/roboflow/frame_01_0004_002_png.rf.c98839b8200c486c8f5e4dd04478ce3f.jpg +0 -0
  43. data/roboflow/frame_01_0004_004_png.rf.99e69a1356cdb3550481ac6e4e3bc985.jpg +0 -0
  44. deepivus/__init__.py +5 -0
  45. deepivus/analysis/__init__.py +13 -0
  46. deepivus/analysis/scoring.py +162 -0
  47. deepivus/cli.py +138 -0
  48. deepivus/config.py +95 -0
  49. deepivus/gui/__init__.py +5 -0
  50. 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()