Commit ·
b91f38a
1
Parent(s): 1cc4fea
Add source files and streaming backup restore instructions
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +2 -0
- BACKUP_RESTORE.md +27 -0
- Rad_IR/radir.egg-info/PKG-INFO +26 -0
- Rad_IR/radir.egg-info/SOURCES.txt +10 -0
- Rad_IR/radir.egg-info/dependency_links.txt +1 -0
- Rad_IR/radir.egg-info/requires.txt +20 -0
- Rad_IR/radir.egg-info/top_level.txt +1 -0
- Rad_IR/radir/RadIR.py +984 -0
- Rad_IR/radir/__init__.py +1 -0
- Rad_IR/radir/__pycache__/RadIR.cpython-313.pyc +0 -0
- Rad_IR/radir/__pycache__/RadIR.cpython-39.pyc +0 -0
- Rad_IR/radir/__pycache__/__init__.cpython-313.pyc +0 -0
- Rad_IR/radir/__pycache__/__init__.cpython-39.pyc +0 -0
- Rad_IR/radir/__pycache__/lorentz.cpython-313.pyc +0 -0
- Rad_IR/radir/__pycache__/lorentz.cpython-39.pyc +0 -0
- Rad_IR/radir/__pycache__/transformer_decoder.cpython-313.pyc +0 -0
- Rad_IR/radir/__pycache__/transformer_decoder.cpython-39.pyc +0 -0
- Rad_IR/radir/data/__pycache__/conditional_dataset_train.cpython-313.pyc +0 -0
- Rad_IR/radir/data/__pycache__/conditional_dataset_train.cpython-39.pyc +0 -0
- Rad_IR/radir/data/__pycache__/hmclip_mimic_box.cpython-313.pyc +0 -0
- Rad_IR/radir/data/__pycache__/hmclip_mimic_box.cpython-39.pyc +0 -0
- Rad_IR/radir/data/bpe_simple_vocab_16e6.txt +0 -0
- Rad_IR/radir/data/build_mimic_h5.py +0 -0
- Rad_IR/radir/data/conditional_dataset_train.py +141 -0
- Rad_IR/radir/data/hmclip_mimic_box.py +358 -0
- Rad_IR/radir/data/utils.py +18 -0
- Rad_IR/radir/lorentz.py +312 -0
- Rad_IR/radir/transformer_decoder.py +154 -0
- Rad_IR/setup.py +32 -0
- backup-manifest.json +20 -0
- restore_backup.py +29 -0
- sbatch/stage1.sh +59 -0
- sbatch/stage1_and_stage2.sh +61 -0
- sbatch/stage2.sh +45 -0
- scripts/README.md +3 -0
- scripts/RadIR_Trainer_CXR_CT.py +482 -0
- scripts/__pycache__/RadIR_Trainer_CXR_CT.cpython-39.pyc +0 -0
- scripts/__pycache__/image_traversals_compare.cpython-313.pyc +0 -0
- scripts/__pycache__/infer_disease_clusters.cpython-39.pyc +0 -0
- scripts/__pycache__/plot_cosne.cpython-39.pyc +0 -0
- scripts/__pycache__/plot_tsne.cpython-313.pyc +0 -0
- scripts/__pycache__/scheduler.cpython-39.pyc +0 -0
- scripts/build_traversal_concepts.py +620 -0
- scripts/checkdata.py +87 -0
- scripts/conditional_dataset_evaluate.py +584 -0
- scripts/conditional_dataset_train.py +825 -0
- scripts/data_process.py +177 -0
- scripts/image_traversals_compare.py +1186 -0
- scripts/infer_disease_clusters.py +513 -0
- scripts/plot_anat_cosne.py +677 -0
.gitattributes
CHANGED
|
@@ -39,3 +39,5 @@ runs/** filter=lfs diff=lfs merge=lfs -text
|
|
| 39 |
wandb/** filter=lfs diff=lfs merge=lfs -text
|
| 40 |
__pycache__/** filter=lfs diff=lfs merge=lfs -text
|
| 41 |
dataset/** filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 39 |
wandb/** filter=lfs diff=lfs merge=lfs -text
|
| 40 |
__pycache__/** filter=lfs diff=lfs merge=lfs -text
|
| 41 |
dataset/** filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
|
| 43 |
+
backup/parts/** filter=lfs diff=lfs merge=lfs -text
|
BACKUP_RESTORE.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# RadIR snapshot backup
|
| 2 |
+
|
| 3 |
+
This repository displays the source code and stores the large data, model, output,
|
| 4 |
+
cache, and log directories as a split `tar.zst` archive in Git LFS.
|
| 5 |
+
|
| 6 |
+
- Source commit: `5b926d2179afa417784ba57459e86d2336d50a52`
|
| 7 |
+
- Archived paths: `dataset, outputs, hf_models, hf_cache, runs, wandb, __pycache__`
|
| 8 |
+
- Each volume is at most 64 MiB and has a SHA-256 checksum in `backup-manifest.json`.
|
| 9 |
+
- This is a snapshot of committed files, not an export of all historical Git objects.
|
| 10 |
+
- Original source files and the original local Git history were not modified.
|
| 11 |
+
|
| 12 |
+
## Restore
|
| 13 |
+
|
| 14 |
+
Wait until `backup-manifest.json` says `complete: true`. Download the repository
|
| 15 |
+
with its LFS objects (not just pointer files). Install `zstd` and `tar`, then run:
|
| 16 |
+
|
| 17 |
+
```bash
|
| 18 |
+
python3 restore_backup.py
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
The restore tool verifies every volume, then streams decompression directly into
|
| 22 |
+
`tar`. It does not create a second concatenated archive. Extracted contents keep
|
| 23 |
+
their original relative paths. Source code is already available as regular files.
|
| 24 |
+
|
| 25 |
+
The uploader uses `hf-mirror.com` for Git and LFS metadata. LFS payloads use the
|
| 26 |
+
storage URLs returned by that service, as in a normal Git LFS transfer. It keeps
|
| 27 |
+
only one archive volume in memory, plus small Git metadata and progress files.
|
Rad_IR/radir.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: radir
|
| 3 |
+
Version: 0.1
|
| 4 |
+
Summary: RADIR
|
| 5 |
+
Requires-Dist: beartype
|
| 6 |
+
Requires-Dist: einops>=0.6
|
| 7 |
+
Requires-Dist: ftfy
|
| 8 |
+
Requires-Dist: regex
|
| 9 |
+
Requires-Dist: XlsxWriter
|
| 10 |
+
Requires-Dist: h5py
|
| 11 |
+
Requires-Dist: matplotlib
|
| 12 |
+
Requires-Dist: seaborn
|
| 13 |
+
Requires-Dist: wilds
|
| 14 |
+
Requires-Dist: click
|
| 15 |
+
Requires-Dist: appdirs
|
| 16 |
+
Requires-Dist: attr
|
| 17 |
+
Requires-Dist: nltk
|
| 18 |
+
Requires-Dist: transformers
|
| 19 |
+
Requires-Dist: monai
|
| 20 |
+
Requires-Dist: positional_encodings
|
| 21 |
+
Requires-Dist: tensorboard
|
| 22 |
+
Requires-Dist: pynvml
|
| 23 |
+
Requires-Dist: wandb
|
| 24 |
+
Requires-Dist: torchvision
|
| 25 |
+
Dynamic: requires-dist
|
| 26 |
+
Dynamic: summary
|
Rad_IR/radir.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
setup.py
|
| 2 |
+
radir/RadIR.py
|
| 3 |
+
radir/__init__.py
|
| 4 |
+
radir/lorentz.py
|
| 5 |
+
radir/transformer_decoder.py
|
| 6 |
+
radir.egg-info/PKG-INFO
|
| 7 |
+
radir.egg-info/SOURCES.txt
|
| 8 |
+
radir.egg-info/dependency_links.txt
|
| 9 |
+
radir.egg-info/requires.txt
|
| 10 |
+
radir.egg-info/top_level.txt
|
Rad_IR/radir.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
Rad_IR/radir.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
beartype
|
| 2 |
+
einops>=0.6
|
| 3 |
+
ftfy
|
| 4 |
+
regex
|
| 5 |
+
XlsxWriter
|
| 6 |
+
h5py
|
| 7 |
+
matplotlib
|
| 8 |
+
seaborn
|
| 9 |
+
wilds
|
| 10 |
+
click
|
| 11 |
+
appdirs
|
| 12 |
+
attr
|
| 13 |
+
nltk
|
| 14 |
+
transformers
|
| 15 |
+
monai
|
| 16 |
+
positional_encodings
|
| 17 |
+
tensorboard
|
| 18 |
+
pynvml
|
| 19 |
+
wandb
|
| 20 |
+
torchvision
|
Rad_IR/radir.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
radir
|
Rad_IR/radir/RadIR.py
ADDED
|
@@ -0,0 +1,984 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
import copy
|
| 3 |
+
from contextlib import contextmanager
|
| 4 |
+
from functools import partial, wraps
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
import torch.nn.functional as F
|
| 9 |
+
from torch import nn, einsum
|
| 10 |
+
from torch.utils.checkpoint import checkpoint
|
| 11 |
+
from einops import rearrange, repeat, reduce
|
| 12 |
+
from einops.layers.torch import Rearrange, Reduce
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
from transformers import BertTokenizer, BertModel
|
| 16 |
+
from radir import lorentz as L
|
| 17 |
+
|
| 18 |
+
from radir.data.hmclip_mimic_box import HyCoClipMimicBoxDataset_JPG, collate_hmclip_box
|
| 19 |
+
from radir.data.conditional_dataset_train import SimpleImageTextDataset, simple_collate
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# NOTE: Import a Cross-Attn Module and PE Module
|
| 24 |
+
from positional_encodings.torch_encodings import PositionalEncoding3D, PositionalEncoding1D, PositionalEncoding2D
|
| 25 |
+
from radir.transformer_decoder import TransformerDecoder, TransformerDecoderLayer
|
| 26 |
+
# from transformer_decoder import TransformerDecoder, TransformerDecoderLayer
|
| 27 |
+
|
| 28 |
+
# NOTE
|
| 29 |
+
|
| 30 |
+
# helper functions
|
| 31 |
+
|
| 32 |
+
def identity(t, *args, **kwargs):
|
| 33 |
+
return t
|
| 34 |
+
|
| 35 |
+
def exists(val):
|
| 36 |
+
return val is not None
|
| 37 |
+
|
| 38 |
+
def default(val, d):
|
| 39 |
+
return val if exists(val) else d
|
| 40 |
+
|
| 41 |
+
@contextmanager
|
| 42 |
+
def null_context():
|
| 43 |
+
yield
|
| 44 |
+
|
| 45 |
+
def max_neg_value(dtype):
|
| 46 |
+
return -torch.finfo(dtype).max
|
| 47 |
+
|
| 48 |
+
def cast_tuple(t):
|
| 49 |
+
return t if isinstance(t, (tuple, list)) else (t,)
|
| 50 |
+
|
| 51 |
+
def masked_mean(t, mask, dim = 1, eps = 1e-6):
|
| 52 |
+
t = t.masked_fill(~mask, 0.)
|
| 53 |
+
numer = t.sum(dim = dim)
|
| 54 |
+
denom = mask.sum(dim = dim).clamp(min = eps)
|
| 55 |
+
return numer / denom
|
| 56 |
+
|
| 57 |
+
def log(t, eps = 1e-20):
|
| 58 |
+
return torch.log(t + eps)
|
| 59 |
+
|
| 60 |
+
def l2norm(t):
|
| 61 |
+
|
| 62 |
+
return F.normalize(t, dim = -1)
|
| 63 |
+
|
| 64 |
+
def matrix_diag(t):
|
| 65 |
+
device = t.device
|
| 66 |
+
i, j = t.shape[-2:]
|
| 67 |
+
num_diag_el = min(i, j)
|
| 68 |
+
i_range = torch.arange(i, device = device)
|
| 69 |
+
j_range = torch.arange(j, device = device)
|
| 70 |
+
diag_mask = rearrange(i_range, 'i -> i 1') == rearrange(j_range, 'j -> 1 j')
|
| 71 |
+
diag_el = t.masked_select(diag_mask)
|
| 72 |
+
return rearrange(diag_el, '(b d) -> b d', d = num_diag_el)
|
| 73 |
+
|
| 74 |
+
# checkpointing helper function
|
| 75 |
+
|
| 76 |
+
def make_checkpointable(fn):
|
| 77 |
+
@wraps(fn)
|
| 78 |
+
def inner(*args):
|
| 79 |
+
input_needs_grad = any([isinstance(el, torch.Tensor) and el.requires_grad for el in args])
|
| 80 |
+
|
| 81 |
+
if not input_needs_grad:
|
| 82 |
+
return fn(*args)
|
| 83 |
+
|
| 84 |
+
return checkpoint(fn, *args)
|
| 85 |
+
|
| 86 |
+
return inner
|
| 87 |
+
|
| 88 |
+
# keyword argument helpers
|
| 89 |
+
|
| 90 |
+
def pick_and_pop(keys, d):
|
| 91 |
+
values = list(map(lambda key: d.pop(key), keys))
|
| 92 |
+
return dict(zip(keys, values))
|
| 93 |
+
|
| 94 |
+
def group_dict_by_key(cond, d):
|
| 95 |
+
return_val = [dict(),dict()]
|
| 96 |
+
for key in d.keys():
|
| 97 |
+
match = bool(cond(key))
|
| 98 |
+
ind = int(not match)
|
| 99 |
+
return_val[ind][key] = d[key]
|
| 100 |
+
return (*return_val,)
|
| 101 |
+
|
| 102 |
+
def string_begins_with(prefix, str):
|
| 103 |
+
return str.startswith(prefix)
|
| 104 |
+
|
| 105 |
+
def group_by_key_prefix(prefix, d):
|
| 106 |
+
return group_dict_by_key(partial(string_begins_with, prefix), d)
|
| 107 |
+
|
| 108 |
+
def groupby_prefix_and_trim(prefix, d):
|
| 109 |
+
with_prefix, without_prefix = group_dict_by_key(partial(string_begins_with, prefix), d)
|
| 110 |
+
trimmed = {k[len(prefix):]: v for k, v in with_prefix.items()}
|
| 111 |
+
return trimmed, without_prefix
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def safe_normalize(feat, max_norm=10.0, eps=1e-6):
|
| 115 |
+
# 支持 (B,D) 或 (B,1,D)
|
| 116 |
+
orig_shape = feat.shape
|
| 117 |
+
feat = feat.reshape(-1, feat.size(-1))
|
| 118 |
+
|
| 119 |
+
norm = feat.norm(dim=-1, keepdim=True) # (B,1)
|
| 120 |
+
scale = (max_norm / (norm + eps)).clamp(max=1.0)
|
| 121 |
+
feat = feat * scale
|
| 122 |
+
|
| 123 |
+
return feat.reshape(orig_shape)
|
| 124 |
+
|
| 125 |
+
# main clip class
|
| 126 |
+
|
| 127 |
+
class RADIR(nn.Module):
|
| 128 |
+
def __init__(
|
| 129 |
+
self,
|
| 130 |
+
image_encoder = None,
|
| 131 |
+
text_encoder = None,
|
| 132 |
+
tokenizer = None,
|
| 133 |
+
fusion_module = None, # NOTE: Pass in a Transformer Decoder Module
|
| 134 |
+
dim_text = 768,
|
| 135 |
+
dim_image = 768,
|
| 136 |
+
dim_latent = 768,
|
| 137 |
+
text_seq_len = 256,
|
| 138 |
+
text_has_cls_token = False,
|
| 139 |
+
text_pad_id = 0,
|
| 140 |
+
text_causal_mask = False,
|
| 141 |
+
text_eos_id = None,
|
| 142 |
+
text_encode_without_mask = False,
|
| 143 |
+
visual_image_size = 224,
|
| 144 |
+
visual_has_cls_token = False,
|
| 145 |
+
channels = 3,
|
| 146 |
+
use_all_token_embeds = False,
|
| 147 |
+
decoupled_contrastive_learning = False,
|
| 148 |
+
use_mlm = False,
|
| 149 |
+
text_ssl_loss_weight = 0.05,
|
| 150 |
+
use_visual_ssl = False,
|
| 151 |
+
visual_ssl = None,
|
| 152 |
+
embed_dim = 512,
|
| 153 |
+
image_ssl_loss_weight = 0.05,
|
| 154 |
+
multiview_loss_weight = 0.1,
|
| 155 |
+
curv_init: float = 1.0,
|
| 156 |
+
learn_curv: bool = True,
|
| 157 |
+
entail_weight: float = 0.3,
|
| 158 |
+
local_aperture_thresh: float = 1.2,
|
| 159 |
+
global_aperture_thresh: float = 0.7,
|
| 160 |
+
use_repo_ce_loss: bool = True
|
| 161 |
+
):
|
| 162 |
+
super().__init__()
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
#assert use_all_token_embeds or (visual_has_cls_token or text_has_cls_token), 'CLS token must be included on both vision and text transformers if you are not using fine-grained contrastive learning loss'
|
| 168 |
+
|
| 169 |
+
# store some parameters for access
|
| 170 |
+
|
| 171 |
+
self.dim_text = dim_text
|
| 172 |
+
self.dim_image = dim_image
|
| 173 |
+
self.dim_latent = dim_latent
|
| 174 |
+
|
| 175 |
+
self.image_size = visual_image_size
|
| 176 |
+
|
| 177 |
+
# instantiate text transformer
|
| 178 |
+
|
| 179 |
+
self.text_pad_id = text_pad_id
|
| 180 |
+
self.text_has_cls_token = text_has_cls_token
|
| 181 |
+
self.text_seq_len = text_seq_len
|
| 182 |
+
|
| 183 |
+
self.text_encode_without_mask = text_encode_without_mask # whether to pass in text mask to text encoder
|
| 184 |
+
|
| 185 |
+
self.text_causal_mask = text_causal_mask
|
| 186 |
+
self.text_eos_id = text_eos_id
|
| 187 |
+
|
| 188 |
+
assert not (text_causal_mask and not exists(text_eos_id)), 'text EOS token id must be given if using causal mask in text transformer'
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
self.text_transformer = text_encoder
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# instantiate image transformer
|
| 195 |
+
|
| 196 |
+
self.visual_has_cls_token = visual_has_cls_token
|
| 197 |
+
|
| 198 |
+
self.visual_transformer = image_encoder
|
| 199 |
+
|
| 200 |
+
# NOTE: Pass in a Transformer Decoder Module and a Positional-Encoding Module
|
| 201 |
+
if exists(fusion_module):
|
| 202 |
+
self.fusion_module = fusion_module
|
| 203 |
+
else:
|
| 204 |
+
decoder_layer = TransformerDecoderLayer(d_model=dim_latent, nhead=8, normalize_before=True)
|
| 205 |
+
decoder_norm = nn.LayerNorm(dim_latent)
|
| 206 |
+
self.fusion_module = TransformerDecoder(decoder_layer=decoder_layer, num_layers=4, norm=decoder_norm)
|
| 207 |
+
|
| 208 |
+
self.pos_embedding = PositionalEncoding2D(dim_latent)(torch.zeros(1, 24, 24, dim_latent)) # 1 H W Dim
|
| 209 |
+
# 修改形状为1 (HW) Dim
|
| 210 |
+
self.pos_embedding = rearrange(self.pos_embedding, 'b h w d -> b (h w) d')
|
| 211 |
+
|
| 212 |
+
# NOTE
|
| 213 |
+
|
| 214 |
+
# text ssl
|
| 215 |
+
|
| 216 |
+
self.use_mlm = use_mlm
|
| 217 |
+
self.text_ssl_loss_weight = text_ssl_loss_weight if use_mlm else 0
|
| 218 |
+
|
| 219 |
+
# image ssl
|
| 220 |
+
|
| 221 |
+
self.use_visual_ssl = use_visual_ssl or exists(visual_ssl)
|
| 222 |
+
self.image_ssl_loss_weight = image_ssl_loss_weight if use_visual_ssl else 0
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
self.to_text_latent = nn.Linear(dim_text, dim_latent, bias = False)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
self.to_visual_latent = nn.Linear(dim_image, dim_latent, bias = False)
|
| 229 |
+
self.to_fused_latent = nn.Linear(dim_latent, dim_latent, bias = False)
|
| 230 |
+
|
| 231 |
+
# Initialize curvature parameter. Hyperboloid curvature will be `-curv`.
|
| 232 |
+
self.curv = nn.Parameter(
|
| 233 |
+
torch.tensor(curv_init).log(), requires_grad=learn_curv
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
# When learning the curvature parameter, restrict it in this interval to
|
| 237 |
+
# prevent training instability.
|
| 238 |
+
self._curv_minmax = {
|
| 239 |
+
"max": math.log(curv_init * 10),
|
| 240 |
+
"min": math.log(curv_init / 10),
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
# temperature
|
| 244 |
+
|
| 245 |
+
self.temperature = nn.Parameter(torch.tensor(1.))
|
| 246 |
+
self.use_repo_ce_loss = use_repo_ce_loss
|
| 247 |
+
self.logit_scale = nn.Parameter(torch.tensor(1 / 0.07).log()) # 与HycoClip一致的可学习温度
|
| 248 |
+
self.entail_weight = entail_weight
|
| 249 |
+
self.global_aperture_thresh = global_aperture_thresh
|
| 250 |
+
self.local_aperture_thresh = local_aperture_thresh
|
| 251 |
+
self.visual_alpha = nn.Parameter(torch.tensor(embed_dim**-0.5).log())
|
| 252 |
+
self.textual_alpha = nn.Parameter(torch.tensor(embed_dim**-0.5).log())
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
# from https://arxiv.org/abs/2111.07783 (FILIP paper)
|
| 256 |
+
self.use_all_token_embeds = use_all_token_embeds
|
| 257 |
+
|
| 258 |
+
# proposed in https://arxiv.org/abs/2110.06848 (DCL) and https://arxiv.org/abs/2110.11316 (CLOOB)
|
| 259 |
+
self.decoupled_contrastive_learning = decoupled_contrastive_learning
|
| 260 |
+
|
| 261 |
+
self.multiview_loss_weight = multiview_loss_weight
|
| 262 |
+
|
| 263 |
+
if tokenizer != None:
|
| 264 |
+
self.tokenizer=tokenizer
|
| 265 |
+
else:
|
| 266 |
+
raise ValueError('Tokenzier is not defined.')
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def load(self, path,allow_partial_load = True):
|
| 270 |
+
print(f'** MODEL ** Load Checkpoint from {path}')
|
| 271 |
+
path = Path(path)
|
| 272 |
+
assert path.exists()
|
| 273 |
+
pkg = torch.load(path, map_location='cpu', weights_only=False)
|
| 274 |
+
model_dict = self.state_dict()
|
| 275 |
+
if 'model' in pkg:
|
| 276 |
+
pkg_state_dict = pkg['model'] # 假设 pkg['model'] 是模型的状态字典
|
| 277 |
+
else:
|
| 278 |
+
pkg_state_dict = pkg
|
| 279 |
+
if allow_partial_load:
|
| 280 |
+
# 检查差异
|
| 281 |
+
unexpected_state_dict = [k for k in pkg_state_dict.keys() if k not in model_dict.keys()]
|
| 282 |
+
missing_state_dict = [k for k in model_dict.keys() if k not in pkg_state_dict.keys()]
|
| 283 |
+
unmatchd_state_dict = [k for k, v in pkg_state_dict.items() if k in model_dict.keys() and v.shape != model_dict[k].shape]
|
| 284 |
+
# 加载部分参数
|
| 285 |
+
state_dict = {k: v for k, v in pkg_state_dict.items() if k in model_dict.keys() and v.shape == model_dict[k].shape}
|
| 286 |
+
model_dict.update(state_dict)
|
| 287 |
+
self.load_state_dict(model_dict)
|
| 288 |
+
# 可以选择输出具体的参数情况
|
| 289 |
+
# print('** MODEL ** The following parameters are UNEXPECTED in checkpoint:\n')
|
| 290 |
+
# print(unexpected_state_dict)
|
| 291 |
+
# print('** MODEL ** The following parameters are MISSING in checkpoint:\n')
|
| 292 |
+
# print(missing_state_dict)
|
| 293 |
+
# print('** MODEL ** The following parameters have DIFFERENT SHAPES in checkpoint:\n')
|
| 294 |
+
# print(unmatchd_state_dict)
|
| 295 |
+
# ('** MODEL ** The following parameters are LOADED in:\n')
|
| 296 |
+
# print(state_dict.keys())
|
| 297 |
+
else:
|
| 298 |
+
self.load_state_dict(pkg_state_dict)
|
| 299 |
+
|
| 300 |
+
def tokenize(self, prompt):
|
| 301 |
+
text_tokens=self.tokenizer(prompt, return_tensors="pt", padding="max_length", truncation=True, max_length=512).to('cuda')
|
| 302 |
+
return text_tokens
|
| 303 |
+
|
| 304 |
+
def token_embedding(self,input_ids):
|
| 305 |
+
input_shape = input_ids.size()
|
| 306 |
+
batch_size, seq_length = input_shape
|
| 307 |
+
if hasattr(self.text_transformer.embeddings, "token_type_ids"):
|
| 308 |
+
print("hahatrue")
|
| 309 |
+
|
| 310 |
+
buffered_token_type_ids = self.text_transformer.embeddings.token_type_ids[:, :seq_length]
|
| 311 |
+
buffered_token_type_ids_expanded = buffered_token_type_ids.expand(batch_size, seq_length)
|
| 312 |
+
token_type_ids = buffered_token_type_ids_expanded
|
| 313 |
+
text_embeddings = self.text_transformer.embeddings(input_ids = input_ids, token_type_ids = token_type_ids)
|
| 314 |
+
return text_embeddings
|
| 315 |
+
|
| 316 |
+
def freeze_text_encoder(self):
|
| 317 |
+
for param in self.text_transformer.parameters():
|
| 318 |
+
param.requires_grad = False
|
| 319 |
+
for param in self.to_text_latent.parameters():
|
| 320 |
+
param.requires_grad = False
|
| 321 |
+
|
| 322 |
+
def open_text_encoder(self):
|
| 323 |
+
for param in self.text_transformer.parameters():
|
| 324 |
+
param.requires_grad = True
|
| 325 |
+
for param in self.to_text_latent.parameters():
|
| 326 |
+
param.requires_grad = True
|
| 327 |
+
|
| 328 |
+
def freeze_vision_encoder(self):
|
| 329 |
+
for param in self.visual_transformer.parameters():
|
| 330 |
+
param.requires_grad = False
|
| 331 |
+
for param in self.to_visual_latent.parameters():
|
| 332 |
+
param.requires_grad = False
|
| 333 |
+
|
| 334 |
+
def open_vision_encoder(self):
|
| 335 |
+
for param in self.visual_transformer.parameters():
|
| 336 |
+
param.requires_grad = True
|
| 337 |
+
for param in self.to_visual_latent.parameters():
|
| 338 |
+
param.requires_grad = True
|
| 339 |
+
|
| 340 |
+
def open_fusion_module(self):
|
| 341 |
+
for param in self.fusion_module.parameters():
|
| 342 |
+
param.requires_grad = True
|
| 343 |
+
for param in self.to_fused_latent.parameters():
|
| 344 |
+
param.requires_grad = True
|
| 345 |
+
|
| 346 |
+
def freeze_fusion_module(self):
|
| 347 |
+
for param in self.fusion_module.parameters():
|
| 348 |
+
param.requires_grad = False
|
| 349 |
+
for param in self.to_fused_latent.parameters():
|
| 350 |
+
param.requires_grad = False
|
| 351 |
+
|
| 352 |
+
def get_log_curv(self):
|
| 353 |
+
return torch.clamp(
|
| 354 |
+
self.curv,
|
| 355 |
+
min=self._curv_minmax["min"],
|
| 356 |
+
max=self._curv_minmax["max"],
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
def get_curv(self):
|
| 360 |
+
with torch.cuda.amp.autocast(enabled=False):
|
| 361 |
+
curv = torch.exp(self.get_log_curv().float())
|
| 362 |
+
return torch.clamp(curv, min=1e-4)
|
| 363 |
+
|
| 364 |
+
def project_img(self, image_feats):
|
| 365 |
+
image_feats = image_feats * self.visual_alpha.exp()
|
| 366 |
+
with torch.cuda.amp.autocast(enabled=False):
|
| 367 |
+
curv = self.get_curv()
|
| 368 |
+
image_feats = L.exp_map0(image_feats.float(), curv)
|
| 369 |
+
return image_feats
|
| 370 |
+
|
| 371 |
+
def project_txt(self, text_feats):
|
| 372 |
+
text_feats = text_feats * self.textual_alpha.exp()
|
| 373 |
+
with torch.cuda.amp.autocast(enabled=False):
|
| 374 |
+
curv = self.get_curv()
|
| 375 |
+
text_feats = L.exp_map0(text_feats.float(), curv)
|
| 376 |
+
return text_feats
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def mask_average_pool(self, x, mask):
|
| 381 |
+
"""
|
| 382 |
+
对 token 特征做 masked average pooling
|
| 383 |
+
|
| 384 |
+
Args:
|
| 385 |
+
x: [B, T, D] (例如 word_feats: [8,128,512])
|
| 386 |
+
mask: [B, Tm] 或 [B, K, Tm]
|
| 387 |
+
目前的情况通常是 [B,111]
|
| 388 |
+
|
| 389 |
+
Returns:
|
| 390 |
+
pooled: [B, K, D]
|
| 391 |
+
"""
|
| 392 |
+
import torch
|
| 393 |
+
import torch.nn.functional as F
|
| 394 |
+
|
| 395 |
+
# x: [B, T, D]
|
| 396 |
+
T = x.size(1)
|
| 397 |
+
|
| 398 |
+
m = mask
|
| 399 |
+
# [B, Tm] -> [B, 1, Tm]
|
| 400 |
+
if m.dim() == 2:
|
| 401 |
+
m = m.unsqueeze(1)
|
| 402 |
+
|
| 403 |
+
# 对齐最后一维到 T(补 0 或截断)
|
| 404 |
+
Tm = m.size(-1)
|
| 405 |
+
if Tm < T:
|
| 406 |
+
m = F.pad(m, (0, T - Tm), value=0) # 右侧补0
|
| 407 |
+
elif Tm > T:
|
| 408 |
+
m = m[..., :T]
|
| 409 |
+
|
| 410 |
+
# einsum 用 float dtype
|
| 411 |
+
m = m.to(dtype=x.dtype)
|
| 412 |
+
|
| 413 |
+
# 避免全0 mask 导致除0
|
| 414 |
+
den = m.sum(dim=-1, keepdim=True).clamp_min(1.0)
|
| 415 |
+
|
| 416 |
+
# [B, K, T] x [B, T, D] -> [B, K, D]
|
| 417 |
+
return torch.einsum('bkt,btd->bkd', m, x) / den
|
| 418 |
+
|
| 419 |
+
def _unpack_text_batch(self, text):
|
| 420 |
+
if isinstance(text, dict):
|
| 421 |
+
input_ids = text.get('caption_ids', text.get('input_ids'))
|
| 422 |
+
token_type_ids = text.get('token_type_ids', None)
|
| 423 |
+
attention_mask = text.get('attention_mask', None)
|
| 424 |
+
else:
|
| 425 |
+
input_ids = text.input_ids
|
| 426 |
+
token_type_ids = getattr(text, 'token_type_ids', None)
|
| 427 |
+
attention_mask = getattr(text, 'attention_mask', None)
|
| 428 |
+
|
| 429 |
+
return input_ids, token_type_ids, attention_mask
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def forward(
|
| 433 |
+
self,
|
| 434 |
+
text, # B
|
| 435 |
+
image, # B C H W D
|
| 436 |
+
device,
|
| 437 |
+
return_loss = True,
|
| 438 |
+
is_condition = False,
|
| 439 |
+
modal_embedding = False,
|
| 440 |
+
phrases_bboxes_mask=None,
|
| 441 |
+
phrase_text_mask=None,
|
| 442 |
+
modal_indexs = None,
|
| 443 |
+
return_latents=True, # 🟢 显式声明
|
| 444 |
+
**kwargs
|
| 445 |
+
):
|
| 446 |
+
if is_condition:
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
B, device = text.input_ids.shape[0], device
|
| 450 |
+
|
| 451 |
+
# derive text mask
|
| 452 |
+
|
| 453 |
+
text_mask =text.attention_mask
|
| 454 |
+
|
| 455 |
+
# get encoded text
|
| 456 |
+
|
| 457 |
+
text_args = (text.input_ids,text.attention_mask)
|
| 458 |
+
|
| 459 |
+
if not self.text_encode_without_mask:
|
| 460 |
+
text_args = (*text_args, text_mask)
|
| 461 |
+
|
| 462 |
+
text_embeddings = self.text_transformer(text.input_ids, attention_mask = text.attention_mask )
|
| 463 |
+
enc_text = text_embeddings[0] # B 512(token_len) 768
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
if image.ndim == 3: # [H, W, ?] 的情况先 unsqueeze
|
| 467 |
+
image = image.unsqueeze(0)
|
| 468 |
+
if image.ndim == 4: # B×C×H×W
|
| 469 |
+
if image.shape[1] != 1:
|
| 470 |
+
image = image.mean(dim=1, keepdim=True)
|
| 471 |
+
elif image.ndim == 5: # B×C×D×H×W
|
| 472 |
+
if image.shape[1] != 1:
|
| 473 |
+
image = image.mean(dim=1, keepdim=True)
|
| 474 |
+
else:
|
| 475 |
+
raise ValueError(...)
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
# NOTE
|
| 482 |
+
modal_indexs = modal_indexs.view(-1) # (B * local_B) 重复之后,condition就能和uncon的情况一样了
|
| 483 |
+
enc_image = self.visual_transformer(image, return_encoded_tokens=True,modal_embedding=modal_embedding,modal_indexs=modal_indexs,is_condition=True) # B*N 24 24 24 512(vis_dim)
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
image_embeds = rearrange(enc_image, 'b t d -> t b d')
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
text_embeds = enc_text[:,0,:] # B 768
|
| 490 |
+
|
| 491 |
+
text_latents = self.to_text_latent(text_embeds).unsqueeze(0) # 1 B*N 512
|
| 492 |
+
image_latents = image_embeds
|
| 493 |
+
t = image_latents.shape[0]
|
| 494 |
+
temp_pos_embedding = self.pos_embedding[:,:t, :] # 1 t 512
|
| 495 |
+
|
| 496 |
+
pos = repeat(temp_pos_embedding, 'one d dim -> d (one bn) dim', bn=image_latents.shape[1]).to(device) # 24*24, B*N, 512
|
| 497 |
+
|
| 498 |
+
fused_latents, _ = self.fusion_module(text_latents, image_latents, pos=pos) # 1 B*N 512
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
fused_latents = fused_latents[0]
|
| 502 |
+
fused_latents = self.to_fused_latent(fused_latents) # B*N 512
|
| 503 |
+
|
| 504 |
+
norms = torch.norm(fused_latents, dim=-1)
|
| 505 |
+
# print('fused_latents范数:', '最小值:', norms.min(), '最大值:', norms.max(), '存在零:', (norms == 0).any())
|
| 506 |
+
if (norms < 1e-6).any():
|
| 507 |
+
print('警告:检测到极小范数')
|
| 508 |
+
# 可选:添加微小噪声避免零范数问题
|
| 509 |
+
fused_latents = fused_latents + torch.randn_like(fused_latents) * 1e-8
|
| 510 |
+
if torch.isnan(fused_latents).any() or torch.isinf(fused_latents).any():
|
| 511 |
+
print('NaN/Inf in vec **before** l2norm')
|
| 512 |
+
|
| 513 |
+
if fused_latents.isnan().any() or fused_latents.isinf().any():
|
| 514 |
+
print('NaN/Inf in vec **after** l2norm')
|
| 515 |
+
if (fused_latents == 0).any():
|
| 516 |
+
print('Zero in vec **after** l2norm')
|
| 517 |
+
|
| 518 |
+
# NOTE
|
| 519 |
+
|
| 520 |
+
if return_latents:
|
| 521 |
+
return text_latents, image_latents, fused_latents, self.temperature.exp()
|
| 522 |
+
|
| 523 |
+
# ——保持与 else 分支一致 ——
|
| 524 |
+
|
| 525 |
+
# 全局文本/图像向量
|
| 526 |
+
text_latents_global = l2norm(self.to_text_latent(enc_text[:, 0, :])) # B x D_latent
|
| 527 |
+
image_global = l2norm(fused_latents.mean(dim=1)) # B x D_latent
|
| 528 |
+
|
| 529 |
+
# 双曲投影与全局蕴含
|
| 530 |
+
#_curv = self.curv.exp()
|
| 531 |
+
with torch.cuda.amp.autocast(enabled=False):
|
| 532 |
+
_curv = self.get_curv()
|
| 533 |
+
text_feats = self.project_txt(text_latents_global)
|
| 534 |
+
image_feats = self.project_img(image_global)
|
| 535 |
+
_angle = L.oxy_angle(text_feats, image_feats, _curv)
|
| 536 |
+
_aperture = L.half_aperture(text_feats, _curv)
|
| 537 |
+
text_image_entailment_loss = torch.clamp(_angle - self.global_aperture_thresh * _aperture, min=0).mean()
|
| 538 |
+
|
| 539 |
+
# 温度缩放(与仓库版相同)
|
| 540 |
+
|
| 541 |
+
#改curv1
|
| 542 |
+
#self.logit_scale.data = torch.clamp(self.logit_scale.data, max=math.log(100.0))
|
| 543 |
+
with torch.no_grad():
|
| 544 |
+
self.logit_scale.clamp_(max=math.log(100.0))
|
| 545 |
+
#改curv2
|
| 546 |
+
|
| 547 |
+
scale = self.logit_scale.exp()
|
| 548 |
+
|
| 549 |
+
# B×B logits(-双曲距离),对称 CE
|
| 550 |
+
targets = torch.arange(image_global.size(0), device=image_global.device)
|
| 551 |
+
image_logits = -L.pairwise_dist(image_feats, text_feats, _curv) # [B, B]
|
| 552 |
+
text_logits = -L.pairwise_dist(text_feats, image_feats, _curv) # [B, B]
|
| 553 |
+
# ABLATION: 去掉 image→text CE,只保留 text→image CE
|
| 554 |
+
i2t_ce_loss = F.cross_entropy(scale * image_logits, targets)
|
| 555 |
+
t2i_ce_loss = F.cross_entropy(scale * text_logits, targets)
|
| 556 |
+
ce_loss = t2i_ce_loss
|
| 557 |
+
# ce_loss = 0.5 * (i2t_ce_loss + t2i_ce_loss)
|
| 558 |
+
|
| 559 |
+
loss = ce_loss + self.entail_weight * text_image_entailment_loss
|
| 560 |
+
|
| 561 |
+
entailment_loss = text_image_entailment_loss
|
| 562 |
+
|
| 563 |
+
return loss, ce_loss, entailment_loss, text_image_entailment_loss
|
| 564 |
+
else:
|
| 565 |
+
#text_mask:文本的注意力掩码(布尔矩阵),形状是 B x seq_len。
|
| 566 |
+
#text_args:传入文本编码器的集合,包括输入的 token ids 和注意力掩码。
|
| 567 |
+
#text_embeddings:文本编码器的输出,包含每个 token 的向量表示。形状是 B x seq_len x dim_text。
|
| 568 |
+
#enc_text:文本编码器的最终输出,通常是取 [CLS] token 的向量表示。形状是 B x seq_len x dim_text。
|
| 569 |
+
#text_latents:把整句输出文本压成一个固定长度的向量,代表这句话的整体语义。形状是 B x 512。
|
| 570 |
+
#text_embeds: 文本的全局向量表示,取自文本编码器的 [CLS] token 输出。形状是 B x dim_text。
|
| 571 |
+
|
| 572 |
+
#local_B: 单个图像的局部块数量(local_B)。
|
| 573 |
+
#modal_indexs:表示每个图像块所属的模态索引,形状是 B x local_B。
|
| 574 |
+
#enc_image:图像编码器的输出,包含每个图像块的向量表示。形状是 (B * local_B) x dim_image。
|
| 575 |
+
#image_embeds:把所有图像块的向量表示重新排列成 (HWD) x (B * local_B) x dim_image 的形状。
|
| 576 |
+
#image_latents:图像块向量,这些局部向量按顺序排成一串,形状是 (HWD) x (B * local_B) x 512。
|
| 577 |
+
#box_image_feats_eu:池化后与文本短语对应的图像块特征,形状是 B x K x D_latent。
|
| 578 |
+
#box_text_feats_eu:池化后与图像区域对应的文本短语特征,形状是 B x K x D_latent。
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
input_ids, token_type_ids, attention_mask = self._unpack_text_batch(text)
|
| 582 |
+
B = input_ids.size(0)
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
with torch.cuda.amp.autocast(enabled=False):
|
| 586 |
+
_curv = self.get_curv()
|
| 587 |
+
|
| 588 |
+
if image.ndim == 3: # [H, W, ?] 的情况先 unsqueeze
|
| 589 |
+
image = image.unsqueeze(0)
|
| 590 |
+
if image.ndim == 4: # B×C×H×W
|
| 591 |
+
if image.shape[1] != 1:
|
| 592 |
+
image = image.mean(dim=1, keepdim=True)
|
| 593 |
+
elif image.ndim == 5: # B×C×D×H×W
|
| 594 |
+
if image.shape[1] != 1:
|
| 595 |
+
image = image.mean(dim=1, keepdim=True)
|
| 596 |
+
else:
|
| 597 |
+
raise ValueError(...)
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
# ====== 文本编码 ======
|
| 602 |
+
text_embeddings = self.text_transformer(
|
| 603 |
+
input_ids=input_ids,
|
| 604 |
+
token_type_ids=token_type_ids,
|
| 605 |
+
attention_mask=attention_mask
|
| 606 |
+
)
|
| 607 |
+
enc_text = text_embeddings[0] # B×T_txt×dim_text
|
| 608 |
+
|
| 609 |
+
#text_latents_global = self.to_text_latent(enc_text[:, 0, :])
|
| 610 |
+
text_latents_global = enc_text[:, 0, :] # 不再过 Linear
|
| 611 |
+
|
| 612 |
+
modality_dict = {'CT': 0, 'CXR': 1}
|
| 613 |
+
modal_indexs = torch.tensor([modality_dict['CXR']], dtype=torch.long, device=device)
|
| 614 |
+
|
| 615 |
+
modal_indexs = modal_indexs.view(-1) # (B * local_B) 重复之后,condition就能和uncon的情况一样了
|
| 616 |
+
|
| 617 |
+
# ====== 图像编码(双路)======
|
| 618 |
+
# Patch 路:box / cross entailment(保留空间 token 网格)
|
| 619 |
+
enc_image_patch = self.visual_transformer(
|
| 620 |
+
image,
|
| 621 |
+
return_encoded_tokens=True,
|
| 622 |
+
modal_embedding=modal_embedding,
|
| 623 |
+
modal_indexs=None,
|
| 624 |
+
is_condition=True,
|
| 625 |
+
) # [B, T_img, D]
|
| 626 |
+
|
| 627 |
+
# Global 路:global CE + global entailment(enc_cls_transformer 整图 CLS)
|
| 628 |
+
enc_image_global = self.visual_transformer(
|
| 629 |
+
image,
|
| 630 |
+
return_encoded_tokens=True,
|
| 631 |
+
modal_embedding=modal_embedding,
|
| 632 |
+
modal_indexs=None,
|
| 633 |
+
is_condition=False,
|
| 634 |
+
) # [B, D]
|
| 635 |
+
|
| 636 |
+
if self.use_all_token_embeds:
|
| 637 |
+
text_embeds = enc_text[:, 1:] if self.text_has_cls_token else enc_text
|
| 638 |
+
else:
|
| 639 |
+
text_embeds = enc_text[:, :] if enc_text.ndim == 3 else enc_text
|
| 640 |
+
|
| 641 |
+
text_embeds = text_embeds[:, 0, :]
|
| 642 |
+
text_latents = l2norm(text_embeds)
|
| 643 |
+
|
| 644 |
+
if enc_image_global.dim() == 2:
|
| 645 |
+
image_latents = l2norm(enc_image_global)
|
| 646 |
+
elif enc_image_global.dim() == 3:
|
| 647 |
+
image_latents = l2norm(enc_image_global[:, 0, :])
|
| 648 |
+
else:
|
| 649 |
+
raise ValueError(
|
| 650 |
+
f"Unexpected global visual feature shape: {enc_image_global.shape}"
|
| 651 |
+
)
|
| 652 |
+
|
| 653 |
+
image_global = image_latents
|
| 654 |
+
|
| 655 |
+
word_feats = enc_text
|
| 656 |
+
patch_feats = enc_image_patch
|
| 657 |
+
|
| 658 |
+
if phrase_text_mask is not None:
|
| 659 |
+
if phrase_text_mask.dim() == 2: # B×T_txt
|
| 660 |
+
phrase_text_mask = phrase_text_mask.unsqueeze(1) # B×1×T_txt
|
| 661 |
+
box_text_feats_eu = self.mask_average_pool(word_feats, phrase_text_mask) # B×K×dim_latent
|
| 662 |
+
|
| 663 |
+
#patch_feats = rearrange(patch_feats_bn, '(b n) t d -> b n t d', n=local_B).mean(dim=1) # B×T_img×dim_latent
|
| 664 |
+
if phrases_bboxes_mask is not None:
|
| 665 |
+
if phrases_bboxes_mask.dim() == 2:
|
| 666 |
+
phrases_bboxes_mask = phrases_bboxes_mask.unsqueeze(1) # B×1×T_img
|
| 667 |
+
box_image_feats_eu = self.mask_average_pool(patch_feats, phrases_bboxes_mask)
|
| 668 |
+
|
| 669 |
+
# 双曲投影 + 蕴含损失计算(仅当提供 box mask 时)
|
| 670 |
+
if phrase_text_mask is not None and phrases_bboxes_mask is not None:
|
| 671 |
+
text_feats = self.project_txt(text_latents_global) # B×D_hyp
|
| 672 |
+
#image_global = patch_feats.mean(dim=1) # B×D_latent
|
| 673 |
+
image_feats = self.project_img(image_global) # B×D_hyp
|
| 674 |
+
box_text_feats = self.project_txt(box_text_feats_eu) # B×K×D_hyp
|
| 675 |
+
box_image_feats = self.project_img(box_image_feats_eu) # B×K×D_hyp
|
| 676 |
+
# 后续四项蕴含损失逻辑保持不变
|
| 677 |
+
|
| 678 |
+
|
| 679 |
+
|
| 680 |
+
if not return_latents:
|
| 681 |
+
return text_latents, image_latents, self.temperature.exp()
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
#celoss用,防止被normalize拉爆
|
| 685 |
+
image_logits = -L.pairwise_dist(image_feats, text_feats, _curv) # [B, B]
|
| 686 |
+
text_logits = -L.pairwise_dist(text_feats, image_feats, _curv) # [B, B]
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
#四项蕴含损失
|
| 691 |
+
_angle = L.oxy_angle(text_feats, image_feats, _curv)
|
| 692 |
+
|
| 693 |
+
_aperture = L.half_aperture(text_feats, _curv)
|
| 694 |
+
|
| 695 |
+
_box_angle = L.oxy_angle(box_text_feats, box_image_feats, _curv)
|
| 696 |
+
|
| 697 |
+
_box_aperture = L.half_aperture(box_text_feats, _curv)
|
| 698 |
+
|
| 699 |
+
_cross_image_angle = L.oxy_angle(box_image_feats, image_feats.unsqueeze(1), _curv)
|
| 700 |
+
|
| 701 |
+
_box_image_aperture = L.half_aperture(box_image_feats, _curv)
|
| 702 |
+
|
| 703 |
+
_cross_text_angle = L.oxy_angle(box_text_feats, text_feats.unsqueeze(1), _curv)
|
| 704 |
+
|
| 705 |
+
_box_text_aperture = L.half_aperture(box_text_feats, _curv)
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
#全文-全图蕴含
|
| 711 |
+
text_image_entailment_loss = torch.clamp(_angle - self.global_aperture_thresh * _aperture, min=0).mean()
|
| 712 |
+
|
| 713 |
+
box_text_image_entailment_loss = torch.clamp(_box_angle - self.global_aperture_thresh * _box_aperture, min=0).mean()
|
| 714 |
+
|
| 715 |
+
cross_image_entailment_loss = torch.clamp(_cross_image_angle - self.local_aperture_thresh * _box_image_aperture, min=0).mean()
|
| 716 |
+
|
| 717 |
+
cross_text_entailment_loss = torch.clamp(_cross_text_angle - self.local_aperture_thresh * _box_text_aperture, min=0).mean()
|
| 718 |
+
|
| 719 |
+
entailment_loss = 0.5 * (
|
| 720 |
+
text_image_entailment_loss
|
| 721 |
+
+ box_text_image_entailment_loss
|
| 722 |
+
+ cross_image_entailment_loss
|
| 723 |
+
+ cross_text_entailment_loss
|
| 724 |
+
)
|
| 725 |
+
|
| 726 |
+
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
# 3) 温度缩放
|
| 730 |
+
|
| 731 |
+
self.logit_scale.data = torch.clamp(self.logit_scale.data, max=4.6052)
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
targets = torch.arange(image_global.size(0), device=image_global.device)
|
| 735 |
+
scale = self.logit_scale.exp()
|
| 736 |
+
|
| 737 |
+
# ABLATION: 去掉 image→text CE,只保留 text→image CE
|
| 738 |
+
i2t_ce_loss = F.cross_entropy(scale * image_logits, targets)
|
| 739 |
+
t2i_ce_loss = F.cross_entropy(scale * text_logits, targets)
|
| 740 |
+
ce_loss = t2i_ce_loss
|
| 741 |
+
# ce_loss = 0.5 * (i2t_ce_loss + t2i_ce_loss)
|
| 742 |
+
|
| 743 |
+
loss = ce_loss + self.entail_weight * entailment_loss
|
| 744 |
+
|
| 745 |
+
return loss, ce_loss, entailment_loss, text_image_entailment_loss
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
if __name__ == '__main__':
|
| 750 |
+
import os
|
| 751 |
+
import argparse
|
| 752 |
+
import torch
|
| 753 |
+
|
| 754 |
+
from transformer_maskgit.ctvit import CTViT
|
| 755 |
+
from transformers import AutoTokenizer, AutoModel, AutoConfig
|
| 756 |
+
|
| 757 |
+
# --------------------------------------------------------
|
| 758 |
+
# Args
|
| 759 |
+
# --------------------------------------------------------
|
| 760 |
+
parser = argparse.ArgumentParser(description='Debug RADIR with batch')
|
| 761 |
+
|
| 762 |
+
parser.add_argument('--batch', type=int, default=8)
|
| 763 |
+
parser.add_argument('--channels', type=int, default=1)
|
| 764 |
+
parser.add_argument('--height', type=int, default=224)
|
| 765 |
+
parser.add_argument('--width', type=int, default=224)
|
| 766 |
+
parser.add_argument('--modal_embedding', action='store_true')
|
| 767 |
+
|
| 768 |
+
# Dataset paths
|
| 769 |
+
parser.add_argument(
|
| 770 |
+
'--dataset_root',
|
| 771 |
+
type=str,
|
| 772 |
+
default=r"/mnt/data-3/users/zhangzeyu/HMClip/RadIR/dataset"
|
| 773 |
+
)
|
| 774 |
+
parser.add_argument(
|
| 775 |
+
'--matched_dir',
|
| 776 |
+
type=str,
|
| 777 |
+
default=r"/mnt/data-3/users/zhangzeyu/HMClip/RadIR/dataset/processed/matched"
|
| 778 |
+
)
|
| 779 |
+
parser.add_argument(
|
| 780 |
+
'--caption_file',
|
| 781 |
+
type=str,
|
| 782 |
+
default="/mnt/data-3/users/zhangzeyu/HMClip/RadIR/dataset/all.jsonl" # 相对 dataset_root
|
| 783 |
+
)
|
| 784 |
+
|
| 785 |
+
# Local HF model path
|
| 786 |
+
parser.add_argument(
|
| 787 |
+
'--model_path',
|
| 788 |
+
type=str,
|
| 789 |
+
default=r"/mnt/data-3/users/zhangzeyu/HMClip/RadIR/hf_models/microsoft/BiomedVLP-CXR-BERT-specialized"
|
| 790 |
+
)
|
| 791 |
+
|
| 792 |
+
args = parser.parse_args()
|
| 793 |
+
|
| 794 |
+
# --------------------------------------------------------
|
| 795 |
+
# Device
|
| 796 |
+
# --------------------------------------------------------
|
| 797 |
+
if not torch.cuda.is_available():
|
| 798 |
+
raise RuntimeError('需要 GPU 环境(CUDA 可用)。')
|
| 799 |
+
|
| 800 |
+
device = torch.device('cuda')
|
| 801 |
+
|
| 802 |
+
# --------------------------------------------------------
|
| 803 |
+
# Debug HF loading
|
| 804 |
+
# --------------------------------------------------------
|
| 805 |
+
from transformers.utils import cached_file
|
| 806 |
+
try:
|
| 807 |
+
path = cached_file(args.model_path, "tokenizer_config.json", local_files_only=True)
|
| 808 |
+
print(">>> HF tokenizer_config.json loaded from:", path)
|
| 809 |
+
except Exception as e:
|
| 810 |
+
print(">>> Failed to load tokenizer_config.json:", e)
|
| 811 |
+
|
| 812 |
+
print(">>> Checking config loading...")
|
| 813 |
+
cfg = AutoConfig.from_pretrained(
|
| 814 |
+
args.model_path,
|
| 815 |
+
trust_remote_code=True,
|
| 816 |
+
local_files_only=True
|
| 817 |
+
)
|
| 818 |
+
print(">>> model_type:", cfg.model_type)
|
| 819 |
+
print(">>> config path:", cfg._name_or_path)
|
| 820 |
+
|
| 821 |
+
# --------------------------------------------------------
|
| 822 |
+
# Load text encoder & tokenizer
|
| 823 |
+
# --------------------------------------------------------
|
| 824 |
+
tokenizer = AutoTokenizer.from_pretrained(
|
| 825 |
+
args.model_path,
|
| 826 |
+
trust_remote_code=True,
|
| 827 |
+
local_files_only=True
|
| 828 |
+
)
|
| 829 |
+
|
| 830 |
+
text_model = AutoModel.from_pretrained(
|
| 831 |
+
args.model_path,
|
| 832 |
+
trust_remote_code=True,
|
| 833 |
+
local_files_only=True
|
| 834 |
+
).to(device)
|
| 835 |
+
|
| 836 |
+
# --------------------------------------------------------
|
| 837 |
+
# Load image encoder (CTViT)
|
| 838 |
+
# --------------------------------------------------------
|
| 839 |
+
image_encoder = CTViT(
|
| 840 |
+
dim=512,
|
| 841 |
+
codebook_size=8192,
|
| 842 |
+
image_size=args.height,
|
| 843 |
+
patch_size=16,
|
| 844 |
+
temporal_patch_size=10,
|
| 845 |
+
spatial_depth=8,
|
| 846 |
+
temporal_depth=6,
|
| 847 |
+
cls_depth=4,
|
| 848 |
+
dim_head=32,
|
| 849 |
+
heads=8,
|
| 850 |
+
channels=args.channels
|
| 851 |
+
).to(device)
|
| 852 |
+
|
| 853 |
+
# --------------------------------------------------------
|
| 854 |
+
# Build RADIR
|
| 855 |
+
# --------------------------------------------------------
|
| 856 |
+
from radir.RadIR import RADIR
|
| 857 |
+
|
| 858 |
+
clip = RADIR(
|
| 859 |
+
tokenizer=tokenizer,
|
| 860 |
+
image_encoder=image_encoder,
|
| 861 |
+
text_encoder=text_model,
|
| 862 |
+
dim_text=768,
|
| 863 |
+
dim_image=512,
|
| 864 |
+
dim_latent=512,
|
| 865 |
+
use_mlm=False,
|
| 866 |
+
use_all_token_embeds=False
|
| 867 |
+
).to(device)
|
| 868 |
+
|
| 869 |
+
# --------------------------------------------------------
|
| 870 |
+
# Build dataset
|
| 871 |
+
# --------------------------------------------------------
|
| 872 |
+
from radir.data.hmclip_mimic_box import (
|
| 873 |
+
HyCoClipMimicBoxDataset_JPG,
|
| 874 |
+
collate_hmclip_box
|
| 875 |
+
)
|
| 876 |
+
from torch.utils.data import DataLoader
|
| 877 |
+
|
| 878 |
+
dataset = HyCoClipMimicBoxDataset_JPG(
|
| 879 |
+
split="train",
|
| 880 |
+
tokenizer=tokenizer,
|
| 881 |
+
dataset_root=args.dataset_root,
|
| 882 |
+
cig_matched_dir=args.matched_dir,
|
| 883 |
+
caption_file=args.caption_file,
|
| 884 |
+
transform=None, # 使用 Dataset 内置 2D + MONAI
|
| 885 |
+
max_words=128,
|
| 886 |
+
imsize=224
|
| 887 |
+
)
|
| 888 |
+
|
| 889 |
+
loader = DataLoader(
|
| 890 |
+
dataset,
|
| 891 |
+
batch_size=args.batch,
|
| 892 |
+
shuffle=True,
|
| 893 |
+
num_workers=0,
|
| 894 |
+
collate_fn=collate_hmclip_box,
|
| 895 |
+
)
|
| 896 |
+
|
| 897 |
+
batch = next(iter(loader))
|
| 898 |
+
|
| 899 |
+
imgs = batch["imgs"].to(device) # [B, 1, 224, 224]
|
| 900 |
+
|
| 901 |
+
enc_text = {
|
| 902 |
+
"input_ids": batch["caption_ids"].to(device),
|
| 903 |
+
"token_type_ids": batch["token_type_ids"].to(device),
|
| 904 |
+
"attention_mask": batch["attention_mask"].to(device),
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
phrase_text_mask = batch["phrase_text_mask"].to(device)
|
| 908 |
+
phrases_bboxes_mask = batch["phrases_bboxes_mask"].to(device)
|
| 909 |
+
|
| 910 |
+
print(f">>> Loaded batch:")
|
| 911 |
+
print(f" imgs: {imgs.shape}")
|
| 912 |
+
print(f" input_ids: {enc_text['input_ids'].shape}")
|
| 913 |
+
print(f" phrase_text_mask: {phrase_text_mask.shape}")
|
| 914 |
+
print(f" phrases_bboxes_mask: {phrases_bboxes_mask.shape}")
|
| 915 |
+
|
| 916 |
+
# --------------------------------------------------------
|
| 917 |
+
# Estimate visual token length T_img
|
| 918 |
+
# --------------------------------------------------------
|
| 919 |
+
with torch.no_grad():
|
| 920 |
+
enc_image_tokens = clip.visual_transformer(
|
| 921 |
+
imgs,
|
| 922 |
+
return_encoded_tokens=True,
|
| 923 |
+
modal_embedding=args.modal_embedding,
|
| 924 |
+
modal_indexs=None,
|
| 925 |
+
is_condition=False
|
| 926 |
+
)
|
| 927 |
+
T_img = enc_image_tokens.shape[1]
|
| 928 |
+
print(">>> T_img =", T_img)
|
| 929 |
+
|
| 930 |
+
# --------------------------------------------------------
|
| 931 |
+
# Adjust masks
|
| 932 |
+
# --------------------------------------------------------
|
| 933 |
+
phrase_text_mask = phrase_text_mask.unsqueeze(1) # [B,1,T_txt]
|
| 934 |
+
phrases_bboxes_mask = phrases_bboxes_mask.unsqueeze(1) # [B,1,T_img?]
|
| 935 |
+
|
| 936 |
+
cur_T_img = phrases_bboxes_mask.shape[-1]
|
| 937 |
+
if cur_T_img != T_img:
|
| 938 |
+
if cur_T_img > T_img:
|
| 939 |
+
phrases_bboxes_mask = phrases_bboxes_mask[..., :T_img]
|
| 940 |
+
else:
|
| 941 |
+
pad = torch.ones(
|
| 942 |
+
imgs.size(0), 1, T_img - cur_T_img,
|
| 943 |
+
device=device
|
| 944 |
+
)
|
| 945 |
+
phrases_bboxes_mask = torch.cat(
|
| 946 |
+
[phrases_bboxes_mask, pad],
|
| 947 |
+
dim=-1
|
| 948 |
+
)
|
| 949 |
+
print(f"[DBG] adjusted phrases_bboxes_mask: {cur_T_img} -> {T_img}")
|
| 950 |
+
|
| 951 |
+
# --------------------------------------------------------
|
| 952 |
+
# Modality index (CXR = 1)
|
| 953 |
+
# --------------------------------------------------------
|
| 954 |
+
modality_dict = {'CT': 0, 'CXR': 1}
|
| 955 |
+
modal_indexs = torch.tensor(
|
| 956 |
+
[modality_dict['CXR']],
|
| 957 |
+
dtype=torch.long,
|
| 958 |
+
device=device
|
| 959 |
+
)
|
| 960 |
+
|
| 961 |
+
# --------------------------------------------------------
|
| 962 |
+
# Forward
|
| 963 |
+
# --------------------------------------------------------
|
| 964 |
+
print(">>> Forward (return_loss=True)")
|
| 965 |
+
|
| 966 |
+
losses = clip(
|
| 967 |
+
text=enc_text,
|
| 968 |
+
image=imgs,
|
| 969 |
+
device=device,
|
| 970 |
+
is_condition=False,
|
| 971 |
+
return_latents=True,
|
| 972 |
+
return_loss=True,
|
| 973 |
+
modal_embedding=args.modal_embedding,
|
| 974 |
+
phrase_text_mask=phrase_text_mask,
|
| 975 |
+
phrases_bboxes_mask=phrases_bboxes_mask,
|
| 976 |
+
modal_indexs=modal_indexs
|
| 977 |
+
)
|
| 978 |
+
|
| 979 |
+
loss, ce_loss, entailment_loss, text_image_entailment_loss = losses
|
| 980 |
+
|
| 981 |
+
print(f"loss_total: {float(loss):.6f}")
|
| 982 |
+
print(f"loss_ce(image<->text): {float(ce_loss):.6f}")
|
| 983 |
+
print(f"loss_entail_global: {float(entailment_loss):.6f}")
|
| 984 |
+
print(f"loss_entail_image_to_text: {float(text_image_entailment_loss):.6f}")
|
Rad_IR/radir/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from radir.RadIR import RADIR
|
Rad_IR/radir/__pycache__/RadIR.cpython-313.pyc
ADDED
|
Binary file (37.1 kB). View file
|
|
|
Rad_IR/radir/__pycache__/RadIR.cpython-39.pyc
ADDED
|
Binary file (20.5 kB). View file
|
|
|
Rad_IR/radir/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (214 Bytes). View file
|
|
|
Rad_IR/radir/__pycache__/__init__.cpython-39.pyc
ADDED
|
Binary file (205 Bytes). View file
|
|
|
Rad_IR/radir/__pycache__/lorentz.cpython-313.pyc
ADDED
|
Binary file (12.2 kB). View file
|
|
|
Rad_IR/radir/__pycache__/lorentz.cpython-39.pyc
ADDED
|
Binary file (8.44 kB). View file
|
|
|
Rad_IR/radir/__pycache__/transformer_decoder.cpython-313.pyc
ADDED
|
Binary file (8.12 kB). View file
|
|
|
Rad_IR/radir/__pycache__/transformer_decoder.cpython-39.pyc
ADDED
|
Binary file (4.77 kB). View file
|
|
|
Rad_IR/radir/data/__pycache__/conditional_dataset_train.cpython-313.pyc
ADDED
|
Binary file (6.14 kB). View file
|
|
|
Rad_IR/radir/data/__pycache__/conditional_dataset_train.cpython-39.pyc
ADDED
|
Binary file (4.09 kB). View file
|
|
|
Rad_IR/radir/data/__pycache__/hmclip_mimic_box.cpython-313.pyc
ADDED
|
Binary file (15.5 kB). View file
|
|
|
Rad_IR/radir/data/__pycache__/hmclip_mimic_box.cpython-39.pyc
ADDED
|
Binary file (8.97 kB). View file
|
|
|
Rad_IR/radir/data/bpe_simple_vocab_16e6.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
Rad_IR/radir/data/build_mimic_h5.py
ADDED
|
File without changes
|
Rad_IR/radir/data/conditional_dataset_train.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import torch
|
| 3 |
+
import json
|
| 4 |
+
import numpy as np
|
| 5 |
+
from PIL import Image
|
| 6 |
+
from torch.utils.data import Dataset
|
| 7 |
+
from torchvision import transforms
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SimpleImageTextDataset(Dataset):
|
| 11 |
+
|
| 12 |
+
def __init__(
|
| 13 |
+
self,
|
| 14 |
+
data_source, # ✅ 可以是 CSV / list / all.jsonl
|
| 15 |
+
tokenizer,
|
| 16 |
+
image_size=224,
|
| 17 |
+
use_modal_embedding=False,
|
| 18 |
+
default_modal_index=0,
|
| 19 |
+
dataset_root="" # ✅ 给 all.jsonl 用
|
| 20 |
+
):
|
| 21 |
+
self.tokenizer = tokenizer
|
| 22 |
+
self.use_modal_embedding = use_modal_embedding
|
| 23 |
+
self.default_modal_index = default_modal_index
|
| 24 |
+
self.image_size = image_size
|
| 25 |
+
self.dataset_root = dataset_root
|
| 26 |
+
|
| 27 |
+
# ------- 读取样本 -------
|
| 28 |
+
if isinstance(data_source, str):
|
| 29 |
+
|
| 30 |
+
# ✅ 1️⃣ 如果是 CSV
|
| 31 |
+
if data_source.endswith(".csv"):
|
| 32 |
+
import pandas as pd
|
| 33 |
+
df = pd.read_csv(data_source)
|
| 34 |
+
self.samples = list(zip(df["image_path"], df["text"]))
|
| 35 |
+
|
| 36 |
+
# ✅ 2️⃣ 如果是 all.jsonl
|
| 37 |
+
elif data_source.endswith(".jsonl"):
|
| 38 |
+
self.samples = self._load_from_jsonl(data_source)
|
| 39 |
+
|
| 40 |
+
else:
|
| 41 |
+
raise ValueError("Unsupported file type. Use CSV or JSONL.")
|
| 42 |
+
|
| 43 |
+
else:
|
| 44 |
+
# ✅ 3️⃣ 直接是 list [(img_path, text), ...]
|
| 45 |
+
self.samples = data_source
|
| 46 |
+
|
| 47 |
+
# ------- 图像 transform -------
|
| 48 |
+
self.transform = transforms.Compose([
|
| 49 |
+
transforms.Grayscale(num_output_channels=1),
|
| 50 |
+
transforms.Resize((image_size, image_size)),
|
| 51 |
+
transforms.ToTensor(),
|
| 52 |
+
transforms.Normalize(mean=[0.5], std=[0.5])
|
| 53 |
+
])
|
| 54 |
+
|
| 55 |
+
print(f"✅ Loaded {len(self.samples)} image-text pairs.")
|
| 56 |
+
|
| 57 |
+
def _load_from_jsonl(self, jsonl_path):
|
| 58 |
+
samples = []
|
| 59 |
+
with open(jsonl_path, "r", encoding="utf-8") as f:
|
| 60 |
+
for line in f:
|
| 61 |
+
item = json.loads(line.strip())
|
| 62 |
+
|
| 63 |
+
rel_path = item["img_path"]
|
| 64 |
+
|
| 65 |
+
rel_path = rel_path.replace("\\", "/")
|
| 66 |
+
|
| 67 |
+
if rel_path.startswith("dataset/"):
|
| 68 |
+
rel_path = rel_path[len("dataset/"):]
|
| 69 |
+
|
| 70 |
+
if self.dataset_root:
|
| 71 |
+
img_path = os.path.join(self.dataset_root, rel_path)
|
| 72 |
+
else:
|
| 73 |
+
img_path = rel_path
|
| 74 |
+
|
| 75 |
+
text = item["text"].replace("\n", " ").strip()
|
| 76 |
+
samples.append((img_path, text))
|
| 77 |
+
|
| 78 |
+
return samples
|
| 79 |
+
|
| 80 |
+
def __len__(self):
|
| 81 |
+
return len(self.samples)
|
| 82 |
+
|
| 83 |
+
def __getitem__(self, index):
|
| 84 |
+
img_path, text_str = self.samples[index]
|
| 85 |
+
|
| 86 |
+
# ---- 1. 载入图像 -> [1,224,224] ----
|
| 87 |
+
img = Image.open(img_path).convert("L")
|
| 88 |
+
img = self.transform(img)
|
| 89 |
+
|
| 90 |
+
# ---- 2. tokenize 文本 ----
|
| 91 |
+
text = self.tokenizer(
|
| 92 |
+
text_str,
|
| 93 |
+
return_tensors="pt",
|
| 94 |
+
truncation=True,
|
| 95 |
+
padding="max_length",
|
| 96 |
+
max_length=512,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
text = {k: v.squeeze(0) for k, v in text.items()}
|
| 100 |
+
|
| 101 |
+
# ---- 3. modal_embedding(可选)----
|
| 102 |
+
if self.use_modal_embedding:
|
| 103 |
+
modal_index = torch.tensor(self.default_modal_index, dtype=torch.long)
|
| 104 |
+
return {
|
| 105 |
+
"image": img,
|
| 106 |
+
"text": text,
|
| 107 |
+
"modal_index": modal_index
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
return {
|
| 111 |
+
"image": img,
|
| 112 |
+
"text": text
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class TextBatch:
|
| 117 |
+
def __init__(self, d):
|
| 118 |
+
for k, v in d.items():
|
| 119 |
+
setattr(self, k, v)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def simple_collate(batch):
|
| 123 |
+
images = torch.stack([item["image"] for item in batch], dim=0)
|
| 124 |
+
|
| 125 |
+
text_dict = {
|
| 126 |
+
"input_ids": torch.stack([item["text"]["input_ids"] for item in batch]),
|
| 127 |
+
"attention_mask": torch.stack([item["text"]["attention_mask"] for item in batch]),
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
if "token_type_ids" in batch[0]["text"]:
|
| 131 |
+
text_dict["token_type_ids"] = torch.stack(
|
| 132 |
+
[item["text"]["token_type_ids"] for item in batch]
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
text = TextBatch(text_dict)
|
| 136 |
+
|
| 137 |
+
if "modal_index" in batch[0]:
|
| 138 |
+
modal_indexs = torch.stack([item["modal_index"] for item in batch])
|
| 139 |
+
return images, text, modal_indexs
|
| 140 |
+
|
| 141 |
+
return images, text
|
Rad_IR/radir/data/hmclip_mimic_box.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
import math
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import torch
|
| 8 |
+
import torch.utils.data as data
|
| 9 |
+
from nltk.tokenize import RegexpTokenizer
|
| 10 |
+
import json
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
#from radir.data.utils import get_imgs_from_pickle
|
| 15 |
+
from tqdm import tqdm
|
| 16 |
+
from transformers import BertTokenizer
|
| 17 |
+
import random
|
| 18 |
+
from PIL import Image
|
| 19 |
+
from torchvision import transforms as T
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# 引入 monai 2D 增强
|
| 23 |
+
from monai.transforms import (
|
| 24 |
+
Compose,
|
| 25 |
+
RandRotate,
|
| 26 |
+
RandFlip,
|
| 27 |
+
RandZoom,
|
| 28 |
+
RandAffine,
|
| 29 |
+
RandGaussianNoise,
|
| 30 |
+
RandScaleIntensity,
|
| 31 |
+
RandAdjustContrast,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 35 |
+
|
| 36 |
+
# avg box num: 5.207
|
| 37 |
+
|
| 38 |
+
class HyCoClipMimicBoxDataset_JPG(data.Dataset):
|
| 39 |
+
def __init__(
|
| 40 |
+
self,
|
| 41 |
+
*,
|
| 42 |
+
split: str, # "train" / "valid"
|
| 43 |
+
tokenizer,
|
| 44 |
+
dataset_root: str, # 项目根目录下 dataset 文件夹路径
|
| 45 |
+
cig_matched_dir: str, # CIG 匹配 .pt 目录
|
| 46 |
+
caption_file: str, # 支持任意 jsonl 文件
|
| 47 |
+
view_col: str = "ViewPosition",
|
| 48 |
+
split_col: str = "split",
|
| 49 |
+
transform=None,
|
| 50 |
+
max_words: int = 128,
|
| 51 |
+
imsize: int = 224
|
| 52 |
+
):
|
| 53 |
+
super().__init__()
|
| 54 |
+
self.split = split
|
| 55 |
+
self.dataset_root = dataset_root
|
| 56 |
+
self.cig_matched_dir = cig_matched_dir
|
| 57 |
+
self.caption_file = caption_file
|
| 58 |
+
self.view_col = view_col
|
| 59 |
+
self.split_col = split_col
|
| 60 |
+
self.max_words = max_words
|
| 61 |
+
self.tokenizer = tokenizer
|
| 62 |
+
self.imsize = imsize
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------- 句子缓存 ----------
|
| 66 |
+
|
| 67 |
+
self.filenames, self.path2sent = self.load_text_data()
|
| 68 |
+
|
| 69 |
+
# ---------- CIG 文件集合 ----------
|
| 70 |
+
#####
|
| 71 |
+
self.cig_file_set = set()
|
| 72 |
+
|
| 73 |
+
if self.cig_matched_dir is not None and os.path.isdir(self.cig_matched_dir):
|
| 74 |
+
self.cig_file_set = {
|
| 75 |
+
f for f in os.listdir(self.cig_matched_dir)
|
| 76 |
+
if f.endswith(".pt")
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
print(f"[CIG] matched_dir={self.cig_matched_dir}, pt_files={len(self.cig_file_set)}")
|
| 80 |
+
#####
|
| 81 |
+
|
| 82 |
+
self.is_train = (self.split == "train")
|
| 83 |
+
|
| 84 |
+
# ---------- 图像处理 ----------
|
| 85 |
+
# 保持原来接口:如果用户传了 transform,就完全按用户的 transform 走
|
| 86 |
+
self.external_transform = transform
|
| 87 |
+
|
| 88 |
+
if self.external_transform is None:
|
| 89 |
+
# 默认:使用 2D 图像处理 + MONAI 2D 数据增强(只在 train 时用)
|
| 90 |
+
|
| 91 |
+
# 2D 数据增强(来自第二段代码的 2D augmentator)
|
| 92 |
+
if self.is_train:
|
| 93 |
+
self.augmentator = Compose([
|
| 94 |
+
RandRotate(range_x=math.radians(15),range_y=math.radians(15),prob=0.5,keep_size=True), # 随机旋转(仅x和y轴)
|
| 95 |
+
RandFlip(prob=0.5, spatial_axis=(0, 1)), # 随机翻转(水平和垂直)
|
| 96 |
+
RandZoom(min_zoom=0.8, max_zoom=1.1, prob=0.5, keep_size=True), # 随机缩放
|
| 97 |
+
RandAffine(prob=0.5, translate_range=(32, 32)), # 随机平移(2D)
|
| 98 |
+
RandGaussianNoise(prob=0.5, mean=0.0, std=0.1), # 高斯噪声
|
| 99 |
+
RandScaleIntensity(factors=[-0.15, 0.15], prob=0.5), # 像素强度缩放
|
| 100 |
+
RandAdjustContrast(prob=0.4, gamma=(0.85, 1.15)), # 对比度调整
|
| 101 |
+
])
|
| 102 |
+
else:
|
| 103 |
+
self.augmentator = None
|
| 104 |
+
else:
|
| 105 |
+
# 如果用户自己传了 transform,就不做 MONAI 的增强
|
| 106 |
+
self.augmentator = None
|
| 107 |
+
|
| 108 |
+
# 构建路径逻辑
|
| 109 |
+
def get_image_path(self, row):
|
| 110 |
+
subject_id = str(row["subject_id"]).zfill(8)
|
| 111 |
+
study_id = str(row["study_id"])
|
| 112 |
+
dicom_id = str(row["dicom_id"])
|
| 113 |
+
p_folder = "p" + subject_id[:2]
|
| 114 |
+
subject_folder = "p" + subject_id
|
| 115 |
+
study_folder = "s" + study_id
|
| 116 |
+
img_name = dicom_id + ".jpg"
|
| 117 |
+
img_path = os.path.join(self.dataset_root, p_folder, subject_folder, study_folder, img_name)
|
| 118 |
+
return img_path
|
| 119 |
+
# 1D mask 修正函数
|
| 120 |
+
def _fix_1d_mask(self, x, target_len, dtype=torch.int8):
|
| 121 |
+
if not torch.is_tensor(x):
|
| 122 |
+
x = torch.tensor(x)
|
| 123 |
+
x = x.to(dtype).view(-1)
|
| 124 |
+
|
| 125 |
+
L = x.numel()
|
| 126 |
+
if L > target_len:
|
| 127 |
+
return x[:target_len]
|
| 128 |
+
if L < target_len:
|
| 129 |
+
return torch.cat(
|
| 130 |
+
[x, torch.zeros(target_len - L, dtype=dtype)],
|
| 131 |
+
dim=0
|
| 132 |
+
)
|
| 133 |
+
return x
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def load_text_data(self):
|
| 137 |
+
import json
|
| 138 |
+
import os
|
| 139 |
+
import re
|
| 140 |
+
|
| 141 |
+
jsonl_path = os.path.join(self.dataset_root, self.caption_file)
|
| 142 |
+
path2sent = {}
|
| 143 |
+
|
| 144 |
+
if not os.path.exists(jsonl_path):
|
| 145 |
+
raise FileNotFoundError(f"Caption file not found at: {jsonl_path}")
|
| 146 |
+
|
| 147 |
+
print(f"Loading captions from: {jsonl_path}")
|
| 148 |
+
|
| 149 |
+
with open(jsonl_path, "r", encoding="utf-8") as f:
|
| 150 |
+
for line in f:
|
| 151 |
+
item = json.loads(line.strip())
|
| 152 |
+
|
| 153 |
+
# --- 修复逻辑开始 ---
|
| 154 |
+
raw_rel_path = item["img_path"]
|
| 155 |
+
|
| 156 |
+
# 1. 统一将所有反斜杠 \ 转换为正斜杠 / (解决跨平台问题)
|
| 157 |
+
fixed_rel_path = raw_rel_path.replace("\\", "/")
|
| 158 |
+
|
| 159 |
+
# 2. 移除可能存在的 dataset/ 前缀 (无论它原来是 \ 还是 /)
|
| 160 |
+
if fixed_rel_path.startswith("dataset/"):
|
| 161 |
+
fixed_rel_path = fixed_rel_path[len("dataset/"):]
|
| 162 |
+
|
| 163 |
+
# 3. 使用 os.path.join 重新拼接并规范化路径
|
| 164 |
+
# os.path.normpath 会自动处理多余的斜杠
|
| 165 |
+
img_path = os.path.normpath(os.path.join(self.dataset_root, fixed_rel_path))
|
| 166 |
+
# --- 修复逻辑结束 ---
|
| 167 |
+
|
| 168 |
+
text = item["text"].replace("\n", " ").strip()
|
| 169 |
+
# 简单分句
|
| 170 |
+
sents = [s.strip() for s in re.split(r"[.?!]", text) if len(s.strip()) > 1]
|
| 171 |
+
path2sent[img_path] = sents
|
| 172 |
+
|
| 173 |
+
filenames = list(path2sent.keys())
|
| 174 |
+
|
| 175 |
+
# 调试输出:打印前两个路径确认是否正确
|
| 176 |
+
if len(filenames) > 0:
|
| 177 |
+
print(f"DEBUG: Sample image path: {filenames[0]}")
|
| 178 |
+
|
| 179 |
+
print(f"Loaded {len(filenames)} image-text pairs.")
|
| 180 |
+
return filenames, path2sent
|
| 181 |
+
|
| 182 |
+
def __len__(self):
|
| 183 |
+
return len(self.filenames)
|
| 184 |
+
|
| 185 |
+
def get_caption(self, img_path):
|
| 186 |
+
if img_path not in self.path2sent:
|
| 187 |
+
raise Exception(f"No sentence found for image path: {img_path}")
|
| 188 |
+
series_sents = self.path2sent[img_path]
|
| 189 |
+
sent = " ".join(filter(lambda x: x != "", series_sents))
|
| 190 |
+
tokens = self.tokenizer(
|
| 191 |
+
sent,
|
| 192 |
+
return_tensors="pt",
|
| 193 |
+
truncation=True,
|
| 194 |
+
padding="max_length",
|
| 195 |
+
max_length=self.max_words,
|
| 196 |
+
)
|
| 197 |
+
x_len = len([t for t in tokens["input_ids"][0] if t != 0])
|
| 198 |
+
return tokens, x_len
|
| 199 |
+
|
| 200 |
+
def _load_2d_image_with_aug(self, img_path):
|
| 201 |
+
"""
|
| 202 |
+
只考虑 2D JPG:
|
| 203 |
+
- 转灰度
|
| 204 |
+
- 转为 tensor [1, H, W],范围 [0,1]
|
| 205 |
+
- 可选 MONAI 增强(train 时)
|
| 206 |
+
- resize 到 (imsize, imsize)
|
| 207 |
+
- 线性映射到 [-1,1](等价于原来的 Normalize(mean=0.5,std=0.5))
|
| 208 |
+
"""
|
| 209 |
+
# 读灰度
|
| 210 |
+
img = Image.open(img_path).convert("L") # 单通道
|
| 211 |
+
|
| 212 |
+
# 转 numpy [H, W],0~1
|
| 213 |
+
img_np = np.array(img, dtype=np.float32) / 255.0
|
| 214 |
+
img_t = torch.from_numpy(img_np).unsqueeze(0) # [1, H, W]
|
| 215 |
+
|
| 216 |
+
# MONAI 数据增强(仅 train & 默认 transform 时)
|
| 217 |
+
if self.augmentator is not None:
|
| 218 |
+
img_t = self.augmentator(img_t) # 仍是 [1, H', W']
|
| 219 |
+
|
| 220 |
+
# 统一 resize 到 (imsize, imsize)
|
| 221 |
+
img_t = torch.nn.functional.interpolate(
|
| 222 |
+
img_t.unsqueeze(0), # [1, 1, H, W]
|
| 223 |
+
size=(self.imsize, self.imsize),
|
| 224 |
+
mode="bilinear",
|
| 225 |
+
align_corners=False,
|
| 226 |
+
).squeeze(0) # [1, imsize, imsize]
|
| 227 |
+
|
| 228 |
+
# 映射到 [-1,1](与原先 ToTensor+Normalize(0.5,0.5) 等价)
|
| 229 |
+
img_t = img_t * 2.0 - 1.0
|
| 230 |
+
|
| 231 |
+
return img_t # [1, imsize, imsize]
|
| 232 |
+
|
| 233 |
+
def __getitem__(self, index):
|
| 234 |
+
img_path = self.filenames[index]
|
| 235 |
+
caps, cap_len = self.get_caption(img_path)
|
| 236 |
+
|
| 237 |
+
# ---------- 读取 JPG + 2D 处理 ----------
|
| 238 |
+
try:
|
| 239 |
+
if self.external_transform is None:
|
| 240 |
+
# 使用我们移植过来的 2D 处理 + 增强
|
| 241 |
+
img = self._load_2d_image_with_aug(img_path) # [1, 224, 224]
|
| 242 |
+
else:
|
| 243 |
+
# 兼容旧代码:如果用户传了 transform,就按旧逻辑来
|
| 244 |
+
img = Image.open(img_path).convert("RGB")
|
| 245 |
+
img = self.external_transform(img)
|
| 246 |
+
except Exception as e:
|
| 247 |
+
raise FileNotFoundError(f"Cannot open image {img_path}: {e}")
|
| 248 |
+
|
| 249 |
+
# ---------- phrase ----------
|
| 250 |
+
dicom_id = os.path.splitext(os.path.basename(img_path))[0]
|
| 251 |
+
cig_file_name = f"{dicom_id}.pt"
|
| 252 |
+
if cig_file_name in self.cig_file_set:
|
| 253 |
+
cig_path = os.path.join(self.cig_matched_dir, cig_file_name)
|
| 254 |
+
d = torch.load(cig_path, weights_only=False)
|
| 255 |
+
phrase_idx = random.randint(0, len(d["unique_phrases"]) - 1)
|
| 256 |
+
phrase = d["unique_phrases"][phrase_idx]
|
| 257 |
+
phrase_text_mask = self._fix_1d_mask(
|
| 258 |
+
d["phrase_text_mask"][phrase_idx],
|
| 259 |
+
self.max_words,
|
| 260 |
+
dtype=torch.int8
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
phrases_bboxes_mask = self._fix_1d_mask(
|
| 264 |
+
d["phrases_bboxes_mask"][phrase_idx],
|
| 265 |
+
196,
|
| 266 |
+
dtype=torch.int8
|
| 267 |
+
)
|
| 268 |
+
else:
|
| 269 |
+
phrase = ""
|
| 270 |
+
phrase_text_mask = torch.zeros((self.max_words,), dtype=torch.int8)
|
| 271 |
+
phrases_bboxes_mask = torch.zeros((196,), dtype=torch.int8)
|
| 272 |
+
|
| 273 |
+
def compute_offsets(text, tokens):
|
| 274 |
+
"""
|
| 275 |
+
手动根据 tokens 在 text 中出现的位置计算 offset_mapping
|
| 276 |
+
slow tokenizer(不支持 offset_mapping)也可以用
|
| 277 |
+
"""
|
| 278 |
+
offsets = []
|
| 279 |
+
start_pos = 0
|
| 280 |
+
for tok in tokens:
|
| 281 |
+
# BertTokenizer 会给 word 加 ## 前缀,需要去掉才能在原文中搜索到
|
| 282 |
+
tok_clean = tok.replace("##", "")
|
| 283 |
+
idx = text.find(tok_clean, start_pos)
|
| 284 |
+
if idx == -1:
|
| 285 |
+
# 没有找到就写 0,0(比如特殊 token)
|
| 286 |
+
offsets.append((0, 0))
|
| 287 |
+
else:
|
| 288 |
+
offsets.append((idx, idx + len(tok_clean)))
|
| 289 |
+
start_pos = idx + len(tok_clean)
|
| 290 |
+
return offsets
|
| 291 |
+
|
| 292 |
+
# ----- 你的代码流程 -----
|
| 293 |
+
# full sentence
|
| 294 |
+
sents = self.path2sent[img_path]
|
| 295 |
+
full_text = " ".join(sents).replace("\n", " ").strip()
|
| 296 |
+
|
| 297 |
+
# tokenize full_text(slow tokenizer)
|
| 298 |
+
tokens_full = self.tokenizer.tokenize(full_text)
|
| 299 |
+
offsets_full = compute_offsets(full_text, tokens_full)
|
| 300 |
+
|
| 301 |
+
# padding 到 max_words
|
| 302 |
+
if len(tokens_full) < self.max_words:
|
| 303 |
+
offsets_full = offsets_full + [(0, 0)] * (self.max_words - len(offsets_full))
|
| 304 |
+
else:
|
| 305 |
+
offsets_full = offsets_full[:self.max_words]
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
return img, caps, cap_len, img_path, phrase, phrase_text_mask, phrases_bboxes_mask
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def collate_hmclip_box(batch):
|
| 312 |
+
# 与 hycoclip/data/pretrain_dataset_box.py 等价实现
|
| 313 |
+
"""sort sequence"""
|
| 314 |
+
imgs, cap_len, ids, tokens, attention = [], [], [], [], []
|
| 315 |
+
path = []
|
| 316 |
+
phrase_lst, phrase_text_mask_lst, phrases_bboxes_mask_lst = [], [], []
|
| 317 |
+
for b in batch:
|
| 318 |
+
img, cap, cap_l, p, ph, ph_t_mask, ph_bbx_mask = b
|
| 319 |
+
imgs.append(img)
|
| 320 |
+
cap_len.append(cap_l)
|
| 321 |
+
ids.append(cap["input_ids"])
|
| 322 |
+
tokens.append(cap["token_type_ids"])
|
| 323 |
+
attention.append(cap["attention_mask"])
|
| 324 |
+
path.append(p)
|
| 325 |
+
|
| 326 |
+
phrase_lst.append(ph)
|
| 327 |
+
phrase_text_mask_lst.append(ph_t_mask)
|
| 328 |
+
phrases_bboxes_mask_lst.append(ph_bbx_mask)
|
| 329 |
+
|
| 330 |
+
# stack
|
| 331 |
+
imgs = torch.stack(imgs)
|
| 332 |
+
ids = torch.stack(ids).squeeze()
|
| 333 |
+
tokens = torch.stack(tokens).squeeze()
|
| 334 |
+
attention = torch.stack(attention).squeeze()
|
| 335 |
+
|
| 336 |
+
phrase_text_mask = torch.stack(phrase_text_mask_lst)
|
| 337 |
+
phrases_bboxes_mask = torch.stack(phrases_bboxes_mask_lst).squeeze()
|
| 338 |
+
|
| 339 |
+
# sort and add to dictionary
|
| 340 |
+
sorted_cap_lens, sorted_cap_indices = torch.sort(
|
| 341 |
+
torch.tensor(cap_len), 0, True)
|
| 342 |
+
|
| 343 |
+
path = np.array(path)
|
| 344 |
+
phrase = np.array(phrase_lst)
|
| 345 |
+
|
| 346 |
+
return_dict = {
|
| 347 |
+
"caption_ids": ids[sorted_cap_indices],
|
| 348 |
+
"token_type_ids": tokens[sorted_cap_indices],
|
| 349 |
+
"attention_mask": attention[sorted_cap_indices],
|
| 350 |
+
"imgs": imgs[sorted_cap_indices],
|
| 351 |
+
"cap_lens": sorted_cap_lens,
|
| 352 |
+
"path": path[sorted_cap_indices],
|
| 353 |
+
|
| 354 |
+
"phrase": phrase[sorted_cap_indices],
|
| 355 |
+
"phrase_text_mask" : phrase_text_mask[sorted_cap_indices],
|
| 356 |
+
"phrases_bboxes_mask" : phrases_bboxes_mask[sorted_cap_indices],
|
| 357 |
+
}
|
| 358 |
+
return return_dict
|
Rad_IR/radir/data/utils.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image
|
| 2 |
+
|
| 3 |
+
def get_imgs_from_pickle(img, transform=None):
|
| 4 |
+
"""
|
| 5 |
+
将 H5/PKL 里读出的 numpy 图像数组转为 RGB PIL,再按需应用 transform。
|
| 6 |
+
参数:
|
| 7 |
+
img: numpy.ndarray,形状一般为 (H, W) 或 (H, W, C)
|
| 8 |
+
transform: 可选的 torchvision transforms
|
| 9 |
+
返回:
|
| 10 |
+
已转换的图像(通常为张量)
|
| 11 |
+
"""
|
| 12 |
+
pil_img = Image.fromarray(img).convert("RGB")
|
| 13 |
+
|
| 14 |
+
if transform is not None:
|
| 15 |
+
pil_img = transform(pil_img)
|
| 16 |
+
|
| 17 |
+
return pil_img
|
| 18 |
+
|
Rad_IR/radir/lorentz.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#---------------------------------------
|
| 2 |
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
| 3 |
+
# All rights reserved.
|
| 4 |
+
|
| 5 |
+
# This source code is licensed under the license found in the
|
| 6 |
+
# LICENSE file in the root directory of this source tree.
|
| 7 |
+
#---------------------------------------
|
| 8 |
+
|
| 9 |
+
# Modified from github.com/facebookresearch/meru
|
| 10 |
+
|
| 11 |
+
"""
|
| 12 |
+
Implementation of common operations for the Lorentz model of hyperbolic geometry.
|
| 13 |
+
This model represents a hyperbolic space of `d` dimensions on the upper-half of
|
| 14 |
+
a two-sheeted hyperboloid in a Euclidean space of `(d+1)` dimensions.
|
| 15 |
+
|
| 16 |
+
Hyperbolic geometry has a direct connection to the study of special relativity
|
| 17 |
+
theory -- implementations in this module borrow some of its terminology. The axis
|
| 18 |
+
of symmetry of the Hyperboloid is called the _time dimension_, while all other
|
| 19 |
+
axes are collectively called _space dimensions_.
|
| 20 |
+
|
| 21 |
+
All functions implemented here only input/output the space components, while
|
| 22 |
+
while calculating the time component according to the Hyperboloid constraint:
|
| 23 |
+
|
| 24 |
+
`x_time = torch.sqrt(1 / curv + torch.norm(x_space) ** 2)`
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import math
|
| 29 |
+
|
| 30 |
+
import torch
|
| 31 |
+
from torch import Tensor
|
| 32 |
+
from loguru import logger
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def pairwise_inner(x: Tensor, y: Tensor, curv: float | Tensor = 1.0):
|
| 36 |
+
"""
|
| 37 |
+
Compute pairwise Lorentzian inner product between input vectors.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
x: Tensor of shape `(B1, D)` giving a space components of a batch
|
| 41 |
+
of vectors on the hyperboloid.
|
| 42 |
+
y: Tensor of shape `(B2, D)` giving a space components of another
|
| 43 |
+
batch of points on the hyperboloid.
|
| 44 |
+
curv: Positive scalar denoting negative hyperboloid curvature.
|
| 45 |
+
eps: Small float number to avoid numerical instability.
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
Tensor of shape `(B1, B2)` giving pairwise Lorentzian inner product
|
| 49 |
+
between input vectors.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
x_time = torch.sqrt(1 / curv + torch.sum(x**2, dim=-1, keepdim=True))
|
| 53 |
+
y_time = torch.sqrt(1 / curv + torch.sum(y**2, dim=-1, keepdim=True))
|
| 54 |
+
xyl = x @ y.T - x_time @ y_time.T
|
| 55 |
+
return xyl
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def pairwise_dist(
|
| 59 |
+
x: Tensor, y: Tensor, curv: float | Tensor = 1.0, eps: float = 1e-8
|
| 60 |
+
) -> Tensor:
|
| 61 |
+
"""
|
| 62 |
+
Compute the pairwise geodesic distance between two batches of points on
|
| 63 |
+
the hyperboloid.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
x: Tensor of shape `(B1, D)` giving a space components of a batch
|
| 67 |
+
of point on the hyperboloid.
|
| 68 |
+
y: Tensor of shape `(B2, D)` giving a space components of another
|
| 69 |
+
batch of points on the hyperboloid.
|
| 70 |
+
curv: Positive scalar denoting negative hyperboloid curvature.
|
| 71 |
+
eps: Small float number to avoid numerical instability.
|
| 72 |
+
|
| 73 |
+
Returns:
|
| 74 |
+
Tensor of shape `(B1, B2)` giving pairwise distance along the geodesics
|
| 75 |
+
connecting the input points.
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# --- Handle (B,1,D) -> (B,D) ---
|
| 81 |
+
if x.dim() == 3 and x.size(1) == 1:
|
| 82 |
+
x = x.squeeze(1)
|
| 83 |
+
|
| 84 |
+
x = x.float()
|
| 85 |
+
y = y.float()
|
| 86 |
+
|
| 87 |
+
# Ensure numerical stability in arc-cosh by clamping input.
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
c_xyl = -curv * pairwise_inner(x, y, curv)
|
| 91 |
+
_distance = torch.acosh(torch.clamp(c_xyl, min=1 + eps))
|
| 92 |
+
return _distance / curv**0.5
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def exp_map0(x: Tensor, curv: float | Tensor = 1.0, eps: float = 1e-8) -> Tensor:
|
| 96 |
+
"""
|
| 97 |
+
Map points from the tangent space at the vertex of hyperboloid, on to the
|
| 98 |
+
hyperboloid. This mapping is done using the exponential map of Lorentz model.
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
x: Tensor of shape `(B, D)` giving batch of Euclidean vectors to project
|
| 102 |
+
onto the hyperboloid. These vectors are interpreted as velocity
|
| 103 |
+
vectors in the tangent space at the hyperboloid vertex.
|
| 104 |
+
curv: Positive scalar denoting negative hyperboloid curvature.
|
| 105 |
+
eps: Small float number to avoid division by zero.
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
Tensor of same shape as `x`, giving space components of the mapped
|
| 109 |
+
vectors on the hyperboloid.
|
| 110 |
+
"""
|
| 111 |
+
|
| 112 |
+
rc_xnorm = curv**0.5 * torch.norm(x, dim=-1, keepdim=True)
|
| 113 |
+
|
| 114 |
+
# Ensure numerical stability in sinh by clamping input.
|
| 115 |
+
sinh_input = torch.clamp(rc_xnorm, min=eps, max=math.asinh(2**15))
|
| 116 |
+
_output = torch.sinh(sinh_input) * x / torch.clamp(rc_xnorm, min=eps)
|
| 117 |
+
return _output
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def log_map0(x: Tensor, curv: float | Tensor = 1.0, eps: float = 1e-8) -> Tensor:
|
| 121 |
+
"""
|
| 122 |
+
Inverse of the exponential map: map points from the hyperboloid on to the
|
| 123 |
+
tangent space at the vertex, using the logarithmic map of Lorentz model.
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
x: Tensor of shape `(B, D)` giving space components of points
|
| 127 |
+
on the hyperboloid.
|
| 128 |
+
curv: Positive scalar denoting negative hyperboloid curvature.
|
| 129 |
+
eps: Small float number to avoid division by zero.
|
| 130 |
+
|
| 131 |
+
Returns:
|
| 132 |
+
Tensor of same shape as `x`, giving Euclidean vectors in the tangent
|
| 133 |
+
space of the hyperboloid vertex.
|
| 134 |
+
"""
|
| 135 |
+
|
| 136 |
+
# Calculate distance of vectors to the hyperboloid vertex.
|
| 137 |
+
rc_x_time = torch.sqrt(1 + curv * torch.sum(x**2, dim=-1, keepdim=True))
|
| 138 |
+
_distance0 = torch.acosh(torch.clamp(rc_x_time, min=1 + eps))
|
| 139 |
+
|
| 140 |
+
rc_xnorm = curv**0.5 * torch.norm(x, dim=-1, keepdim=True)
|
| 141 |
+
_output = _distance0 * x / torch.clamp(rc_xnorm, min=eps)
|
| 142 |
+
return _output
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def half_aperture(
|
| 146 |
+
x: Tensor, curv: float | Tensor = 1.0, min_radius: float = 0.1, eps: float = 1e-6
|
| 147 |
+
) -> Tensor:
|
| 148 |
+
"""
|
| 149 |
+
Compute the half aperture angle of the entailment cone formed by vectors on
|
| 150 |
+
the hyperboloid. The given vector would meet the apex of this cone, and the
|
| 151 |
+
cone itself extends outwards to infinity.
|
| 152 |
+
|
| 153 |
+
Args:
|
| 154 |
+
x: Tensor of shape `(B, D)` giving a batch of space components of
|
| 155 |
+
vectors on the hyperboloid.
|
| 156 |
+
curv: Positive scalar denoting negative hyperboloid curvature.
|
| 157 |
+
min_radius: Radius of a small neighborhood around vertex of the hyperboloid
|
| 158 |
+
where cone aperture is left undefined. Input vectors lying inside this
|
| 159 |
+
neighborhood (having smaller norm) will be projected on the boundary.
|
| 160 |
+
eps: Small float number to avoid numerical instability.
|
| 161 |
+
|
| 162 |
+
Returns:
|
| 163 |
+
Tensor of shape `(B, )` giving the half-aperture of entailment cones
|
| 164 |
+
formed by input vectors. Values of this tensor lie in `(0, pi/2)`.
|
| 165 |
+
"""
|
| 166 |
+
|
| 167 |
+
# --- Handle (B,1,D) -> (B,D) ---
|
| 168 |
+
if x.dim() == 3 and x.size(1) == 1:
|
| 169 |
+
x = x.squeeze(1)
|
| 170 |
+
|
| 171 |
+
x = x.float()
|
| 172 |
+
|
| 173 |
+
# Ensure numerical stability in arc-sin by clamping input.
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
asin_input = 2 * min_radius / (torch.norm(x, dim=-1) * curv**0.5 + eps)
|
| 177 |
+
asin_input = torch.clamp(asin_input, -1 + eps, 1 - eps)
|
| 178 |
+
_half_aperture = torch.asin(torch.clamp(asin_input, min=-1 + eps, max=1 - eps))
|
| 179 |
+
|
| 180 |
+
return _half_aperture
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def oxy_angle(x: Tensor, y: Tensor, curv: float | Tensor = 1.0, eps: float = 1e-7):
|
| 184 |
+
|
| 185 |
+
"""
|
| 186 |
+
Given two vectors `x` and `y` on the hyperboloid, compute the exterior
|
| 187 |
+
angle at `x` in the hyperbolic triangle `Oxy` where `O` is the origin
|
| 188 |
+
of the hyperboloid.
|
| 189 |
+
|
| 190 |
+
This expression is derived using the Hyperbolic law of cosines.
|
| 191 |
+
|
| 192 |
+
Args:
|
| 193 |
+
x: Tensor of shape `(B, D)` giving a batch of space components of
|
| 194 |
+
vectors on the hyperboloid.
|
| 195 |
+
y: Tensor of same shape as `x` giving another batch of vectors.
|
| 196 |
+
curv: Positive scalar denoting negative hyperboloid curvature.
|
| 197 |
+
|
| 198 |
+
Returns:
|
| 199 |
+
Tensor of shape `(B, )` giving the required angle. Values of this
|
| 200 |
+
tensor lie in `(0, pi)`.
|
| 201 |
+
"""
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# --- Handle (B,1,D) -> (B,D) ---
|
| 205 |
+
if x.dim() == 3 and x.size(1) == 1:
|
| 206 |
+
x = x.squeeze(1)
|
| 207 |
+
if y.dim() == 3 and y.size(1) == 1:
|
| 208 |
+
y = y.squeeze(1)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
x = x.float()
|
| 212 |
+
y = y.float()
|
| 213 |
+
|
| 214 |
+
# -------- curvature 数值保护 --------
|
| 215 |
+
if isinstance(curv, Tensor):
|
| 216 |
+
curv = curv.float().clamp(min=eps)
|
| 217 |
+
|
| 218 |
+
# Calculate time components of inputs (multiplied with `sqrt(curv)`):
|
| 219 |
+
x_time = torch.sqrt(1 / curv + torch.sum(x**2, dim=-1))
|
| 220 |
+
y_time = torch.sqrt(1 / curv + torch.sum(y**2, dim=-1))
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# Calculate lorentzian inner product multiplied with curvature. We do not use
|
| 224 |
+
# the `pairwise_inner` implementation to save some operations (since we only
|
| 225 |
+
# need the diagonal elements).
|
| 226 |
+
|
| 227 |
+
c_xyl = curv * (torch.sum(x * y, dim=-1) - x_time * y_time)
|
| 228 |
+
|
| 229 |
+
# Make the numerator and denominator for input to arc-cosh, shape: (B, )
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
acos_numer = y_time + c_xyl * x_time
|
| 233 |
+
acos_denom = torch.sqrt(torch.clamp(c_xyl**2 - 1, min=eps))
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
acos_input = acos_numer / (torch.norm(x, dim=-1) * acos_denom + eps)
|
| 237 |
+
if not torch.isfinite(acos_input).all():
|
| 238 |
+
print("❌ acos_input has NaN/Inf!")
|
| 239 |
+
print(" c_xyl:", c_xyl)
|
| 240 |
+
print(" acos_numer:", acos_numer)
|
| 241 |
+
print(" acos_denom:", acos_denom)
|
| 242 |
+
print(" norm_x:", torch.norm(x, dim=-1))
|
| 243 |
+
|
| 244 |
+
# print("c_xyl min/mean/max:",
|
| 245 |
+
# c_xyl.min().item(), c_xyl.mean().item(), c_xyl.max().item())
|
| 246 |
+
# print("acos_denom min/mean:",
|
| 247 |
+
# acos_denom.min().item(), acos_denom.mean().item())
|
| 248 |
+
# print("norm_x min/mean:",
|
| 249 |
+
# torch.norm(x, dim=-1).min().item(), torch.norm(x, dim=-1).mean().item())
|
| 250 |
+
# print("acos_numer min/mean:",
|
| 251 |
+
# acos_numer.min().item(), acos_numer.mean().item())
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
# print("acos_input min/max/mean:",
|
| 256 |
+
# acos_input.min().item(), acos_input.max().item(), acos_input.mean().item(),
|
| 257 |
+
# "pct<=-1:", (acos_input <= -1).float().mean().item(),
|
| 258 |
+
# "pct>= 1:", (acos_input >= 1).float().mean().item())
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
_angle = torch.acos(torch.clamp(acos_input, min=-1 + eps, max=1 - eps))
|
| 262 |
+
|
| 263 |
+
if not torch.isfinite(_angle).all():
|
| 264 |
+
print("❌ _angle has NaN/Inf!")
|
| 265 |
+
|
| 266 |
+
return _angle
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def oxy_angle_eval(x: Tensor, y: Tensor, curv: float | Tensor = 1.0, eps: float = 1e-8):
|
| 270 |
+
"""
|
| 271 |
+
Given two vectors `x` and `y` on the hyperboloid, compute the exterior
|
| 272 |
+
angle at `x` in the hyperbolic triangle `Oxy` where `O` is the origin
|
| 273 |
+
of the hyperboloid.
|
| 274 |
+
|
| 275 |
+
This expression is derived using the Hyperbolic law of cosines.
|
| 276 |
+
|
| 277 |
+
Args:
|
| 278 |
+
x: Tensor of shape `(B, D)` giving a batch of space components of
|
| 279 |
+
vectors on the hyperboloid.
|
| 280 |
+
y: Tensor of same shape as `x` giving another batch of vectors.
|
| 281 |
+
curv: Positive scalar denoting negative hyperboloid curvature.
|
| 282 |
+
|
| 283 |
+
Returns:
|
| 284 |
+
Tensor of shape `(B, )` giving the required angle. Values of this
|
| 285 |
+
tensor lie in `(0, pi)`.
|
| 286 |
+
"""
|
| 287 |
+
|
| 288 |
+
# Calculate time components of inputs (multiplied with `sqrt(curv)`):
|
| 289 |
+
x_time = torch.sqrt(1 / curv + torch.sum(x**2, dim=-1, keepdim=True))
|
| 290 |
+
y_time = torch.sqrt(1 / curv + torch.sum(y**2, dim=-1, keepdim=True))
|
| 291 |
+
|
| 292 |
+
logger.info(f"x_time shape: {x_time.size()}")
|
| 293 |
+
logger.info(f"y_time shape: {y_time.size()}")
|
| 294 |
+
|
| 295 |
+
# Calculate lorentzian inner product multiplied with curvature. We do not use
|
| 296 |
+
# the `pairwise_inner` implementation to save some operations (since we only
|
| 297 |
+
# need the diagonal elements).
|
| 298 |
+
|
| 299 |
+
# c_xyl = curv * (torch.sum(x * y, dim=-1) - x_time * y_time)
|
| 300 |
+
c_xyl = curv * (y @ x.T - y_time @ x_time.T)
|
| 301 |
+
logger.info(f"c_xyl shape: {c_xyl.size()}")
|
| 302 |
+
|
| 303 |
+
# Make the numerator and denominator for input to arc-cosh, shape: (B, )
|
| 304 |
+
acos_numer = y_time + c_xyl * x_time.T
|
| 305 |
+
logger.info(f"acos_numer shape: {acos_numer.size()}")
|
| 306 |
+
acos_denom = torch.sqrt(torch.clamp(c_xyl**2 - 1, min=eps))
|
| 307 |
+
logger.info(f"acos_denom shape: {acos_denom.size()}")
|
| 308 |
+
|
| 309 |
+
acos_input = acos_numer / (torch.norm(x, dim=-1, keepdim=True).T * acos_denom + eps)
|
| 310 |
+
_angle = - torch.acos(torch.clamp(acos_input, min=-1 + eps, max=1 - eps))
|
| 311 |
+
|
| 312 |
+
return _angle
|
Rad_IR/radir/transformer_decoder.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Code modified from DETR tranformer:
|
| 3 |
+
https://github.com/facebookresearch/detr
|
| 4 |
+
Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import copy
|
| 8 |
+
from typing import Optional, List
|
| 9 |
+
import pickle as cp
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
import torch.nn.functional as F
|
| 13 |
+
from torch import nn, Tensor
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class TransformerDecoder(nn.Module):
|
| 17 |
+
|
| 18 |
+
def __init__(self, decoder_layer, num_layers, norm=None, return_intermediate=False):
|
| 19 |
+
super().__init__()
|
| 20 |
+
self.layers = _get_clones(decoder_layer, num_layers)
|
| 21 |
+
self.num_layers = num_layers
|
| 22 |
+
self.norm = norm
|
| 23 |
+
self.return_intermediate = return_intermediate
|
| 24 |
+
|
| 25 |
+
def forward(self, tgt, memory,
|
| 26 |
+
tgt_mask: Optional[Tensor] = None,
|
| 27 |
+
memory_mask: Optional[Tensor] = None,
|
| 28 |
+
tgt_key_padding_mask: Optional[Tensor] = None,
|
| 29 |
+
memory_key_padding_mask: Optional[Tensor] = None,
|
| 30 |
+
pos: Optional[Tensor] = None,
|
| 31 |
+
query_pos: Optional[Tensor] = None):
|
| 32 |
+
output = tgt
|
| 33 |
+
T,B,C = memory.shape
|
| 34 |
+
intermediate = []
|
| 35 |
+
atten_layers = []
|
| 36 |
+
for n,layer in enumerate(self.layers):
|
| 37 |
+
|
| 38 |
+
residual=True
|
| 39 |
+
output,ws = layer(output, memory, tgt_mask=tgt_mask,
|
| 40 |
+
memory_mask=memory_mask,
|
| 41 |
+
tgt_key_padding_mask=tgt_key_padding_mask,
|
| 42 |
+
memory_key_padding_mask=memory_key_padding_mask,
|
| 43 |
+
pos=pos, query_pos=query_pos,residual=residual)
|
| 44 |
+
atten_layers.append(ws)
|
| 45 |
+
if self.return_intermediate:
|
| 46 |
+
intermediate.append(self.norm(output))
|
| 47 |
+
if self.norm is not None:
|
| 48 |
+
output = self.norm(output)
|
| 49 |
+
if self.return_intermediate:
|
| 50 |
+
intermediate.pop()
|
| 51 |
+
intermediate.append(output)
|
| 52 |
+
|
| 53 |
+
if self.return_intermediate:
|
| 54 |
+
return torch.stack(intermediate)
|
| 55 |
+
return output,atten_layers
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class TransformerDecoderLayer(nn.Module):
|
| 59 |
+
|
| 60 |
+
def __init__(self, d_model, nhead, dim_feedforward=2048, dropout=0.1,
|
| 61 |
+
activation="relu", normalize_before=False):
|
| 62 |
+
super().__init__()
|
| 63 |
+
# self.self_attn = nn.MultiheadAttention(d_model, nhead, dropout=dropout)
|
| 64 |
+
self.multihead_attn = nn.MultiheadAttention(d_model, nhead, dropout=dropout)
|
| 65 |
+
# Implementation of Feedforward model
|
| 66 |
+
self.linear1 = nn.Linear(d_model, dim_feedforward)
|
| 67 |
+
self.dropout = nn.Dropout(dropout)
|
| 68 |
+
self.linear2 = nn.Linear(dim_feedforward, d_model)
|
| 69 |
+
|
| 70 |
+
self.norm1 = nn.LayerNorm(d_model)
|
| 71 |
+
self.norm2 = nn.LayerNorm(d_model)
|
| 72 |
+
self.norm3 = nn.LayerNorm(d_model)
|
| 73 |
+
self.dropout1 = nn.Dropout(dropout)
|
| 74 |
+
self.dropout2 = nn.Dropout(dropout)
|
| 75 |
+
self.dropout3 = nn.Dropout(dropout)
|
| 76 |
+
|
| 77 |
+
self.activation = _get_activation_fn(activation)
|
| 78 |
+
self.normalize_before = normalize_before
|
| 79 |
+
def with_pos_embed(self, tensor, pos: Optional[Tensor]):
|
| 80 |
+
return tensor if pos is None else tensor + pos
|
| 81 |
+
|
| 82 |
+
def forward_post(self, tgt, memory,
|
| 83 |
+
tgt_mask: Optional[Tensor] = None,
|
| 84 |
+
memory_mask: Optional[Tensor] = None,
|
| 85 |
+
tgt_key_padding_mask: Optional[Tensor] = None,
|
| 86 |
+
memory_key_padding_mask: Optional[Tensor] = None,
|
| 87 |
+
pos: Optional[Tensor] = None,
|
| 88 |
+
query_pos: Optional[Tensor] = None,
|
| 89 |
+
residual=True):
|
| 90 |
+
q = k = self.with_pos_embed(tgt, query_pos)
|
| 91 |
+
tgt2,ws = self.self_attn(q, k, value=tgt, attn_mask=tgt_mask,
|
| 92 |
+
key_padding_mask=tgt_key_padding_mask)
|
| 93 |
+
tgt = self.norm1(tgt)
|
| 94 |
+
tgt2,ws = self.multihead_attn(query=self.with_pos_embed(tgt, query_pos),
|
| 95 |
+
key=self.with_pos_embed(memory, pos),
|
| 96 |
+
value=memory, attn_mask=memory_mask,
|
| 97 |
+
key_padding_mask=memory_key_padding_mask)
|
| 98 |
+
|
| 99 |
+
# attn_weights [B,NUM_Q,T]
|
| 100 |
+
tgt = tgt + self.dropout2(tgt2)
|
| 101 |
+
tgt = self.norm2(tgt)
|
| 102 |
+
tgt2 = self.linear2(self.dropout(self.activation(self.linear1(tgt))))
|
| 103 |
+
tgt = tgt + self.dropout3(tgt2)
|
| 104 |
+
tgt = self.norm3(tgt)
|
| 105 |
+
return tgt,ws
|
| 106 |
+
|
| 107 |
+
def forward_pre(self, tgt, memory,
|
| 108 |
+
tgt_mask: Optional[Tensor] = None,
|
| 109 |
+
memory_mask: Optional[Tensor] = None,
|
| 110 |
+
tgt_key_padding_mask: Optional[Tensor] = None,
|
| 111 |
+
memory_key_padding_mask: Optional[Tensor] = None,
|
| 112 |
+
pos: Optional[Tensor] = None,
|
| 113 |
+
query_pos: Optional[Tensor] = None):
|
| 114 |
+
|
| 115 |
+
tgt2 = self.norm2(tgt)
|
| 116 |
+
tgt2,attn_weights = self.multihead_attn(query=self.with_pos_embed(tgt2, query_pos),
|
| 117 |
+
key=self.with_pos_embed(memory, pos),
|
| 118 |
+
value=memory, attn_mask=memory_mask,
|
| 119 |
+
key_padding_mask=memory_key_padding_mask)
|
| 120 |
+
tgt = tgt + self.dropout2(tgt2)
|
| 121 |
+
tgt2 = self.norm3(tgt)
|
| 122 |
+
tgt2 = self.linear2(self.dropout(self.activation(self.linear1(tgt2))))
|
| 123 |
+
tgt = tgt + self.dropout3(tgt2)
|
| 124 |
+
return tgt,attn_weights
|
| 125 |
+
|
| 126 |
+
def forward(self, tgt, memory,
|
| 127 |
+
tgt_mask: Optional[Tensor] = None,
|
| 128 |
+
memory_mask: Optional[Tensor] = None,
|
| 129 |
+
tgt_key_padding_mask: Optional[Tensor] = None,
|
| 130 |
+
memory_key_padding_mask: Optional[Tensor] = None,
|
| 131 |
+
pos: Optional[Tensor] = None,
|
| 132 |
+
query_pos: Optional[Tensor] = None,
|
| 133 |
+
residual=True):
|
| 134 |
+
if self.normalize_before:
|
| 135 |
+
return self.forward_pre(tgt, memory, tgt_mask, memory_mask,
|
| 136 |
+
tgt_key_padding_mask, memory_key_padding_mask, pos, query_pos)
|
| 137 |
+
return self.forward_post(tgt, memory, tgt_mask, memory_mask,
|
| 138 |
+
tgt_key_padding_mask, memory_key_padding_mask, pos, query_pos,residual)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _get_clones(module, N):
|
| 142 |
+
return nn.ModuleList([copy.deepcopy(module) for i in range(N)])
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _get_activation_fn(activation):
|
| 147 |
+
"""Return an activation function given a string"""
|
| 148 |
+
if activation == "relu":
|
| 149 |
+
return F.relu
|
| 150 |
+
if activation == "gelu":
|
| 151 |
+
return F.gelu
|
| 152 |
+
if activation == "glu":
|
| 153 |
+
return F.glu
|
| 154 |
+
raise RuntimeError(F"activation should be relu/gelu, not {activation}.")
|
Rad_IR/setup.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from setuptools import setup, find_packages
|
| 2 |
+
|
| 3 |
+
setup(
|
| 4 |
+
name = 'radir',
|
| 5 |
+
packages = find_packages(exclude=[]),
|
| 6 |
+
include_package_data = True,
|
| 7 |
+
version = '0.1',
|
| 8 |
+
description = 'RADIR',
|
| 9 |
+
install_requires=[
|
| 10 |
+
'beartype',
|
| 11 |
+
'einops>=0.6',
|
| 12 |
+
'ftfy',
|
| 13 |
+
'regex',
|
| 14 |
+
"XlsxWriter",
|
| 15 |
+
"h5py",
|
| 16 |
+
"matplotlib",
|
| 17 |
+
"seaborn",
|
| 18 |
+
"wilds",
|
| 19 |
+
#'ImageNetV2_pytorch @ git+https://github.com/modestyachts/ImageNetV2_pytorch.git',
|
| 20 |
+
"click",
|
| 21 |
+
"appdirs",
|
| 22 |
+
"attr",
|
| 23 |
+
"nltk",
|
| 24 |
+
'transformers',
|
| 25 |
+
'monai',
|
| 26 |
+
'positional_encodings',
|
| 27 |
+
'tensorboard',
|
| 28 |
+
'pynvml',
|
| 29 |
+
'wandb',
|
| 30 |
+
'torchvision',
|
| 31 |
+
],
|
| 32 |
+
)
|
backup-manifest.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"format": "split-tar-zstd-lfs-v1",
|
| 3 |
+
"source_commit": "5b926d2179afa417784ba57459e86d2336d50a52",
|
| 4 |
+
"complete": false,
|
| 5 |
+
"archived_paths": [
|
| 6 |
+
"dataset",
|
| 7 |
+
"outputs",
|
| 8 |
+
"hf_models",
|
| 9 |
+
"hf_cache",
|
| 10 |
+
"runs",
|
| 11 |
+
"wandb",
|
| 12 |
+
"__pycache__"
|
| 13 |
+
],
|
| 14 |
+
"volume_size_bytes": 67108864,
|
| 15 |
+
"parts": [],
|
| 16 |
+
"compressed_bytes": 0,
|
| 17 |
+
"archive_sha256": null,
|
| 18 |
+
"restore": "python3 restore_backup.py",
|
| 19 |
+
"source_files_unchanged": true
|
| 20 |
+
}
|
restore_backup.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Verify downloaded volumes, then extract without concatenating a large archive."""
|
| 3 |
+
import hashlib,json,subprocess,sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
root=Path(__file__).resolve().parent
|
| 6 |
+
m=json.loads((root/'backup-manifest.json').read_text())
|
| 7 |
+
if not m['complete']: raise SystemExit('This backup is still uploading.')
|
| 8 |
+
for part in m['parts']:
|
| 9 |
+
p=root/part['path']; h=hashlib.sha256()
|
| 10 |
+
with p.open('rb') as f:
|
| 11 |
+
for block in iter(lambda:f.read(4*1024*1024),b''):h.update(block)
|
| 12 |
+
if p.stat().st_size!=part['size'] or h.hexdigest()!=part['sha256']:
|
| 13 |
+
raise SystemExit('Missing/corrupt volume or unmaterialized LFS pointer: '+str(p))
|
| 14 |
+
print('All volume checksums verified.',flush=True)
|
| 15 |
+
out=Path(sys.argv[1]).resolve() if len(sys.argv)>1 else root
|
| 16 |
+
out.mkdir(parents=True,exist_ok=True)
|
| 17 |
+
z=subprocess.Popen(['zstd','-d','-c'],stdin=subprocess.PIPE,stdout=subprocess.PIPE)
|
| 18 |
+
t=subprocess.Popen(['tar','-xf','-','-C',str(out)],stdin=z.stdout)
|
| 19 |
+
z.stdout.close()
|
| 20 |
+
try:
|
| 21 |
+
for part in m['parts']:
|
| 22 |
+
with (root/part['path']).open('rb') as f:
|
| 23 |
+
for block in iter(lambda:f.read(4*1024*1024),b''):z.stdin.write(block)
|
| 24 |
+
z.stdin.close()
|
| 25 |
+
if z.wait()!=0 or t.wait()!=0:raise SystemExit('Extraction failed.')
|
| 26 |
+
finally:
|
| 27 |
+
for proc in [z,t]:
|
| 28 |
+
if proc.poll() is None:proc.terminate()
|
| 29 |
+
print('Restored the archived directories to',out)
|
sbatch/stage1.sh
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export HF_HOME=/mnt/data-3/users/zhangzeyu/HMClip/hf_cache
|
| 2 |
+
export TRANSFORMERS_CACHE=/mnt/data-3/users/zhangzeyu/HMClip/hf_cache
|
| 3 |
+
export CUDA_VISIBLE_DEVICES=0,1,2,3,4,5,6,7
|
| 4 |
+
export RESULTS_FOLDER=./outputs
|
| 5 |
+
|
| 6 |
+
set -e
|
| 7 |
+
|
| 8 |
+
export MASTER_PORT=$((RANDOM % 987 + 29000))
|
| 9 |
+
|
| 10 |
+
accelerate launch \
|
| 11 |
+
--num_processes=8 \
|
| 12 |
+
--num_machines=1 \
|
| 13 |
+
--dynamo_backend=no \
|
| 14 |
+
--mixed_precision=fp16 \
|
| 15 |
+
--gradient_accumulation_steps=8 \
|
| 16 |
+
--main_process_port "${MASTER_PORT}" \
|
| 17 |
+
scripts/run_CXR_CT_train.py \
|
| 18 |
+
--checkpoint "/mnt/data-3/users/zhangzeyu/HMClip/RadIR/Rad_IR/checkpoints/CTClip.8000.pt" \
|
| 19 |
+
--name 'CXRCT_UNI_stage1' \
|
| 20 |
+
--allow_partial_load \
|
| 21 |
+
--log_dir "/mnt/data-3/users/zhangzeyu/HMClip/RadIR/logs/CXRCT_UNI_stage1/" \
|
| 22 |
+
--pin_memory \
|
| 23 |
+
--open_pretrained_encoders \
|
| 24 |
+
--freeze_fusion_module \
|
| 25 |
+
--use_image2image_loss 1 \
|
| 26 |
+
--use_uncon_infoNCE_loss 1 \
|
| 27 |
+
--use_uncon_triplet_loss 0 \
|
| 28 |
+
--positive_distance_threshold 0.25 \
|
| 29 |
+
--positive_threshold 0.75 \
|
| 30 |
+
--anatomy_filter '[["train"], ["train_0", "train_1", "train_2", "train_3"]]' \
|
| 31 |
+
--stage1 \
|
| 32 |
+
--uncon_soft_label \
|
| 33 |
+
--uncon_similarity_lookup_table_train './dataset/CTRATE-IR' \
|
| 34 |
+
--uncon_similarity_lookup_table_valid './dataset/CTRATE-IR' \
|
| 35 |
+
--uncon_data_train_jsonl './dataset/CTRATE-IR' \
|
| 36 |
+
--uncon_data_valid_jsonl './dataset/CTRATE-IR' \
|
| 37 |
+
--uncon_dataset_names 'CTRATE-IR' \
|
| 38 |
+
--uncon_train_filter '[["train"]]' \
|
| 39 |
+
--uncon_valid 'test' \
|
| 40 |
+
--uncon_modality '3D' \
|
| 41 |
+
--train_no_aug \
|
| 42 |
+
--infonce_temp \
|
| 43 |
+
--modal_embedding \
|
| 44 |
+
--save_results_every 500 \
|
| 45 |
+
--save_model_every 6000 \
|
| 46 |
+
--num_train_steps 12000 \
|
| 47 |
+
--warmup_steps 1000 \
|
| 48 |
+
--lr 1e-4 \
|
| 49 |
+
--uncon_batch_size 6 \
|
| 50 |
+
--uncon_batch_size_valid 6 \
|
| 51 |
+
--uncon_num_workers 4 \
|
| 52 |
+
--data_train_jsonl ./dataset/MIMIC-IR/train_caption.csv ./dataset/CTRATE-IR/train_filtered.jsonl \
|
| 53 |
+
--data_valid_jsonl ./dataset/MIMIC-IR/val_caption.csv ./dataset/CTRATE-IR/test.jsonl \
|
| 54 |
+
--data_train_csv_dir ./dataset/MIMIC-IR ./dataset/CTRATE-IR \
|
| 55 |
+
--data_valid_csv_dir ./dataset/MIMIC-IR ./dataset/CTRATE-IR \
|
| 56 |
+
--data_train_npy_dir ./dataset/MIMIC-IR/train_ratescore.npy ./dataset/CTRATE-IR/CT_train_ratescore_compressed.npz \
|
| 57 |
+
--data_valid_npy_dir ./dataset/MIMIC-IR/val_ratescore.npy ./dataset/CTRATE-IR/CT_test_ratescore_compressed.npz
|
| 58 |
+
export RESULTS_FOLDER=./outputs
|
| 59 |
+
|
sbatch/stage1_and_stage2.sh
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
set -e
|
| 2 |
+
|
| 3 |
+
export MASTER_PORT=$((RANDOM % 987 + 29000))
|
| 4 |
+
|
| 5 |
+
accelerate launch \
|
| 6 |
+
--num_processes=4 \
|
| 7 |
+
--num_machines=1 \
|
| 8 |
+
--dynamo_backend=no \
|
| 9 |
+
--mixed_precision=fp16 \
|
| 10 |
+
--gradient_accumulation_steps=8 \
|
| 11 |
+
--main_process_port "${MASTER_PORT}" \
|
| 12 |
+
/scripts/run_CXR_CT_train.py \
|
| 13 |
+
--checkpoint "xxx" \
|
| 14 |
+
--name 'CXRCT_UNI_stage1_and_stage2' \
|
| 15 |
+
--allow_partial_load \
|
| 16 |
+
--pin_memory \
|
| 17 |
+
--open_pretrained_encoders \
|
| 18 |
+
--open_fusion_module \
|
| 19 |
+
--use_triplet_loss 1.0 \
|
| 20 |
+
--use_infoNCE_loss 0.0 \
|
| 21 |
+
--use_image2image_loss 1 \
|
| 22 |
+
--use_uncon_infoNCE_loss 1 \
|
| 23 |
+
--use_uncon_triplet_loss 0 \
|
| 24 |
+
--positive_distance_threshold 0.25 \
|
| 25 |
+
--positive_threshold 0.75 \
|
| 26 |
+
--stage1 \
|
| 27 |
+
--stage2 \
|
| 28 |
+
--anatomy_filter '[["bone","heart","bronchie","trachea","pleura","vertebrae","liver","aorta","spinal canal","gallbladder","clavicle","heart ascending aorta","pulmonary artery","breast","pancreas","stomach"],["lungs","heart","bones","abdomen","spine","right lung","pulmonary vasculature","aorta","airway structures","left lower lobe","right lower lobe","right upper lobe","thoracic aorta","ventricle","atrium","carina","pulmonary artery","aortic arch","bronchi","costophrenic angle","aortic valve","pericardium"]]' \
|
| 29 |
+
--dataset_names 'CT_RATE' 'MIMIC_CXR' \
|
| 30 |
+
--modality '3D' '2D' \
|
| 31 |
+
--data_train_jsonl '/dataset/CT_RATE/train.jsonl' '/dataset/CXR/MIMIC_CXR/train.jsonl' \
|
| 32 |
+
--data_valid_jsonl '/dataset/CT_RATE/test.jsonl' '/dataset/CXR/MIMIC_CXR/test.jsonl' \
|
| 33 |
+
--data_train_csv_dir '/dataset/CT_RATE/anatomy/train_entity' '/dataset/CXR/MIMIC_CXR/train_entity' \
|
| 34 |
+
--data_valid_csv_dir '/dataset/CT_RATE/anatomy/val_entity' '/dataset/CXR/MIMIC_CXR/test_entity' \
|
| 35 |
+
--data_train_npy_dir '/dataset/CT_RATE/anatomy/train_ratescore' '/dataset/CXR/MIMIC_CXR/train_ratescore' \
|
| 36 |
+
--data_valid_npy_dir '/dataset/CT_RATE/anatomy/val_ratescore' '/dataset/CXR/MIMIC_CXR/test_ratescore' \
|
| 37 |
+
--uncon_soft_label \
|
| 38 |
+
--uncon_similarity_lookup_table_train '/dataset/CT_RATE' '/dataset/CXR/MIMIC_CXR/all' \
|
| 39 |
+
--uncon_similarity_lookup_table_valid '/dataset/CT_RATE' '/dataset/CXR/MIMIC_CXR/all' \
|
| 40 |
+
--uncon_data_train_jsonl '/dataset/CT_RATE' '/dataset/CXR/MIMIC_CXR/all' \
|
| 41 |
+
--uncon_data_valid_jsonl '/dataset/CT_RATE' '/dataset/CXR/MIMIC_CXR/all' \
|
| 42 |
+
--uncon_dataset_names 'CT_RATE' 'MIMIC-CXR' \
|
| 43 |
+
--uncon_train_filter '[["train"],["train_0","train_1","train_2","train_3"]]' \
|
| 44 |
+
--uncon_valid 'test' 'test' \
|
| 45 |
+
--uncon_modality '3D' '2D' \
|
| 46 |
+
--train_no_aug \
|
| 47 |
+
--infonce_temp \
|
| 48 |
+
--modal_embedding \
|
| 49 |
+
--save_results_every 500 \
|
| 50 |
+
--save_model_every 6000 \
|
| 51 |
+
--num_train_steps 12000 \
|
| 52 |
+
--warmup_steps 1000 \
|
| 53 |
+
--lr 1e-4 \
|
| 54 |
+
--batch_size 1 2 \
|
| 55 |
+
--local_batch_size 6 30 \
|
| 56 |
+
--uncon_batch_size 6 60 \
|
| 57 |
+
--uncon_batch_size_valid 6 60 \
|
| 58 |
+
--uncon_num_workers 4 \
|
| 59 |
+
--con_num_workers 4
|
| 60 |
+
|
| 61 |
+
|
sbatch/stage2.sh
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
set -e
|
| 2 |
+
|
| 3 |
+
export MASTER_PORT=$((RANDOM % 987 + 29000))
|
| 4 |
+
|
| 5 |
+
accelerate launch \
|
| 6 |
+
--num_processes=8 \
|
| 7 |
+
--num_machines=1 \
|
| 8 |
+
--dynamo_backend=no \
|
| 9 |
+
--mixed_precision=fp16 \
|
| 10 |
+
--gradient_accumulation_steps=8 \
|
| 11 |
+
--main_process_port "${MASTER_PORT}" \
|
| 12 |
+
/scripts/run_CXR_CT_train.py \
|
| 13 |
+
--checkpoint "xxx.pt" \
|
| 14 |
+
--name 'CXRCT_UNI_stage2' \
|
| 15 |
+
--results_folder 'results_folder' \
|
| 16 |
+
--allow_partial_load \
|
| 17 |
+
--fusion_module 'crossattn' \
|
| 18 |
+
--pin_memory \
|
| 19 |
+
--freeze_pretrained_encoders \
|
| 20 |
+
--open_fusion_module \
|
| 21 |
+
--use_triplet_loss 1.0 \
|
| 22 |
+
--use_infoNCE_loss 0.0 \
|
| 23 |
+
--positive_distance_threshold 0.25 \
|
| 24 |
+
--positive_threshold 0.75 \
|
| 25 |
+
--stage2 \
|
| 26 |
+
--anatomy_filter '[["bone","heart","bronchie","trachea","pleura","vertebrae","liver","aorta","spinal canal","gallbladder","clavicle","heart ascending aorta","pulmonary artery","breast","pancreas","stomach"],["lungs","heart","bones","abdomen","spine","right lung","pulmonary vasculature","aorta","airway structures","left lower lobe","right lower lobe","right upper lobe","thoracic aorta","ventricle","atrium","carina","pulmonary artery","aortic arch","bronchi","costophrenic angle","aortic valve","pericardium"]]' \
|
| 27 |
+
--dataset_names 'CT_RATE' 'MIMIC_CXR' \
|
| 28 |
+
--modality '3D' '2D' \
|
| 29 |
+
--data_train_jsonl '/dataset/CT_RATE/train.jsonl' '/dataset/CXR/MIMIC_CXR/train.jsonl' \
|
| 30 |
+
--data_valid_jsonl '/dataset/CT_RATE/test.jsonl' '/dataset/CXR/MIMIC_CXR/test.jsonl' \
|
| 31 |
+
--data_train_csv_dir '/dataset/CT_RATE/anatomy/train_entity' '/dataset/CXR/MIMIC_CXR/train_entity' \
|
| 32 |
+
--data_valid_csv_dir '/dataset/CT_RATE/anatomy/val_entity' '/dataset/CXR/MIMIC_CXR/test_entity' \
|
| 33 |
+
--data_train_npy_dir '/dataset/CT_RATE/anatomy/train_ratescore' '/dataset/CXR/MIMIC_CXR/train_ratescore' \
|
| 34 |
+
--data_valid_npy_dir '/dataset/CT_RATE/anatomy/val_ratescore' '/dataset/CXR/MIMIC_CXR/test_ratescore' \
|
| 35 |
+
--train_no_aug \
|
| 36 |
+
--infonce_temp \
|
| 37 |
+
--modal_embedding \
|
| 38 |
+
--save_results_every 500 \
|
| 39 |
+
--save_model_every 6000 \
|
| 40 |
+
--num_train_steps 12000 \
|
| 41 |
+
--warmup_steps 1000 \
|
| 42 |
+
--lr 1e-4 \
|
| 43 |
+
--batch_size 1 2 \
|
| 44 |
+
--local_batch_size 6 30 \
|
| 45 |
+
--con_num_workers 4
|
scripts/README.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
### 给一些修改配置文件的建议
|
| 2 |
+
|
| 3 |
+
bash 直接执行即可。对于每一个选项都解释其含义
|
scripts/RadIR_Trainer_CXR_CT.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from datetime import timedelta
|
| 4 |
+
import time
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
import wandb
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
from torch import nn
|
| 11 |
+
from torch.utils.data import DataLoader
|
| 12 |
+
from torch.utils.tensorboard import SummaryWriter
|
| 13 |
+
|
| 14 |
+
from accelerate import Accelerator
|
| 15 |
+
from accelerate.utils import DistributedDataParallelKwargs, InitProcessGroupKwargs
|
| 16 |
+
|
| 17 |
+
from transformer_maskgit.transformer_maskgit.optimizer import get_optimizer
|
| 18 |
+
from scripts.scheduler import cosine_lr
|
| 19 |
+
|
| 20 |
+
# === 导入两个 Dataset ===
|
| 21 |
+
from Rad_IR.radir.data.hmclip_mimic_box import HyCoClipMimicBoxDataset_JPG, collate_hmclip_box
|
| 22 |
+
|
| 23 |
+
def cycle(dl):
|
| 24 |
+
"""让 DataLoader 无限循环"""
|
| 25 |
+
while True:
|
| 26 |
+
for data in dl:
|
| 27 |
+
yield data
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class AverageMeter:
|
| 32 |
+
"""统计均值的工具类"""
|
| 33 |
+
def __init__(self):
|
| 34 |
+
self.reset()
|
| 35 |
+
|
| 36 |
+
def reset(self):
|
| 37 |
+
self.sum = 0
|
| 38 |
+
self.count = 0
|
| 39 |
+
self.val = 0
|
| 40 |
+
self.avg = 0
|
| 41 |
+
|
| 42 |
+
def update(self, val, n=1):
|
| 43 |
+
"""更新一次计数"""
|
| 44 |
+
self.val = float(val)
|
| 45 |
+
self.sum += float(val) * n
|
| 46 |
+
self.count += n
|
| 47 |
+
self.avg = self.sum / self.count if self.count > 0 else 0.0
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class RadIRTrainer(nn.Module):
|
| 51 |
+
"""
|
| 52 |
+
RadIR 模型训练器:
|
| 53 |
+
- 自动支持 Stage1(Box 监督)
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def __init__(
|
| 57 |
+
self,
|
| 58 |
+
RADIR: nn.Module,
|
| 59 |
+
*,
|
| 60 |
+
tokenizer,
|
| 61 |
+
train_dataset,
|
| 62 |
+
valid_dataset,
|
| 63 |
+
num_train_steps,
|
| 64 |
+
current_step=0,
|
| 65 |
+
warmup_steps=1000,
|
| 66 |
+
batch_size=32,
|
| 67 |
+
lr=5e-5,
|
| 68 |
+
wd=0.0,
|
| 69 |
+
max_grad_norm=0.5,
|
| 70 |
+
save_results_every=500,
|
| 71 |
+
save_model_every=2000,
|
| 72 |
+
results_folder="./outputs",
|
| 73 |
+
num_workers=4,
|
| 74 |
+
pin_memory=False,
|
| 75 |
+
accelerate_kwargs=dict(),
|
| 76 |
+
modal_embedding=False,
|
| 77 |
+
debug=False,
|
| 78 |
+
):
|
| 79 |
+
super().__init__()
|
| 80 |
+
|
| 81 |
+
self.best_val_loss = float('inf')
|
| 82 |
+
self.debug = debug
|
| 83 |
+
self.modal_embedding = modal_embedding
|
| 84 |
+
self.save_results_every = save_results_every
|
| 85 |
+
self.save_model_every = save_model_every
|
| 86 |
+
|
| 87 |
+
# -------------------------
|
| 88 |
+
# 判断当前是 Stage1
|
| 89 |
+
# -------------------------
|
| 90 |
+
self.is_stage1 = True # 只用 Stage1
|
| 91 |
+
print(f"\n[Trainer 初始化] 当前模式: Stage1(Simple)\n")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# -------------------------
|
| 95 |
+
# 初始化 Accelerator(DDP + 混合精度)
|
| 96 |
+
# -------------------------
|
| 97 |
+
ddp_kwargs = DistributedDataParallelKwargs(find_unused_parameters=True)
|
| 98 |
+
pg_kwargs = InitProcessGroupKwargs(timeout=timedelta(seconds=36000))
|
| 99 |
+
self.accelerator = Accelerator(
|
| 100 |
+
mixed_precision="no",
|
| 101 |
+
kwargs_handlers=[ddp_kwargs, pg_kwargs],
|
| 102 |
+
**accelerate_kwargs,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
self.Rad_IR = RADIR
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
self.tokenizer = tokenizer
|
| 110 |
+
self.device = self.accelerator.device
|
| 111 |
+
|
| 112 |
+
# -------------------------
|
| 113 |
+
# 基本训练参数
|
| 114 |
+
# -------------------------
|
| 115 |
+
self.num_train_steps = int(num_train_steps)
|
| 116 |
+
self.current_step = int(current_step)
|
| 117 |
+
self.batch_size = batch_size
|
| 118 |
+
self.max_grad_norm = max_grad_norm
|
| 119 |
+
|
| 120 |
+
# -------------------------
|
| 121 |
+
# 优化器 + 学习率调度
|
| 122 |
+
# -------------------------
|
| 123 |
+
all_params = list(self.Rad_IR.parameters())
|
| 124 |
+
init_lr = lr[0] if isinstance(lr, (list, tuple)) else float(lr)
|
| 125 |
+
|
| 126 |
+
self.optim = get_optimizer(all_params, lr=init_lr, wd=wd)
|
| 127 |
+
|
| 128 |
+
self.lr_scheduler = cosine_lr(
|
| 129 |
+
self.optim, init_lr, warmup_steps, self.num_train_steps
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# -------------------------
|
| 133 |
+
# DataLoader(根据 stage 自动切换 collate)
|
| 134 |
+
# -------------------------
|
| 135 |
+
collate_fn = collate_hmclip_box
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
self.train_dl = DataLoader(
|
| 140 |
+
train_dataset,
|
| 141 |
+
batch_size=batch_size,
|
| 142 |
+
shuffle=True,
|
| 143 |
+
num_workers=num_workers,
|
| 144 |
+
pin_memory=pin_memory,
|
| 145 |
+
drop_last=True,
|
| 146 |
+
collate_fn=collate_fn,
|
| 147 |
+
)
|
| 148 |
+
self.valid_dl = DataLoader(
|
| 149 |
+
valid_dataset,
|
| 150 |
+
batch_size=batch_size,
|
| 151 |
+
shuffle=False,
|
| 152 |
+
num_workers=num_workers,
|
| 153 |
+
pin_memory=pin_memory,
|
| 154 |
+
drop_last=False,
|
| 155 |
+
collate_fn=collate_fn,
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
# -------------------------
|
| 159 |
+
# Accelerator 包装模型、优化器、DataLoader
|
| 160 |
+
# -------------------------
|
| 161 |
+
(
|
| 162 |
+
self.Rad_IR,
|
| 163 |
+
self.optim,
|
| 164 |
+
self.train_dl,
|
| 165 |
+
self.valid_dl,
|
| 166 |
+
) = self.accelerator.prepare(
|
| 167 |
+
self.Rad_IR,
|
| 168 |
+
self.optim,
|
| 169 |
+
self.train_dl,
|
| 170 |
+
self.valid_dl,
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
self.train_iter = cycle(self.train_dl)
|
| 174 |
+
|
| 175 |
+
# -------------------------
|
| 176 |
+
# 日志 & 输出目录
|
| 177 |
+
# -------------------------
|
| 178 |
+
self.results_folder = Path(results_folder)
|
| 179 |
+
self.results_folder.mkdir(parents=True, exist_ok=True)
|
| 180 |
+
|
| 181 |
+
tb_path = self.results_folder / "tensorboard"
|
| 182 |
+
tb_path.mkdir(parents=True, exist_ok=True)
|
| 183 |
+
self.tb_writer = SummaryWriter(str(tb_path))
|
| 184 |
+
|
| 185 |
+
# -------------------------
|
| 186 |
+
# 统计指标
|
| 187 |
+
# -------------------------
|
| 188 |
+
self.uncon_loss_m = AverageMeter() # 总 loss
|
| 189 |
+
self.it_ce_loss_m = AverageMeter() # image-text CE
|
| 190 |
+
self.it_entailment_loss_m = AverageMeter() # image-text entailment
|
| 191 |
+
self.ii_text_image_entailment_loss_m = AverageMeter() # image-image entailment
|
| 192 |
+
|
| 193 |
+
print(f"[Trainer] 已准备完毕,总训练步数:{self.num_train_steps}")
|
| 194 |
+
# -------------------------
|
| 195 |
+
# 时间统计
|
| 196 |
+
# -------------------------
|
| 197 |
+
self.train_start_time = None
|
| 198 |
+
self.step_time_m = AverageMeter()
|
| 199 |
+
|
| 200 |
+
# =======================================================
|
| 201 |
+
# 前向:根据 Stage 自动切换分支
|
| 202 |
+
# =======================================================
|
| 203 |
+
def _forward_batch(self, batch):
|
| 204 |
+
imgs = batch["imgs"].to(self.device)
|
| 205 |
+
caption_ids = batch["caption_ids"].to(self.device)
|
| 206 |
+
token_type_ids = batch["token_type_ids"].to(self.device)
|
| 207 |
+
attention_mask = batch["attention_mask"].to(self.device)
|
| 208 |
+
|
| 209 |
+
phrase_text_mask = batch["phrase_text_mask"].to(self.device)
|
| 210 |
+
phrases_bboxes_mask = batch["phrases_bboxes_mask"].to(self.device)
|
| 211 |
+
|
| 212 |
+
text_batch = {
|
| 213 |
+
"caption_ids": caption_ids,
|
| 214 |
+
"token_type_ids": token_type_ids,
|
| 215 |
+
"attention_mask": attention_mask,
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
loss, ce_loss, entail_loss, global_entail_loss = self.Rad_IR(
|
| 223 |
+
text=text_batch,
|
| 224 |
+
image=imgs,
|
| 225 |
+
device=self.device,
|
| 226 |
+
is_condition=False,
|
| 227 |
+
return_loss=True,
|
| 228 |
+
modal_embedding=self.modal_embedding,
|
| 229 |
+
phrase_text_mask=phrase_text_mask,
|
| 230 |
+
phrases_bboxes_mask=phrases_bboxes_mask,
|
| 231 |
+
return_latents=True,
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
return loss, ce_loss, entail_loss, global_entail_loss
|
| 235 |
+
|
| 236 |
+
# =======================================================
|
| 237 |
+
# 主训练循环
|
| 238 |
+
# =======================================================
|
| 239 |
+
def train_model(self, resume_path=None):
|
| 240 |
+
|
| 241 |
+
# ======================
|
| 242 |
+
# 先尝试 Resume 恢复训练
|
| 243 |
+
# ======================
|
| 244 |
+
if resume_path is not None:
|
| 245 |
+
self.load_checkpoint(resume_path)
|
| 246 |
+
|
| 247 |
+
self.Rad_IR.train()
|
| 248 |
+
self.train_start_time = time.time()
|
| 249 |
+
|
| 250 |
+
torch.autograd.set_detect_anomaly(True)
|
| 251 |
+
|
| 252 |
+
for step in range(self.current_step, self.num_train_steps):
|
| 253 |
+
self.current_step = step
|
| 254 |
+
step_start_time = time.time()
|
| 255 |
+
|
| 256 |
+
self.lr_scheduler.step()
|
| 257 |
+
# ============================
|
| 258 |
+
# ✅ Stage 1
|
| 259 |
+
# ============================
|
| 260 |
+
with self.accelerator.accumulate(self.Rad_IR):
|
| 261 |
+
|
| 262 |
+
batch = next(self.train_iter)
|
| 263 |
+
loss, ce_loss, entail_loss, global_entail_loss = self._forward_batch(batch)
|
| 264 |
+
#加检查1
|
| 265 |
+
for name, val in {
|
| 266 |
+
"loss": loss,
|
| 267 |
+
"ce": ce_loss,
|
| 268 |
+
"entail": entail_loss,
|
| 269 |
+
"global_entail": global_entail_loss,
|
| 270 |
+
}.items():
|
| 271 |
+
if not torch.isfinite(val):
|
| 272 |
+
print(f"[NaN LOSS] step={step}, {name}")
|
| 273 |
+
self._dump_bad_batch(batch, step)
|
| 274 |
+
raise RuntimeError("NaN in loss")
|
| 275 |
+
#加检查2
|
| 276 |
+
self.optim.zero_grad()
|
| 277 |
+
self.accelerator.backward(loss)
|
| 278 |
+
#加检查1
|
| 279 |
+
for n, p in self.Rad_IR.named_parameters():
|
| 280 |
+
if p.grad is not None and not torch.isfinite(p.grad).all():
|
| 281 |
+
print(f"[NaN GRAD] step={step}, param={n}")
|
| 282 |
+
raise RuntimeError("NaN in grad")
|
| 283 |
+
#加检查2
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
# ============================
|
| 287 |
+
# ✅ 只有在 sync_gradients 才 step
|
| 288 |
+
# ============================
|
| 289 |
+
if self.accelerator.sync_gradients:
|
| 290 |
+
|
| 291 |
+
if self.max_grad_norm is not None:
|
| 292 |
+
self.accelerator.clip_grad_norm_(
|
| 293 |
+
self.Rad_IR.parameters(), self.max_grad_norm
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
self.optim.step()
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
self.optim.zero_grad(set_to_none=True)
|
| 300 |
+
|
| 301 |
+
# ============================
|
| 302 |
+
# ✅ 时间统计
|
| 303 |
+
# ============================
|
| 304 |
+
if torch.cuda.is_available():
|
| 305 |
+
torch.cuda.synchronize()
|
| 306 |
+
step_time = time.time() - step_start_time
|
| 307 |
+
self.step_time_m.update(step_time)
|
| 308 |
+
|
| 309 |
+
# ✅ loss 记录(已有的 AverageMeter)
|
| 310 |
+
# ===== 从 GPU 拿回 4 个 loss =====
|
| 311 |
+
loss_val = float(loss.detach().cpu())
|
| 312 |
+
ce_loss_val = float(ce_loss.detach().cpu())
|
| 313 |
+
entail_loss_val = float(entail_loss.detach().cpu())
|
| 314 |
+
ii_entail_loss_val = float(global_entail_loss.detach().cpu())
|
| 315 |
+
|
| 316 |
+
# ===== 更新 4 个 meter =====
|
| 317 |
+
self.uncon_loss_m.update(loss_val, 1)
|
| 318 |
+
self.it_ce_loss_m.update(ce_loss_val, 1)
|
| 319 |
+
self.it_entailment_loss_m.update(entail_loss_val, 1)
|
| 320 |
+
self.ii_text_image_entailment_loss_m.update(ii_entail_loss_val, 1)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
# ============================
|
| 324 |
+
# ✅ 日志
|
| 325 |
+
# ============================
|
| 326 |
+
if self.accelerator.is_main_process and (step + 1) % 10 == 0:
|
| 327 |
+
eta_seconds = int(self.step_time_m.avg * (self.num_train_steps - step - 1))
|
| 328 |
+
eta_str = str(timedelta(seconds=eta_seconds))
|
| 329 |
+
|
| 330 |
+
print(
|
| 331 |
+
f"[Step {step+1}/{self.num_train_steps}] "
|
| 332 |
+
f"loss={self.uncon_loss_m.avg:.4f}, "
|
| 333 |
+
f"ce={self.it_ce_loss_m.avg:.4f}, "
|
| 334 |
+
f"entail={self.it_entailment_loss_m.avg:.4f}, "
|
| 335 |
+
f"step_time={self.step_time_m.avg:.3f}s, ETA={eta_str}"
|
| 336 |
+
)
|
| 337 |
+
wandb.log({
|
| 338 |
+
"train/loss": self.uncon_loss_m.avg,
|
| 339 |
+
"train/ce_loss": self.it_ce_loss_m.avg,
|
| 340 |
+
"train/entail_loss": self.it_entailment_loss_m.avg,
|
| 341 |
+
"train/global_entail_loss": self.ii_text_image_entailment_loss_m.avg,
|
| 342 |
+
"lr": self.optim.param_groups[0]["lr"],
|
| 343 |
+
"step_time": self.step_time_m.avg,
|
| 344 |
+
"step": step + 1
|
| 345 |
+
}, step=step + 1)
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
# ============================
|
| 349 |
+
# ✅ 验证
|
| 350 |
+
# ============================
|
| 351 |
+
if (step + 1) % self.save_results_every == 0:
|
| 352 |
+
val_loss = self.evaluate(step + 1)
|
| 353 |
+
|
| 354 |
+
# ========= 更新 best =========
|
| 355 |
+
if self.accelerator.is_main_process:
|
| 356 |
+
if val_loss < self.best_val_loss:
|
| 357 |
+
self.best_val_loss = val_loss
|
| 358 |
+
self.save_checkpoint(step + 1, is_best=True)
|
| 359 |
+
|
| 360 |
+
# ============================
|
| 361 |
+
# ✅ 存模型
|
| 362 |
+
# ============================
|
| 363 |
+
if (step + 1) % self.save_model_every == 0:
|
| 364 |
+
self.save_checkpoint(step + 1)
|
| 365 |
+
|
| 366 |
+
# ✅ 最终评估 + 保存
|
| 367 |
+
self.evaluate(self.num_train_steps)
|
| 368 |
+
self.save_checkpoint(self.num_train_steps, is_final=True)
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
# =======================================================
|
| 372 |
+
# 验证集评估
|
| 373 |
+
# =======================================================
|
| 374 |
+
@torch.no_grad()
|
| 375 |
+
def evaluate(self, step):
|
| 376 |
+
self.Rad_IR.eval()
|
| 377 |
+
eval_start_time = time.time() # ✅ 验证开始计时
|
| 378 |
+
loss_m = AverageMeter()
|
| 379 |
+
ce_m = AverageMeter()
|
| 380 |
+
entail_m = AverageMeter()
|
| 381 |
+
g_entail_m = AverageMeter()
|
| 382 |
+
|
| 383 |
+
for batch in self.valid_dl:
|
| 384 |
+
loss, ce_loss, entail_loss, g_entail_loss = self._forward_batch(batch)
|
| 385 |
+
|
| 386 |
+
bs = batch["imgs"].size(0)
|
| 387 |
+
loss_m.update(loss.item(), bs)
|
| 388 |
+
ce_m.update(ce_loss.item(), bs)
|
| 389 |
+
entail_m.update(entail_loss.item(), bs)
|
| 390 |
+
g_entail_m.update(g_entail_loss.item(), bs)
|
| 391 |
+
|
| 392 |
+
if self.accelerator.is_main_process:
|
| 393 |
+
print(
|
| 394 |
+
f"[Eval step {step}] val_loss={loss_m.avg:.4f}, "
|
| 395 |
+
f"val_ce={ce_m.avg:.4f}, val_entail={entail_m.avg:.4f}"
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
self.tb_writer.add_scalar("val/loss", loss_m.avg, step)
|
| 399 |
+
self.tb_writer.add_scalar("val/ce_loss", ce_m.avg, step)
|
| 400 |
+
|
| 401 |
+
self.Rad_IR.train()
|
| 402 |
+
eval_time = time.time() - eval_start_time
|
| 403 |
+
eval_time_str = str(timedelta(seconds=int(eval_time)))
|
| 404 |
+
|
| 405 |
+
if self.accelerator.is_main_process:
|
| 406 |
+
print(f"[Eval Done] step={step} 耗时:{eval_time_str}")
|
| 407 |
+
self.tb_writer.add_scalar("time/eval_time", eval_time, step)
|
| 408 |
+
|
| 409 |
+
wandb.log({
|
| 410 |
+
"val/loss": loss_m.avg,
|
| 411 |
+
"val/ce_loss": ce_m.avg,
|
| 412 |
+
"val/entail_loss": entail_m.avg,
|
| 413 |
+
"val/global_entail_loss": g_entail_m.avg,
|
| 414 |
+
}, step=step)
|
| 415 |
+
|
| 416 |
+
return loss_m.avg
|
| 417 |
+
|
| 418 |
+
# =======================================================
|
| 419 |
+
# 模型保存
|
| 420 |
+
# =======================================================
|
| 421 |
+
def save_checkpoint(self, step, is_final=False, is_best=False):
|
| 422 |
+
if not self.accelerator.is_main_process:
|
| 423 |
+
return
|
| 424 |
+
|
| 425 |
+
if is_final:
|
| 426 |
+
ckpt_name = "radir_final.pt"
|
| 427 |
+
elif is_best:
|
| 428 |
+
ckpt_name = "radir_best.pt"
|
| 429 |
+
else:
|
| 430 |
+
ckpt_name = "radir_last.pt" # 覆盖最新
|
| 431 |
+
|
| 432 |
+
ckpt_path = self.results_folder / ckpt_name
|
| 433 |
+
|
| 434 |
+
state = {
|
| 435 |
+
"model": self.accelerator.get_state_dict(self.Rad_IR),
|
| 436 |
+
"optimizer": self.optim.state_dict(),
|
| 437 |
+
"scheduler": self.lr_scheduler.state_dict(),
|
| 438 |
+
"step": step,
|
| 439 |
+
"best_val_loss": self.best_val_loss,
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
torch.save(state, ckpt_path)
|
| 443 |
+
print(f"[Checkpoint] 保存: {ckpt_path}")
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
# =======================================================
|
| 447 |
+
# 模型恢复
|
| 448 |
+
# =======================================================
|
| 449 |
+
def load_checkpoint(self, ckpt_path):
|
| 450 |
+
if not os.path.exists(ckpt_path):
|
| 451 |
+
raise FileNotFoundError(f"Checkpoint 不存在: {ckpt_path}")
|
| 452 |
+
|
| 453 |
+
print(f"[Resume] 从 {ckpt_path} 恢复训练")
|
| 454 |
+
|
| 455 |
+
ckpt = torch.load(ckpt_path, map_location="cpu")
|
| 456 |
+
|
| 457 |
+
# 1️⃣ 恢复模型
|
| 458 |
+
self.accelerator.unwrap_model(self.Rad_IR).load_state_dict(ckpt["model"])
|
| 459 |
+
|
| 460 |
+
# 2️⃣ 恢复优化器
|
| 461 |
+
self.optim.load_state_dict(ckpt["optimizer"])
|
| 462 |
+
|
| 463 |
+
# 3️⃣ 恢复 scheduler(如果存在)
|
| 464 |
+
if "scheduler" in ckpt:
|
| 465 |
+
self.lr_scheduler.load_state_dict(ckpt["scheduler"])
|
| 466 |
+
print("[Resume] Scheduler 已恢复")
|
| 467 |
+
else:
|
| 468 |
+
print("[Resume] ⚠️ 未发现 scheduler,使用新的 scheduler")
|
| 469 |
+
|
| 470 |
+
# 4️⃣ 恢复训练进度
|
| 471 |
+
self.current_step = ckpt["step"]
|
| 472 |
+
|
| 473 |
+
# 5️⃣ 恢复 best_val_loss
|
| 474 |
+
self.best_val_loss = ckpt.get("best_val_loss", float("inf"))
|
| 475 |
+
|
| 476 |
+
print("resume lr =", self.optim.param_groups[0]["lr"])
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
print(
|
| 480 |
+
f"[Resume] 恢复完成:step = {self.current_step}, "
|
| 481 |
+
f"best_val_loss = {self.best_val_loss:.4f}"
|
| 482 |
+
)
|
scripts/__pycache__/RadIR_Trainer_CXR_CT.cpython-39.pyc
ADDED
|
Binary file (9.3 kB). View file
|
|
|
scripts/__pycache__/image_traversals_compare.cpython-313.pyc
ADDED
|
Binary file (48.3 kB). View file
|
|
|
scripts/__pycache__/infer_disease_clusters.cpython-39.pyc
ADDED
|
Binary file (14.1 kB). View file
|
|
|
scripts/__pycache__/plot_cosne.cpython-39.pyc
ADDED
|
Binary file (17.5 kB). View file
|
|
|
scripts/__pycache__/plot_tsne.cpython-313.pyc
ADDED
|
Binary file (18.1 kB). View file
|
|
|
scripts/__pycache__/scheduler.cpython-39.pyc
ADDED
|
Binary file (784 Bytes). View file
|
|
|
scripts/build_traversal_concepts.py
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Build a MERU-style multi-granularity CXR concept pool for image traversals.
|
| 4 |
+
|
| 5 |
+
Design (why this is more scientific than mining raw report sentences):
|
| 6 |
+
|
| 7 |
+
1. MERU Fig.5 uses short noun/adj captions at multiple granularities, not long
|
| 8 |
+
free-text captions. Long MIMIC boilerplate ("No acute osseous abnormalities
|
| 9 |
+
are identified") dominate nearest-neighbor retrieval and break hierarchy.
|
| 10 |
+
|
| 11 |
+
2. Hierarchy mirrors radiology reporting + hyperbolic entailment intuition:
|
| 12 |
+
modality / study → organ system → anatomy → finding / attribute
|
| 13 |
+
Closer to [ROOT] should be more abstract.
|
| 14 |
+
|
| 15 |
+
3. Finding vocabulary is anchored to CheXpert-style labels used in this repo
|
| 16 |
+
(see infer_disease_clusters.DISEASE_NAMES) plus common CXR observations,
|
| 17 |
+
with short "no X" counterparts so Normal cases are representable.
|
| 18 |
+
|
| 19 |
+
4. Additionally mine short, high-frequency clinical phrases from train.jsonl +
|
| 20 |
+
valid.jsonl (with filters), and ground a few phrases in the current query
|
| 21 |
+
images' Findings so traversal concepts can match the paired reports.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import json
|
| 27 |
+
import re
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 31 |
+
OUT = ROOT / "scripts" / "traversal_concepts.txt"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _dedupe(items: list[str]) -> list[str]:
|
| 35 |
+
seen: set[str] = set()
|
| 36 |
+
out: list[str] = []
|
| 37 |
+
for s in items:
|
| 38 |
+
s = " ".join(s.strip().split())
|
| 39 |
+
if not s or s.startswith("#"):
|
| 40 |
+
continue
|
| 41 |
+
key = s.lower()
|
| 42 |
+
if key in seen:
|
| 43 |
+
continue
|
| 44 |
+
seen.add(key)
|
| 45 |
+
out.append(s)
|
| 46 |
+
return out
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def build_sections() -> list[tuple[str, list[str]]]:
|
| 50 |
+
# --- L0: modality / study (most abstract) ---
|
| 51 |
+
l0 = [
|
| 52 |
+
"radiograph",
|
| 53 |
+
"x-ray",
|
| 54 |
+
"imaging study",
|
| 55 |
+
"chest radiograph",
|
| 56 |
+
"chest x-ray",
|
| 57 |
+
"chest imaging",
|
| 58 |
+
"thoracic imaging",
|
| 59 |
+
"radiographic examination",
|
| 60 |
+
"frontal chest radiograph",
|
| 61 |
+
"lateral chest radiograph",
|
| 62 |
+
"PA chest radiograph",
|
| 63 |
+
"AP chest radiograph",
|
| 64 |
+
"portable chest radiograph",
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
# --- L1: organ systems / regions (bridging toward anatomy) ---
|
| 68 |
+
l1 = [
|
| 69 |
+
"chest",
|
| 70 |
+
"thorax",
|
| 71 |
+
"thoracic cavity",
|
| 72 |
+
"chest wall",
|
| 73 |
+
"lungs",
|
| 74 |
+
"pulmonary system",
|
| 75 |
+
"cardiopulmonary system",
|
| 76 |
+
"cardiovascular structures",
|
| 77 |
+
"cardiomediastinal structures",
|
| 78 |
+
"pleural space",
|
| 79 |
+
"pleural spaces",
|
| 80 |
+
"mediastinum",
|
| 81 |
+
"central airways",
|
| 82 |
+
"bony thorax",
|
| 83 |
+
"upper abdomen",
|
| 84 |
+
"neck base",
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
# --- L2: anatomy (mid → fine; Chest ImaGenome–style regions) ---
|
| 88 |
+
l2 = [
|
| 89 |
+
# lungs / lobes / zones
|
| 90 |
+
"right lung",
|
| 91 |
+
"left lung",
|
| 92 |
+
"both lungs",
|
| 93 |
+
"lung",
|
| 94 |
+
"lung parenchyma",
|
| 95 |
+
"lung bases",
|
| 96 |
+
"lung apex",
|
| 97 |
+
"lung apices",
|
| 98 |
+
"upper lobes",
|
| 99 |
+
"lower lobes",
|
| 100 |
+
"middle lobe",
|
| 101 |
+
"lingula",
|
| 102 |
+
"right upper lobe",
|
| 103 |
+
"right middle lobe",
|
| 104 |
+
"right lower lobe",
|
| 105 |
+
"left upper lobe",
|
| 106 |
+
"left lower lobe",
|
| 107 |
+
"right lung base",
|
| 108 |
+
"left lung base",
|
| 109 |
+
"bilateral lung bases",
|
| 110 |
+
"perihilar region",
|
| 111 |
+
"perihilar regions",
|
| 112 |
+
"peripheral lung",
|
| 113 |
+
"central lung",
|
| 114 |
+
# airways / hilum
|
| 115 |
+
"hilum",
|
| 116 |
+
"hila",
|
| 117 |
+
"right hilum",
|
| 118 |
+
"left hilum",
|
| 119 |
+
"hilar structures",
|
| 120 |
+
"trachea",
|
| 121 |
+
"carina",
|
| 122 |
+
"airways",
|
| 123 |
+
"main bronchi",
|
| 124 |
+
"bronchi",
|
| 125 |
+
# heart / mediastinum / vessels
|
| 126 |
+
"heart",
|
| 127 |
+
"cardiac silhouette",
|
| 128 |
+
"heart border",
|
| 129 |
+
"left heart border",
|
| 130 |
+
"right heart border",
|
| 131 |
+
"cardiomediastinal silhouette",
|
| 132 |
+
"mediastinal contours",
|
| 133 |
+
"mediastinal silhouette",
|
| 134 |
+
"hilar contours",
|
| 135 |
+
"aorta",
|
| 136 |
+
"aortic arch",
|
| 137 |
+
"descending aorta",
|
| 138 |
+
"ascending aorta",
|
| 139 |
+
"pulmonary artery",
|
| 140 |
+
"pulmonary vasculature",
|
| 141 |
+
"pulmonary vessels",
|
| 142 |
+
"superior vena cava",
|
| 143 |
+
# pleura / diaphragm
|
| 144 |
+
"pleura",
|
| 145 |
+
"pleural surface",
|
| 146 |
+
"costophrenic angle",
|
| 147 |
+
"costophrenic angles",
|
| 148 |
+
"right costophrenic angle",
|
| 149 |
+
"left costophrenic angle",
|
| 150 |
+
"hemidiaphragm",
|
| 151 |
+
"right hemidiaphragm",
|
| 152 |
+
"left hemidiaphragm",
|
| 153 |
+
"diaphragm",
|
| 154 |
+
"diaphragmatic contour",
|
| 155 |
+
# bones / soft tissue
|
| 156 |
+
"ribs",
|
| 157 |
+
"rib",
|
| 158 |
+
"clavicle",
|
| 159 |
+
"clavicles",
|
| 160 |
+
"scapula",
|
| 161 |
+
"sternum",
|
| 162 |
+
"spine",
|
| 163 |
+
"thoracic spine",
|
| 164 |
+
"vertebral bodies",
|
| 165 |
+
"soft tissues",
|
| 166 |
+
"breast shadows",
|
| 167 |
+
"shoulder girdle",
|
| 168 |
+
]
|
| 169 |
+
|
| 170 |
+
# --- L3: CheXpert-aligned findings + localized short phrases ---
|
| 171 |
+
findings_pos = [
|
| 172 |
+
# Project primary labels
|
| 173 |
+
"pneumothorax",
|
| 174 |
+
"pulmonary edema",
|
| 175 |
+
"edema",
|
| 176 |
+
"pneumonia",
|
| 177 |
+
"pleural effusion",
|
| 178 |
+
"effusion",
|
| 179 |
+
"cardiomegaly",
|
| 180 |
+
"atelectasis",
|
| 181 |
+
# CheXpert / MIMIC common
|
| 182 |
+
"consolidation",
|
| 183 |
+
"focal consolidation",
|
| 184 |
+
"patchy consolidation",
|
| 185 |
+
"lung opacity",
|
| 186 |
+
"airspace opacity",
|
| 187 |
+
"airspace disease",
|
| 188 |
+
"enlarged cardiomediastinum",
|
| 189 |
+
"widened mediastinum",
|
| 190 |
+
"lung lesion",
|
| 191 |
+
"pulmonary nodule",
|
| 192 |
+
"nodule",
|
| 193 |
+
"mass",
|
| 194 |
+
"fracture",
|
| 195 |
+
"rib fracture",
|
| 196 |
+
"support devices",
|
| 197 |
+
# Localized / severity-ish findings (short)
|
| 198 |
+
"bibasilar atelectasis",
|
| 199 |
+
"basilar atelectasis",
|
| 200 |
+
"left lower lobe atelectasis",
|
| 201 |
+
"right lower lobe atelectasis",
|
| 202 |
+
"right upper lobe atelectasis",
|
| 203 |
+
"left upper lobe atelectasis",
|
| 204 |
+
"platelike atelectasis",
|
| 205 |
+
"compressive atelectasis",
|
| 206 |
+
"left pleural effusion",
|
| 207 |
+
"right pleural effusion",
|
| 208 |
+
"bilateral pleural effusion",
|
| 209 |
+
"small pleural effusion",
|
| 210 |
+
"large pleural effusion",
|
| 211 |
+
"layering pleural effusion",
|
| 212 |
+
"left pneumothorax",
|
| 213 |
+
"right pneumothorax",
|
| 214 |
+
"small pneumothorax",
|
| 215 |
+
"large pneumothorax",
|
| 216 |
+
"apical pneumothorax",
|
| 217 |
+
"mild pulmonary edema",
|
| 218 |
+
"pulmonary vascular congestion",
|
| 219 |
+
"vascular congestion",
|
| 220 |
+
"cephalization",
|
| 221 |
+
"interstitial edema",
|
| 222 |
+
"interstitial markings",
|
| 223 |
+
"increased interstitial markings",
|
| 224 |
+
"kerley b lines",
|
| 225 |
+
"infiltrate",
|
| 226 |
+
"opacity",
|
| 227 |
+
"basilar opacity",
|
| 228 |
+
"bibasilar opacities",
|
| 229 |
+
"right lower lobe opacity",
|
| 230 |
+
"left lower lobe opacity",
|
| 231 |
+
"perihilar opacity",
|
| 232 |
+
"emphysema",
|
| 233 |
+
"hyperinflation",
|
| 234 |
+
"fibrosis",
|
| 235 |
+
"scarring",
|
| 236 |
+
"pleural scarring",
|
| 237 |
+
"calcified granuloma",
|
| 238 |
+
"granuloma",
|
| 239 |
+
"calcification",
|
| 240 |
+
"hilar calcification",
|
| 241 |
+
"scoliosis",
|
| 242 |
+
"kyphosis",
|
| 243 |
+
"low lung volumes",
|
| 244 |
+
"elevated hemidiaphragm",
|
| 245 |
+
"elevated right hemidiaphragm",
|
| 246 |
+
"elevated left hemidiaphragm",
|
| 247 |
+
"blunted costophrenic angle",
|
| 248 |
+
"blunted costophrenic angles",
|
| 249 |
+
"hilar lymphadenopathy",
|
| 250 |
+
"lymphadenopathy",
|
| 251 |
+
"bronchiectasis",
|
| 252 |
+
"cavity",
|
| 253 |
+
"cavitation",
|
| 254 |
+
"pleural thickening",
|
| 255 |
+
"subcutaneous emphysema",
|
| 256 |
+
]
|
| 257 |
+
|
| 258 |
+
# Short negations / normal status (balanced; include mid-level normals)
|
| 259 |
+
findings_neg = [
|
| 260 |
+
"no finding",
|
| 261 |
+
"normal study",
|
| 262 |
+
"normal chest",
|
| 263 |
+
"unremarkable chest",
|
| 264 |
+
"unremarkable study",
|
| 265 |
+
"clear lungs",
|
| 266 |
+
"lungs are clear",
|
| 267 |
+
"lungs remain clear",
|
| 268 |
+
"clear lung fields",
|
| 269 |
+
"no focal opacity",
|
| 270 |
+
"no focal airspace opacity",
|
| 271 |
+
"no pneumothorax",
|
| 272 |
+
"no pleural effusion",
|
| 273 |
+
"no effusion",
|
| 274 |
+
"no pulmonary edema",
|
| 275 |
+
"no edema",
|
| 276 |
+
"no pneumonia",
|
| 277 |
+
"no consolidation",
|
| 278 |
+
"no focal consolidation",
|
| 279 |
+
"no atelectasis",
|
| 280 |
+
"no cardiomegaly",
|
| 281 |
+
"no fracture",
|
| 282 |
+
"normal heart size",
|
| 283 |
+
"normal cardiac silhouette",
|
| 284 |
+
"normal cardiomediastinal silhouette",
|
| 285 |
+
"normal mediastinal contours",
|
| 286 |
+
"normal hilar contours",
|
| 287 |
+
"normal pulmonary vasculature",
|
| 288 |
+
"no vascular congestion",
|
| 289 |
+
"costophrenic angles are clear",
|
| 290 |
+
"costophrenic angles are sharp",
|
| 291 |
+
"sharp costophrenic angles",
|
| 292 |
+
"no acute cardiopulmonary process",
|
| 293 |
+
"no acute abnormality",
|
| 294 |
+
"no acute disease",
|
| 295 |
+
"stable chest",
|
| 296 |
+
"unchanged chest",
|
| 297 |
+
]
|
| 298 |
+
|
| 299 |
+
# Severity / laterality composed phrases
|
| 300 |
+
attrs = [
|
| 301 |
+
"mild cardiomegaly",
|
| 302 |
+
"moderate cardiomegaly",
|
| 303 |
+
"severe cardiomegaly",
|
| 304 |
+
"top normal heart size",
|
| 305 |
+
"borderline cardiomegaly",
|
| 306 |
+
"mild pulmonary edema",
|
| 307 |
+
"moderate pulmonary edema",
|
| 308 |
+
"severe pulmonary edema",
|
| 309 |
+
"small pleural effusion",
|
| 310 |
+
"moderate pleural effusion",
|
| 311 |
+
"large pleural effusion",
|
| 312 |
+
"trace pleural effusion",
|
| 313 |
+
"tiny pleural effusion",
|
| 314 |
+
"bilateral pleural effusion",
|
| 315 |
+
"bilateral atelectasis",
|
| 316 |
+
"mild atelectasis",
|
| 317 |
+
"mild bibasilar atelectasis",
|
| 318 |
+
"stable cardiomegaly",
|
| 319 |
+
"stable pleural effusion",
|
| 320 |
+
"stable atelectasis",
|
| 321 |
+
"stable opacity",
|
| 322 |
+
"chronic opacity",
|
| 323 |
+
"acute opacity",
|
| 324 |
+
"improving edema",
|
| 325 |
+
"worsening edema",
|
| 326 |
+
"improving effusion",
|
| 327 |
+
"worsening effusion",
|
| 328 |
+
]
|
| 329 |
+
|
| 330 |
+
# Devices / tubes / lines
|
| 331 |
+
devices = [
|
| 332 |
+
"endotracheal tube",
|
| 333 |
+
"tracheostomy tube",
|
| 334 |
+
"chest tube",
|
| 335 |
+
"right chest tube",
|
| 336 |
+
"left chest tube",
|
| 337 |
+
"nasogastric tube",
|
| 338 |
+
"orogastric tube",
|
| 339 |
+
"PICC line",
|
| 340 |
+
"central venous catheter",
|
| 341 |
+
"internal jugular catheter",
|
| 342 |
+
"subclavian catheter",
|
| 343 |
+
"swan ganz catheter",
|
| 344 |
+
"pacemaker",
|
| 345 |
+
"defibrillator",
|
| 346 |
+
"icd lead",
|
| 347 |
+
"sternal wires",
|
| 348 |
+
"prosthetic valve",
|
| 349 |
+
"aortic valve prosthesis",
|
| 350 |
+
"mitral valve prosthesis",
|
| 351 |
+
"postoperative changes",
|
| 352 |
+
"median sternotomy",
|
| 353 |
+
]
|
| 354 |
+
|
| 355 |
+
return [
|
| 356 |
+
("L0 modality / study (most abstract)", l0),
|
| 357 |
+
("L1 organ system / region", l1),
|
| 358 |
+
("L2 anatomy", l2),
|
| 359 |
+
("L3 findings (CheXpert-aligned, short phrases)", findings_pos),
|
| 360 |
+
("L3 normal / negation status (short)", findings_neg),
|
| 361 |
+
("L4 attributes / severity", attrs),
|
| 362 |
+
("devices / support", devices),
|
| 363 |
+
]
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
# ---------------------------------------------------------------------------
|
| 367 |
+
# Mine short phrases from real MIMIC reports (train.jsonl + valid.jsonl)
|
| 368 |
+
# ---------------------------------------------------------------------------
|
| 369 |
+
|
| 370 |
+
_CLINICAL_RE = re.compile(
|
| 371 |
+
r"\b(lung|lungs|heart|cardiac|mediastinal|mediastinum|pleural|effusion|"
|
| 372 |
+
r"pneumothorax|atelectasis|atelectatic|edema|consolidation|opacity|"
|
| 373 |
+
r"cardiomegaly|granuloma|granulomatous|nodule|diaphragm|hemidiaphragm|"
|
| 374 |
+
r"costophrenic|vascular|vasculature|pneumonia|infiltrate|volume|volumes|"
|
| 375 |
+
r"hilar|hila|trachea|aorta|clear|normal|stable|blunting|elevated|"
|
| 376 |
+
r"hyperinflated|cephalization|opacity|opacities|scar|scarring)\b",
|
| 377 |
+
re.I,
|
| 378 |
+
)
|
| 379 |
+
_REJECT_RE = re.compile(
|
| 380 |
+
r"\b(comparison|compared|previous studies|prior study|___|please|"
|
| 381 |
+
r"recommend|correlate|history|indication|technique|year.?old|"
|
| 382 |
+
r"male|female|osseous|patient|dr\.|dictated|electronically|"
|
| 383 |
+
r"dating back|better seen|evaluated on ct|cts dating|"
|
| 384 |
+
r"ventriculoperitoneal|glenohumeral|thoracolumbar|dish\b|"
|
| 385 |
+
r"free air|below the right hemidiaphragm|abdomen|"
|
| 386 |
+
r"concerning for pneumonia|well-expanded|well inflated|"
|
| 387 |
+
r"unchanged|again visualized|displaced fracture|fracture is seen)\b",
|
| 388 |
+
re.I,
|
| 389 |
+
)
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def _normalize_phrase(s: str) -> str:
|
| 393 |
+
s = s.strip().rstrip(".").lower()
|
| 394 |
+
s = re.sub(r"\s+", " ", s)
|
| 395 |
+
s = re.sub(r"^(there (is|are)|the|a|an)\s+", "", s)
|
| 396 |
+
s = re.sub(
|
| 397 |
+
r"\s+(is|are|was|were)?\s*(seen|present|identified|noted|visualized)\s*$",
|
| 398 |
+
"",
|
| 399 |
+
s,
|
| 400 |
+
)
|
| 401 |
+
s = re.sub(r"\s+", " ", s).strip(" ,;")
|
| 402 |
+
# Prefer short atomic forms for common normals
|
| 403 |
+
replacements = {
|
| 404 |
+
"lungs are grossly clear": "lungs are clear",
|
| 405 |
+
"lungs appear clear": "lungs are clear",
|
| 406 |
+
"lungs remain clear": "lungs are clear",
|
| 407 |
+
"heart size is within normal limits": "heart size is normal",
|
| 408 |
+
"heart is normal in size": "heart size is normal",
|
| 409 |
+
"heart is at the upper limits of normal size": "top normal heart size",
|
| 410 |
+
"no pneumothoraces": "no pneumothorax",
|
| 411 |
+
"no pleural effusions": "no pleural effusion",
|
| 412 |
+
"bibasilar atelectatic changes": "bibasilar atelectasis",
|
| 413 |
+
"elevation of the mid to posterior left hemidiaphragm": (
|
| 414 |
+
"elevated left hemidiaphragm"
|
| 415 |
+
),
|
| 416 |
+
"elevation of the left hemidiaphragm": "elevated left hemidiaphragm",
|
| 417 |
+
"minimal blunting of the left costophrenic angle": (
|
| 418 |
+
"blunted left costophrenic angle"
|
| 419 |
+
),
|
| 420 |
+
"rounded calcified nodule": "calcified nodule",
|
| 421 |
+
"no signs for acute pulmonary edema": "no pulmonary edema",
|
| 422 |
+
"no definite consolidation": "no consolidation",
|
| 423 |
+
"lungs are otherwise without a focal consolidation": "no focal consolidation",
|
| 424 |
+
"cardiac and mediastinal silhouettes are unremarkable": (
|
| 425 |
+
"normal cardiac and mediastinal silhouettes"
|
| 426 |
+
),
|
| 427 |
+
"cardiac and mediastinal silhouettes are stable": (
|
| 428 |
+
"stable cardiac and mediastinal silhouettes"
|
| 429 |
+
),
|
| 430 |
+
"cardiac and mediastinal contours appears stable": (
|
| 431 |
+
"stable cardiac and mediastinal contours"
|
| 432 |
+
),
|
| 433 |
+
"cardiac and mediastinal contours are stable": (
|
| 434 |
+
"stable cardiac and mediastinal contours"
|
| 435 |
+
),
|
| 436 |
+
}
|
| 437 |
+
return replacements.get(s, s)
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def _split_sentences(text: str) -> list[str]:
|
| 441 |
+
text = re.sub(r"\s+", " ", text.strip())
|
| 442 |
+
parts = re.split(r"(?<=[.!?])\s+", text)
|
| 443 |
+
return [p.strip().rstrip(".") for p in parts if p.strip()]
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def mine_report_phrases(
|
| 447 |
+
jsonl_paths: list[Path],
|
| 448 |
+
min_freq: int = 40,
|
| 449 |
+
max_new: int = 100,
|
| 450 |
+
) -> list[str]:
|
| 451 |
+
"""
|
| 452 |
+
High-frequency short clinical sentences from real reports, normalized to
|
| 453 |
+
short forms. Rejects long multi-finding boilerplate and non-clinical junk.
|
| 454 |
+
"""
|
| 455 |
+
from collections import Counter
|
| 456 |
+
|
| 457 |
+
cnt: Counter[str] = Counter()
|
| 458 |
+
for path in jsonl_paths:
|
| 459 |
+
if not path.is_file():
|
| 460 |
+
continue
|
| 461 |
+
with path.open(encoding="utf-8") as f:
|
| 462 |
+
for line in f:
|
| 463 |
+
text = str(json.loads(line).get("text", ""))
|
| 464 |
+
for sent in _split_sentences(text):
|
| 465 |
+
if not _CLINICAL_RE.search(sent):
|
| 466 |
+
continue
|
| 467 |
+
if _REJECT_RE.search(sent):
|
| 468 |
+
continue
|
| 469 |
+
# Skip laundry-list negatives (old attractor templates)
|
| 470 |
+
if sent.count(",") >= 2 or sent.lower().count(" or ") >= 2:
|
| 471 |
+
continue
|
| 472 |
+
norm = _normalize_phrase(sent)
|
| 473 |
+
n_words = len(norm.split())
|
| 474 |
+
if n_words < 2 or n_words > 7:
|
| 475 |
+
continue
|
| 476 |
+
if len(norm) > 55:
|
| 477 |
+
continue
|
| 478 |
+
if not _CLINICAL_RE.search(norm):
|
| 479 |
+
continue
|
| 480 |
+
if _REJECT_RE.search(norm):
|
| 481 |
+
continue
|
| 482 |
+
cnt[norm] += 1
|
| 483 |
+
|
| 484 |
+
ranked = [(s, c) for s, c in cnt.most_common() if c >= min_freq]
|
| 485 |
+
out: list[str] = []
|
| 486 |
+
for s, _ in ranked:
|
| 487 |
+
out.append(s)
|
| 488 |
+
if len(out) >= max_new:
|
| 489 |
+
break
|
| 490 |
+
return out
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
def phrases_from_query_reports(
|
| 494 |
+
jsonl_paths: list[Path],
|
| 495 |
+
dicom_ids: list[str],
|
| 496 |
+
) -> list[str]:
|
| 497 |
+
"""
|
| 498 |
+
Short concepts grounded in Findings of the current traversal query images.
|
| 499 |
+
Ensures the pool can express what those reports actually say.
|
| 500 |
+
"""
|
| 501 |
+
need = set(dicom_ids)
|
| 502 |
+
found: dict[str, str] = {}
|
| 503 |
+
for path in jsonl_paths:
|
| 504 |
+
if not path.is_file() or len(found) >= len(need):
|
| 505 |
+
continue
|
| 506 |
+
with path.open(encoding="utf-8") as f:
|
| 507 |
+
for line in f:
|
| 508 |
+
row = json.loads(line)
|
| 509 |
+
dicom = Path(str(row.get("img_path", ""))).stem
|
| 510 |
+
if dicom in need and dicom not in found:
|
| 511 |
+
found[dicom] = str(row.get("text", ""))
|
| 512 |
+
|
| 513 |
+
# Hand-curated short extracts tied to those reports (not free long sentences)
|
| 514 |
+
grounded: list[str] = [
|
| 515 |
+
# 46583e03 — normal / clear
|
| 516 |
+
"heart size is normal",
|
| 517 |
+
"lungs are clear",
|
| 518 |
+
"no consolidation",
|
| 519 |
+
"no pleural effusion",
|
| 520 |
+
"no pneumothorax",
|
| 521 |
+
"no pulmonary edema",
|
| 522 |
+
# 1f199273 — elevated hemidiaphragm / CPA blunting; NO definite effusion
|
| 523 |
+
"no focal consolidation",
|
| 524 |
+
"elevated left hemidiaphragm",
|
| 525 |
+
"blunted left costophrenic angle",
|
| 526 |
+
"no definite pleural effusion",
|
| 527 |
+
"normal cardiac and mediastinal silhouettes",
|
| 528 |
+
# e5ba5704 — calcified granuloma; no acute airspace disease
|
| 529 |
+
"calcified granuloma",
|
| 530 |
+
"calcified nodule",
|
| 531 |
+
"right lung base",
|
| 532 |
+
"granulomatous disease",
|
| 533 |
+
"stable cardiac and mediastinal silhouettes",
|
| 534 |
+
# d1552af1 — low volumes + bibasilar atelectasis
|
| 535 |
+
"lung volumes are low",
|
| 536 |
+
"bibasilar atelectasis",
|
| 537 |
+
"stable cardiac and mediastinal contours",
|
| 538 |
+
"no focal consolidation",
|
| 539 |
+
]
|
| 540 |
+
# Also pull any short clinical sentences directly from these reports
|
| 541 |
+
for text in found.values():
|
| 542 |
+
for sent in _split_sentences(text):
|
| 543 |
+
if not _CLINICAL_RE.search(sent) or _REJECT_RE.search(sent):
|
| 544 |
+
continue
|
| 545 |
+
if sent.count(",") >= 2 or sent.lower().count(" or ") >= 2:
|
| 546 |
+
continue
|
| 547 |
+
norm = _normalize_phrase(sent)
|
| 548 |
+
if 2 <= len(norm.split()) <= 7 and len(norm) <= 55:
|
| 549 |
+
grounded.append(norm)
|
| 550 |
+
return _dedupe(grounded)
|
| 551 |
+
|
| 552 |
+
|
| 553 |
+
def main() -> None:
|
| 554 |
+
data_dir = ROOT / "dataset"
|
| 555 |
+
jsonls = [data_dir / "train.jsonl", data_dir / "valid.jsonl"]
|
| 556 |
+
query_dicoms = [
|
| 557 |
+
"46583e03-f42311f4-87dca60a-c4c12f22-0fe13c7f",
|
| 558 |
+
"1f199273-2f4b7e8c-9041b5c2-18e7ca21-26142e3c",
|
| 559 |
+
"e5ba5704-ce2f09d3-e28fe2a2-8a9aca96-86f4966a",
|
| 560 |
+
"d1552af1-5b159d3e-4058cc59-8af87caf-375f46e7",
|
| 561 |
+
]
|
| 562 |
+
|
| 563 |
+
sections = build_sections()
|
| 564 |
+
mined = mine_report_phrases(jsonls, min_freq=40, max_new=90)
|
| 565 |
+
grounded = phrases_from_query_reports(jsonls, query_dicoms)
|
| 566 |
+
sections.append(
|
| 567 |
+
(
|
| 568 |
+
"L3 mined from train+valid reports (short, freq>=40, de-templated)",
|
| 569 |
+
mined,
|
| 570 |
+
)
|
| 571 |
+
)
|
| 572 |
+
sections.append(
|
| 573 |
+
(
|
| 574 |
+
"L3 grounded in current traversal query Findings",
|
| 575 |
+
grounded,
|
| 576 |
+
)
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
lines: list[str] = [
|
| 580 |
+
"# MERU-style multi-granularity CXR concept pool for image traversals.",
|
| 581 |
+
"#",
|
| 582 |
+
"# Construction (see scripts/build_traversal_concepts.py):",
|
| 583 |
+
"# - Curated coarse→fine hierarchy (modality → organ → anatomy → finding).",
|
| 584 |
+
"# - Plus short phrases mined from dataset/train.jsonl + valid.jsonl",
|
| 585 |
+
"# (freq filter, clinical keep-list, reject long multi-finding templates).",
|
| 586 |
+
"# - Plus short concepts grounded in current traversal query reports.",
|
| 587 |
+
"#",
|
| 588 |
+
"# Regenerate: python scripts/build_traversal_concepts.py",
|
| 589 |
+
"",
|
| 590 |
+
]
|
| 591 |
+
seen: set[str] = set()
|
| 592 |
+
n = 0
|
| 593 |
+
n_mined = 0
|
| 594 |
+
n_grounded = 0
|
| 595 |
+
for title, items in sections:
|
| 596 |
+
lines.append(f"# --- {title} ---")
|
| 597 |
+
added_here = 0
|
| 598 |
+
for s in _dedupe(items):
|
| 599 |
+
key = s.lower()
|
| 600 |
+
if key in seen:
|
| 601 |
+
continue
|
| 602 |
+
seen.add(key)
|
| 603 |
+
lines.append(s)
|
| 604 |
+
n += 1
|
| 605 |
+
added_here += 1
|
| 606 |
+
if "mined from train" in title:
|
| 607 |
+
n_mined = added_here
|
| 608 |
+
if "grounded in current" in title:
|
| 609 |
+
n_grounded = added_here
|
| 610 |
+
lines.append("")
|
| 611 |
+
|
| 612 |
+
OUT.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
| 613 |
+
print(
|
| 614 |
+
f"Wrote {OUT} ({n} unique concepts, excl. [ROOT]; "
|
| 615 |
+
f"+{n_mined} mined, +{n_grounded} query-grounded new)"
|
| 616 |
+
)
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
if __name__ == "__main__":
|
| 620 |
+
main()
|
scripts/checkdata.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from tqdm import tqdm
|
| 3 |
+
import torch
|
| 4 |
+
from transformers import BertTokenizer
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
| 9 |
+
if PROJECT_ROOT not in sys.path:
|
| 10 |
+
sys.path.insert(0, PROJECT_ROOT)
|
| 11 |
+
|
| 12 |
+
from Rad_IR.radir.data.hmclip_mimic_box import HyCoClipMimicBoxDataset_JPG, collate_hmclip_box
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main():
|
| 16 |
+
tokenizer = BertTokenizer.from_pretrained("bert-base-uncased")
|
| 17 |
+
|
| 18 |
+
dataset = HyCoClipMimicBoxDataset_JPG(
|
| 19 |
+
split="train",
|
| 20 |
+
tokenizer=tokenizer,
|
| 21 |
+
dataset_root="/mnt/data-3/users/zhangzeyu/HMClip/RadIR/dataset",
|
| 22 |
+
cig_matched_dir="/mnt/data-3/users/zhangzeyu/HMClip/RadIR/dataset/processed/matched",
|
| 23 |
+
caption_file="valid.jsonl",
|
| 24 |
+
transform=None,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
bad_empty = 0
|
| 28 |
+
bad_short = 0
|
| 29 |
+
bad_phrase = 0
|
| 30 |
+
bad_image = 0
|
| 31 |
+
|
| 32 |
+
for i in tqdm(range(len(dataset))):
|
| 33 |
+
try:
|
| 34 |
+
img, caps, cap_len, img_path, phrase, ph_mask, bbx_mask = dataset[i]
|
| 35 |
+
except Exception as e:
|
| 36 |
+
print(f"\n❌ load error at {i}: {e}")
|
| 37 |
+
bad_image += 1
|
| 38 |
+
continue
|
| 39 |
+
|
| 40 |
+
input_ids = caps["input_ids"][0]
|
| 41 |
+
attention_mask = caps["attention_mask"][0]
|
| 42 |
+
|
| 43 |
+
# 1️⃣ attention_mask 全 0
|
| 44 |
+
if attention_mask.sum().item() == 0:
|
| 45 |
+
bad_empty += 1
|
| 46 |
+
print("\n❌ Empty caption")
|
| 47 |
+
print("index:", i)
|
| 48 |
+
print("img_path:", img_path)
|
| 49 |
+
continue
|
| 50 |
+
|
| 51 |
+
# 2️⃣ token 太少
|
| 52 |
+
if (input_ids != 0).sum().item() < 3:
|
| 53 |
+
bad_short += 1
|
| 54 |
+
print("\n⚠️ Too short caption")
|
| 55 |
+
print("index:", i)
|
| 56 |
+
print("img_path:", img_path)
|
| 57 |
+
|
| 58 |
+
# 3️⃣ phrase / mask 不一致
|
| 59 |
+
if phrase == "" and ph_mask.sum().item() != 0:
|
| 60 |
+
bad_phrase += 1
|
| 61 |
+
print("\n❌ phrase empty but mask not zero")
|
| 62 |
+
print("index:", i)
|
| 63 |
+
print("img_path:", img_path)
|
| 64 |
+
|
| 65 |
+
if phrase != "" and ph_mask.sum().item() == 0:
|
| 66 |
+
bad_phrase += 1
|
| 67 |
+
print("\n❌ phrase exists but mask all zero")
|
| 68 |
+
print("index:", i)
|
| 69 |
+
print("phrase:", phrase)
|
| 70 |
+
print("img_path:", img_path)
|
| 71 |
+
|
| 72 |
+
# 4️⃣ image NaN / Inf(保险)
|
| 73 |
+
if not torch.isfinite(img).all():
|
| 74 |
+
bad_image += 1
|
| 75 |
+
print("\n❌ NaN/Inf in image")
|
| 76 |
+
print("index:", i)
|
| 77 |
+
print("img_path:", img_path)
|
| 78 |
+
|
| 79 |
+
print("\n========== SUMMARY ==========")
|
| 80 |
+
print("Total samples:", len(dataset))
|
| 81 |
+
print("Empty captions:", bad_empty)
|
| 82 |
+
print("Too short captions:", bad_short)
|
| 83 |
+
print("Phrase issues:", bad_phrase)
|
| 84 |
+
print("Bad images:", bad_image)
|
| 85 |
+
|
| 86 |
+
if __name__ == "__main__":
|
| 87 |
+
main()
|
scripts/conditional_dataset_evaluate.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import glob
|
| 3 |
+
import json
|
| 4 |
+
import torch
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import numpy as np
|
| 7 |
+
from PIL import Image
|
| 8 |
+
from torch.utils.data import Dataset, Subset
|
| 9 |
+
import torchvision.transforms as transforms
|
| 10 |
+
from functools import partial
|
| 11 |
+
import torch.nn.functional as F
|
| 12 |
+
import nibabel as nib
|
| 13 |
+
import tqdm
|
| 14 |
+
import random
|
| 15 |
+
import time
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def custom_data_split(dataset, rank, world_size):
|
| 19 |
+
# 计算每个进程应该处理的数据集的范围
|
| 20 |
+
anatomy_split, anatomy_name = dataset.get_anatomy_split() # [[0, 10], [10, 20], ...] # 第一个anatomy的数据对应了0~10(左闭右开)
|
| 21 |
+
anatomy_split_size = [split[1] - split[0] for split in anatomy_split]
|
| 22 |
+
|
| 23 |
+
sorted_indices = sorted(range(len(anatomy_split_size)), key=lambda i: anatomy_split_size[i], reverse=True)
|
| 24 |
+
anatomy_split_size = [anatomy_split_size[i] for i in sorted_indices]
|
| 25 |
+
anatomy_split = [anatomy_split[i] for i in sorted_indices]
|
| 26 |
+
anatomy_name = [anatomy_name[i] for i in sorted_indices]
|
| 27 |
+
|
| 28 |
+
anatomy_in_partitions = [[] for _ in range(world_size)]
|
| 29 |
+
split_in_partitions = [[] for _ in range(world_size)]
|
| 30 |
+
for i, (name, split) in enumerate(zip(anatomy_name, anatomy_split)):
|
| 31 |
+
anatomy_in_partitions[i % world_size].append(name)
|
| 32 |
+
split_in_partitions[i % world_size].append(split)
|
| 33 |
+
|
| 34 |
+
idx_in_this_partition = []
|
| 35 |
+
anatomy_in_this_partition = anatomy_in_partitions[rank]
|
| 36 |
+
split_in_this_partition = split_in_partitions[rank]
|
| 37 |
+
for split in split_in_this_partition:
|
| 38 |
+
idx_in_this_partition += list(range(split[0], split[1]))
|
| 39 |
+
|
| 40 |
+
# 使用 Subset 来选择数据集的一部分
|
| 41 |
+
subset = Subset(dataset, idx_in_this_partition)
|
| 42 |
+
|
| 43 |
+
return subset, anatomy_in_this_partition
|
| 44 |
+
|
| 45 |
+
def resize_array(array, current_spacing, target_spacing):
|
| 46 |
+
"""
|
| 47 |
+
Resize the array to match the target spacing.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
array (torch.Tensor): Input array to be resized.
|
| 51 |
+
current_spacing (tuple): Current voxel spacing (z_spacing, xy_spacing, xy_spacing).
|
| 52 |
+
target_spacing (tuple): Target voxel spacing (target_z_spacing, target_x_spacing, target_y_spacing).
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
np.ndarray: Resized array.
|
| 56 |
+
"""
|
| 57 |
+
# Calculate new dimensions
|
| 58 |
+
original_shape = array.shape[2:]
|
| 59 |
+
scaling_factors = [
|
| 60 |
+
current_spacing[i] / target_spacing[i] for i in range(len(original_shape))
|
| 61 |
+
]
|
| 62 |
+
new_shape = [
|
| 63 |
+
int(original_shape[i] * scaling_factors[i]) for i in range(len(original_shape))
|
| 64 |
+
]
|
| 65 |
+
# Resize the array
|
| 66 |
+
resized_array = F.interpolate(array, size=new_shape, mode='trilinear', align_corners=False).cpu().numpy()
|
| 67 |
+
return resized_array
|
| 68 |
+
modality_dict = {'3D':0,'2D':1}
|
| 69 |
+
class Conditional_ImageReportDataset_Eval(Dataset):
|
| 70 |
+
def __init__(self, jsonl_file, csv_file_dir, npy_file_dir, anatomy_filter,modality='2D', max_samples=10000,modal_embedding=False):
|
| 71 |
+
self.anatomy_filter = anatomy_filter
|
| 72 |
+
self.modality = modality
|
| 73 |
+
self.modal_embedding = modal_embedding
|
| 74 |
+
self.id2image_path = self.prepare_image_paths(jsonl_file)
|
| 75 |
+
self.id2image = {}
|
| 76 |
+
self.anatomy2id_ls = self.prepare_anatomy_data(csv_file_dir, max_samples) # DEBUG 每个类只取max_samples个测试
|
| 77 |
+
self.anatomy2simi_tab = self.prepare_similarity_table(npy_file_dir, max_samples)
|
| 78 |
+
self.check_integrity(self.anatomy2id_ls, self.anatomy2simi_tab)
|
| 79 |
+
|
| 80 |
+
# self.anatomy_ls = list(self.anatomy2id_ls.keys()) # NOTE 不按照anatomy为单位采
|
| 81 |
+
# NOTE anatomy2id_ls是形如{'lung':[a, b, c, d ...], ...}的字典,展开成id_anatomy_ls=[['lung', a], ['lung', b] ...]这样的list
|
| 82 |
+
self.id_antomy_ls = [[anatomy, img_id] for anatomy, id_list in self.anatomy2id_ls.items() for img_id in id_list]
|
| 83 |
+
# self.id_antomy_ls = [[anatomy, img_id] for [anatomy, img_id] in self.id_antomy_ls if img_id in self.id2image_path] # WARNING 这样filter会导致
|
| 84 |
+
|
| 85 |
+
self.anatomy_idx_range = {}
|
| 86 |
+
for idx, (anatomy, _) in enumerate(self.id_antomy_ls):
|
| 87 |
+
if anatomy not in self.anatomy_idx_range:
|
| 88 |
+
self.anatomy_idx_range[anatomy] = [idx, idx]
|
| 89 |
+
else:
|
| 90 |
+
self.anatomy_idx_range[anatomy][1] = idx
|
| 91 |
+
# Optionally, convert indices to tuples for clarity and print the ranges
|
| 92 |
+
self.anatomy_idx_range = {anat: [r[0], r[1]] for anat, r in self.anatomy_idx_range.items()}
|
| 93 |
+
|
| 94 |
+
if int(os.environ.get("RANK", 0)) == 0:
|
| 95 |
+
print(f'** DATA ** Load {len(self.id2image_path)} images.')
|
| 96 |
+
print(f'** DATA ** Load {len(self.anatomy2id_ls)} anatomy.')
|
| 97 |
+
|
| 98 |
+
def prepare_image_paths(self, jsonl_file):
|
| 99 |
+
id2image_path = {}
|
| 100 |
+
|
| 101 |
+
with open(jsonl_file, 'r') as f:
|
| 102 |
+
data = f.readlines()
|
| 103 |
+
data = [json.loads(l) for l in data]
|
| 104 |
+
for d in data:
|
| 105 |
+
data_id = d['name'] # valid_692_a_1.nii.gz
|
| 106 |
+
id2image_path[data_id] = d['img_path']
|
| 107 |
+
|
| 108 |
+
return id2image_path
|
| 109 |
+
|
| 110 |
+
def prepare_anatomy_data(self, csv_file_dir, max_samples):
|
| 111 |
+
anatomy2id_ls = {} # 'lung': ['valid_692_a_1.nii.gz', ...]
|
| 112 |
+
|
| 113 |
+
for csv_file in sorted(os.listdir(csv_file_dir)):
|
| 114 |
+
anatomy_name = csv_file.replace('.csv', '') # lung.csv -> lung
|
| 115 |
+
|
| 116 |
+
# DEBUG
|
| 117 |
+
if anatomy_name not in self.anatomy_filter:
|
| 118 |
+
continue
|
| 119 |
+
# DEBUG
|
| 120 |
+
|
| 121 |
+
anatomy2id_ls[anatomy_name] = []
|
| 122 |
+
df = pd.read_csv(os.path.join(csv_file_dir, csv_file))
|
| 123 |
+
for index, row in df.head(max_samples).iterrows():
|
| 124 |
+
anatomy2id_ls[anatomy_name].append(row['File Path'])
|
| 125 |
+
|
| 126 |
+
return anatomy2id_ls
|
| 127 |
+
|
| 128 |
+
def prepare_similarity_table(self, npy_file_dir, max_samples):
|
| 129 |
+
anatomy2simi_tab = {} # 'lung': a tensor with shape NxN
|
| 130 |
+
|
| 131 |
+
for npy_file in sorted(os.listdir(npy_file_dir)):
|
| 132 |
+
anatomy_name = npy_file.replace('.npy', '') # lung.npy -> lung
|
| 133 |
+
|
| 134 |
+
# DEBUG
|
| 135 |
+
if anatomy_name not in self.anatomy_filter:
|
| 136 |
+
continue
|
| 137 |
+
# DEBUG
|
| 138 |
+
|
| 139 |
+
simi_tab = np.load(os.path.join(npy_file_dir, npy_file))[:max_samples, :max_samples]
|
| 140 |
+
simi_tab = np.clip(simi_tab, 0, 1)
|
| 141 |
+
simi_tab = np.round(simi_tab * 100).astype(np.uint8) # 将 0~1 的浮点数转为 0~100 的整数 (保留两位小数)
|
| 142 |
+
for i in range(simi_tab.shape[0]):
|
| 143 |
+
for j in range(simi_tab.shape[0]):
|
| 144 |
+
simi_tab[i, j] = max(simi_tab[i, j], simi_tab[j, i])
|
| 145 |
+
anatomy2simi_tab[anatomy_name] = simi_tab
|
| 146 |
+
|
| 147 |
+
return anatomy2simi_tab
|
| 148 |
+
|
| 149 |
+
def check_integrity(self, anatomy2id_ls, anatomy2simi_tab):
|
| 150 |
+
id_keys = set(anatomy2id_ls.keys())
|
| 151 |
+
simi_keys = set(anatomy2simi_tab.keys())
|
| 152 |
+
if id_keys != simi_keys:
|
| 153 |
+
missing_in_simi = id_keys - simi_keys
|
| 154 |
+
missing_in_id = simi_keys - id_keys
|
| 155 |
+
raise ValueError(f"Inconsistent keys detected. Missing in similarity table: {missing_in_simi}. Missing in anatomy data: {missing_in_id}.")
|
| 156 |
+
|
| 157 |
+
for anatomy in id_keys:
|
| 158 |
+
if not (len(anatomy2id_ls[anatomy]) == anatomy2simi_tab[anatomy].shape[0] and len(anatomy2id_ls[anatomy]) == anatomy2simi_tab[anatomy].shape[1]):
|
| 159 |
+
raise ValueError(f"Length of anatomy data {len(anatomy2id_ls[anatomy])} and similarity table {anatomy2simi_tab[anatomy].shape} do not match for {anatomy}.")
|
| 160 |
+
|
| 161 |
+
# Here we check if all samples are readable and valid (at least has one positive candidate excluding itself)
|
| 162 |
+
filtered_sample_id_ls = []
|
| 163 |
+
excluded_index_ls = []
|
| 164 |
+
no_valid_candidate = 0
|
| 165 |
+
tmp_simi_tab = anatomy2simi_tab[anatomy]
|
| 166 |
+
|
| 167 |
+
for i, sample_id in enumerate(anatomy2id_ls[anatomy]):
|
| 168 |
+
if np.any(np.delete(tmp_simi_tab[i], i) > 90):
|
| 169 |
+
# image_file_name = sample_id.replace('.nii.gz', '.npz')
|
| 170 |
+
if sample_id in self.id2image_path and os.path.exists(self.id2image_path[sample_id]):
|
| 171 |
+
filtered_sample_id_ls.append(sample_id)
|
| 172 |
+
continue
|
| 173 |
+
else:
|
| 174 |
+
excluded_index_ls.append(i)
|
| 175 |
+
else:
|
| 176 |
+
no_valid_candidate += 1
|
| 177 |
+
excluded_index_ls.append(i)
|
| 178 |
+
anatomy2id_ls[anatomy] = filtered_sample_id_ls
|
| 179 |
+
|
| 180 |
+
anatomy2simi_tab[anatomy] = np.delete(anatomy2simi_tab[anatomy], excluded_index_ls, axis=0)
|
| 181 |
+
anatomy2simi_tab[anatomy] = np.delete(anatomy2simi_tab[anatomy], excluded_index_ls, axis=1)
|
| 182 |
+
|
| 183 |
+
print(f'For {anatomy}, {no_valid_candidate} samples has no valid candidate, removed')
|
| 184 |
+
|
| 185 |
+
assert len(anatomy2id_ls[anatomy]) == anatomy2simi_tab[anatomy].shape[0] and len(anatomy2id_ls[anatomy]) == anatomy2simi_tab[anatomy].shape[1]
|
| 186 |
+
|
| 187 |
+
def get_anatomy_split(self):
|
| 188 |
+
anatomy_split = list(self.anatomy_idx_range.values())
|
| 189 |
+
anatomy_name = list(self.anatomy_idx_range.keys())
|
| 190 |
+
return anatomy_split, anatomy_name
|
| 191 |
+
|
| 192 |
+
def __len__(self):
|
| 193 |
+
# return len(self.anatomy_ls) # NOTE 不按照anatomy为单位采
|
| 194 |
+
return len(self.id_antomy_ls)
|
| 195 |
+
|
| 196 |
+
def get_anatomy2id_ls(self):
|
| 197 |
+
return self.anatomy2id_ls
|
| 198 |
+
|
| 199 |
+
def get_similarity_table(self, anatomy):
|
| 200 |
+
return self.anatomy2simi_tab[anatomy]
|
| 201 |
+
|
| 202 |
+
def nii_img_to_tensor(self, path):
|
| 203 |
+
|
| 204 |
+
nii_img = safe_load_image(sample["image"])
|
| 205 |
+
img_data = nii_img.get_fdata()
|
| 206 |
+
img_data = np.flip(img_data, axis=0)
|
| 207 |
+
img_data = np.flip(img_data, axis=1)
|
| 208 |
+
|
| 209 |
+
# WARNING Respacing
|
| 210 |
+
img_data = img_data.transpose(2, 0, 1)
|
| 211 |
+
img_data = np.copy(img_data)
|
| 212 |
+
tensor = torch.tensor(img_data)
|
| 213 |
+
tensor = tensor.unsqueeze(0).unsqueeze(0)
|
| 214 |
+
|
| 215 |
+
target_x_spacing = 0.75
|
| 216 |
+
target_y_spacing = 0.75
|
| 217 |
+
target_z_spacing = 1.5
|
| 218 |
+
current = (3, 1, 1) # this is all set to 3 1 1
|
| 219 |
+
target = (target_z_spacing, target_x_spacing, target_y_spacing)
|
| 220 |
+
|
| 221 |
+
img_data = resize_array(tensor, current, target)
|
| 222 |
+
img_data = img_data[0][0]
|
| 223 |
+
img_data= np.transpose(img_data, (1, 2, 0))
|
| 224 |
+
|
| 225 |
+
# WARNING Normalization 2
|
| 226 |
+
hu_min, hu_max = -1000, 1000
|
| 227 |
+
img_data = np.clip(img_data, hu_min, hu_max)
|
| 228 |
+
img_data = (((img_data ) / 1000)).astype(np.float32)
|
| 229 |
+
|
| 230 |
+
tensor = torch.tensor(img_data)
|
| 231 |
+
|
| 232 |
+
# WARNING Padding or Crop
|
| 233 |
+
|
| 234 |
+
# Get the dimensions of the input tensor
|
| 235 |
+
target_shape = (480,480,240) # h w d
|
| 236 |
+
|
| 237 |
+
# Extract dimensions
|
| 238 |
+
h, w, d = tensor.shape
|
| 239 |
+
|
| 240 |
+
# Calculate cropping/padding values for height, width, and depth
|
| 241 |
+
dh, dw, dd = target_shape
|
| 242 |
+
h_start = max((h - dh) // 2, 0)
|
| 243 |
+
h_end = min(h_start + dh, h)
|
| 244 |
+
w_start = max((w - dw) // 2, 0)
|
| 245 |
+
w_end = min(w_start + dw, w)
|
| 246 |
+
d_start = max((d - dd) // 2, 0)
|
| 247 |
+
d_end = min(d_start + dd, d)
|
| 248 |
+
|
| 249 |
+
# Crop or pad the tensor
|
| 250 |
+
tensor = tensor[h_start:h_end, w_start:w_end, d_start:d_end]
|
| 251 |
+
|
| 252 |
+
pad_h_before = (dh - tensor.size(0)) // 2
|
| 253 |
+
pad_h_after = dh - tensor.size(0) - pad_h_before
|
| 254 |
+
|
| 255 |
+
pad_w_before = (dw - tensor.size(1)) // 2
|
| 256 |
+
pad_w_after = dw - tensor.size(1) - pad_w_before
|
| 257 |
+
|
| 258 |
+
pad_d_before = (dd - tensor.size(2)) // 2
|
| 259 |
+
pad_d_after = dd - tensor.size(2) - pad_d_before
|
| 260 |
+
|
| 261 |
+
tensor = torch.nn.functional.pad(tensor, (pad_d_before, pad_d_after, pad_w_before, pad_w_after, pad_h_before, pad_h_after), value=-1)
|
| 262 |
+
|
| 263 |
+
tensor = tensor.permute(2, 0, 1) # d h w
|
| 264 |
+
|
| 265 |
+
tensor = tensor.unsqueeze(0) # 1 d h w
|
| 266 |
+
|
| 267 |
+
return tensor
|
| 268 |
+
|
| 269 |
+
def load_2d_image_to_tensor(self, image_path,resize=True):
|
| 270 |
+
"""
|
| 271 |
+
Load a 2D grayscale image and convert it to a tensor with dimensions [1, 1, 480, 480].
|
| 272 |
+
|
| 273 |
+
Args:
|
| 274 |
+
image_path (str): Path to the image file.
|
| 275 |
+
|
| 276 |
+
Returns:
|
| 277 |
+
tensor (torch.Tensor): Tensor with shape [1, 1, 480, 480].
|
| 278 |
+
"""
|
| 279 |
+
# Load the image as grayscale
|
| 280 |
+
image = Image.open(image_path).convert('L')
|
| 281 |
+
|
| 282 |
+
# Resize to 480x480 if needed
|
| 283 |
+
if resize:
|
| 284 |
+
if image.size != (480, 480):
|
| 285 |
+
image = image.resize((480, 480), Image.BILINEAR)
|
| 286 |
+
else:
|
| 287 |
+
if image.size != (480, 480):
|
| 288 |
+
# 计算可以开始裁剪的最大左上角坐标
|
| 289 |
+
width, height = image.size
|
| 290 |
+
crop_size = 480
|
| 291 |
+
if width < crop_size or height < crop_size:
|
| 292 |
+
raise ValueError(f"Image size {image.size} is smaller than the crop size {crop_size}.")
|
| 293 |
+
max_left = width - crop_size
|
| 294 |
+
max_top = height - crop_size
|
| 295 |
+
|
| 296 |
+
# 随机选择裁剪的左上角坐标
|
| 297 |
+
left = random.randint(0, max(0, max_left))
|
| 298 |
+
top = random.randint(0, max(0, max_top))
|
| 299 |
+
|
| 300 |
+
# 裁剪图像
|
| 301 |
+
image = image.crop((left, top, left + crop_size, top + crop_size))
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# Convert to numpy array and normalize to [0, 1]
|
| 305 |
+
img_array = np.array(image, dtype=np.float32) / 255.0
|
| 306 |
+
|
| 307 |
+
# Convert to tensor
|
| 308 |
+
tensor = torch.from_numpy(img_array)
|
| 309 |
+
|
| 310 |
+
# Add batch and channel dimensions
|
| 311 |
+
tensor = tensor.unsqueeze(0).unsqueeze(0) # [1, 1, 480, 480]
|
| 312 |
+
|
| 313 |
+
return tensor
|
| 314 |
+
|
| 315 |
+
def respace_crop_pad(self, img_tensor):
|
| 316 |
+
# assume we have loaded all the images
|
| 317 |
+
|
| 318 |
+
target_x_spacing = 0.75
|
| 319 |
+
target_y_spacing = 0.75
|
| 320 |
+
target_z_spacing = 1.5
|
| 321 |
+
current = (3, 1, 1) # this is all set to 3 1 1
|
| 322 |
+
target = (target_z_spacing, target_x_spacing, target_y_spacing)
|
| 323 |
+
|
| 324 |
+
img_data = resize_array(img_tensor, current, target)
|
| 325 |
+
img_data = img_data[0][0]
|
| 326 |
+
img_data= np.transpose(img_data, (1, 2, 0))
|
| 327 |
+
|
| 328 |
+
# WARNING Normalization 2
|
| 329 |
+
hu_min, hu_max = -1000, 1000
|
| 330 |
+
img_data = np.clip(img_data, hu_min, hu_max)
|
| 331 |
+
img_data = (((img_data ) / 1000)).astype(np.float32)
|
| 332 |
+
|
| 333 |
+
tensor = torch.tensor(img_data)
|
| 334 |
+
|
| 335 |
+
# WARNING Padding or Crop
|
| 336 |
+
|
| 337 |
+
# Get the dimensions of the input tensor
|
| 338 |
+
target_shape = (480,480,240) # h w d
|
| 339 |
+
|
| 340 |
+
# Extract dimensions
|
| 341 |
+
h, w, d = tensor.shape
|
| 342 |
+
|
| 343 |
+
# Calculate cropping/padding values for height, width, and depth
|
| 344 |
+
dh, dw, dd = target_shape
|
| 345 |
+
h_start = max((h - dh) // 2, 0)
|
| 346 |
+
h_end = min(h_start + dh, h)
|
| 347 |
+
w_start = max((w - dw) // 2, 0)
|
| 348 |
+
w_end = min(w_start + dw, w)
|
| 349 |
+
d_start = max((d - dd) // 2, 0)
|
| 350 |
+
d_end = min(d_start + dd, d)
|
| 351 |
+
|
| 352 |
+
# Crop or pad the tensor
|
| 353 |
+
tensor = tensor[h_start:h_end, w_start:w_end, d_start:d_end]
|
| 354 |
+
|
| 355 |
+
pad_h_before = (dh - tensor.size(0)) // 2
|
| 356 |
+
pad_h_after = dh - tensor.size(0) - pad_h_before
|
| 357 |
+
|
| 358 |
+
pad_w_before = (dw - tensor.size(1)) // 2
|
| 359 |
+
pad_w_after = dw - tensor.size(1) - pad_w_before
|
| 360 |
+
|
| 361 |
+
pad_d_before = (dd - tensor.size(2)) // 2
|
| 362 |
+
pad_d_after = dd - tensor.size(2) - pad_d_before
|
| 363 |
+
|
| 364 |
+
tensor = torch.nn.functional.pad(tensor, (pad_d_before, pad_d_after, pad_w_before, pad_w_after, pad_h_before, pad_h_after), value=-1)
|
| 365 |
+
|
| 366 |
+
tensor = tensor.permute(2, 0, 1) # d h w
|
| 367 |
+
|
| 368 |
+
tensor = tensor.unsqueeze(0) # 1 d h w
|
| 369 |
+
|
| 370 |
+
return tensor
|
| 371 |
+
|
| 372 |
+
def load_image(self, path):
|
| 373 |
+
nii_img = safe_load_image(sample["image"])
|
| 374 |
+
img_data = nii_img.get_fdata()
|
| 375 |
+
img_data = np.flip(img_data, axis=0)
|
| 376 |
+
img_data = np.flip(img_data, axis=1)
|
| 377 |
+
|
| 378 |
+
# WARNING Respacing
|
| 379 |
+
img_data = img_data.transpose(2, 0, 1)
|
| 380 |
+
img_data = np.copy(img_data)
|
| 381 |
+
tensor = torch.tensor(img_data)
|
| 382 |
+
tensor = tensor.unsqueeze(0).unsqueeze(0)
|
| 383 |
+
|
| 384 |
+
return tensor
|
| 385 |
+
|
| 386 |
+
def MLS_nii_img_to_tensor(self, path):
|
| 387 |
+
|
| 388 |
+
nii_img = safe_load_image(sample["image"])
|
| 389 |
+
img_data = nii_img.get_fdata()
|
| 390 |
+
img_data = np.flip(img_data, axis=0)
|
| 391 |
+
img_data = np.flip(img_data, axis=1)
|
| 392 |
+
|
| 393 |
+
# WARNING Respacing
|
| 394 |
+
img_data = img_data.transpose(2, 0, 1)
|
| 395 |
+
img_data = np.copy(img_data)
|
| 396 |
+
tensor = torch.tensor(img_data)
|
| 397 |
+
tensor = tensor.unsqueeze(0).unsqueeze(0)
|
| 398 |
+
|
| 399 |
+
target_x_spacing = 0.75
|
| 400 |
+
target_y_spacing = 0.75
|
| 401 |
+
target_z_spacing = 1.5
|
| 402 |
+
current = (3, 1, 1) # this is all set to 3 1 1
|
| 403 |
+
target = (target_z_spacing, target_x_spacing, target_y_spacing)
|
| 404 |
+
|
| 405 |
+
img_data = resize_array(tensor, current, target)
|
| 406 |
+
img_data = img_data[0][0]
|
| 407 |
+
img_data= np.transpose(img_data, (1, 2, 0))
|
| 408 |
+
|
| 409 |
+
# WARNING Normalization 2
|
| 410 |
+
hu_min, hu_max = -1000, 1000
|
| 411 |
+
img_data = np.clip(img_data, hu_min, hu_max)
|
| 412 |
+
img_data = (((img_data ) / 1000)).astype(np.float32)
|
| 413 |
+
|
| 414 |
+
tensor = torch.tensor(img_data)
|
| 415 |
+
|
| 416 |
+
# WARNING Padding or Crop
|
| 417 |
+
|
| 418 |
+
# Get the dimensions of the input tensor
|
| 419 |
+
target_shape = (480,480,240) # h w d
|
| 420 |
+
|
| 421 |
+
# Extract dimensions
|
| 422 |
+
h, w, d = tensor.shape
|
| 423 |
+
|
| 424 |
+
# Calculate cropping/padding values for height, width, and depth
|
| 425 |
+
dh, dw, dd = target_shape
|
| 426 |
+
h_start = max((h - dh) // 2, 0)
|
| 427 |
+
h_end = min(h_start + dh, h)
|
| 428 |
+
w_start = max((w - dw) // 2, 0)
|
| 429 |
+
w_end = min(w_start + dw, w)
|
| 430 |
+
d_start = max((d - dd) // 2, 0)
|
| 431 |
+
d_end = min(d_start + dd, d)
|
| 432 |
+
|
| 433 |
+
# Crop or pad the tensor
|
| 434 |
+
tensor = tensor[h_start:h_end, w_start:w_end, d_start:d_end]
|
| 435 |
+
|
| 436 |
+
pad_h_before = (dh - tensor.size(0)) // 2
|
| 437 |
+
pad_h_after = dh - tensor.size(0) - pad_h_before
|
| 438 |
+
|
| 439 |
+
pad_w_before = (dw - tensor.size(1)) // 2
|
| 440 |
+
pad_w_after = dw - tensor.size(1) - pad_w_before
|
| 441 |
+
|
| 442 |
+
pad_d_before = (dd - tensor.size(2)) // 2
|
| 443 |
+
pad_d_after = dd - tensor.size(2) - pad_d_before
|
| 444 |
+
|
| 445 |
+
tensor = torch.nn.functional.pad(tensor, (pad_d_before, pad_d_after, pad_w_before, pad_w_after, pad_h_before, pad_h_after), value=-1)
|
| 446 |
+
|
| 447 |
+
tensor = tensor.permute(2, 0, 1) # d h w
|
| 448 |
+
|
| 449 |
+
tensor = tensor.unsqueeze(0) # 1 d h w
|
| 450 |
+
|
| 451 |
+
return tensor
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def __getitem__(self, index):
|
| 455 |
+
"""
|
| 456 |
+
Returns:
|
| 457 |
+
video_tensor (tensor): 1, D, H, W
|
| 458 |
+
anatomy (str): anatomy name
|
| 459 |
+
sampled_id (str): image id
|
| 460 |
+
"""
|
| 461 |
+
anatomy, sampled_id = self.id_antomy_ls[index]
|
| 462 |
+
|
| 463 |
+
# NOTE: All processed in advance
|
| 464 |
+
if '3D' in self.modality:
|
| 465 |
+
image_file_name = sampled_id.replace('.nii.gz', '.npz')
|
| 466 |
+
if sampled_id in self.id2image_path and os.path.exists(self.id2image_path[sampled_id]):
|
| 467 |
+
video_tensor = self.MLS_nii_img_to_tensor(self.id2image_path[sampled_id])
|
| 468 |
+
else:
|
| 469 |
+
raise ValueError(f"Image file {image_file_name} not found.")
|
| 470 |
+
elif '2D' in self.modality:
|
| 471 |
+
if sampled_id in self.id2image_path and os.path.exists(self.id2image_path[sampled_id]):
|
| 472 |
+
video_tensor = self.load_2d_image_to_tensor(self.id2image_path[sampled_id])
|
| 473 |
+
else:
|
| 474 |
+
raise ValueError(f"Image file {image_file_name} not found.")
|
| 475 |
+
# return torch.tensor(video_tensor), anatomy, sampled_id
|
| 476 |
+
if self.modal_embedding:
|
| 477 |
+
return video_tensor.clone().detach(), anatomy, sampled_id, modality_dict[self.modality]
|
| 478 |
+
else:
|
| 479 |
+
return video_tensor.clone().detach(), anatomy, sampled_id
|
| 480 |
+
|
| 481 |
+
if __name__ == '__main__':
|
| 482 |
+
from torch.utils.data import DataLoader
|
| 483 |
+
from accelerate import Accelerator
|
| 484 |
+
from accelerate import DistributedDataParallelKwargs
|
| 485 |
+
from accelerate.utils import InitProcessGroupKwargs
|
| 486 |
+
|
| 487 |
+
# ddp_kwargs = DistributedDataParallelKwargs(find_unused_parameters=True)
|
| 488 |
+
# kwargs = InitProcessGroupKwargs()
|
| 489 |
+
# accelerator = Accelerator(kwargs_handlers=[ddp_kwargs, kwargs])
|
| 490 |
+
|
| 491 |
+
# data_valid_jsonl = '/mnt/petrelfs/zhangtengfei/RadIR/dataset/CXR/MIMIC_CXR/test.jsonl'
|
| 492 |
+
# data_valid_csv_dir = '/mnt/petrelfs/zhangtengfei/RadIR/dataset/CXR/MIMIC_CXR/test_entity'
|
| 493 |
+
# data_valid_npy_dir = '/mnt/petrelfs/zhangtengfei/RadIR/dataset/CXR/MIMIC_CXR/test_ratescore'
|
| 494 |
+
|
| 495 |
+
# valid_ds = Conditional_CTReportDataset_Eval(jsonl_file=data_valid_jsonl, csv_file_dir=data_valid_csv_dir, npy_file_dir=data_valid_npy_dir, anatomy_filter=["left hemidiaphragm","airspace"], max_samples=10000,modality='2D')
|
| 496 |
+
|
| 497 |
+
# rank = torch.distributed.get_rank() # 获取当前进程的 rank
|
| 498 |
+
# world_size = torch.distributed.get_world_size() # 获取总进程数
|
| 499 |
+
|
| 500 |
+
# subset, anatomy_in_this_partition = custom_data_split(valid_ds, rank, world_size)
|
| 501 |
+
|
| 502 |
+
# valid_dl = DataLoader(
|
| 503 |
+
# subset,
|
| 504 |
+
# num_workers=8,
|
| 505 |
+
# batch_size=16,
|
| 506 |
+
# shuffle = False,
|
| 507 |
+
# )
|
| 508 |
+
|
| 509 |
+
# valid_dl = iter(valid_dl)
|
| 510 |
+
|
| 511 |
+
# statistic = {}
|
| 512 |
+
# for video, anatomy_ls, sampled_id_ls in iter(valid_dl):
|
| 513 |
+
# for anatomy, sample_id in zip(anatomy_ls, sampled_id_ls):
|
| 514 |
+
# if anatomy not in statistic:
|
| 515 |
+
# statistic[anatomy] = set()
|
| 516 |
+
# statistic[anatomy].add(sample_id)
|
| 517 |
+
|
| 518 |
+
# print(f'RANK {rank}:', statistic.keys(), anatomy_in_this_partition)
|
| 519 |
+
# for k, v in statistic.items():
|
| 520 |
+
# print(f'RANK {rank}: {k} - {len(v)}')
|
| 521 |
+
|
| 522 |
+
ddp_kwargs = DistributedDataParallelKwargs(find_unused_parameters=True)
|
| 523 |
+
kwargs = InitProcessGroupKwargs()
|
| 524 |
+
accelerator = Accelerator(kwargs_handlers=[ddp_kwargs, kwargs])
|
| 525 |
+
|
| 526 |
+
data_valid_jsonl = '/mnt/data-3/users/zhangzeyu/HMClip/RadIR/dataset/CTRATE-IR/test.jsonl'
|
| 527 |
+
data_valid_csv_dir = '/mnt/data-3/users/zhangzeyu/HMClip/RadIR/dataset/CTRATE-IR/anatomy/val_entity'
|
| 528 |
+
data_valid_npy_dir = '/mnt/data-3/users/zhangzeyu/HMClip/RadIR/dataset/CTRATE-IR/anatomy/val_ratescore_compressed'
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
valid_ds = Conditional_CTReportDataset_Eval(jsonl_file=data_valid_jsonl, csv_file_dir=data_valid_csv_dir, npy_file_dir=data_valid_npy_dir, anatomy_filter=["right rib","thyroid gland"], max_samples=10000,modality='3D')
|
| 532 |
+
|
| 533 |
+
rank = torch.distributed.get_rank() # 获取当前进程的 rank
|
| 534 |
+
world_size = torch.distributed.get_world_size() # 获取总进程数
|
| 535 |
+
|
| 536 |
+
subset, anatomy_in_this_partition = custom_data_split(valid_ds, rank, world_size)
|
| 537 |
+
|
| 538 |
+
valid_dl = DataLoader(
|
| 539 |
+
subset,
|
| 540 |
+
num_workers=8,
|
| 541 |
+
batch_size=16,
|
| 542 |
+
shuffle = False,
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
valid_dl = iter(valid_dl)
|
| 546 |
+
|
| 547 |
+
statistic = {}
|
| 548 |
+
for video, anatomy_ls, sampled_id_ls in iter(valid_dl):
|
| 549 |
+
for anatomy, sample_id in zip(anatomy_ls, sampled_id_ls):
|
| 550 |
+
if anatomy not in statistic:
|
| 551 |
+
statistic[anatomy] = set()
|
| 552 |
+
statistic[anatomy].add(sample_id)
|
| 553 |
+
|
| 554 |
+
print(f'RANK {rank}:', statistic.keys(), anatomy_in_this_partition)
|
| 555 |
+
for k, v in statistic.items():
|
| 556 |
+
print(f'RANK {rank}: {k} - {len(v)}')
|
| 557 |
+
|
| 558 |
+
def safe_load_image(path):
|
| 559 |
+
"""
|
| 560 |
+
Robust image loader that supports both .nii.gz and .npy files.
|
| 561 |
+
If .nii.gz is missing, automatically falls back to _compressed.npy.
|
| 562 |
+
"""
|
| 563 |
+
try:
|
| 564 |
+
if os.path.exists(path):
|
| 565 |
+
if path.endswith('.nii.gz'):
|
| 566 |
+
data = nib.load(path).get_fdata()
|
| 567 |
+
return data
|
| 568 |
+
elif path.endswith('.npy'):
|
| 569 |
+
data = np.load(path)
|
| 570 |
+
return data
|
| 571 |
+
|
| 572 |
+
# fallback to compressed npy
|
| 573 |
+
npy_fallback = path.replace('.nii.gz', '_compressed.npy')
|
| 574 |
+
if os.path.exists(npy_fallback):
|
| 575 |
+
data = np.load(npy_fallback)
|
| 576 |
+
print(f"[Info] Fallback to compressed file: {npy_fallback}")
|
| 577 |
+
return data
|
| 578 |
+
|
| 579 |
+
# all failed
|
| 580 |
+
raise FileNotFoundError(f"{path} and fallback {npy_fallback} not found")
|
| 581 |
+
|
| 582 |
+
except Exception as e:
|
| 583 |
+
print(f"[Warning] Error loading {path}: {e}")
|
| 584 |
+
return None
|
scripts/conditional_dataset_train.py
ADDED
|
@@ -0,0 +1,825 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import glob
|
| 3 |
+
import json
|
| 4 |
+
import torch
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import numpy as np
|
| 7 |
+
from PIL import Image
|
| 8 |
+
from torch.utils.data import Dataset
|
| 9 |
+
import torchvision.transforms as transforms
|
| 10 |
+
from functools import partial
|
| 11 |
+
import torch.nn.functional as F
|
| 12 |
+
import nibabel as nib
|
| 13 |
+
from tqdm import tqdm
|
| 14 |
+
import random
|
| 15 |
+
import math
|
| 16 |
+
from monai.transforms import Compose, RandRotate, RandFlip, RandZoom, RandAffine, RandGaussianNoise, RandScaleIntensity, RandAdjustContrast,RandCoarseDropout
|
| 17 |
+
from monai.data.meta_tensor import MetaTensor
|
| 18 |
+
import nibabel as nib
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def safe_load_image(path):
|
| 22 |
+
"""
|
| 23 |
+
Robust image loader that supports both .nii.gz and .npy files.
|
| 24 |
+
If .nii.gz is missing, automatically falls back to _compressed.npy.
|
| 25 |
+
"""
|
| 26 |
+
try:
|
| 27 |
+
if os.path.exists(path):
|
| 28 |
+
if path.endswith('.nii.gz'):
|
| 29 |
+
data = nib.load(path).get_fdata()
|
| 30 |
+
return data
|
| 31 |
+
elif path.endswith('.npy'):
|
| 32 |
+
data = np.load(path)
|
| 33 |
+
return data
|
| 34 |
+
|
| 35 |
+
# fallback to compressed npy
|
| 36 |
+
npy_fallback = path.replace('.nii.gz', '_compressed.npy')
|
| 37 |
+
if os.path.exists(npy_fallback):
|
| 38 |
+
data = np.load(npy_fallback)
|
| 39 |
+
print(f"[Info] Fallback to compressed file: {npy_fallback}")
|
| 40 |
+
return data
|
| 41 |
+
|
| 42 |
+
# all failed
|
| 43 |
+
raise FileNotFoundError(f"{path} and fallback {npy_fallback} not found")
|
| 44 |
+
|
| 45 |
+
except Exception as e:
|
| 46 |
+
print(f"[Warning] Error loading {path}: {e}")
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def resize_array_to_tensor(array, current_spacing, target_spacing):
|
| 51 |
+
"""
|
| 52 |
+
Resize the array to match the target spacing.
|
| 53 |
+
|
| 54 |
+
Args:
|
| 55 |
+
array (torch.Tensor): Input array to be resized.
|
| 56 |
+
current_spacing (tuple): Current voxel spacing (z_spacing, xy_spacing, xy_spacing).
|
| 57 |
+
target_spacing (tuple): Target voxel spacing (target_z_spacing, target_x_spacing, target_y_spacing).
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
np.ndarray: Resized array.
|
| 61 |
+
"""
|
| 62 |
+
# Calculate new dimensions
|
| 63 |
+
original_shape = array.shape[2:]
|
| 64 |
+
scaling_factors = [
|
| 65 |
+
current_spacing[i] / target_spacing[i] for i in range(len(original_shape))
|
| 66 |
+
]
|
| 67 |
+
new_shape = [
|
| 68 |
+
int(original_shape[i] * scaling_factors[i]) for i in range(len(original_shape))
|
| 69 |
+
]
|
| 70 |
+
# Resize the array
|
| 71 |
+
resized_array = F.interpolate(array, size=new_shape, mode='trilinear', align_corners=False)
|
| 72 |
+
return resized_array
|
| 73 |
+
|
| 74 |
+
def resize_array(array, current_spacing, target_spacing):
|
| 75 |
+
"""
|
| 76 |
+
Resize the array to match the target spacing.
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
array (torch.Tensor): Input array to be resized.
|
| 80 |
+
current_spacing (tuple): Current voxel spacing (z_spacing, xy_spacing, xy_spacing).
|
| 81 |
+
target_spacing (tuple): Target voxel spacing (target_z_spacing, target_x_spacing, target_y_spacing).
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
np.ndarray: Resized array.
|
| 85 |
+
"""
|
| 86 |
+
# Calculate new dimensions
|
| 87 |
+
original_shape = array.shape[2:]
|
| 88 |
+
scaling_factors = [
|
| 89 |
+
current_spacing[i] / target_spacing[i] for i in range(len(original_shape))
|
| 90 |
+
]
|
| 91 |
+
new_shape = [
|
| 92 |
+
int(original_shape[i] * scaling_factors[i]) for i in range(len(original_shape))
|
| 93 |
+
]
|
| 94 |
+
# Resize the array
|
| 95 |
+
resized_array = F.interpolate(array, size=new_shape, mode='trilinear', align_corners=False).cpu().numpy()
|
| 96 |
+
return resized_array
|
| 97 |
+
|
| 98 |
+
modality_dict = {'3D':0,'2D':1}
|
| 99 |
+
class Conditional_ImageReportDataset_Train(Dataset):
|
| 100 |
+
def __init__(self, local_batch_size, jsonl_file, csv_file_dir, npy_file_dir, anatomy_filter, positive_threshold, negative_threshold, max_samples=30000,modality='CT', need_aug=True,modal_embedding=False,is_train=True):
|
| 101 |
+
self.anatomy_filter = anatomy_filter
|
| 102 |
+
self.is_train = is_train
|
| 103 |
+
self.modal_embedding = modal_embedding
|
| 104 |
+
self.csv_file_dir = csv_file_dir
|
| 105 |
+
self.npy_file_dir = npy_file_dir
|
| 106 |
+
self.max_samples = max_samples
|
| 107 |
+
self.modality = modality
|
| 108 |
+
self.local_batch_size = local_batch_size # for each anatomy, how many samples to be selected
|
| 109 |
+
|
| 110 |
+
if positive_threshold == 0 and negative_threshold != 1: # >0 & <0.4
|
| 111 |
+
positive_threshold = negative_threshold
|
| 112 |
+
if positive_threshold != 1 and negative_threshold == 1: # >0.7 & <1
|
| 113 |
+
negative_threshold = positive_threshold
|
| 114 |
+
|
| 115 |
+
self.positive_threshold = positive_threshold
|
| 116 |
+
self.negative_threshold = negative_threshold
|
| 117 |
+
|
| 118 |
+
self.id2image_path = self.prepare_image_paths(jsonl_file)
|
| 119 |
+
|
| 120 |
+
self.prepare_anatomy_data()
|
| 121 |
+
|
| 122 |
+
self.anatomy_ls = list(self.anatomy2id_ls.keys())
|
| 123 |
+
self.anatomy_weight = [round(math.log(len(self.anatomy2id_ls[anatomy]))) for anatomy in self.anatomy_ls] # weight 与 sqrt(size) 相关
|
| 124 |
+
self.anatomy_weight = [w/sum(self.anatomy_weight) for w in self.anatomy_weight] # NOTE: Balancing between anatomys
|
| 125 |
+
# self.anatomy_weight = [1/len(self.anatomy_ls) for anatomy in self.anatomy_ls]
|
| 126 |
+
|
| 127 |
+
if need_aug:
|
| 128 |
+
if '3D' in self.modality:
|
| 129 |
+
self.augmentator = Compose([
|
| 130 |
+
RandRotate(range_x=15, range_y=15, range_z=15, prob=0.3, keep_size=True), # 随机旋转
|
| 131 |
+
RandFlip(prob=0.5, spatial_axis=0), # 随机翻转(沿深度轴)
|
| 132 |
+
RandZoom(min_zoom=0.85, max_zoom=1.15, prob=0.3, keep_size=True), # 随机缩放
|
| 133 |
+
RandAffine(prob=0.3, translate_range=(24, 48, 48)), # 随机平移
|
| 134 |
+
RandGaussianNoise(prob=0.3, mean=0.0, std=0.05), # 添加高斯噪声
|
| 135 |
+
RandScaleIntensity(factors=[-0.1, 0.1], prob=0.3),
|
| 136 |
+
RandAdjustContrast(prob=0.3, gamma=(0.9, 1.1)), # 增加对比度调整
|
| 137 |
+
RandCoarseDropout(prob=0.2, holes=8, spatial_size=3) # 模拟局部缺失
|
| 138 |
+
])
|
| 139 |
+
elif '2D' in self.modality:
|
| 140 |
+
self.augmentator = Compose([
|
| 141 |
+
RandRotate(range_x=15, range_y=15, prob=0.5, keep_size=True), # 随机旋转(仅x和y轴)
|
| 142 |
+
RandFlip(prob=0.5, spatial_axis=(0,1)), # 随机翻转(仅水平和垂直轴)
|
| 143 |
+
RandZoom(min_zoom=0.8, max_zoom=1.1, prob=0.5, keep_size=True), # 随机缩放
|
| 144 |
+
RandAffine(prob=0.5, translate_range=(32, 32)), # 随机平移(仅2D平移)
|
| 145 |
+
RandGaussianNoise(prob=0.5, mean=0.0, std=0.1), # 添加高斯噪声
|
| 146 |
+
RandScaleIntensity(factors=[-0.15, 0.15], prob=0.5), # 随机调整像素强度
|
| 147 |
+
RandAdjustContrast(prob=0.4, gamma=(0.85, 1.15)) # 增加对比度变化
|
| 148 |
+
])
|
| 149 |
+
else:
|
| 150 |
+
self.augmentator = None
|
| 151 |
+
|
| 152 |
+
if int(os.environ.get("RANK", 0)) == 0:
|
| 153 |
+
print(f'** DATA ** Load {len(self.id2image_path)} images.')
|
| 154 |
+
print(f'** DATA ** Load {len(self.anatomy2id_ls)} anatomy.')
|
| 155 |
+
|
| 156 |
+
def prepare_image_paths(self, jsonl_file):
|
| 157 |
+
id2image_path = {}
|
| 158 |
+
|
| 159 |
+
with open(jsonl_file, 'r') as f:
|
| 160 |
+
data = f.readlines()
|
| 161 |
+
data = [json.loads(l) for l in data]
|
| 162 |
+
for d in data:
|
| 163 |
+
# 这里有问题的
|
| 164 |
+
data_id = d['name'] # valid_692_a_1.nii.gz
|
| 165 |
+
id2image_path[data_id] = d['img_path']
|
| 166 |
+
|
| 167 |
+
return id2image_path
|
| 168 |
+
|
| 169 |
+
def prepare_anatomy_data(self):
|
| 170 |
+
self.anatomy2id_ls = {} # 'lung': ['valid_692_a_1.nii.gz', ...]
|
| 171 |
+
self.anatomy2simi_tab = {} # 'lung': a tensor with shape NxN
|
| 172 |
+
|
| 173 |
+
for csv_file in os.listdir(self.csv_file_dir):
|
| 174 |
+
anatomy_name = csv_file.replace('.csv', '') # lung.csv -> lung
|
| 175 |
+
|
| 176 |
+
# DEBUG
|
| 177 |
+
if anatomy_name not in self.anatomy_filter:
|
| 178 |
+
continue
|
| 179 |
+
# DEBUG
|
| 180 |
+
|
| 181 |
+
self.anatomy2id_ls[anatomy_name] = []
|
| 182 |
+
df = pd.read_csv(os.path.join(self.csv_file_dir, csv_file))
|
| 183 |
+
print(f"{csv_file} has {len(df)} rows.")
|
| 184 |
+
if len(df) > self.max_samples:
|
| 185 |
+
# 随机抽取max_samples个样本,并按照原csv顺序排序,以保持与simi_tab索引对应
|
| 186 |
+
sampled_df = df.sample(n=self.max_samples, random_state=42).sort_index()
|
| 187 |
+
sampled_indices = sampled_df.index.tolist() # 例如抽取[0, 3, 5]行
|
| 188 |
+
else:
|
| 189 |
+
sampled_df = df
|
| 190 |
+
sampled_indices = sampled_df.index.tolist()
|
| 191 |
+
for _, row in sampled_df.iterrows():
|
| 192 |
+
self.anatomy2id_ls[anatomy_name].append(row['File Path'])
|
| 193 |
+
|
| 194 |
+
npy_file = csv_file.replace('.csv', '.npy')
|
| 195 |
+
full_simi_tab = np.load(os.path.join(self.npy_file_dir, npy_file))
|
| 196 |
+
# 截取原csv对应的大小
|
| 197 |
+
full_simi_tab = full_simi_tab[:len(df), :len(df)]
|
| 198 |
+
full_simi_tab = np.clip(full_simi_tab, 0, 1)
|
| 199 |
+
# 根据抽样的索引构造子矩阵,确保与csv中样本一一对应
|
| 200 |
+
sampled_simi_tab = full_simi_tab[np.ix_(sampled_indices, sampled_indices)] # 例如只保留[0, 3, 5]行/列
|
| 201 |
+
simi_tab_scaled = np.round(sampled_simi_tab * 100).astype(np.uint8)
|
| 202 |
+
self.anatomy2simi_tab[anatomy_name] = simi_tab_scaled
|
| 203 |
+
|
| 204 |
+
self.check_integrity()
|
| 205 |
+
|
| 206 |
+
def check_integrity(self):
|
| 207 |
+
id_keys = set(self.anatomy2id_ls.keys())
|
| 208 |
+
simi_keys = set(self.anatomy2simi_tab.keys())
|
| 209 |
+
if id_keys != simi_keys:
|
| 210 |
+
missing_in_simi = id_keys - simi_keys
|
| 211 |
+
missing_in_id = simi_keys - id_keys
|
| 212 |
+
raise ValueError(f"Inconsistent keys detected. Missing in similarity table: {missing_in_simi}. Missing in anatomy data: {missing_in_id}.")
|
| 213 |
+
# 修改
|
| 214 |
+
if self.modality == '3D':
|
| 215 |
+
for anatomy in id_keys:
|
| 216 |
+
if not (len(self.anatomy2id_ls[anatomy]) == self.anatomy2simi_tab[anatomy].shape[0] and len(self.anatomy2id_ls[anatomy]) == self.anatomy2simi_tab[anatomy].shape[1]):
|
| 217 |
+
raise ValueError(f"Length of anatomy data {len(self.anatomy2id_ls[anatomy])} and similarity table {self.anatomy2simi_tab[anatomy].shape} do not match for {anatomy}.")
|
| 218 |
+
|
| 219 |
+
# Here we check if all samples are readable
|
| 220 |
+
filtered_sample_id_ls = []
|
| 221 |
+
valid_indices = []
|
| 222 |
+
for i, sample_id in enumerate(self.anatomy2id_ls[anatomy]):
|
| 223 |
+
# image_file_name = sample_id.replace('.nii.gz', '.npz')
|
| 224 |
+
if sample_id in self.id2image_path and os.path.exists(self.id2image_path[sample_id]):
|
| 225 |
+
filtered_sample_id_ls.append(sample_id)
|
| 226 |
+
valid_indices.append(i)
|
| 227 |
+
continue
|
| 228 |
+
else:
|
| 229 |
+
print(f'{sample_id} is missing, removed')
|
| 230 |
+
# # Remove the invalid sample from self.anatomy2id_ls and update simi_tab accordingly
|
| 231 |
+
# self.anatomy2simi_tab[anatomy] = np.delete(self.anatomy2simi_tab[anatomy], i, axis=0)
|
| 232 |
+
# self.anatomy2simi_tab[anatomy] = np.delete(self.anatomy2simi_tab[anatomy], i, axis=1)
|
| 233 |
+
self.anatomy2simi_tab[anatomy] = self.anatomy2simi_tab[anatomy][np.ix_(valid_indices, valid_indices)] # 保留有效索引对应的子矩阵
|
| 234 |
+
self.anatomy2id_ls[anatomy] = filtered_sample_id_ls
|
| 235 |
+
|
| 236 |
+
assert len(self.anatomy2id_ls[anatomy]) == self.anatomy2simi_tab[anatomy].shape[0] and len(self.anatomy2id_ls[anatomy]) == self.anatomy2simi_tab[anatomy].shape[1]
|
| 237 |
+
elif self.modality == '2D':
|
| 238 |
+
for anatomy in id_keys:
|
| 239 |
+
if not (len(self.anatomy2id_ls[anatomy]) == self.anatomy2simi_tab[anatomy].shape[0] and len(self.anatomy2id_ls[anatomy]) == self.anatomy2simi_tab[anatomy].shape[1]):
|
| 240 |
+
raise ValueError(f"Length of anatomy data {len(self.anatomy2id_ls[anatomy])} and similarity table {self.anatomy2simi_tab[anatomy].shape} do not match for {anatomy}.")
|
| 241 |
+
|
| 242 |
+
# Here we check if all samples are readable
|
| 243 |
+
filtered_sample_id_ls = []
|
| 244 |
+
valid_indices = []
|
| 245 |
+
for i, sample_id in enumerate(self.anatomy2id_ls[anatomy]):
|
| 246 |
+
if sample_id in self.id2image_path and os.path.exists(self.id2image_path[sample_id]):
|
| 247 |
+
filtered_sample_id_ls.append(sample_id)
|
| 248 |
+
valid_indices.append(i)
|
| 249 |
+
continue
|
| 250 |
+
else:
|
| 251 |
+
print(f'{sample_id} is missing, removed')
|
| 252 |
+
# # Remove the invalid sample from self.anatomy2id_ls and update simi_tab accordingly
|
| 253 |
+
# self.anatomy2simi_tab[anatomy] = np.delete(self.anatomy2simi_tab[anatomy], i, axis=0)
|
| 254 |
+
# self.anatomy2simi_tab[anatomy] = np.delete(self.anatomy2simi_tab[anatomy], i, axis=1)
|
| 255 |
+
self.anatomy2simi_tab[anatomy] = self.anatomy2simi_tab[anatomy][np.ix_(valid_indices, valid_indices)] # 保留有效索引对应的子矩阵
|
| 256 |
+
self.anatomy2id_ls[anatomy] = filtered_sample_id_ls
|
| 257 |
+
|
| 258 |
+
assert len(self.anatomy2id_ls[anatomy]) == self.anatomy2simi_tab[anatomy].shape[0] and len(self.anatomy2id_ls[anatomy]) == self.anatomy2simi_tab[anatomy].shape[1]
|
| 259 |
+
else:
|
| 260 |
+
raise ValueError(f"Unsupported modality: {self.modality}. Supported modalities are '3D' and '2D'.")
|
| 261 |
+
def __len__(self):
|
| 262 |
+
return 100000
|
| 263 |
+
|
| 264 |
+
def MLS_nii_img_to_tensor(self, path):
|
| 265 |
+
|
| 266 |
+
nii_img = safe_load_image(sample["image"])
|
| 267 |
+
img_data = nii_img.get_fdata()
|
| 268 |
+
img_data = np.flip(img_data, axis=0)
|
| 269 |
+
img_data = np.flip(img_data, axis=1)
|
| 270 |
+
|
| 271 |
+
# WARNING Respacing
|
| 272 |
+
img_data = img_data.transpose(2, 0, 1)
|
| 273 |
+
img_data = np.copy(img_data)
|
| 274 |
+
tensor = torch.tensor(img_data)
|
| 275 |
+
tensor = tensor.unsqueeze(0).unsqueeze(0)
|
| 276 |
+
|
| 277 |
+
target_x_spacing = 0.75
|
| 278 |
+
target_y_spacing = 0.75
|
| 279 |
+
target_z_spacing = 1.5
|
| 280 |
+
current = (3, 1, 1) # this is all set to 3 1 1
|
| 281 |
+
target = (target_z_spacing, target_x_spacing, target_y_spacing)
|
| 282 |
+
|
| 283 |
+
img_data = resize_array(tensor, current, target)
|
| 284 |
+
img_data = img_data[0][0]
|
| 285 |
+
img_data= np.transpose(img_data, (1, 2, 0))
|
| 286 |
+
|
| 287 |
+
# WARNING Normalization 2
|
| 288 |
+
hu_min, hu_max = -1000, 1000
|
| 289 |
+
img_data = np.clip(img_data, hu_min, hu_max)
|
| 290 |
+
img_data = (((img_data ) / 1000)).astype(np.float32)
|
| 291 |
+
|
| 292 |
+
tensor = torch.tensor(img_data)
|
| 293 |
+
|
| 294 |
+
# WARNING Padding or Crop
|
| 295 |
+
|
| 296 |
+
# Get the dimensions of the input tensor
|
| 297 |
+
target_shape = (480,480,240) # h w d
|
| 298 |
+
|
| 299 |
+
# Extract dimensions
|
| 300 |
+
h, w, d = tensor.shape
|
| 301 |
+
|
| 302 |
+
# Calculate cropping/padding values for height, width, and depth
|
| 303 |
+
dh, dw, dd = target_shape
|
| 304 |
+
h_start = max((h - dh) // 2, 0)
|
| 305 |
+
h_end = min(h_start + dh, h)
|
| 306 |
+
w_start = max((w - dw) // 2, 0)
|
| 307 |
+
w_end = min(w_start + dw, w)
|
| 308 |
+
d_start = max((d - dd) // 2, 0)
|
| 309 |
+
d_end = min(d_start + dd, d)
|
| 310 |
+
|
| 311 |
+
# Crop or pad the tensor
|
| 312 |
+
tensor = tensor[h_start:h_end, w_start:w_end, d_start:d_end]
|
| 313 |
+
|
| 314 |
+
pad_h_before = (dh - tensor.size(0)) // 2
|
| 315 |
+
pad_h_after = dh - tensor.size(0) - pad_h_before
|
| 316 |
+
|
| 317 |
+
pad_w_before = (dw - tensor.size(1)) // 2
|
| 318 |
+
pad_w_after = dw - tensor.size(1) - pad_w_before
|
| 319 |
+
|
| 320 |
+
pad_d_before = (dd - tensor.size(2)) // 2
|
| 321 |
+
pad_d_after = dd - tensor.size(2) - pad_d_before
|
| 322 |
+
|
| 323 |
+
tensor = torch.nn.functional.pad(tensor, (pad_d_before, pad_d_after, pad_w_before, pad_w_after, pad_h_before, pad_h_after), value=-1)
|
| 324 |
+
|
| 325 |
+
tensor = tensor.permute(2, 0, 1) # d h w
|
| 326 |
+
|
| 327 |
+
tensor = tensor.unsqueeze(0) # 1 d h w
|
| 328 |
+
|
| 329 |
+
return tensor
|
| 330 |
+
|
| 331 |
+
def nii_img_to_tensor(self, path):
|
| 332 |
+
|
| 333 |
+
nii_img = safe_load_image(sample["image"])
|
| 334 |
+
img_data = nii_img.get_fdata()
|
| 335 |
+
img_data = np.flip(img_data, axis=0)
|
| 336 |
+
img_data = np.flip(img_data, axis=1)
|
| 337 |
+
|
| 338 |
+
# WARNING Respacing
|
| 339 |
+
img_data = img_data.transpose(2, 0, 1)
|
| 340 |
+
img_data = np.copy(img_data)
|
| 341 |
+
tensor = torch.tensor(img_data)
|
| 342 |
+
tensor = tensor.unsqueeze(0).unsqueeze(0)
|
| 343 |
+
|
| 344 |
+
target_x_spacing = 0.75
|
| 345 |
+
target_y_spacing = 0.75
|
| 346 |
+
target_z_spacing = 1.5
|
| 347 |
+
current = (3, 1, 1) # this is all set to 3 1 1
|
| 348 |
+
target = (target_z_spacing, target_x_spacing, target_y_spacing)
|
| 349 |
+
|
| 350 |
+
img_data = resize_array(tensor, current, target)
|
| 351 |
+
img_data = img_data[0][0]
|
| 352 |
+
img_data= np.transpose(img_data, (1, 2, 0))
|
| 353 |
+
|
| 354 |
+
# WARNING Normalization 2
|
| 355 |
+
hu_min, hu_max = -1000, 1000
|
| 356 |
+
img_data = np.clip(img_data, hu_min, hu_max)
|
| 357 |
+
img_data = (((img_data ) / 1000)).astype(np.float32)
|
| 358 |
+
|
| 359 |
+
tensor = torch.tensor(img_data)
|
| 360 |
+
target_shape = (480,480,240) # h w d
|
| 361 |
+
|
| 362 |
+
# Extract dimensions
|
| 363 |
+
h, w, d = tensor.shape
|
| 364 |
+
|
| 365 |
+
# Calculate cropping/padding values for height, width, and depth
|
| 366 |
+
dh, dw, dd = target_shape
|
| 367 |
+
h_start = max((h - dh) // 2, 0)
|
| 368 |
+
h_end = min(h_start + dh, h)
|
| 369 |
+
w_start = max((w - dw) // 2, 0)
|
| 370 |
+
w_end = min(w_start + dw, w)
|
| 371 |
+
d_start = max((d - dd) // 2, 0)
|
| 372 |
+
d_end = min(d_start + dd, d)
|
| 373 |
+
|
| 374 |
+
# # Add random shift to the crop region
|
| 375 |
+
# hw_max_shift = 128
|
| 376 |
+
# z_max_shift = 64
|
| 377 |
+
# if z_max_shift > 0 or hw_max_shift > 0:
|
| 378 |
+
# # Generate random shifts within [-max_shift, max_shift]
|
| 379 |
+
# h_shift = np.random.randint(-hw_max_shift, hw_max_shift + 1)
|
| 380 |
+
# w_shift = np.random.randint(-hw_max_shift, hw_max_shift + 1)
|
| 381 |
+
# d_shift = np.random.randint(-z_max_shift, z_max_shift + 1)
|
| 382 |
+
|
| 383 |
+
# # Apply shifts, ensuring the crop region remains within the image bounds
|
| 384 |
+
# h_start = max(h_start + h_shift, 0)
|
| 385 |
+
# h_end = min(h_start + dh, h)
|
| 386 |
+
# w_start = max(w_start + w_shift, 0)
|
| 387 |
+
# w_end = min(w_start + dw, w)
|
| 388 |
+
# d_start = max(d_start + d_shift, 0)
|
| 389 |
+
# d_end = min(d_start + dd, d)
|
| 390 |
+
|
| 391 |
+
# Crop or pad the tensor
|
| 392 |
+
tensor = tensor[h_start:h_end, w_start:w_end, d_start:d_end]
|
| 393 |
+
|
| 394 |
+
pad_h_before = (dh - tensor.size(0)) // 2
|
| 395 |
+
pad_h_after = dh - tensor.size(0) - pad_h_before
|
| 396 |
+
|
| 397 |
+
pad_w_before = (dw - tensor.size(1)) // 2
|
| 398 |
+
pad_w_after = dw - tensor.size(1) - pad_w_before
|
| 399 |
+
|
| 400 |
+
pad_d_before = (dd - tensor.size(2)) // 2
|
| 401 |
+
pad_d_after = dd - tensor.size(2) - pad_d_before
|
| 402 |
+
|
| 403 |
+
tensor = torch.nn.functional.pad(tensor, (pad_d_before, pad_d_after, pad_w_before, pad_w_after, pad_h_before, pad_h_after), value=-1)
|
| 404 |
+
|
| 405 |
+
tensor = tensor.permute(2, 0, 1) # d h w
|
| 406 |
+
|
| 407 |
+
tensor = tensor.unsqueeze(0) # 1 d h w
|
| 408 |
+
|
| 409 |
+
return tensor
|
| 410 |
+
|
| 411 |
+
def load_2d_image_to_tensor(self, image_path,resize=False):
|
| 412 |
+
"""
|
| 413 |
+
Load a 2D grayscale image and convert it to a tensor with dimensions [1, 1, 480, 480].
|
| 414 |
+
|
| 415 |
+
Args:
|
| 416 |
+
image_path (str): Path to the image file.
|
| 417 |
+
|
| 418 |
+
Returns:
|
| 419 |
+
tensor (torch.Tensor): Tensor with shape [1, 1, 480, 480].
|
| 420 |
+
"""
|
| 421 |
+
# Load the image as grayscale
|
| 422 |
+
image = Image.open(image_path).convert('L')
|
| 423 |
+
|
| 424 |
+
# Resize to 480x480 if needed
|
| 425 |
+
if resize:
|
| 426 |
+
if image.size != (480, 480):
|
| 427 |
+
image = image.resize((480, 480), Image.BILINEAR)
|
| 428 |
+
else:
|
| 429 |
+
if image.size != (480, 480):
|
| 430 |
+
# 计算可以开始裁剪的最大左上角坐标
|
| 431 |
+
width, height = image.size
|
| 432 |
+
crop_size = 480
|
| 433 |
+
if width < crop_size or height < crop_size:
|
| 434 |
+
raise ValueError(f"Image size {image.size} is smaller than the crop size {crop_size}.")
|
| 435 |
+
max_left = width - crop_size
|
| 436 |
+
max_top = height - crop_size
|
| 437 |
+
|
| 438 |
+
# 随机选择裁剪的左上角坐标
|
| 439 |
+
left = random.randint(0, max(0, max_left))
|
| 440 |
+
top = random.randint(0, max(0, max_top))
|
| 441 |
+
|
| 442 |
+
# 裁剪图像
|
| 443 |
+
image = image.crop((left, top, left + crop_size, top + crop_size))
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
# Convert to numpy array and normalize to [0, 1]
|
| 447 |
+
img_array = np.array(image, dtype=np.float32) / 255.0
|
| 448 |
+
|
| 449 |
+
# Convert to tensor
|
| 450 |
+
tensor = torch.from_numpy(img_array)
|
| 451 |
+
|
| 452 |
+
# Add batch and channel dimensions
|
| 453 |
+
tensor = tensor.unsqueeze(0).unsqueeze(0) # [1, 1, 480, 480]
|
| 454 |
+
|
| 455 |
+
return tensor
|
| 456 |
+
|
| 457 |
+
def __getitem__(self, index):
|
| 458 |
+
"""
|
| 459 |
+
Returns:
|
| 460 |
+
video_tensor (tensor): N, D, H, W
|
| 461 |
+
input_text (List of str): N
|
| 462 |
+
"""
|
| 463 |
+
anatomy = np.random.choice(self.anatomy_ls, size=1, p=self.anatomy_weight)[0]
|
| 464 |
+
similarity = self.anatomy2simi_tab[anatomy]
|
| 465 |
+
num_samples = similarity.shape[0]
|
| 466 |
+
|
| 467 |
+
# Get upper-triangular indices
|
| 468 |
+
rows, cols = np.triu_indices(num_samples, k=1)
|
| 469 |
+
pos_mask = (similarity[rows, cols] > self.positive_threshold) & (rows != cols)
|
| 470 |
+
neg_mask = (similarity[rows, cols] < self.negative_threshold) & (rows != cols)
|
| 471 |
+
pos_candidate_pairs = list(zip(rows[pos_mask], cols[pos_mask]))
|
| 472 |
+
neg_candidate_pairs = list(zip(rows[neg_mask], cols[neg_mask]))
|
| 473 |
+
|
| 474 |
+
# Sampling
|
| 475 |
+
pos_pair_count = neg_pair_count = self.local_batch_size // 4
|
| 476 |
+
if self.local_batch_size % 4 > 1:
|
| 477 |
+
pos_pair_count += 1
|
| 478 |
+
if len(pos_candidate_pairs) >= pos_pair_count:
|
| 479 |
+
selected_pos = random.sample(pos_candidate_pairs, pos_pair_count)
|
| 480 |
+
else:
|
| 481 |
+
selected_pos = random.sample(list(zip(rows, cols)), pos_pair_count)
|
| 482 |
+
if len(neg_candidate_pairs) >= neg_pair_count:
|
| 483 |
+
selected_neg = random.sample(neg_candidate_pairs, neg_pair_count)
|
| 484 |
+
else:
|
| 485 |
+
selected_neg = random.sample(list(zip(rows, cols)), neg_pair_count)
|
| 486 |
+
sampled_indexes = [idx for pair in (selected_pos + selected_neg) for idx in pair]
|
| 487 |
+
|
| 488 |
+
# If local_batch_size is odd, add one more sample
|
| 489 |
+
if self.local_batch_size % 2 == 1:
|
| 490 |
+
remaining = list(set(range(num_samples)) - set(sampled_indexes))
|
| 491 |
+
extra = random.choice(remaining) if remaining else random.choice(list(range(num_samples)))
|
| 492 |
+
sampled_indexes.append(extra)
|
| 493 |
+
|
| 494 |
+
assert len(sampled_indexes) == self.local_batch_size
|
| 495 |
+
|
| 496 |
+
sampled_ids = [self.anatomy2id_ls[anatomy][idx] for idx in sampled_indexes]
|
| 497 |
+
# sampled_ids = [sample_id for sample_id in sampled_ids if sample_id in self.id2image_path] # WARNING 假如有一个sample_id的image不存在,那么会导致stacked_video_tensor后面的元素整体向前移动一位,和similarity_tab对不上
|
| 498 |
+
|
| 499 |
+
similarity_tab = np.zeros(
|
| 500 |
+
(self.local_batch_size, self.local_batch_size),
|
| 501 |
+
dtype=np.uint8 # 使用 uint8 类型
|
| 502 |
+
)
|
| 503 |
+
|
| 504 |
+
# 修改赋值逻辑(需要反向缩放)
|
| 505 |
+
for local_i, global_i in enumerate(sampled_indexes):
|
| 506 |
+
for local_j, global_j in enumerate(sampled_indexes):
|
| 507 |
+
raw_value = max(
|
| 508 |
+
self.anatomy2simi_tab[anatomy][global_i, global_j],
|
| 509 |
+
self.anatomy2simi_tab[anatomy][global_j, global_i]
|
| 510 |
+
)
|
| 511 |
+
# 直接使用存储的 uint8 值(无需转换)
|
| 512 |
+
similarity_tab[local_i, local_j] = raw_value # 已经是 0~100 的整数
|
| 513 |
+
# # 修改
|
| 514 |
+
video_tensors = []
|
| 515 |
+
|
| 516 |
+
if '3D' in self.modality:
|
| 517 |
+
for sampled_id in sampled_ids:
|
| 518 |
+
try:
|
| 519 |
+
# 返回的 video_tensor 已经是 [1, D, H, W]
|
| 520 |
+
if self.is_train:
|
| 521 |
+
video_tensor = self.nii_img_to_tensor(self.id2image_path[sampled_id])
|
| 522 |
+
else:
|
| 523 |
+
video_tensor = self.MLS_nii_img_to_tensor(self.id2image_path[sampled_id])
|
| 524 |
+
assert video_tensor.dim() == 4, f"Expected 4D tensor, got {video_tensor.dim()}D tensor for {sampled_id}"
|
| 525 |
+
if self.augmentator is not None and self.is_train:
|
| 526 |
+
video_tensor = self.augmentator(video_tensor)
|
| 527 |
+
video_tensors.append(video_tensor)
|
| 528 |
+
|
| 529 |
+
except Exception as e:
|
| 530 |
+
print(f"Error processing {sampled_id}: {e}. Skipping.")
|
| 531 |
+
# 如果一个样本失败,为了保持批次大小,可以加载一个全零张量或重复上一个
|
| 532 |
+
if video_tensors:
|
| 533 |
+
video_tensors.append(torch.zeros_like(video_tensors[-1]))
|
| 534 |
+
else: # 如果第一个就失败了
|
| 535 |
+
video_tensors.append(torch.zeros((1, 240, 480, 480), dtype=torch.float32))
|
| 536 |
+
|
| 537 |
+
# 此时 video_tensors 是一个列表,包含 N 个 [1, D, H, W] 的张量
|
| 538 |
+
stacked_video_tensor = torch.cat(video_tensors, dim=0) # -> [N, D, H, W]
|
| 539 |
+
|
| 540 |
+
# if self.augmentator is not None:
|
| 541 |
+
# stacked_video_tensor = self.augmentator(stacked_video_tensor) # MONAI增广可以直接处理 [N, D, H, W]
|
| 542 |
+
stacked_video_tensor = stacked_video_tensor.unsqueeze(1) # -> [N, 1, D, H, W]
|
| 543 |
+
|
| 544 |
+
elif '2D' in self.modality:
|
| 545 |
+
for sampled_id in sampled_ids:
|
| 546 |
+
try:
|
| 547 |
+
# 返回的 video_tensor 已经是 [1, 1, H, W]
|
| 548 |
+
video_tensor = self.load_2d_image_to_tensor(self.id2image_path[sampled_id],resize=True)
|
| 549 |
+
video_tensor = video_tensor.squeeze().unsqueeze(0) # 确保是 [1, H, W]
|
| 550 |
+
if self.augmentator is not None and self.is_train:
|
| 551 |
+
video_tensor = self.augmentator(video_tensor)
|
| 552 |
+
|
| 553 |
+
video_tensor = video_tensor.unsqueeze(0) # 变成 [1, 1, H, W]
|
| 554 |
+
video_tensors.append(video_tensor)
|
| 555 |
+
except Exception as e:
|
| 556 |
+
print(f"Error processing {sampled_id}: {e}. Skipping.")
|
| 557 |
+
# 如果一个样本失败,为了保持批次大小,可以加载一个全零张量或重复上一个
|
| 558 |
+
if video_tensors:
|
| 559 |
+
video_tensors.append(torch.zeros_like(video_tensors[-1]))
|
| 560 |
+
else:
|
| 561 |
+
video_tensors.append(torch.zeros((1, 1, 480, 480), dtype=torch.float32))
|
| 562 |
+
# 此时 video_tensors 是一个列表,包含 N 个 [1, 1, H, W] 的张量
|
| 563 |
+
stacked_video_tensor = torch.cat(video_tensors, dim=0) # -> [N, 1, H, W]
|
| 564 |
+
|
| 565 |
+
# 确保最终形状符合预期
|
| 566 |
+
stacked_video_tensor = stacked_video_tensor.unsqueeze(1) # -> [N, 1, 1, H, W]
|
| 567 |
+
if isinstance(stacked_video_tensor, MetaTensor):
|
| 568 |
+
stacked_video_tensor = stacked_video_tensor.as_tensor()
|
| 569 |
+
if self.modal_embedding:
|
| 570 |
+
return {'video_tensor': stacked_video_tensor, 'similarity_tab': torch.tensor(similarity_tab), 'anatomy': anatomy, 'sampled_ids':sampled_ids, 'modality':modality_dict[self.modality]}
|
| 571 |
+
else:
|
| 572 |
+
return {'video_tensor': stacked_video_tensor, 'similarity_tab': torch.tensor(similarity_tab), 'anatomy': anatomy, 'sampled_ids':sampled_ids}
|
| 573 |
+
|
| 574 |
+
def collate_fn(batch):
|
| 575 |
+
# batch 是一个列表,其中每个元素是 __getitem__ 返回的字典
|
| 576 |
+
|
| 577 |
+
# 使用列表推导式和 torch.stack 高效地组合批次
|
| 578 |
+
videos = torch.stack([item['video_tensor'] for item in batch], dim=0)
|
| 579 |
+
similarity_tabs = torch.stack([item['similarity_tab'] for item in batch], dim=0)
|
| 580 |
+
|
| 581 |
+
# 其他非张量数据正常收集
|
| 582 |
+
anatomies = [item['anatomy'] for item in batch]
|
| 583 |
+
sampled_ids_list = [item['sampled_ids'] for item in batch]
|
| 584 |
+
if 'modality' in batch[0]:
|
| 585 |
+
modalities = torch.tensor([item['modality'] for item in batch])
|
| 586 |
+
return videos, similarity_tabs, anatomies, sampled_ids_list, modalities
|
| 587 |
+
else:
|
| 588 |
+
return videos, similarity_tabs, anatomies, sampled_ids_list
|
| 589 |
+
|
| 590 |
+
# if __name__ == '__main__':
|
| 591 |
+
# from torch.utils.data import Dataset, DataLoader, random_split
|
| 592 |
+
# print('** CXR **')
|
| 593 |
+
# dataset = Conditional_CTReportDataset_Train(
|
| 594 |
+
# local_batch_size=8,
|
| 595 |
+
# modality='2D',
|
| 596 |
+
# jsonl_file='/mnt/petrelfs/zhangtengfei/RadIR/dataset/CXR/MIMIC_CXR/train.jsonl',
|
| 597 |
+
# csv_file_dir='/mnt/petrelfs/zhangtengfei/RadIR/dataset/CXR/MIMIC_CXR/train_entity',
|
| 598 |
+
# npy_file_dir='/mnt/petrelfs/zhangtengfei/RadIR/dataset/CXR/MIMIC_CXR/train_ratescore',
|
| 599 |
+
# positive_threshold=0.75,
|
| 600 |
+
# negative_threshold=1,
|
| 601 |
+
# anatomy_filter=['left hemidiaphragm', 'airspace']
|
| 602 |
+
# )
|
| 603 |
+
|
| 604 |
+
# dl = DataLoader(
|
| 605 |
+
# dataset,
|
| 606 |
+
# num_workers=8,
|
| 607 |
+
# batch_size=1,
|
| 608 |
+
# shuffle = False,
|
| 609 |
+
# drop_last = False,
|
| 610 |
+
# pin_memory = False,
|
| 611 |
+
# collate_fn = collate_fn
|
| 612 |
+
# )
|
| 613 |
+
|
| 614 |
+
# dl_iter = iter(dl)
|
| 615 |
+
|
| 616 |
+
# for threshold in [0.1, 0.15, 0.2, 0.25, 0.3]:
|
| 617 |
+
|
| 618 |
+
# count = 0
|
| 619 |
+
# triplet_count = 0
|
| 620 |
+
|
| 621 |
+
# while True:
|
| 622 |
+
|
| 623 |
+
# count += 1
|
| 624 |
+
# if count > 50:
|
| 625 |
+
# break
|
| 626 |
+
|
| 627 |
+
# video, similarity_tab, anatomy, batched_sampled_ids = next(dl_iter) # 1 8 8
|
| 628 |
+
|
| 629 |
+
# print(batched_sampled_ids)
|
| 630 |
+
# print(similarity_tab)
|
| 631 |
+
|
| 632 |
+
# mask = (similarity_tab.unsqueeze(3) - similarity_tab.unsqueeze(2)) > 0.2 # N N N
|
| 633 |
+
# triplet_count += torch.sum(mask)
|
| 634 |
+
|
| 635 |
+
# print(f'** {threshold} **')
|
| 636 |
+
# print(similarity_tab.shape)
|
| 637 |
+
# print(triplet_count/count)
|
| 638 |
+
|
| 639 |
+
# # > 0.1 时 8x8x8 的 matrix 上平均有 _ 个有效对
|
| 640 |
+
# # > 0.15 时 8x8x8 的 matrix 上平均有 _ 个有效对
|
| 641 |
+
# # > 0.2 时 8x8x8 的 matrix 上平均有 _ 个有效对
|
| 642 |
+
# # > 0.25 时 8x8x8 的 matrix 上平均有 _ 个有效对
|
| 643 |
+
# # > 0.3 时 8x8x8 的 matrix 上平均有 _ 个有效��
|
| 644 |
+
# print('** CT **')
|
| 645 |
+
# dataset = Conditional_CTReportDataset_Train(
|
| 646 |
+
# local_batch_size=8,
|
| 647 |
+
# modality='3D',
|
| 648 |
+
# jsonl_file='/mnt/petrelfs/zhangtengfei/RadIR/dataset/CT_RATE/train_replaced.jsonl',
|
| 649 |
+
# csv_file_dir='/mnt/petrelfs/zhangtengfei/RadIR/dataset/CT_RATE/anatomy/train_entity',
|
| 650 |
+
# npy_file_dir='/mnt/petrelfs/zhangtengfei/RadIR/dataset/CT_RATE/anatomy/train_ratescore',
|
| 651 |
+
# positive_threshold=0.75,
|
| 652 |
+
# negative_threshold=1,
|
| 653 |
+
# anatomy_filter=['right rib', 'thyroid gland']
|
| 654 |
+
# )
|
| 655 |
+
|
| 656 |
+
# dl = DataLoader(
|
| 657 |
+
# dataset,
|
| 658 |
+
# num_workers=8,
|
| 659 |
+
# batch_size=1,
|
| 660 |
+
# shuffle = False,
|
| 661 |
+
# drop_last = False,
|
| 662 |
+
# pin_memory = False,
|
| 663 |
+
# collate_fn = collate_fn
|
| 664 |
+
# )
|
| 665 |
+
|
| 666 |
+
# dl_iter = iter(dl)
|
| 667 |
+
|
| 668 |
+
# for threshold in [0.1, 0.15, 0.2, 0.25, 0.3]:
|
| 669 |
+
|
| 670 |
+
# count = 0
|
| 671 |
+
# triplet_count = 0
|
| 672 |
+
|
| 673 |
+
# while True:
|
| 674 |
+
|
| 675 |
+
# count += 1
|
| 676 |
+
# if count > 50:
|
| 677 |
+
# break
|
| 678 |
+
|
| 679 |
+
# video, similarity_tab, anatomy, batched_sampled_ids = next(dl_iter) # 1 8 8
|
| 680 |
+
|
| 681 |
+
# print(batched_sampled_ids)
|
| 682 |
+
# print(similarity_tab)
|
| 683 |
+
|
| 684 |
+
# mask = (similarity_tab.unsqueeze(3) - similarity_tab.unsqueeze(2)) > 0.2 # N N N
|
| 685 |
+
# triplet_count += torch.sum(mask)
|
| 686 |
+
|
| 687 |
+
# print(f'** {threshold} **')
|
| 688 |
+
# print(similarity_tab.shape)
|
| 689 |
+
# print(triplet_count/count)
|
| 690 |
+
|
| 691 |
+
if __name__ == '__main__':
|
| 692 |
+
from torch.utils.data import Dataset, DataLoader, random_split
|
| 693 |
+
import time
|
| 694 |
+
from tqdm import tqdm
|
| 695 |
+
|
| 696 |
+
print('** 测试数据加载耗时 **')
|
| 697 |
+
|
| 698 |
+
# 创建CXR数据集
|
| 699 |
+
print('** 创建CXR数据集 **')
|
| 700 |
+
cxr_dataset = Conditional_CTReportDataset_Train(
|
| 701 |
+
local_batch_size=35,
|
| 702 |
+
modality='2D',
|
| 703 |
+
|
| 704 |
+
jsonl_file=None, # ⚠️ 你没有 .jsonl 文件,直接设为 None 或删除此参数
|
| 705 |
+
csv_file_dir='/mnt/data-3/users/zhangzeyu/HMClip/RadIR/dataset/MIMIC-IR/anatomy/train_caption',
|
| 706 |
+
npy_file_dir='/mnt/data-3/users/zhangzeyu/HMClip/RadIR/dataset/MIMIC-IR/anatomy/train_ratescore',
|
| 707 |
+
|
| 708 |
+
positive_threshold=0.75,
|
| 709 |
+
negative_threshold=1,
|
| 710 |
+
anatomy_filter=['lungs', 'ventricle', 'right upper lobe'],
|
| 711 |
+
need_aug=True
|
| 712 |
+
)
|
| 713 |
+
|
| 714 |
+
# 创建CT数据集
|
| 715 |
+
print('** 创建CT数据集 **')
|
| 716 |
+
ct_dataset = Conditional_CTReportDataset_Train(
|
| 717 |
+
local_batch_size=7,
|
| 718 |
+
modality='3D',
|
| 719 |
+
jsonl_file = '/mnt/data-3/users/zhangzeyu/HMClip/RadIR/dataset/CTRATE-IR/train.jsonl',
|
| 720 |
+
csv_file_dir = '/mnt/data-3/users/zhangzeyu/HMClip/RadIR/dataset/CTRATE-IR/anatomy/train_entity',
|
| 721 |
+
npy_file_dir = '/mnt/data-3/users/zhangzeyu/HMClip/RadIR/dataset/CTRATE-IR/anatomy/train_ratescore_compressed',
|
| 722 |
+
positive_threshold=0.75,
|
| 723 |
+
negative_threshold=1,
|
| 724 |
+
anatomy_filter=['bone', 'pulmonary artery', 'stomach'],
|
| 725 |
+
need_aug=True
|
| 726 |
+
)
|
| 727 |
+
|
| 728 |
+
# 测试不同的工作线程数量
|
| 729 |
+
for num_workers in [2, 4,6, 8,12]:
|
| 730 |
+
print(f"\n使用 {num_workers} 个工作线程")
|
| 731 |
+
|
| 732 |
+
# 测试CXR数据集
|
| 733 |
+
dl_cxr = DataLoader(
|
| 734 |
+
cxr_dataset,
|
| 735 |
+
num_workers=num_workers,
|
| 736 |
+
batch_size=2,
|
| 737 |
+
shuffle=False,
|
| 738 |
+
drop_last=False,
|
| 739 |
+
pin_memory=True,
|
| 740 |
+
collate_fn=collate_fn
|
| 741 |
+
)
|
| 742 |
+
|
| 743 |
+
# 预热(初始化可能很慢)
|
| 744 |
+
print("预热CXR数据加载器...")
|
| 745 |
+
for _ in range(5):
|
| 746 |
+
next(iter(dl_cxr))
|
| 747 |
+
|
| 748 |
+
# 测量CXR数据加载时间
|
| 749 |
+
print("开始测量CXR数据加载时间...")
|
| 750 |
+
start_time = time.time()
|
| 751 |
+
total_batches = min(20, len(dl_cxr)) # 限制测试数量
|
| 752 |
+
|
| 753 |
+
for i, _ in enumerate(tqdm(dl_cxr, total=total_batches)):
|
| 754 |
+
if i >= total_batches - 1:
|
| 755 |
+
break
|
| 756 |
+
|
| 757 |
+
cxr_time = time.time() - start_time
|
| 758 |
+
print(f"CXR数据集: {total_batches}个批次耗时 {cxr_time:.2f}秒, 平均每批次 {cxr_time/total_batches:.4f}秒")
|
| 759 |
+
|
| 760 |
+
# 测试CT数据集
|
| 761 |
+
dl_ct = DataLoader(
|
| 762 |
+
ct_dataset,
|
| 763 |
+
num_workers=num_workers,
|
| 764 |
+
batch_size=1,
|
| 765 |
+
shuffle=False,
|
| 766 |
+
drop_last=False,
|
| 767 |
+
pin_memory=True,
|
| 768 |
+
collate_fn=collate_fn
|
| 769 |
+
)
|
| 770 |
+
|
| 771 |
+
# 预热
|
| 772 |
+
print("预热CT数据加载器...")
|
| 773 |
+
for _ in range(5):
|
| 774 |
+
next(iter(dl_ct))
|
| 775 |
+
|
| 776 |
+
# 测量CT数据加载时间
|
| 777 |
+
print("开始测量CT数据加载时间...")
|
| 778 |
+
start_time = time.time()
|
| 779 |
+
total_batches = min(20, len(dl_ct)) # 限制测试数量
|
| 780 |
+
|
| 781 |
+
for i, _ in enumerate(tqdm(dl_ct, total=total_batches)):
|
| 782 |
+
if i >= total_batches - 1:
|
| 783 |
+
break
|
| 784 |
+
|
| 785 |
+
ct_time = time.time() - start_time
|
| 786 |
+
print(f"CT数据集: {total_batches}个批次耗��� {ct_time:.2f}秒, 平均每批次 {ct_time/total_batches:.4f}秒")
|
| 787 |
+
|
| 788 |
+
# # 测试不同的batch_size
|
| 789 |
+
# num_workers = 8 # 使用固定的工作线程数
|
| 790 |
+
# print(f"\n使用固定的{num_workers}个工作线程,测试不同batch_size")
|
| 791 |
+
|
| 792 |
+
# for batch_size in [1, 2, 4, 8, 16]:
|
| 793 |
+
# print(f"\nbatch_size = {batch_size}")
|
| 794 |
+
|
| 795 |
+
# # CXR数据集
|
| 796 |
+
# dl_cxr = DataLoader(
|
| 797 |
+
# cxr_dataset,
|
| 798 |
+
# num_workers=num_workers,
|
| 799 |
+
# batch_size=batch_size,
|
| 800 |
+
# shuffle=False,
|
| 801 |
+
# drop_last=False,
|
| 802 |
+
# pin_memory=True,
|
| 803 |
+
# collate_fn=collate_fn
|
| 804 |
+
# )
|
| 805 |
+
|
| 806 |
+
# # 预热
|
| 807 |
+
# print("预热CXR数据加载器...")
|
| 808 |
+
# for _ in range(3):
|
| 809 |
+
# next(iter(dl_cxr))
|
| 810 |
+
|
| 811 |
+
# # 测量CXR数据加载时间
|
| 812 |
+
# print("开始测量CXR数据加载时间...")
|
| 813 |
+
# start_time = time.time()
|
| 814 |
+
# total_batches = min(50, len(dl_cxr)) # 限制测试数量
|
| 815 |
+
|
| 816 |
+
# for i, _ in enumerate(tqdm(dl_cxr, total=total_batches)):
|
| 817 |
+
# if i >= total_batches - 1:
|
| 818 |
+
# break
|
| 819 |
+
|
| 820 |
+
# cxr_time = time.time() - start_time
|
| 821 |
+
# print(f"CXR数据集(batch_size={batch_size}): {total_batches}个批次耗时 {cxr_time:.2f}秒, "
|
| 822 |
+
# f"平均每批次 {cxr_time/total_batches:.4f}秒, "
|
| 823 |
+
# f"平均每样本 {cxr_time/(total_batches*batch_size):.4f}秒")
|
| 824 |
+
|
| 825 |
+
|
scripts/data_process.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import nibabel as nib
|
| 2 |
+
import torch
|
| 3 |
+
import numpy as np
|
| 4 |
+
import torch.nn.functional as F
|
| 5 |
+
from PIL import Image
|
| 6 |
+
import random
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def resize_array(array, current_spacing, target_spacing):
|
| 14 |
+
"""
|
| 15 |
+
Resize the array to match the target spacing.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
array (torch.Tensor): Input array to be resized.
|
| 19 |
+
current_spacing (tuple): Current voxel spacing (z_spacing, xy_spacing, xy_spacing).
|
| 20 |
+
target_spacing (tuple): Target voxel spacing (target_z_spacing, target_x_spacing, target_y_spacing).
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
np.ndarray: Resized array.
|
| 24 |
+
"""
|
| 25 |
+
# Calculate new dimensions
|
| 26 |
+
original_shape = array.shape[2:]
|
| 27 |
+
scaling_factors = [
|
| 28 |
+
current_spacing[i] / target_spacing[i] for i in range(len(original_shape))
|
| 29 |
+
]
|
| 30 |
+
new_shape = [
|
| 31 |
+
int(original_shape[i] * scaling_factors[i]) for i in range(len(original_shape))
|
| 32 |
+
]
|
| 33 |
+
# Resize the array
|
| 34 |
+
resized_array = F.interpolate(array, size=new_shape, mode='trilinear', align_corners=False).cpu().numpy()
|
| 35 |
+
return resized_array
|
| 36 |
+
|
| 37 |
+
def load_3d_image_to_tensor(path):
|
| 38 |
+
|
| 39 |
+
nii_img = safe_load_image(sample["image"])
|
| 40 |
+
img_data = nii_img.get_fdata()
|
| 41 |
+
img_data = np.flip(img_data, axis=0)
|
| 42 |
+
img_data = np.flip(img_data, axis=1)
|
| 43 |
+
|
| 44 |
+
# WARNING Respacing
|
| 45 |
+
img_data = img_data.transpose(2, 0, 1)
|
| 46 |
+
img_data = np.copy(img_data)
|
| 47 |
+
tensor = torch.tensor(img_data)
|
| 48 |
+
tensor = tensor.unsqueeze(0).unsqueeze(0)
|
| 49 |
+
|
| 50 |
+
target_x_spacing = 0.75
|
| 51 |
+
target_y_spacing = 0.75
|
| 52 |
+
target_z_spacing = 1.5
|
| 53 |
+
current = (3, 1, 1) # this is all set to 3 1 1
|
| 54 |
+
target = (target_z_spacing, target_x_spacing, target_y_spacing)
|
| 55 |
+
|
| 56 |
+
img_data = resize_array(tensor, current, target)
|
| 57 |
+
img_data = img_data[0][0]
|
| 58 |
+
img_data= np.transpose(img_data, (1, 2, 0))
|
| 59 |
+
|
| 60 |
+
# WARNING Normalization 2
|
| 61 |
+
hu_min, hu_max = -1000, 1000
|
| 62 |
+
img_data = np.clip(img_data, hu_min, hu_max)
|
| 63 |
+
img_data = (((img_data ) / 1000)).astype(np.float32)
|
| 64 |
+
|
| 65 |
+
tensor = torch.tensor(img_data)
|
| 66 |
+
|
| 67 |
+
# WARNING Padding or Crop
|
| 68 |
+
|
| 69 |
+
# Get the dimensions of the input tensor
|
| 70 |
+
target_shape = (480,480,240) # h w d
|
| 71 |
+
|
| 72 |
+
# Extract dimensions
|
| 73 |
+
h, w, d = tensor.shape
|
| 74 |
+
|
| 75 |
+
# Calculate cropping/padding values for height, width, and depth
|
| 76 |
+
dh, dw, dd = target_shape
|
| 77 |
+
h_start = max((h - dh) // 2, 0)
|
| 78 |
+
h_end = min(h_start + dh, h)
|
| 79 |
+
w_start = max((w - dw) // 2, 0)
|
| 80 |
+
w_end = min(w_start + dw, w)
|
| 81 |
+
d_start = max((d - dd) // 2, 0)
|
| 82 |
+
d_end = min(d_start + dd, d)
|
| 83 |
+
|
| 84 |
+
# Crop or pad the tensor
|
| 85 |
+
tensor = tensor[h_start:h_end, w_start:w_end, d_start:d_end]
|
| 86 |
+
|
| 87 |
+
pad_h_before = (dh - tensor.size(0)) // 2
|
| 88 |
+
pad_h_after = dh - tensor.size(0) - pad_h_before
|
| 89 |
+
|
| 90 |
+
pad_w_before = (dw - tensor.size(1)) // 2
|
| 91 |
+
pad_w_after = dw - tensor.size(1) - pad_w_before
|
| 92 |
+
|
| 93 |
+
pad_d_before = (dd - tensor.size(2)) // 2
|
| 94 |
+
pad_d_after = dd - tensor.size(2) - pad_d_before
|
| 95 |
+
|
| 96 |
+
tensor = torch.nn.functional.pad(tensor, (pad_d_before, pad_d_after, pad_w_before, pad_w_after, pad_h_before, pad_h_after), value=-1)
|
| 97 |
+
|
| 98 |
+
tensor = tensor.permute(2, 0, 1) # d h w
|
| 99 |
+
|
| 100 |
+
tensor = tensor.unsqueeze(0) # 1 d h w
|
| 101 |
+
|
| 102 |
+
return tensor
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def load_2d_image_to_tensor(image_path,resize=False):
|
| 106 |
+
"""
|
| 107 |
+
Load a 2D grayscale image and convert it to a tensor with dimensions [1, 1, 480, 480].
|
| 108 |
+
|
| 109 |
+
Args:
|
| 110 |
+
image_path (str): Path to the image file.
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
tensor (torch.Tensor): Tensor with shape [1, 1, 480, 480].
|
| 114 |
+
"""
|
| 115 |
+
# Load the image as grayscale
|
| 116 |
+
image = Image.open(image_path).convert('L')
|
| 117 |
+
|
| 118 |
+
# Resize to 480x480 if needed
|
| 119 |
+
if resize:
|
| 120 |
+
if image.size != (480, 480):
|
| 121 |
+
image = image.resize((480, 480), Image.BILINEAR)
|
| 122 |
+
else:
|
| 123 |
+
if image.size != (480, 480):
|
| 124 |
+
# 计算可以开始裁剪的最大左上角坐标
|
| 125 |
+
width, height = image.size
|
| 126 |
+
crop_size = 480
|
| 127 |
+
if width < crop_size or height < crop_size:
|
| 128 |
+
raise ValueError(f"Image size {image.size} is smaller than the crop size {crop_size}.")
|
| 129 |
+
max_left = width - crop_size
|
| 130 |
+
max_top = height - crop_size
|
| 131 |
+
|
| 132 |
+
# 随机选择裁剪的左上角坐标
|
| 133 |
+
left = random.randint(0, max(0, max_left))
|
| 134 |
+
top = random.randint(0, max(0, max_top))
|
| 135 |
+
|
| 136 |
+
# 裁剪图像
|
| 137 |
+
image = image.crop((left, top, left + crop_size, top + crop_size))
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# Convert to numpy array and normalize to [0, 1]
|
| 141 |
+
img_array = (np.array(image, dtype=np.float32) / 255.0) * 2 -1
|
| 142 |
+
|
| 143 |
+
# Convert to tensor
|
| 144 |
+
tensor = torch.from_numpy(img_array)
|
| 145 |
+
|
| 146 |
+
# Add batch and channel dimensions
|
| 147 |
+
tensor = tensor.unsqueeze(0).unsqueeze(0) # [1, 1, 480, 480]
|
| 148 |
+
|
| 149 |
+
return tensor
|
| 150 |
+
|
| 151 |
+
def safe_load_image(path):
|
| 152 |
+
"""
|
| 153 |
+
Robust image loader that supports both .nii.gz and .npy files.
|
| 154 |
+
If .nii.gz is missing, automatically falls back to _compressed.npy.
|
| 155 |
+
"""
|
| 156 |
+
try:
|
| 157 |
+
if os.path.exists(path):
|
| 158 |
+
if path.endswith('.nii.gz'):
|
| 159 |
+
data = nib.load(path).get_fdata()
|
| 160 |
+
return data
|
| 161 |
+
elif path.endswith('.npy'):
|
| 162 |
+
data = np.load(path)
|
| 163 |
+
return data
|
| 164 |
+
|
| 165 |
+
# fallback to compressed npy
|
| 166 |
+
npy_fallback = path.replace('.nii.gz', '_compressed.npy')
|
| 167 |
+
if os.path.exists(npy_fallback):
|
| 168 |
+
data = np.load(npy_fallback)
|
| 169 |
+
print(f"[Info] Fallback to compressed file: {npy_fallback}")
|
| 170 |
+
return data
|
| 171 |
+
|
| 172 |
+
# all failed
|
| 173 |
+
raise FileNotFoundError(f"{path} and fallback {npy_fallback} not found")
|
| 174 |
+
|
| 175 |
+
except Exception as e:
|
| 176 |
+
print(f"[Warning] Error loading {path}: {e}")
|
| 177 |
+
return None
|
scripts/image_traversals_compare.py
ADDED
|
@@ -0,0 +1,1186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
MERU-style image traversals comparing RadIR/RadIR (hyperbolic) vs RadIR_compare (Euclidean).
|
| 4 |
+
|
| 5 |
+
For each query CXR:
|
| 6 |
+
1. Encode the image with both models.
|
| 7 |
+
2. Walk along the geodesic / ray from the image embedding toward the origin [ROOT].
|
| 8 |
+
3. At each step, retrieve the nearest neighbor from a shared multi-granularity concept pool.
|
| 9 |
+
Hyperbolic path optionally keeps only texts that entail the current point
|
| 10 |
+
(MERU-style cone filter), then takes NN in that subset.
|
| 11 |
+
4. Render a MERU Figure-5 style side-by-side panel.
|
| 12 |
+
|
| 13 |
+
Usage (from RadIR/RadIR):
|
| 14 |
+
python scripts/image_traversals_compare.py \\
|
| 15 |
+
--images path1.jpg path2.jpg \\
|
| 16 |
+
--out outputs/traversals_compare.png
|
| 17 |
+
|
| 18 |
+
# Auto-pick diverse disease cases from valid.jsonl + disease labels:
|
| 19 |
+
python scripts/image_traversals_compare.py --auto_pick 4 --out outputs/traversals_compare.png
|
| 20 |
+
|
| 21 |
+
# Disable entailment filter (pure hyperbolic NN):
|
| 22 |
+
python scripts/image_traversals_compare.py --auto_pick 4 --no_entailment
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import csv
|
| 29 |
+
import gc
|
| 30 |
+
import json
|
| 31 |
+
import os
|
| 32 |
+
import re
|
| 33 |
+
import sys
|
| 34 |
+
import textwrap
|
| 35 |
+
from pathlib import Path
|
| 36 |
+
from typing import Sequence
|
| 37 |
+
|
| 38 |
+
import matplotlib.pyplot as plt
|
| 39 |
+
import numpy as np
|
| 40 |
+
import torch
|
| 41 |
+
import torch.nn.functional as F
|
| 42 |
+
from matplotlib.patches import FancyBboxPatch
|
| 43 |
+
from PIL import Image
|
| 44 |
+
|
| 45 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 46 |
+
# Workspace layout: HMClip/RadIR/RadIR and HMClip/RadIR_compare
|
| 47 |
+
_hmclip = ROOT.parent.parent
|
| 48 |
+
if (_hmclip / "RadIR_compare").is_dir():
|
| 49 |
+
COMPARE_ROOT = _hmclip / "RadIR_compare"
|
| 50 |
+
elif (ROOT.parent / "RadIR_compare").is_dir():
|
| 51 |
+
COMPARE_ROOT = ROOT.parent / "RadIR_compare"
|
| 52 |
+
else:
|
| 53 |
+
COMPARE_ROOT = _hmclip / "RadIR_compare"
|
| 54 |
+
|
| 55 |
+
ROOT_LABEL = "[ROOT]"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# Concept pool
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
def load_concept_pool(
|
| 63 |
+
concepts_file: str | None,
|
| 64 |
+
extra_captions: int = 0,
|
| 65 |
+
caption_jsonl: str | None = None,
|
| 66 |
+
seed: int = 42,
|
| 67 |
+
) -> list[str]:
|
| 68 |
+
texts: list[str] = []
|
| 69 |
+
seen: set[str] = set()
|
| 70 |
+
|
| 71 |
+
def _add(s: str) -> None:
|
| 72 |
+
s = " ".join(s.strip().split())
|
| 73 |
+
if not s:
|
| 74 |
+
return
|
| 75 |
+
key = s.lower()
|
| 76 |
+
if key in seen or key == ROOT_LABEL.lower():
|
| 77 |
+
return
|
| 78 |
+
seen.add(key)
|
| 79 |
+
texts.append(s)
|
| 80 |
+
|
| 81 |
+
path = concepts_file or str(ROOT / "scripts" / "traversal_concepts.txt")
|
| 82 |
+
with open(path, encoding="utf-8") as f:
|
| 83 |
+
for line in f:
|
| 84 |
+
line = line.strip()
|
| 85 |
+
if not line or line.startswith("#"):
|
| 86 |
+
continue
|
| 87 |
+
_add(line)
|
| 88 |
+
|
| 89 |
+
if extra_captions > 0 and caption_jsonl and os.path.isfile(caption_jsonl):
|
| 90 |
+
rng = np.random.default_rng(seed)
|
| 91 |
+
rows = []
|
| 92 |
+
with open(caption_jsonl, encoding="utf-8") as f:
|
| 93 |
+
for line in f:
|
| 94 |
+
item = json.loads(line)
|
| 95 |
+
text = str(item.get("text", "")).strip()
|
| 96 |
+
if not text:
|
| 97 |
+
continue
|
| 98 |
+
first = text.split(".")[0].strip()
|
| 99 |
+
if 8 <= len(first) <= 80:
|
| 100 |
+
rows.append(first)
|
| 101 |
+
if rows:
|
| 102 |
+
idx = rng.choice(len(rows), size=min(extra_captions, len(rows)), replace=False)
|
| 103 |
+
for i in idx:
|
| 104 |
+
_add(rows[int(i)])
|
| 105 |
+
|
| 106 |
+
texts.append(ROOT_LABEL)
|
| 107 |
+
return texts
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ---------------------------------------------------------------------------
|
| 111 |
+
# Image I/O
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
|
| 114 |
+
def resolve_image_path(path: str, dataset_root: str) -> str:
|
| 115 |
+
candidates = [
|
| 116 |
+
path,
|
| 117 |
+
os.path.join(dataset_root, path),
|
| 118 |
+
os.path.join(dataset_root, path.replace("dataset/", "", 1)),
|
| 119 |
+
os.path.join(str(ROOT), path),
|
| 120 |
+
os.path.join(str(ROOT), path.replace("dataset/", "dataset/", 1)),
|
| 121 |
+
]
|
| 122 |
+
for c in candidates:
|
| 123 |
+
if os.path.isfile(c):
|
| 124 |
+
return os.path.abspath(c)
|
| 125 |
+
raise FileNotFoundError(f"Image not found: {path}")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def load_image_tensor(path: str, imsize: int, with_depth: bool) -> torch.Tensor:
|
| 129 |
+
"""
|
| 130 |
+
Grayscale -> [-1, 1].
|
| 131 |
+
with_depth=False -> [1, H, W] (RadIR/RadIR hyp)
|
| 132 |
+
with_depth=True -> [1, 1, H, W] (RadIR_compare, C×D×H×W)
|
| 133 |
+
"""
|
| 134 |
+
img = Image.open(path).convert("L")
|
| 135 |
+
arr = np.array(img, dtype=np.float32) / 255.0
|
| 136 |
+
t = torch.from_numpy(arr).unsqueeze(0) # [1, H, W]
|
| 137 |
+
t = F.interpolate(
|
| 138 |
+
t.unsqueeze(0), size=(imsize, imsize), mode="bilinear", align_corners=False
|
| 139 |
+
).squeeze(0)
|
| 140 |
+
t = t * 2.0 - 1.0
|
| 141 |
+
if with_depth:
|
| 142 |
+
t = t.unsqueeze(0) # [1, 1, H, W]
|
| 143 |
+
return t
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def preprocess_hyp_batch(image: torch.Tensor) -> torch.Tensor:
|
| 147 |
+
if image.ndim == 3:
|
| 148 |
+
image = image.unsqueeze(0)
|
| 149 |
+
if image.ndim == 4:
|
| 150 |
+
if image.shape[1] != 1:
|
| 151 |
+
image = image.mean(dim=1, keepdim=True)
|
| 152 |
+
elif image.ndim == 5:
|
| 153 |
+
if image.shape[1] != 1:
|
| 154 |
+
image = image.mean(dim=1, keepdim=True)
|
| 155 |
+
else:
|
| 156 |
+
raise ValueError(f"Unexpected image shape {tuple(image.shape)}")
|
| 157 |
+
return image
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# ---------------------------------------------------------------------------
|
| 161 |
+
# Auto-pick demo images
|
| 162 |
+
# ---------------------------------------------------------------------------
|
| 163 |
+
|
| 164 |
+
def auto_pick_images(
|
| 165 |
+
dataset_root: str,
|
| 166 |
+
labels_csv: str,
|
| 167 |
+
jsonl: str,
|
| 168 |
+
n: int,
|
| 169 |
+
seed: int = 42,
|
| 170 |
+
) -> list[str]:
|
| 171 |
+
labels = {}
|
| 172 |
+
with open(labels_csv, encoding="utf-8") as f:
|
| 173 |
+
for row in csv.DictReader(f):
|
| 174 |
+
labels[row["dicom_id"]] = row
|
| 175 |
+
|
| 176 |
+
wanted = [
|
| 177 |
+
"normal_only",
|
| 178 |
+
"Pneumonia_pos",
|
| 179 |
+
"Effusion_pos",
|
| 180 |
+
"Cardiomegaly_pos",
|
| 181 |
+
"Edema_pos",
|
| 182 |
+
"Atelectasis_pos",
|
| 183 |
+
"Pneumothorax_pos",
|
| 184 |
+
]
|
| 185 |
+
found: dict[str, str] = {}
|
| 186 |
+
with open(jsonl, encoding="utf-8") as f:
|
| 187 |
+
for line in f:
|
| 188 |
+
item = json.loads(line)
|
| 189 |
+
did = os.path.splitext(os.path.basename(item["img_path"]))[0]
|
| 190 |
+
lab = labels.get(did)
|
| 191 |
+
if lab is None:
|
| 192 |
+
continue
|
| 193 |
+
for k in wanted:
|
| 194 |
+
if k in found or lab.get(k) != "1":
|
| 195 |
+
continue
|
| 196 |
+
if k != "normal_only":
|
| 197 |
+
others = sum(
|
| 198 |
+
int(lab[x])
|
| 199 |
+
for x in wanted
|
| 200 |
+
if x not in ("normal_only", k)
|
| 201 |
+
)
|
| 202 |
+
if others > 0:
|
| 203 |
+
continue
|
| 204 |
+
try:
|
| 205 |
+
full = resolve_image_path(item["img_path"], dataset_root)
|
| 206 |
+
except FileNotFoundError:
|
| 207 |
+
continue
|
| 208 |
+
found[k] = full
|
| 209 |
+
break
|
| 210 |
+
if len(found) >= n:
|
| 211 |
+
break
|
| 212 |
+
|
| 213 |
+
paths = [found[k] for k in wanted if k in found][:n]
|
| 214 |
+
if len(paths) < n:
|
| 215 |
+
with open(jsonl, encoding="utf-8") as f:
|
| 216 |
+
for line in f:
|
| 217 |
+
item = json.loads(line)
|
| 218 |
+
try:
|
| 219 |
+
full = resolve_image_path(item["img_path"], dataset_root)
|
| 220 |
+
except FileNotFoundError:
|
| 221 |
+
continue
|
| 222 |
+
if full not in paths:
|
| 223 |
+
paths.append(full)
|
| 224 |
+
if len(paths) >= n:
|
| 225 |
+
break
|
| 226 |
+
return paths
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# ---------------------------------------------------------------------------
|
| 230 |
+
# Module path helpers (avoid radir / CTViT collisions between trees)
|
| 231 |
+
# ---------------------------------------------------------------------------
|
| 232 |
+
|
| 233 |
+
def _purge_modules(prefixes: Sequence[str]) -> None:
|
| 234 |
+
doomed = [
|
| 235 |
+
k
|
| 236 |
+
for k in list(sys.modules)
|
| 237 |
+
if any(k == p or k.startswith(p + ".") for p in prefixes)
|
| 238 |
+
]
|
| 239 |
+
for k in doomed:
|
| 240 |
+
del sys.modules[k]
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def _push_paths(paths: Sequence[str]) -> list[str]:
|
| 244 |
+
old = list(sys.path)
|
| 245 |
+
for p in reversed(paths):
|
| 246 |
+
if p in sys.path:
|
| 247 |
+
sys.path.remove(p)
|
| 248 |
+
sys.path.insert(0, p)
|
| 249 |
+
return old
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# ---------------------------------------------------------------------------
|
| 253 |
+
# Hyperbolic (RadIR/RadIR) encode + traverse
|
| 254 |
+
# ---------------------------------------------------------------------------
|
| 255 |
+
|
| 256 |
+
@torch.no_grad()
|
| 257 |
+
def run_hyperbolic(
|
| 258 |
+
image_paths: list[str],
|
| 259 |
+
concepts: list[str],
|
| 260 |
+
ckpt: str,
|
| 261 |
+
model_path: str,
|
| 262 |
+
device: str,
|
| 263 |
+
imsize: int = 224,
|
| 264 |
+
steps: int = 50,
|
| 265 |
+
text_batch: int = 64,
|
| 266 |
+
use_entailment: bool = True,
|
| 267 |
+
aperture_thresh: float = 1.2,
|
| 268 |
+
) -> tuple[list[list[str]], float]:
|
| 269 |
+
_purge_modules(["radir", "transformer_maskgit", "Rad_IR"])
|
| 270 |
+
rad_ir_root = str(ROOT / "Rad_IR")
|
| 271 |
+
# Inner package lives at ROOT/transformer_maskgit/transformer_maskgit/
|
| 272 |
+
old = _push_paths(
|
| 273 |
+
[str(ROOT), rad_ir_root, str(ROOT / "transformer_maskgit")]
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
try:
|
| 277 |
+
from transformers import AutoModel, AutoTokenizer
|
| 278 |
+
|
| 279 |
+
from radir import lorentz as L
|
| 280 |
+
from radir.RadIR import RADIR, l2norm
|
| 281 |
+
from transformer_maskgit.ctvit import CTViT
|
| 282 |
+
|
| 283 |
+
tokenizer = AutoTokenizer.from_pretrained(
|
| 284 |
+
model_path, trust_remote_code=True, local_files_only=True
|
| 285 |
+
)
|
| 286 |
+
text_model = AutoModel.from_pretrained(
|
| 287 |
+
model_path, trust_remote_code=True, local_files_only=True
|
| 288 |
+
)
|
| 289 |
+
image_encoder = CTViT(
|
| 290 |
+
dim=768,
|
| 291 |
+
codebook_size=8192,
|
| 292 |
+
image_size=imsize,
|
| 293 |
+
patch_size=16,
|
| 294 |
+
temporal_patch_size=10,
|
| 295 |
+
spatial_depth=8,
|
| 296 |
+
temporal_depth=6,
|
| 297 |
+
cls_depth=4,
|
| 298 |
+
dim_head=32,
|
| 299 |
+
heads=8,
|
| 300 |
+
channels=1,
|
| 301 |
+
)
|
| 302 |
+
model = RADIR(
|
| 303 |
+
tokenizer=tokenizer,
|
| 304 |
+
image_encoder=image_encoder,
|
| 305 |
+
text_encoder=text_model,
|
| 306 |
+
dim_text=768,
|
| 307 |
+
dim_image=512,
|
| 308 |
+
dim_latent=512,
|
| 309 |
+
use_mlm=False,
|
| 310 |
+
use_all_token_embeds=False,
|
| 311 |
+
)
|
| 312 |
+
model.load(ckpt)
|
| 313 |
+
model.to(device)
|
| 314 |
+
model.eval()
|
| 315 |
+
curv = model.get_curv().detach()
|
| 316 |
+
|
| 317 |
+
txt_feats = []
|
| 318 |
+
for i in range(0, len(concepts), text_batch):
|
| 319 |
+
batch = concepts[i : i + text_batch]
|
| 320 |
+
real = [t for t in batch if t != ROOT_LABEL]
|
| 321 |
+
feats_map: dict[str, torch.Tensor] = {}
|
| 322 |
+
if real:
|
| 323 |
+
tok = tokenizer(
|
| 324 |
+
real,
|
| 325 |
+
return_tensors="pt",
|
| 326 |
+
padding=True,
|
| 327 |
+
truncation=True,
|
| 328 |
+
max_length=128,
|
| 329 |
+
)
|
| 330 |
+
tok = {k: v.to(device) for k, v in tok.items()}
|
| 331 |
+
if "token_type_ids" not in tok:
|
| 332 |
+
tok["token_type_ids"] = torch.zeros_like(tok["input_ids"])
|
| 333 |
+
enc = model.text_transformer(
|
| 334 |
+
input_ids=tok["input_ids"],
|
| 335 |
+
token_type_ids=tok["token_type_ids"],
|
| 336 |
+
attention_mask=tok["attention_mask"],
|
| 337 |
+
)[0]
|
| 338 |
+
cls = enc[:, 0, :]
|
| 339 |
+
hyp = model.project_txt(cls)
|
| 340 |
+
for t, h in zip(real, hyp):
|
| 341 |
+
feats_map[t] = h
|
| 342 |
+
for t in batch:
|
| 343 |
+
if t == ROOT_LABEL:
|
| 344 |
+
dim = next(iter(feats_map.values())).shape[-1] if feats_map else 768
|
| 345 |
+
txt_feats.append(torch.zeros(dim, device=device))
|
| 346 |
+
else:
|
| 347 |
+
txt_feats.append(feats_map[t])
|
| 348 |
+
text_pool = torch.stack(txt_feats, dim=0).float()
|
| 349 |
+
root_idx = concepts.index(ROOT_LABEL) if ROOT_LABEL in concepts else -1
|
| 350 |
+
text_norms = torch.norm(text_pool, dim=-1)
|
| 351 |
+
text_aperture = None
|
| 352 |
+
if use_entailment:
|
| 353 |
+
text_aperture = L.half_aperture(text_pool, curv)
|
| 354 |
+
print(
|
| 355 |
+
f"Entailment filter ON (soft; aperture_thresh={aperture_thresh}, "
|
| 356 |
+
f"no hard radius gate — looser than training for traversal viz)"
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
all_paths: list[list[str]] = []
|
| 360 |
+
fallback_count = 0
|
| 361 |
+
total_steps = 0
|
| 362 |
+
for ip in image_paths:
|
| 363 |
+
img = load_image_tensor(ip, imsize=imsize, with_depth=False)
|
| 364 |
+
imgs = preprocess_hyp_batch(img.unsqueeze(0).to(device))
|
| 365 |
+
enc = model.visual_transformer(
|
| 366 |
+
imgs,
|
| 367 |
+
return_encoded_tokens=True,
|
| 368 |
+
modal_embedding=False,
|
| 369 |
+
modal_indexs=None,
|
| 370 |
+
is_condition=False,
|
| 371 |
+
)
|
| 372 |
+
if enc.dim() == 2:
|
| 373 |
+
image_global = l2norm(enc)
|
| 374 |
+
else:
|
| 375 |
+
image_global = l2norm(enc[:, 0, :])
|
| 376 |
+
z0 = model.project_img(image_global)
|
| 377 |
+
|
| 378 |
+
u0 = L.log_map0(z0.float(), curv)
|
| 379 |
+
labels: list[str] = []
|
| 380 |
+
ts = torch.linspace(1.0, 0.0, steps, device=device)
|
| 381 |
+
for t in ts:
|
| 382 |
+
u_t = (t * u0).float()
|
| 383 |
+
z_t = L.exp_map0(u_t, curv)
|
| 384 |
+
dist = L.pairwise_dist(z_t, text_pool, curv)[0]
|
| 385 |
+
total_steps += 1
|
| 386 |
+
|
| 387 |
+
if use_entailment and text_aperture is not None:
|
| 388 |
+
# MERU-style cone check, but softened for visualization:
|
| 389 |
+
# 1) wider aperture_thresh than training (default 1.2 vs 0.7)
|
| 390 |
+
# 2) soft penalty instead of hard mask (outside-cone texts can
|
| 391 |
+
# still win if much closer)
|
| 392 |
+
# 3) no hard text_norm <= z_norm gate (that collapsed mid-path
|
| 393 |
+
# to only [ROOT] in practice)
|
| 394 |
+
z_exp = z_t.expand_as(text_pool)
|
| 395 |
+
angle = L.oxy_angle(text_pool, z_exp, curv)
|
| 396 |
+
energy = torch.clamp(
|
| 397 |
+
angle - aperture_thresh * text_aperture, min=0.0
|
| 398 |
+
)
|
| 399 |
+
if root_idx >= 0:
|
| 400 |
+
energy[root_idx] = 0.0
|
| 401 |
+
# soft_weight ~1: energy (radians) added to hyperbolic dist
|
| 402 |
+
soft_weight = 1.0
|
| 403 |
+
scores = dist + soft_weight * energy
|
| 404 |
+
nn_idx = int(scores.argmin().item())
|
| 405 |
+
else:
|
| 406 |
+
nn_idx = int(dist.argmin().item())
|
| 407 |
+
labels.append(concepts[nn_idx])
|
| 408 |
+
all_paths.append(labels)
|
| 409 |
+
|
| 410 |
+
if use_entailment and total_steps > 0:
|
| 411 |
+
print(
|
| 412 |
+
f"Entailment: soft penalty (weight=1.0), "
|
| 413 |
+
f"aperture_thresh={aperture_thresh}"
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
curv_val = float(curv.item())
|
| 417 |
+
del model, text_model, image_encoder, text_pool
|
| 418 |
+
return all_paths, curv_val
|
| 419 |
+
finally:
|
| 420 |
+
sys.path[:] = old
|
| 421 |
+
_purge_modules(["radir", "transformer_maskgit", "Rad_IR"])
|
| 422 |
+
gc.collect()
|
| 423 |
+
if torch.cuda.is_available():
|
| 424 |
+
torch.cuda.empty_cache()
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
# ---------------------------------------------------------------------------
|
| 428 |
+
# Euclidean (RadIR_compare) encode + traverse
|
| 429 |
+
# ---------------------------------------------------------------------------
|
| 430 |
+
|
| 431 |
+
@torch.no_grad()
|
| 432 |
+
def run_euclidean(
|
| 433 |
+
image_paths: list[str],
|
| 434 |
+
concepts: list[str],
|
| 435 |
+
ckpt: str,
|
| 436 |
+
model_path: str,
|
| 437 |
+
device: str,
|
| 438 |
+
imsize: int = 480,
|
| 439 |
+
steps: int = 50,
|
| 440 |
+
text_batch: int = 32,
|
| 441 |
+
) -> list[list[str]]:
|
| 442 |
+
_purge_modules(["radir", "transformer_maskgit", "Rad_IR", "hf_model_paths"])
|
| 443 |
+
compare_scripts = str(COMPARE_ROOT / "scripts")
|
| 444 |
+
compare_rad = str(COMPARE_ROOT / "Rad_IR")
|
| 445 |
+
old = _push_paths(
|
| 446 |
+
[
|
| 447 |
+
str(COMPARE_ROOT),
|
| 448 |
+
compare_rad,
|
| 449 |
+
compare_scripts,
|
| 450 |
+
str(COMPARE_ROOT / "transformer_maskgit"),
|
| 451 |
+
]
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
try:
|
| 455 |
+
from transformers import BertModel, BertTokenizer
|
| 456 |
+
|
| 457 |
+
from radir.RadIR import RADIR, l2norm
|
| 458 |
+
from transformer_maskgit.ctvit import CTViT
|
| 459 |
+
|
| 460 |
+
tokenizer = BertTokenizer.from_pretrained(
|
| 461 |
+
model_path, local_files_only=True, do_lower_case=True
|
| 462 |
+
)
|
| 463 |
+
text_encoder = BertModel.from_pretrained(model_path, local_files_only=True)
|
| 464 |
+
image_encoder = CTViT(
|
| 465 |
+
dim=512,
|
| 466 |
+
codebook_size=8192,
|
| 467 |
+
image_size=imsize,
|
| 468 |
+
patch_size=20,
|
| 469 |
+
temporal_patch_size=10,
|
| 470 |
+
spatial_depth=8,
|
| 471 |
+
temporal_depth=6,
|
| 472 |
+
cls_depth=4,
|
| 473 |
+
dim_head=32,
|
| 474 |
+
heads=8,
|
| 475 |
+
)
|
| 476 |
+
model = RADIR(
|
| 477 |
+
image_encoder=image_encoder,
|
| 478 |
+
text_encoder=text_encoder,
|
| 479 |
+
tokenizer=tokenizer,
|
| 480 |
+
dim_text=768,
|
| 481 |
+
dim_image=512,
|
| 482 |
+
dim_latent=512,
|
| 483 |
+
use_mlm=False,
|
| 484 |
+
use_all_token_embeds=False,
|
| 485 |
+
)
|
| 486 |
+
model.load(ckpt)
|
| 487 |
+
model.to(device)
|
| 488 |
+
model.eval()
|
| 489 |
+
|
| 490 |
+
# Encode non-ROOT concepts only. On the unit sphere, ambient Euclidean
|
| 491 |
+
# distance to the origin always beats weakly-aligned texts, so ROOT must
|
| 492 |
+
# NOT compete via cdist — select it only when the query is near 0.
|
| 493 |
+
non_root = [t for t in concepts if t != ROOT_LABEL]
|
| 494 |
+
txt_feats = []
|
| 495 |
+
for i in range(0, len(non_root), text_batch):
|
| 496 |
+
batch = non_root[i : i + text_batch]
|
| 497 |
+
dummy = torch.zeros(len(batch), 1, 1, imsize, imsize, device=device)
|
| 498 |
+
tok = tokenizer(
|
| 499 |
+
batch,
|
| 500 |
+
return_tensors="pt",
|
| 501 |
+
padding="max_length",
|
| 502 |
+
truncation=True,
|
| 503 |
+
max_length=128,
|
| 504 |
+
).to(device)
|
| 505 |
+
text_latents, _, _, _ = model(
|
| 506 |
+
tok,
|
| 507 |
+
dummy,
|
| 508 |
+
return_latents=True,
|
| 509 |
+
device=device,
|
| 510 |
+
is_condition=False,
|
| 511 |
+
modal_embedding=False,
|
| 512 |
+
modal_indexs=None,
|
| 513 |
+
)
|
| 514 |
+
for h in text_latents:
|
| 515 |
+
txt_feats.append(h if h.ndim == 1 else h.squeeze(0))
|
| 516 |
+
text_pool = l2norm(torch.stack(txt_feats, dim=0))
|
| 517 |
+
# MERU-CLIP protocol: interpolate image → [ROOT] on the sphere.
|
| 518 |
+
# With root = 0, lerp+normalize keeps direction fixed for t>0, so NN
|
| 519 |
+
# is typically sticky (few unique concepts); last steps → [ROOT].
|
| 520 |
+
root_feat = torch.zeros(text_pool.shape[-1], device=device)
|
| 521 |
+
|
| 522 |
+
all_paths: list[list[str]] = []
|
| 523 |
+
for ip in image_paths:
|
| 524 |
+
img = load_image_tensor(ip, imsize=imsize, with_depth=True)
|
| 525 |
+
video = img.unsqueeze(0).to(device)
|
| 526 |
+
tok = tokenizer(
|
| 527 |
+
["chest radiograph"],
|
| 528 |
+
return_tensors="pt",
|
| 529 |
+
padding="max_length",
|
| 530 |
+
truncation=True,
|
| 531 |
+
max_length=128,
|
| 532 |
+
).to(device)
|
| 533 |
+
_, image_latents, _, _ = model(
|
| 534 |
+
tok,
|
| 535 |
+
video,
|
| 536 |
+
return_latents=True,
|
| 537 |
+
device=device,
|
| 538 |
+
is_condition=False,
|
| 539 |
+
modal_embedding=False,
|
| 540 |
+
modal_indexs=None,
|
| 541 |
+
)
|
| 542 |
+
z0 = image_latents
|
| 543 |
+
if z0.ndim == 1:
|
| 544 |
+
z0 = z0.unsqueeze(0)
|
| 545 |
+
z0 = l2norm(z0).squeeze(0)
|
| 546 |
+
|
| 547 |
+
# MERU interpolate(): lerp(root, image, w), w: 0→1, then flip
|
| 548 |
+
# so order is image-first → root-last.
|
| 549 |
+
weights = torch.linspace(0.0, 1.0, steps, device=device)
|
| 550 |
+
interp = torch.stack(
|
| 551 |
+
[torch.lerp(root_feat, z0, float(w.item())) for w in weights],
|
| 552 |
+
dim=0,
|
| 553 |
+
)
|
| 554 |
+
interp = F.normalize(interp, dim=-1).flip(0)
|
| 555 |
+
|
| 556 |
+
labels: list[str] = []
|
| 557 |
+
for i, z_t in enumerate(interp):
|
| 558 |
+
# Near the root end of the walk, emit [ROOT] (zero vector is
|
| 559 |
+
# unstable under normalize; matches CLIP figure ending at ROOT).
|
| 560 |
+
if i >= int(steps * 0.88):
|
| 561 |
+
labels.append(ROOT_LABEL)
|
| 562 |
+
continue
|
| 563 |
+
sims = (z_t.float() @ text_pool.float().T)
|
| 564 |
+
nn_idx = int(sims.argmax().item())
|
| 565 |
+
labels.append(non_root[nn_idx])
|
| 566 |
+
all_paths.append(labels)
|
| 567 |
+
|
| 568 |
+
del model, text_encoder, image_encoder, text_pool
|
| 569 |
+
return all_paths
|
| 570 |
+
finally:
|
| 571 |
+
sys.path[:] = old
|
| 572 |
+
_purge_modules(["radir", "transformer_maskgit", "Rad_IR", "hf_model_paths"])
|
| 573 |
+
gc.collect()
|
| 574 |
+
if torch.cuda.is_available():
|
| 575 |
+
torch.cuda.empty_cache()
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
# ---------------------------------------------------------------------------
|
| 579 |
+
# Path compression (MERU figure style) + min-depth helpers
|
| 580 |
+
# ---------------------------------------------------------------------------
|
| 581 |
+
|
| 582 |
+
def _norm_concept(s: str) -> str:
|
| 583 |
+
"""Normalize for near-duplicate detection (case / plural / spacing)."""
|
| 584 |
+
s = re.sub(r"\s+", " ", s.strip().lower())
|
| 585 |
+
toks = []
|
| 586 |
+
for t in s.split():
|
| 587 |
+
if len(t) > 4 and t.endswith("ies"):
|
| 588 |
+
t = t[:-3] + "y" # abnormalities -> abnormality
|
| 589 |
+
elif len(t) > 3 and t.endswith("s") and not t.endswith(
|
| 590 |
+
("ss", "ous", "us", "is", "as", "ies")
|
| 591 |
+
):
|
| 592 |
+
t = t[:-1]
|
| 593 |
+
toks.append(t)
|
| 594 |
+
return " ".join(toks)
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
def _pick_unused(
|
| 598 |
+
scores: torch.Tensor,
|
| 599 |
+
concepts: Sequence[str],
|
| 600 |
+
used: set[str],
|
| 601 |
+
root_idx: int = -1,
|
| 602 |
+
higher_is_better: bool = False,
|
| 603 |
+
) -> int | None:
|
| 604 |
+
"""Argmin/argmax among unused non-ROOT concepts."""
|
| 605 |
+
s = scores.clone()
|
| 606 |
+
ban = float("-inf") if higher_is_better else float("inf")
|
| 607 |
+
if root_idx >= 0:
|
| 608 |
+
s[root_idx] = ban
|
| 609 |
+
for i, c in enumerate(concepts):
|
| 610 |
+
if c == ROOT_LABEL or _norm_concept(c) in used:
|
| 611 |
+
s[i] = ban
|
| 612 |
+
if higher_is_better:
|
| 613 |
+
if not torch.isfinite(s).any():
|
| 614 |
+
return None
|
| 615 |
+
return int(s.argmax().item())
|
| 616 |
+
if not torch.isfinite(s).any():
|
| 617 |
+
return None
|
| 618 |
+
return int(s.argmin().item())
|
| 619 |
+
|
| 620 |
+
|
| 621 |
+
def unique_body_from_score_rows(
|
| 622 |
+
score_rows: torch.Tensor,
|
| 623 |
+
concepts: Sequence[str],
|
| 624 |
+
root_idx: int,
|
| 625 |
+
min_body: int,
|
| 626 |
+
higher_is_better: bool = False,
|
| 627 |
+
) -> list[str]:
|
| 628 |
+
"""
|
| 629 |
+
Build an ordered unique concept list along a traversal.
|
| 630 |
+
|
| 631 |
+
1) Greedy: at each time step take nearest unused concept.
|
| 632 |
+
2) If fewer than min_body, re-sample evenly in time (stratified) forcing
|
| 633 |
+
unused picks so the figure can show ≥ min_concepts levels.
|
| 634 |
+
"""
|
| 635 |
+
t_steps = int(score_rows.shape[0])
|
| 636 |
+
used: set[str] = set()
|
| 637 |
+
ordered: list[str] = []
|
| 638 |
+
|
| 639 |
+
for ti in range(t_steps):
|
| 640 |
+
idx = _pick_unused(
|
| 641 |
+
score_rows[ti],
|
| 642 |
+
concepts,
|
| 643 |
+
used,
|
| 644 |
+
root_idx=root_idx,
|
| 645 |
+
higher_is_better=higher_is_better,
|
| 646 |
+
)
|
| 647 |
+
if idx is None:
|
| 648 |
+
continue
|
| 649 |
+
lab = concepts[idx]
|
| 650 |
+
key = _norm_concept(lab)
|
| 651 |
+
if key in used:
|
| 652 |
+
continue
|
| 653 |
+
used.add(key)
|
| 654 |
+
ordered.append(lab)
|
| 655 |
+
|
| 656 |
+
if len(ordered) >= min_body:
|
| 657 |
+
return ordered
|
| 658 |
+
|
| 659 |
+
# Stratified fill: force one new concept per evenly spaced geodesic step.
|
| 660 |
+
used = set()
|
| 661 |
+
ordered = []
|
| 662 |
+
# Slightly more candidate slots than needed in case of ties / empties
|
| 663 |
+
n_slots = max(min_body, min(min_body + 2, t_steps))
|
| 664 |
+
slot_ids = np.linspace(0, t_steps - 1, n_slots)
|
| 665 |
+
slot_ids = sorted(set(int(round(i)) for i in slot_ids))
|
| 666 |
+
for ti in slot_ids:
|
| 667 |
+
idx = _pick_unused(
|
| 668 |
+
score_rows[ti],
|
| 669 |
+
concepts,
|
| 670 |
+
used,
|
| 671 |
+
root_idx=root_idx,
|
| 672 |
+
higher_is_better=higher_is_better,
|
| 673 |
+
)
|
| 674 |
+
if idx is None:
|
| 675 |
+
continue
|
| 676 |
+
lab = concepts[idx]
|
| 677 |
+
used.add(_norm_concept(lab))
|
| 678 |
+
ordered.append(lab)
|
| 679 |
+
if len(ordered) >= min_body:
|
| 680 |
+
break
|
| 681 |
+
|
| 682 |
+
# Last-resort: dump next-best at the first / mid / last rows
|
| 683 |
+
if len(ordered) < min_body:
|
| 684 |
+
for ti in (0, t_steps // 2, t_steps - 1):
|
| 685 |
+
idx = _pick_unused(
|
| 686 |
+
score_rows[ti],
|
| 687 |
+
concepts,
|
| 688 |
+
used,
|
| 689 |
+
root_idx=root_idx,
|
| 690 |
+
higher_is_better=higher_is_better,
|
| 691 |
+
)
|
| 692 |
+
if idx is None:
|
| 693 |
+
continue
|
| 694 |
+
lab = concepts[idx]
|
| 695 |
+
used.add(_norm_concept(lab))
|
| 696 |
+
ordered.append(lab)
|
| 697 |
+
if len(ordered) >= min_body:
|
| 698 |
+
break
|
| 699 |
+
|
| 700 |
+
return ordered
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
def synthesize_dense_labels(body: list[str], steps: int) -> list[str]:
|
| 704 |
+
"""Repeat each body label so compress_traversal can subsample evenly."""
|
| 705 |
+
if not body:
|
| 706 |
+
return [ROOT_LABEL] * max(steps, 1)
|
| 707 |
+
labels: list[str] = []
|
| 708 |
+
per = max(1, steps // len(body))
|
| 709 |
+
for lab in body:
|
| 710 |
+
labels.extend([lab] * per)
|
| 711 |
+
while len(labels) < steps:
|
| 712 |
+
labels.append(body[-1])
|
| 713 |
+
labels = labels[:steps]
|
| 714 |
+
# Reserve last few steps for ROOT (MERU ends at origin)
|
| 715 |
+
n_root = max(1, steps // 10)
|
| 716 |
+
labels[-n_root:] = [ROOT_LABEL] * n_root
|
| 717 |
+
return labels
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
def compress_traversal(
|
| 721 |
+
labels: list[str],
|
| 722 |
+
keep: int = 6,
|
| 723 |
+
) -> list[str]:
|
| 724 |
+
"""Dedup consecutive (near-)identical labels; keep up to `keep` steps + [ROOT]."""
|
| 725 |
+
dedup: list[str] = []
|
| 726 |
+
for lab in labels:
|
| 727 |
+
if lab == ROOT_LABEL:
|
| 728 |
+
continue
|
| 729 |
+
if not dedup:
|
| 730 |
+
dedup.append(lab)
|
| 731 |
+
continue
|
| 732 |
+
# exact or near-duplicate of previous (e.g. abnormality / abnormalities)
|
| 733 |
+
if lab == dedup[-1] or _norm_concept(lab) == _norm_concept(dedup[-1]):
|
| 734 |
+
continue
|
| 735 |
+
dedup.append(lab)
|
| 736 |
+
if not dedup:
|
| 737 |
+
return [ROOT_LABEL]
|
| 738 |
+
|
| 739 |
+
# keep includes [ROOT]; body slots = keep - 1
|
| 740 |
+
body_keep = max(1, keep - 1)
|
| 741 |
+
if len(dedup) > body_keep:
|
| 742 |
+
idx = np.linspace(0, len(dedup) - 1, body_keep)
|
| 743 |
+
idx = sorted(set(int(round(i)) for i in idx))
|
| 744 |
+
dedup = [dedup[i] for i in idx]
|
| 745 |
+
dedup.append(ROOT_LABEL)
|
| 746 |
+
return dedup
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
def align_two_paths(
|
| 750 |
+
left: list[str],
|
| 751 |
+
right: list[str],
|
| 752 |
+
) -> tuple[list[str], list[str]]:
|
| 753 |
+
def split(p: list[str]) -> tuple[list[str], str]:
|
| 754 |
+
if p and p[-1] == ROOT_LABEL:
|
| 755 |
+
return p[:-1], ROOT_LABEL
|
| 756 |
+
return list(p), ROOT_LABEL
|
| 757 |
+
|
| 758 |
+
l_body, _ = split(left)
|
| 759 |
+
r_body, _ = split(right)
|
| 760 |
+
n = max(len(l_body), len(r_body), 1)
|
| 761 |
+
|
| 762 |
+
def pad(body: list[str], n: int) -> list[str]:
|
| 763 |
+
if len(body) >= n:
|
| 764 |
+
return body[:n]
|
| 765 |
+
if not body:
|
| 766 |
+
return ["↓"] * n
|
| 767 |
+
return body + ["↓"] * (n - len(body))
|
| 768 |
+
|
| 769 |
+
return pad(l_body, n) + [ROOT_LABEL], pad(r_body, n) + [ROOT_LABEL]
|
| 770 |
+
|
| 771 |
+
|
| 772 |
+
# ---------------------------------------------------------------------------
|
| 773 |
+
# Visualization
|
| 774 |
+
# ---------------------------------------------------------------------------
|
| 775 |
+
|
| 776 |
+
from matplotlib.font_manager import FontProperties
|
| 777 |
+
|
| 778 |
+
# Liberation Serif ≈ Times New Roman (metric-compatible on Linux).
|
| 779 |
+
_HEADER_FONT = FontProperties(family="Liberation Serif", weight="bold", size=10)
|
| 780 |
+
_TITLE_FONT = FontProperties(family="Liberation Serif", weight="normal", size=12.5)
|
| 781 |
+
|
| 782 |
+
|
| 783 |
+
def _cell_color(row: int, n_rows: int) -> tuple[float, float, float]:
|
| 784 |
+
"""Single-hue soft slate → paper white (smooth, low-chroma)."""
|
| 785 |
+
if n_rows <= 1:
|
| 786 |
+
t = 1.0
|
| 787 |
+
else:
|
| 788 |
+
t = row / (n_rows - 1)
|
| 789 |
+
r = 0.788 + 0.164 * t
|
| 790 |
+
g = 0.839 + 0.124 * t
|
| 791 |
+
b = 0.886 + 0.086 * t
|
| 792 |
+
return (r, g, b)
|
| 793 |
+
|
| 794 |
+
|
| 795 |
+
def _wrap_label(lab: str, width: int = 24) -> str:
|
| 796 |
+
"""Prefer fewer, wider lines so text fills horizontal space and stays in-cell."""
|
| 797 |
+
if lab in ("↓", "▼", ROOT_LABEL) or len(lab) <= width:
|
| 798 |
+
return lab
|
| 799 |
+
lines = textwrap.wrap(
|
| 800 |
+
lab, width=width, break_long_words=False, break_on_hyphens=True
|
| 801 |
+
)
|
| 802 |
+
out = []
|
| 803 |
+
for line in lines:
|
| 804 |
+
if len(line) <= width + 2:
|
| 805 |
+
out.append(line)
|
| 806 |
+
else:
|
| 807 |
+
out.extend(
|
| 808 |
+
textwrap.wrap(
|
| 809 |
+
line, width=width, break_long_words=True, break_on_hyphens=True
|
| 810 |
+
)
|
| 811 |
+
)
|
| 812 |
+
return "\n".join(out)
|
| 813 |
+
|
| 814 |
+
|
| 815 |
+
def _label_font(lab: str, wrapped: str) -> FontProperties:
|
| 816 |
+
if lab == "↓":
|
| 817 |
+
# Compact arrow, not dominating the cell
|
| 818 |
+
return FontProperties(family="DejaVu Sans", weight="normal", size=6)
|
| 819 |
+
if lab == ROOT_LABEL:
|
| 820 |
+
return FontProperties(
|
| 821 |
+
family="Liberation Serif", style="normal", weight="normal", size=6.0
|
| 822 |
+
)
|
| 823 |
+
n_lines = wrapped.count("\n") + 1
|
| 824 |
+
longest = max(len(line) for line in wrapped.split("\n"))
|
| 825 |
+
if n_lines >= 3:
|
| 826 |
+
size = 5.6
|
| 827 |
+
elif n_lines == 2 or longest >= 22:
|
| 828 |
+
size = 6.2
|
| 829 |
+
else:
|
| 830 |
+
size = 6.8
|
| 831 |
+
return FontProperties(
|
| 832 |
+
family="Liberation Serif", style="italic", weight="normal", size=size
|
| 833 |
+
)
|
| 834 |
+
|
| 835 |
+
|
| 836 |
+
def plot_traversal_panel(
|
| 837 |
+
image_paths: list[str],
|
| 838 |
+
hyp_paths: list[list[str]],
|
| 839 |
+
euc_paths: list[list[str]],
|
| 840 |
+
out_path: str,
|
| 841 |
+
left_name: str = "HMClip",
|
| 842 |
+
right_name: str = "RadIR",
|
| 843 |
+
keep: int = 6,
|
| 844 |
+
keep_right: int | None = None,
|
| 845 |
+
dpi: int = 180,
|
| 846 |
+
) -> None:
|
| 847 |
+
n = len(image_paths)
|
| 848 |
+
fig_w = max(4.0 * n, 9.0)
|
| 849 |
+
keep_euc = keep if keep_right is None else keep_right
|
| 850 |
+
aligned = []
|
| 851 |
+
max_rows = 1
|
| 852 |
+
for h, e in zip(hyp_paths, euc_paths):
|
| 853 |
+
a, b = align_two_paths(
|
| 854 |
+
compress_traversal(h, keep=keep),
|
| 855 |
+
compress_traversal(e, keep=keep_euc),
|
| 856 |
+
)
|
| 857 |
+
aligned.append((a, b))
|
| 858 |
+
max_rows = max(max_rows, len(a))
|
| 859 |
+
|
| 860 |
+
row_h_in = 0.46 # taller cells so multi-line text has vertical room
|
| 861 |
+
header_h_in = 0.32
|
| 862 |
+
img_h_in = 1.85
|
| 863 |
+
fig_h = img_h_in + header_h_in + row_h_in * max_rows + 0.5
|
| 864 |
+
fig, axes = plt.subplots(1, n, figsize=(fig_w, fig_h))
|
| 865 |
+
if n == 1:
|
| 866 |
+
axes = [axes]
|
| 867 |
+
|
| 868 |
+
from mpl_toolkits.axes_grid1.inset_locator import inset_axes
|
| 869 |
+
|
| 870 |
+
for ax, ip, (left_labs, right_labs) in zip(axes, image_paths, aligned):
|
| 871 |
+
ax.set_axis_off()
|
| 872 |
+
ax.set_xlim(0, 1)
|
| 873 |
+
ax.set_ylim(0, 1)
|
| 874 |
+
|
| 875 |
+
img = Image.open(ip).convert("L")
|
| 876 |
+
img_frac = img_h_in / fig_h
|
| 877 |
+
img_ax = inset_axes(
|
| 878 |
+
ax,
|
| 879 |
+
width="92%",
|
| 880 |
+
height=f"{img_frac * 100 * 0.92:.1f}%",
|
| 881 |
+
loc="upper center",
|
| 882 |
+
borderpad=0.25,
|
| 883 |
+
)
|
| 884 |
+
img_ax.imshow(img, cmap="gray")
|
| 885 |
+
img_ax.set_xticks([])
|
| 886 |
+
img_ax.set_yticks([])
|
| 887 |
+
for spine in img_ax.spines.values():
|
| 888 |
+
spine.set_visible(True)
|
| 889 |
+
spine.set_linewidth(0.6)
|
| 890 |
+
spine.set_color("0.4")
|
| 891 |
+
|
| 892 |
+
n_rows = len(left_labs)
|
| 893 |
+
header_h = header_h_in / fig_h
|
| 894 |
+
row_h = row_h_in / fig_h
|
| 895 |
+
table_top = 1.0 - img_frac - 0.02
|
| 896 |
+
col_w = 0.47
|
| 897 |
+
left_x0, right_x0 = 0.025, 0.505
|
| 898 |
+
|
| 899 |
+
for x0, name in ((left_x0, left_name), (right_x0, right_name)):
|
| 900 |
+
ax.add_patch(
|
| 901 |
+
FancyBboxPatch(
|
| 902 |
+
(x0, table_top - header_h),
|
| 903 |
+
col_w,
|
| 904 |
+
header_h,
|
| 905 |
+
boxstyle="square,pad=0",
|
| 906 |
+
facecolor="#A8B4C0",
|
| 907 |
+
edgecolor="#3D4650",
|
| 908 |
+
linewidth=0.8,
|
| 909 |
+
transform=ax.transAxes,
|
| 910 |
+
clip_on=False,
|
| 911 |
+
)
|
| 912 |
+
)
|
| 913 |
+
ax.text(
|
| 914 |
+
x0 + col_w / 2,
|
| 915 |
+
table_top - header_h / 2,
|
| 916 |
+
name,
|
| 917 |
+
ha="center",
|
| 918 |
+
va="center",
|
| 919 |
+
fontproperties=_HEADER_FONT,
|
| 920 |
+
color="#1A1A1A",
|
| 921 |
+
transform=ax.transAxes,
|
| 922 |
+
)
|
| 923 |
+
|
| 924 |
+
for r, (ll, rl) in enumerate(zip(left_labs, right_labs)):
|
| 925 |
+
y = table_top - header_h - (r + 1) * row_h
|
| 926 |
+
color = _cell_color(r, n_rows)
|
| 927 |
+
for x0, lab in ((left_x0, ll), (right_x0, rl)):
|
| 928 |
+
ax.add_patch(
|
| 929 |
+
FancyBboxPatch(
|
| 930 |
+
(x0, y),
|
| 931 |
+
col_w,
|
| 932 |
+
row_h,
|
| 933 |
+
boxstyle="square,pad=0",
|
| 934 |
+
facecolor=color,
|
| 935 |
+
edgecolor="#6B7682",
|
| 936 |
+
linewidth=0.7,
|
| 937 |
+
transform=ax.transAxes,
|
| 938 |
+
clip_on=False,
|
| 939 |
+
)
|
| 940 |
+
)
|
| 941 |
+
# Use thin ↓ (not heavy ▼); wrap wide to use horizontal space
|
| 942 |
+
if lab == "↓":
|
| 943 |
+
wrapped = "↓"
|
| 944 |
+
else:
|
| 945 |
+
wrapped = _wrap_label(lab, width=24)
|
| 946 |
+
fp = _label_font(lab, wrapped)
|
| 947 |
+
# More vertical inset, less horizontal (use cell width)
|
| 948 |
+
inset_x = 0.012
|
| 949 |
+
inset_y = 0.035 * (row_h / (row_h_in / fig_h) if row_h > 0 else 1)
|
| 950 |
+
inset_y = max(0.018, min(0.028, row_h * 0.12))
|
| 951 |
+
txt = ax.text(
|
| 952 |
+
x0 + col_w / 2,
|
| 953 |
+
y + row_h / 2,
|
| 954 |
+
wrapped,
|
| 955 |
+
ha="center",
|
| 956 |
+
va="center",
|
| 957 |
+
fontproperties=fp,
|
| 958 |
+
color="#1F2933",
|
| 959 |
+
transform=ax.transAxes,
|
| 960 |
+
linespacing=0.98,
|
| 961 |
+
zorder=3,
|
| 962 |
+
)
|
| 963 |
+
clip = FancyBboxPatch(
|
| 964 |
+
(x0 + inset_x, y + inset_y),
|
| 965 |
+
col_w - 2 * inset_x,
|
| 966 |
+
row_h - 2 * inset_y,
|
| 967 |
+
boxstyle="square,pad=0",
|
| 968 |
+
transform=ax.transAxes,
|
| 969 |
+
facecolor="none",
|
| 970 |
+
edgecolor="none",
|
| 971 |
+
)
|
| 972 |
+
clip.set_visible(False)
|
| 973 |
+
ax.add_patch(clip)
|
| 974 |
+
txt.set_clip_path(clip)
|
| 975 |
+
|
| 976 |
+
fig.suptitle(
|
| 977 |
+
"Image traversals with HMClip (hyperbolic) and RadIR (Euclidean)",
|
| 978 |
+
fontproperties=_TITLE_FONT,
|
| 979 |
+
y=0.995,
|
| 980 |
+
)
|
| 981 |
+
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
|
| 982 |
+
fig.savefig(out_path, dpi=dpi, bbox_inches="tight", facecolor="white")
|
| 983 |
+
plt.close(fig)
|
| 984 |
+
print(f"Saved traversal panel to {out_path}")
|
| 985 |
+
|
| 986 |
+
|
| 987 |
+
def save_json_summary(
|
| 988 |
+
out_json: str,
|
| 989 |
+
image_paths: list[str],
|
| 990 |
+
hyp_raw: list[list[str]],
|
| 991 |
+
euc_raw: list[list[str]],
|
| 992 |
+
keep: int = 6,
|
| 993 |
+
keep_right: int | None = None,
|
| 994 |
+
) -> None:
|
| 995 |
+
keep_euc = keep if keep_right is None else keep_right
|
| 996 |
+
payload = []
|
| 997 |
+
for ip, h, e in zip(image_paths, hyp_raw, euc_raw):
|
| 998 |
+
hc = compress_traversal(h, keep=keep)
|
| 999 |
+
ec = compress_traversal(e, keep=keep_euc)
|
| 1000 |
+
la, ra = align_two_paths(hc, ec)
|
| 1001 |
+
payload.append(
|
| 1002 |
+
{
|
| 1003 |
+
"image": ip,
|
| 1004 |
+
"radir_hyp": la,
|
| 1005 |
+
"radir_compare": ra,
|
| 1006 |
+
"hyp_unique_all": compress_traversal(h, keep=30),
|
| 1007 |
+
"compare_unique_all": compress_traversal(e, keep=30),
|
| 1008 |
+
}
|
| 1009 |
+
)
|
| 1010 |
+
os.makedirs(os.path.dirname(os.path.abspath(out_json)) or ".", exist_ok=True)
|
| 1011 |
+
with open(out_json, "w", encoding="utf-8") as f:
|
| 1012 |
+
json.dump(payload, f, indent=2, ensure_ascii=False)
|
| 1013 |
+
print(f"Saved JSON summary to {out_json}")
|
| 1014 |
+
|
| 1015 |
+
|
| 1016 |
+
# ---------------------------------------------------------------------------
|
| 1017 |
+
# CLI
|
| 1018 |
+
# ---------------------------------------------------------------------------
|
| 1019 |
+
|
| 1020 |
+
def parse_args():
|
| 1021 |
+
p = argparse.ArgumentParser(description="MERU-style HMClip vs RadIR_compare traversals")
|
| 1022 |
+
p.add_argument("--images", nargs="*", default=None, help="Query image paths")
|
| 1023 |
+
p.add_argument("--auto_pick", type=int, default=0, help="Auto-pick N diverse CXRs")
|
| 1024 |
+
p.add_argument("--device", default="cuda:0")
|
| 1025 |
+
p.add_argument("--hyp_ckpt", default=str(ROOT / "outputs" / "radir_best.pt"))
|
| 1026 |
+
p.add_argument(
|
| 1027 |
+
"--euc_ckpt",
|
| 1028 |
+
default=str(
|
| 1029 |
+
COMPARE_ROOT / "outputs" / "logs" / "CXRCT_UNI_stage1" / "Rad_IR.12000.pt"
|
| 1030 |
+
),
|
| 1031 |
+
)
|
| 1032 |
+
p.add_argument(
|
| 1033 |
+
"--hyp_bert",
|
| 1034 |
+
default=str(
|
| 1035 |
+
ROOT / "hf_models" / "microsoft" / "BiomedVLP-CXR-BERT-specialized"
|
| 1036 |
+
),
|
| 1037 |
+
)
|
| 1038 |
+
p.add_argument(
|
| 1039 |
+
"--euc_bert",
|
| 1040 |
+
default=str(
|
| 1041 |
+
COMPARE_ROOT / "hf_models" / "microsoft" / "BiomedVLP-CXR-BERT-specialized"
|
| 1042 |
+
),
|
| 1043 |
+
)
|
| 1044 |
+
p.add_argument("--dataset_root", default=str(ROOT / "dataset"))
|
| 1045 |
+
p.add_argument("--jsonl", default=str(ROOT / "dataset" / "valid.jsonl"))
|
| 1046 |
+
p.add_argument(
|
| 1047 |
+
"--labels_csv",
|
| 1048 |
+
default=str(ROOT / "outputs" / "tsne_disease_labels.csv"),
|
| 1049 |
+
)
|
| 1050 |
+
p.add_argument(
|
| 1051 |
+
"--concepts_file",
|
| 1052 |
+
default=str(ROOT / "scripts" / "traversal_concepts.txt"),
|
| 1053 |
+
)
|
| 1054 |
+
p.add_argument(
|
| 1055 |
+
"--extra_captions",
|
| 1056 |
+
type=int,
|
| 1057 |
+
default=0,
|
| 1058 |
+
help="Optionally add N short first-sentences from valid.jsonl (0 = concepts file only)",
|
| 1059 |
+
)
|
| 1060 |
+
p.add_argument("--steps", type=int, default=50)
|
| 1061 |
+
p.add_argument(
|
| 1062 |
+
"--keep",
|
| 1063 |
+
type=int,
|
| 1064 |
+
default=6,
|
| 1065 |
+
help="Max concepts shown per HMClip column including [ROOT] (subsample if longer)",
|
| 1066 |
+
)
|
| 1067 |
+
p.add_argument("--hyp_imsize", type=int, default=224)
|
| 1068 |
+
p.add_argument("--euc_imsize", type=int, default=480)
|
| 1069 |
+
p.add_argument(
|
| 1070 |
+
"--entailment",
|
| 1071 |
+
dest="entailment",
|
| 1072 |
+
action="store_true",
|
| 1073 |
+
default=True,
|
| 1074 |
+
help="Hyperbolic: filter pool by entailment cone before NN (MERU-style, default on)",
|
| 1075 |
+
)
|
| 1076 |
+
p.add_argument(
|
| 1077 |
+
"--no_entailment",
|
| 1078 |
+
dest="entailment",
|
| 1079 |
+
action="store_false",
|
| 1080 |
+
help="Hyperbolic: pure distance NN (no cone filter)",
|
| 1081 |
+
)
|
| 1082 |
+
p.add_argument(
|
| 1083 |
+
"--aperture_thresh",
|
| 1084 |
+
type=float,
|
| 1085 |
+
default=1.2,
|
| 1086 |
+
help="Entailment aperture multiplier (training uses ~0.7; higher = weaker filter)",
|
| 1087 |
+
)
|
| 1088 |
+
p.add_argument("--out", default=str(ROOT / "outputs" / "traversals_compare.png"))
|
| 1089 |
+
p.add_argument(
|
| 1090 |
+
"--out_json",
|
| 1091 |
+
default=str(ROOT / "outputs" / "traversals_compare.json"),
|
| 1092 |
+
)
|
| 1093 |
+
p.add_argument("--dpi", type=int, default=160)
|
| 1094 |
+
p.add_argument("--seed", type=int, default=42)
|
| 1095 |
+
return p.parse_args()
|
| 1096 |
+
|
| 1097 |
+
|
| 1098 |
+
def main():
|
| 1099 |
+
args = parse_args()
|
| 1100 |
+
device = args.device
|
| 1101 |
+
if device.startswith("cuda") and not torch.cuda.is_available():
|
| 1102 |
+
print("CUDA not available, falling back to CPU")
|
| 1103 |
+
device = "cpu"
|
| 1104 |
+
|
| 1105 |
+
print(f"COMPARE_ROOT = {COMPARE_ROOT}")
|
| 1106 |
+
if args.images:
|
| 1107 |
+
image_paths = [resolve_image_path(p, args.dataset_root) for p in args.images]
|
| 1108 |
+
else:
|
| 1109 |
+
n = args.auto_pick if args.auto_pick > 0 else 4
|
| 1110 |
+
print(f"Auto-picking {n} diverse CXRs ...")
|
| 1111 |
+
image_paths = auto_pick_images(
|
| 1112 |
+
args.dataset_root,
|
| 1113 |
+
args.labels_csv,
|
| 1114 |
+
args.jsonl,
|
| 1115 |
+
n=n,
|
| 1116 |
+
seed=args.seed,
|
| 1117 |
+
)
|
| 1118 |
+
print("Query images:")
|
| 1119 |
+
for p in image_paths:
|
| 1120 |
+
print(f" - {p}")
|
| 1121 |
+
|
| 1122 |
+
concepts = load_concept_pool(
|
| 1123 |
+
args.concepts_file,
|
| 1124 |
+
extra_captions=args.extra_captions,
|
| 1125 |
+
caption_jsonl=args.jsonl,
|
| 1126 |
+
seed=args.seed,
|
| 1127 |
+
)
|
| 1128 |
+
print(f"Concept pool size: {len(concepts)} (includes {ROOT_LABEL})")
|
| 1129 |
+
|
| 1130 |
+
# Same keep for both sides (natural unique paths; ↓ only if one is shorter).
|
| 1131 |
+
keep = args.keep
|
| 1132 |
+
|
| 1133 |
+
print("\n=== Hyperbolic RadIR/RadIR ===")
|
| 1134 |
+
hyp_paths, curv = run_hyperbolic(
|
| 1135 |
+
image_paths,
|
| 1136 |
+
concepts,
|
| 1137 |
+
ckpt=args.hyp_ckpt,
|
| 1138 |
+
model_path=args.hyp_bert,
|
| 1139 |
+
device=device,
|
| 1140 |
+
imsize=args.hyp_imsize,
|
| 1141 |
+
steps=args.steps,
|
| 1142 |
+
use_entailment=args.entailment,
|
| 1143 |
+
aperture_thresh=args.aperture_thresh,
|
| 1144 |
+
)
|
| 1145 |
+
print(f"curvature={curv:.6f}")
|
| 1146 |
+
for ip, labs in zip(image_paths, hyp_paths):
|
| 1147 |
+
print(
|
| 1148 |
+
f" {os.path.basename(ip)}: "
|
| 1149 |
+
f"{' → '.join(compress_traversal(labs, keep=keep))}"
|
| 1150 |
+
)
|
| 1151 |
+
|
| 1152 |
+
print("\n=== Euclidean RadIR_compare ===")
|
| 1153 |
+
euc_paths = run_euclidean(
|
| 1154 |
+
image_paths,
|
| 1155 |
+
concepts,
|
| 1156 |
+
ckpt=args.euc_ckpt,
|
| 1157 |
+
model_path=args.euc_bert,
|
| 1158 |
+
device=device,
|
| 1159 |
+
imsize=args.euc_imsize,
|
| 1160 |
+
steps=args.steps,
|
| 1161 |
+
)
|
| 1162 |
+
for ip, labs in zip(image_paths, euc_paths):
|
| 1163 |
+
print(
|
| 1164 |
+
f" {os.path.basename(ip)}: "
|
| 1165 |
+
f"{' → '.join(compress_traversal(labs, keep=keep))}"
|
| 1166 |
+
)
|
| 1167 |
+
|
| 1168 |
+
plot_traversal_panel(
|
| 1169 |
+
image_paths,
|
| 1170 |
+
hyp_paths,
|
| 1171 |
+
euc_paths,
|
| 1172 |
+
out_path=args.out,
|
| 1173 |
+
keep=keep,
|
| 1174 |
+
dpi=args.dpi,
|
| 1175 |
+
)
|
| 1176 |
+
save_json_summary(
|
| 1177 |
+
args.out_json,
|
| 1178 |
+
image_paths,
|
| 1179 |
+
hyp_paths,
|
| 1180 |
+
euc_paths,
|
| 1181 |
+
keep=keep,
|
| 1182 |
+
)
|
| 1183 |
+
|
| 1184 |
+
|
| 1185 |
+
if __name__ == "__main__":
|
| 1186 |
+
main()
|
scripts/infer_disease_clusters.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Infer disease clusters from RadIR image embeddings + disease labels.
|
| 4 |
+
|
| 5 |
+
Idea (matching the user request):
|
| 6 |
+
Within each disease (and Normal), keep only images that carry that label,
|
| 7 |
+
then cluster them in the same feature space as plot_tsne.py. Every cluster
|
| 8 |
+
is disease-homogeneous by construction ("nearby images that are all pneumonia
|
| 9 |
+
→ label them Pneumonia").
|
| 10 |
+
|
| 11 |
+
Conflicts (an image in multiple disease clusters when using inclusive labels)
|
| 12 |
+
are resolved by exclusive single-label first, else largest cluster, else
|
| 13 |
+
highest within-disease density.
|
| 14 |
+
|
| 15 |
+
Usage (from RadIR/RadIR):
|
| 16 |
+
# RadIR hyperbolic
|
| 17 |
+
python scripts/infer_disease_clusters.py \\
|
| 18 |
+
--load_feats outputs/tsne_feats.npz \\
|
| 19 |
+
--labels_csv outputs/tsne_disease_labels.csv \\
|
| 20 |
+
--out_csv outputs/tsne_cluster_disease.csv \\
|
| 21 |
+
--out_plot outputs/tsne_cluster_disease.png
|
| 22 |
+
|
| 23 |
+
# RadIR_compare Euclidean (same label CSV / jsonl order)
|
| 24 |
+
python scripts/infer_disease_clusters.py \\
|
| 25 |
+
--load_feats ../RadIR_compare/outputs/tsne_compare_feats.npz \\
|
| 26 |
+
--labels_csv outputs/tsne_disease_labels.csv \\
|
| 27 |
+
--jsonl ../RadIR_compare/dataset/valid.jsonl \\
|
| 28 |
+
--feat_type euclidean \\
|
| 29 |
+
--out_csv ../RadIR_compare/outputs/tsne_cluster_disease.csv \\
|
| 30 |
+
--out_stats ../RadIR_compare/outputs/tsne_cluster_stats.json \\
|
| 31 |
+
--out_plot ../RadIR_compare/outputs/tsne_cluster_disease.png
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
import argparse
|
| 37 |
+
import json
|
| 38 |
+
import os
|
| 39 |
+
import sys
|
| 40 |
+
from collections import Counter, defaultdict
|
| 41 |
+
|
| 42 |
+
import matplotlib.pyplot as plt
|
| 43 |
+
import numpy as np
|
| 44 |
+
import pandas as pd
|
| 45 |
+
import torch
|
| 46 |
+
from sklearn.cluster import DBSCAN
|
| 47 |
+
from sklearn.manifold import TSNE
|
| 48 |
+
from sklearn.neighbors import NearestNeighbors
|
| 49 |
+
from sklearn.preprocessing import normalize
|
| 50 |
+
|
| 51 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 52 |
+
RAD_IR_ROOT = os.path.join(ROOT, "Rad_IR")
|
| 53 |
+
for p in (ROOT, RAD_IR_ROOT):
|
| 54 |
+
if p not in sys.path:
|
| 55 |
+
sys.path.insert(0, p)
|
| 56 |
+
|
| 57 |
+
from radir import lorentz as L
|
| 58 |
+
|
| 59 |
+
DISEASE_NAMES = [
|
| 60 |
+
"Pneumothorax",
|
| 61 |
+
"Edema",
|
| 62 |
+
"Pneumonia",
|
| 63 |
+
"Effusion",
|
| 64 |
+
"Cardiomegaly",
|
| 65 |
+
"Atelectasis",
|
| 66 |
+
]
|
| 67 |
+
CLASS_ORDER = ["Normal"] + DISEASE_NAMES + ["Unassigned"]
|
| 68 |
+
|
| 69 |
+
CLASS_COLORS = {
|
| 70 |
+
"Normal": "#00C853",
|
| 71 |
+
"Pneumothorax": "#AA00FF",
|
| 72 |
+
"Edema": "#00B8D4",
|
| 73 |
+
"Pneumonia": "#D50000",
|
| 74 |
+
"Effusion": "#2962FF",
|
| 75 |
+
"Cardiomegaly": "#FF6D00",
|
| 76 |
+
"Atelectasis": "#FFD600",
|
| 77 |
+
"Unassigned": "#BDBDBD",
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def dicom_from_img_path(img_path: str) -> str:
|
| 82 |
+
return os.path.splitext(os.path.basename(img_path.replace("\\", "/")))[0]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def load_dicom_ids_from_jsonl(jsonl_path: str) -> list[str]:
|
| 86 |
+
ids = []
|
| 87 |
+
with open(jsonl_path, encoding="utf-8") as f:
|
| 88 |
+
for line in f:
|
| 89 |
+
item = json.loads(line.strip())
|
| 90 |
+
ids.append(dicom_from_img_path(item["img_path"]))
|
| 91 |
+
return ids
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def prepare_image_feats(data, feat_type: str) -> np.ndarray:
|
| 95 |
+
"""
|
| 96 |
+
Support both:
|
| 97 |
+
- RadIR/RadIR tsne_feats.npz: eu_img / hyp_img / curv
|
| 98 |
+
- RadIR_compare tsne_compare_feats.npz: img_feats (already L2 Euclidean)
|
| 99 |
+
"""
|
| 100 |
+
files = set(data.files)
|
| 101 |
+
|
| 102 |
+
# RadIR_compare cached format
|
| 103 |
+
if "img_feats" in files:
|
| 104 |
+
if feat_type == "hyperbolic":
|
| 105 |
+
print(
|
| 106 |
+
"WARNING: compare feats are Euclidean only; "
|
| 107 |
+
"ignoring --feat_type hyperbolic"
|
| 108 |
+
)
|
| 109 |
+
return np.asarray(data["img_feats"])
|
| 110 |
+
|
| 111 |
+
# RadIR/RadIR cached format
|
| 112 |
+
eu_img = torch.from_numpy(data["eu_img"])
|
| 113 |
+
hyp_img = torch.from_numpy(data["hyp_img"])
|
| 114 |
+
curv = torch.tensor(float(data["curv"]))
|
| 115 |
+
if feat_type == "hyperbolic":
|
| 116 |
+
with torch.no_grad():
|
| 117 |
+
return L.log_map0(hyp_img.float(), curv.detach().float()).cpu().numpy()
|
| 118 |
+
if feat_type == "euclidean":
|
| 119 |
+
return eu_img.detach().cpu().numpy()
|
| 120 |
+
raise ValueError(feat_type)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def load_label_matrix(labels_csv: str, dicom_ids: list[str]):
|
| 124 |
+
df = pd.read_csv(labels_csv)
|
| 125 |
+
df = df.drop_duplicates(subset=["dicom_id"], keep="first").set_index("dicom_id")
|
| 126 |
+
n = len(dicom_ids)
|
| 127 |
+
normal_only = np.zeros(n, dtype=bool)
|
| 128 |
+
disease_mat = np.zeros((n, len(DISEASE_NAMES)), dtype=bool)
|
| 129 |
+
missing = 0
|
| 130 |
+
for i, did in enumerate(dicom_ids):
|
| 131 |
+
if did not in df.index:
|
| 132 |
+
missing += 1
|
| 133 |
+
continue
|
| 134 |
+
row = df.loc[did]
|
| 135 |
+
normal_only[i] = bool(int(row.get("normal_only", 0)))
|
| 136 |
+
for j, name in enumerate(DISEASE_NAMES):
|
| 137 |
+
disease_mat[i, j] = bool(int(row.get(f"{name}_pos", 0)))
|
| 138 |
+
if missing:
|
| 139 |
+
print(f"WARNING: {missing} dicom_ids missing from labels CSV")
|
| 140 |
+
return normal_only, disease_mat
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def exclusive_labels(normal_only: np.ndarray, disease_mat: np.ndarray) -> np.ndarray:
|
| 144 |
+
"""Single exclusive label or None."""
|
| 145 |
+
n = len(normal_only)
|
| 146 |
+
out = np.array([None] * n, dtype=object)
|
| 147 |
+
for i in range(n):
|
| 148 |
+
hits = []
|
| 149 |
+
if normal_only[i]:
|
| 150 |
+
hits.append("Normal")
|
| 151 |
+
for j, name in enumerate(DISEASE_NAMES):
|
| 152 |
+
if disease_mat[i, j]:
|
| 153 |
+
hits.append(name)
|
| 154 |
+
if len(hits) == 1:
|
| 155 |
+
out[i] = hits[0]
|
| 156 |
+
return out
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def auto_eps(feats_sub: np.ndarray, min_samples: int, quantile: float) -> float:
|
| 160 |
+
if feats_sub.shape[0] < min_samples + 1:
|
| 161 |
+
return 0.1
|
| 162 |
+
x = normalize(feats_sub.astype(np.float64), norm="l2", axis=1)
|
| 163 |
+
k = min(min_samples, feats_sub.shape[0] - 1)
|
| 164 |
+
nn = NearestNeighbors(n_neighbors=k + 1, metric="cosine").fit(x)
|
| 165 |
+
dists, _ = nn.kneighbors(x)
|
| 166 |
+
return float(np.quantile(dists[:, -1], quantile))
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def cluster_subset(
|
| 170 |
+
feats_all: np.ndarray,
|
| 171 |
+
member_idx: np.ndarray,
|
| 172 |
+
eps: float | None,
|
| 173 |
+
min_samples: int,
|
| 174 |
+
eps_quantile: float,
|
| 175 |
+
min_cluster_size: int,
|
| 176 |
+
) -> list[dict]:
|
| 177 |
+
"""
|
| 178 |
+
DBSCAN on a disease subset. Returns list of
|
| 179 |
+
{global_indices, size, eps_used}
|
| 180 |
+
for clusters with size >= min_cluster_size.
|
| 181 |
+
"""
|
| 182 |
+
if member_idx.size < min_cluster_size:
|
| 183 |
+
return []
|
| 184 |
+
|
| 185 |
+
sub = feats_all[member_idx]
|
| 186 |
+
eps_used = eps
|
| 187 |
+
if eps_used is None:
|
| 188 |
+
eps_used = auto_eps(sub, min_samples, eps_quantile)
|
| 189 |
+
|
| 190 |
+
x = normalize(sub.astype(np.float64), norm="l2", axis=1)
|
| 191 |
+
labels = DBSCAN(
|
| 192 |
+
eps=eps_used, min_samples=min_samples, metric="cosine"
|
| 193 |
+
).fit_predict(x)
|
| 194 |
+
|
| 195 |
+
clusters = []
|
| 196 |
+
for cid in sorted(set(labels.tolist()) - {-1}):
|
| 197 |
+
local = np.where(labels == cid)[0]
|
| 198 |
+
if local.size < min_cluster_size:
|
| 199 |
+
continue
|
| 200 |
+
global_idx = member_idx[local]
|
| 201 |
+
clusters.append(
|
| 202 |
+
{
|
| 203 |
+
"global_indices": global_idx.astype(int),
|
| 204 |
+
"size": int(local.size),
|
| 205 |
+
"eps_used": float(eps_used),
|
| 206 |
+
"local_cluster_id": int(cid),
|
| 207 |
+
}
|
| 208 |
+
)
|
| 209 |
+
return clusters
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def infer_disease_clusters(
|
| 213 |
+
feats: np.ndarray,
|
| 214 |
+
normal_only: np.ndarray,
|
| 215 |
+
disease_mat: np.ndarray,
|
| 216 |
+
seed_mode: str,
|
| 217 |
+
eps: float | None,
|
| 218 |
+
min_samples: int,
|
| 219 |
+
eps_quantile: float,
|
| 220 |
+
min_cluster_size: int,
|
| 221 |
+
) -> tuple[np.ndarray, np.ndarray, list[dict]]:
|
| 222 |
+
"""
|
| 223 |
+
Returns:
|
| 224 |
+
assigned: [N] disease name or 'Unassigned'
|
| 225 |
+
cluster_uid: [N] int, -1 if unassigned
|
| 226 |
+
stats: list of cluster summaries
|
| 227 |
+
"""
|
| 228 |
+
n = feats.shape[0]
|
| 229 |
+
excl = exclusive_labels(normal_only, disease_mat)
|
| 230 |
+
|
| 231 |
+
# candidates: list of (disease, global_indices, size, eps, seed_mode)
|
| 232 |
+
candidates: list[dict] = []
|
| 233 |
+
class_names = ["Normal"] + DISEASE_NAMES
|
| 234 |
+
|
| 235 |
+
for name in class_names:
|
| 236 |
+
if seed_mode == "exclusive":
|
| 237 |
+
member_idx = np.where(excl == name)[0]
|
| 238 |
+
elif seed_mode == "inclusive":
|
| 239 |
+
if name == "Normal":
|
| 240 |
+
member_idx = np.where(normal_only)[0]
|
| 241 |
+
else:
|
| 242 |
+
j = DISEASE_NAMES.index(name)
|
| 243 |
+
member_idx = np.where(disease_mat[:, j])[0]
|
| 244 |
+
else:
|
| 245 |
+
raise ValueError(seed_mode)
|
| 246 |
+
|
| 247 |
+
print(
|
| 248 |
+
f" Seed {name:14s} ({seed_mode}): {member_idx.size} images",
|
| 249 |
+
end="",
|
| 250 |
+
)
|
| 251 |
+
clusters = cluster_subset(
|
| 252 |
+
feats,
|
| 253 |
+
member_idx,
|
| 254 |
+
eps=eps,
|
| 255 |
+
min_samples=min_samples,
|
| 256 |
+
eps_quantile=eps_quantile,
|
| 257 |
+
min_cluster_size=min_cluster_size,
|
| 258 |
+
)
|
| 259 |
+
print(f" → {len(clusters)} clusters ≥{min_cluster_size}")
|
| 260 |
+
for c in clusters:
|
| 261 |
+
candidates.append(
|
| 262 |
+
{
|
| 263 |
+
"disease": name,
|
| 264 |
+
"global_indices": c["global_indices"],
|
| 265 |
+
"size": c["size"],
|
| 266 |
+
"eps_used": c["eps_used"],
|
| 267 |
+
"seed_mode": seed_mode,
|
| 268 |
+
}
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
# Resolve conflicts: each sample may be claimed by multiple disease clusters.
|
| 272 |
+
# Score = (is_exclusive_match, cluster_size, -mean_dist_to_centroid)
|
| 273 |
+
claims: dict[int, list[tuple]] = defaultdict(list)
|
| 274 |
+
for cid, c in enumerate(candidates):
|
| 275 |
+
disease = c["disease"]
|
| 276 |
+
idxs = c["global_indices"]
|
| 277 |
+
sub = normalize(feats[idxs].astype(np.float64), norm="l2", axis=1)
|
| 278 |
+
centroid = normalize(sub.mean(axis=0, keepdims=True), norm="l2")[0]
|
| 279 |
+
# cosine distance to centroid
|
| 280 |
+
dist = 1.0 - (sub @ centroid)
|
| 281 |
+
for k, i in enumerate(idxs):
|
| 282 |
+
is_excl = 1 if excl[i] == disease else 0
|
| 283 |
+
claims[int(i)].append((is_excl, c["size"], -float(dist[k]), cid, disease))
|
| 284 |
+
|
| 285 |
+
assigned = np.array(["Unassigned"] * n, dtype=object)
|
| 286 |
+
cluster_uid = np.full(n, -1, dtype=int)
|
| 287 |
+
for i, opts in claims.items():
|
| 288 |
+
opts.sort(reverse=True) # prefer exclusive match, then larger cluster
|
| 289 |
+
_, _, _, cid, disease = opts[0]
|
| 290 |
+
assigned[i] = disease
|
| 291 |
+
cluster_uid[i] = cid
|
| 292 |
+
|
| 293 |
+
stats = []
|
| 294 |
+
for cid, c in enumerate(candidates):
|
| 295 |
+
idxs = c["global_indices"]
|
| 296 |
+
kept = idxs[assigned[idxs] == c["disease"]]
|
| 297 |
+
stats.append(
|
| 298 |
+
{
|
| 299 |
+
"cluster_uid": cid,
|
| 300 |
+
"disease": c["disease"],
|
| 301 |
+
"seed_mode": c["seed_mode"],
|
| 302 |
+
"seed_size": c["size"],
|
| 303 |
+
"assigned_size": int(kept.size),
|
| 304 |
+
"eps_used": c["eps_used"],
|
| 305 |
+
"exclusive_in_seed": int(
|
| 306 |
+
sum(1 for i in idxs if excl[i] == c["disease"])
|
| 307 |
+
),
|
| 308 |
+
}
|
| 309 |
+
)
|
| 310 |
+
return assigned, cluster_uid, stats
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def run_tsne_2d(feats: np.ndarray, perplexity: float, seed: int) -> np.ndarray:
|
| 314 |
+
n = feats.shape[0]
|
| 315 |
+
max_perp = max(2.0, (n - 1) / 3.0)
|
| 316 |
+
eff = min(perplexity, max_perp)
|
| 317 |
+
return TSNE(
|
| 318 |
+
n_components=2,
|
| 319 |
+
perplexity=eff,
|
| 320 |
+
random_state=seed,
|
| 321 |
+
init="pca",
|
| 322 |
+
learning_rate="auto",
|
| 323 |
+
).fit_transform(feats)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def plot_assigned(
|
| 327 |
+
emb: np.ndarray,
|
| 328 |
+
assigned: np.ndarray,
|
| 329 |
+
out_path: str,
|
| 330 |
+
title: str,
|
| 331 |
+
dpi: int,
|
| 332 |
+
) -> None:
|
| 333 |
+
fig, ax = plt.subplots(figsize=(11, 9))
|
| 334 |
+
# draw Unassigned first (background)
|
| 335 |
+
for cls in ["Unassigned"] + [c for c in CLASS_ORDER if c != "Unassigned"]:
|
| 336 |
+
mask = assigned == cls
|
| 337 |
+
if not mask.any():
|
| 338 |
+
continue
|
| 339 |
+
ax.scatter(
|
| 340 |
+
emb[mask, 0],
|
| 341 |
+
emb[mask, 1],
|
| 342 |
+
c=CLASS_COLORS.get(cls, "#333"),
|
| 343 |
+
marker="o",
|
| 344 |
+
alpha=0.35 if cls == "Unassigned" else 0.9,
|
| 345 |
+
s=18 if cls == "Unassigned" else 40,
|
| 346 |
+
edgecolors="none" if cls == "Unassigned" else "black",
|
| 347 |
+
linewidths=0.0 if cls == "Unassigned" else 0.35,
|
| 348 |
+
label=f"{cls} (n={mask.sum()})",
|
| 349 |
+
zorder=1 if cls == "Unassigned" else 2,
|
| 350 |
+
)
|
| 351 |
+
ax.set_title(title)
|
| 352 |
+
ax.set_xlabel("t-SNE 1")
|
| 353 |
+
ax.set_ylabel("t-SNE 2")
|
| 354 |
+
ax.legend(loc="best", fontsize=8, frameon=True)
|
| 355 |
+
ax.grid(True, alpha=0.2)
|
| 356 |
+
fig.tight_layout()
|
| 357 |
+
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
|
| 358 |
+
fig.savefig(out_path, dpi=dpi, bbox_inches="tight")
|
| 359 |
+
plt.close(fig)
|
| 360 |
+
print(f"Saved plot to {out_path}")
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def parse_args():
|
| 364 |
+
p = argparse.ArgumentParser(
|
| 365 |
+
description="Disease-seeded cluster inference on RadIR image embeddings"
|
| 366 |
+
)
|
| 367 |
+
p.add_argument(
|
| 368 |
+
"--load_feats",
|
| 369 |
+
default=os.path.join(ROOT, "outputs/tsne_feats.npz"),
|
| 370 |
+
)
|
| 371 |
+
p.add_argument(
|
| 372 |
+
"--labels_csv",
|
| 373 |
+
default=os.path.join(ROOT, "outputs/tsne_disease_labels.csv"),
|
| 374 |
+
)
|
| 375 |
+
p.add_argument(
|
| 376 |
+
"--jsonl",
|
| 377 |
+
default=os.path.join(ROOT, "dataset/valid.jsonl"),
|
| 378 |
+
)
|
| 379 |
+
p.add_argument(
|
| 380 |
+
"--feat_type",
|
| 381 |
+
choices=["hyperbolic", "euclidean"],
|
| 382 |
+
default="hyperbolic",
|
| 383 |
+
)
|
| 384 |
+
p.add_argument(
|
| 385 |
+
"--seed_mode",
|
| 386 |
+
choices=["exclusive", "inclusive"],
|
| 387 |
+
default="exclusive",
|
| 388 |
+
help="exclusive: only single-label images as seeds (cleaner); "
|
| 389 |
+
"inclusive: all positives of each disease",
|
| 390 |
+
)
|
| 391 |
+
p.add_argument(
|
| 392 |
+
"--eps",
|
| 393 |
+
type=float,
|
| 394 |
+
default=None,
|
| 395 |
+
help="DBSCAN cosine eps within each disease; default=auto per disease",
|
| 396 |
+
)
|
| 397 |
+
p.add_argument("--eps_quantile", type=float, default=0.6)
|
| 398 |
+
p.add_argument("--min_samples", type=int, default=5)
|
| 399 |
+
p.add_argument("--min_cluster_size", type=int, default=8)
|
| 400 |
+
p.add_argument("--perplexity", type=float, default=30.0)
|
| 401 |
+
p.add_argument("--seed", type=int, default=42)
|
| 402 |
+
p.add_argument(
|
| 403 |
+
"--out_csv",
|
| 404 |
+
default=os.path.join(ROOT, "outputs/tsne_cluster_disease.csv"),
|
| 405 |
+
)
|
| 406 |
+
p.add_argument(
|
| 407 |
+
"--out_stats",
|
| 408 |
+
default=os.path.join(ROOT, "outputs/tsne_cluster_stats.json"),
|
| 409 |
+
)
|
| 410 |
+
p.add_argument(
|
| 411 |
+
"--out_plot",
|
| 412 |
+
default=os.path.join(ROOT, "outputs/tsne_cluster_disease.png"),
|
| 413 |
+
)
|
| 414 |
+
p.add_argument("--dpi", type=int, default=150)
|
| 415 |
+
return p.parse_args()
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def main():
|
| 419 |
+
args = parse_args()
|
| 420 |
+
|
| 421 |
+
print(f"Loading features: {args.load_feats}")
|
| 422 |
+
data = np.load(args.load_feats, allow_pickle=True)
|
| 423 |
+
img_feats = prepare_image_feats(data, args.feat_type)
|
| 424 |
+
n = img_feats.shape[0]
|
| 425 |
+
print(f" image feats: {img_feats.shape}, feat_type={args.feat_type}")
|
| 426 |
+
|
| 427 |
+
dicom_ids = load_dicom_ids_from_jsonl(args.jsonl)
|
| 428 |
+
if len(dicom_ids) != n:
|
| 429 |
+
raise RuntimeError(f"jsonl ({len(dicom_ids)}) != feats ({n})")
|
| 430 |
+
|
| 431 |
+
normal_only, disease_mat = load_label_matrix(args.labels_csv, dicom_ids)
|
| 432 |
+
excl = exclusive_labels(normal_only, disease_mat)
|
| 433 |
+
print(
|
| 434 |
+
f"Exclusive single-label: {sum(e is not None for e in excl)} / {n}"
|
| 435 |
+
)
|
| 436 |
+
print(f"Seed mode: {args.seed_mode}")
|
| 437 |
+
|
| 438 |
+
print("\n===== Within-disease clustering =====")
|
| 439 |
+
assigned, cluster_uid, stats = infer_disease_clusters(
|
| 440 |
+
img_feats,
|
| 441 |
+
normal_only,
|
| 442 |
+
disease_mat,
|
| 443 |
+
seed_mode=args.seed_mode,
|
| 444 |
+
eps=args.eps,
|
| 445 |
+
min_samples=args.min_samples,
|
| 446 |
+
eps_quantile=args.eps_quantile,
|
| 447 |
+
min_cluster_size=args.min_cluster_size,
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
print("\n===== Clusters kept =====")
|
| 451 |
+
stats_sorted = sorted(stats, key=lambda s: (-s["assigned_size"], s["disease"]))
|
| 452 |
+
for s in stats_sorted:
|
| 453 |
+
if s["assigned_size"] <= 0:
|
| 454 |
+
continue
|
| 455 |
+
print(
|
| 456 |
+
f" [{s['cluster_uid']:>3d}] {s['disease']:14s} "
|
| 457 |
+
f"assigned={s['assigned_size']:<4d} "
|
| 458 |
+
f"(seed={s['seed_size']}, excl_in_seed={s['exclusive_in_seed']}, "
|
| 459 |
+
f"eps={s['eps_used']:.3f})"
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
print("\n===== Assigned counts =====")
|
| 463 |
+
for cls in CLASS_ORDER:
|
| 464 |
+
c = int((assigned == cls).sum())
|
| 465 |
+
if c:
|
| 466 |
+
print(f" {cls:14s} {c}")
|
| 467 |
+
|
| 468 |
+
os.makedirs(os.path.dirname(os.path.abspath(args.out_csv)) or ".", exist_ok=True)
|
| 469 |
+
rows = {
|
| 470 |
+
"dicom_id": dicom_ids,
|
| 471 |
+
"cluster_uid": cluster_uid,
|
| 472 |
+
"cluster_disease": assigned,
|
| 473 |
+
"exclusive_label": [
|
| 474 |
+
e if e is not None else "" for e in excl.tolist()
|
| 475 |
+
],
|
| 476 |
+
"normal_only": normal_only.astype(int),
|
| 477 |
+
}
|
| 478 |
+
for j, name in enumerate(DISEASE_NAMES):
|
| 479 |
+
rows[f"{name}_pos"] = disease_mat[:, j].astype(int)
|
| 480 |
+
pd.DataFrame(rows).to_csv(args.out_csv, index=False)
|
| 481 |
+
print(f"\nSaved {args.out_csv}")
|
| 482 |
+
|
| 483 |
+
with open(args.out_stats, "w", encoding="utf-8") as f:
|
| 484 |
+
json.dump(
|
| 485 |
+
{
|
| 486 |
+
"seed_mode": args.seed_mode,
|
| 487 |
+
"feat_type": args.feat_type,
|
| 488 |
+
"eps": args.eps,
|
| 489 |
+
"eps_quantile": args.eps_quantile,
|
| 490 |
+
"min_samples": args.min_samples,
|
| 491 |
+
"min_cluster_size": args.min_cluster_size,
|
| 492 |
+
"n_samples": n,
|
| 493 |
+
"assigned_counts": {
|
| 494 |
+
cls: int((assigned == cls).sum()) for cls in CLASS_ORDER
|
| 495 |
+
},
|
| 496 |
+
"clusters": stats_sorted,
|
| 497 |
+
},
|
| 498 |
+
f,
|
| 499 |
+
indent=2,
|
| 500 |
+
)
|
| 501 |
+
print(f"Saved {args.out_stats}")
|
| 502 |
+
|
| 503 |
+
print("Running t-SNE for visualization ...")
|
| 504 |
+
emb = run_tsne_2d(img_feats, args.perplexity, args.seed)
|
| 505 |
+
title = (
|
| 506 |
+
f"Disease-seeded image clusters "
|
| 507 |
+
f"({args.seed_mode}, {args.feat_type}, min_size={args.min_cluster_size})"
|
| 508 |
+
)
|
| 509 |
+
plot_assigned(emb, assigned, args.out_plot, title, args.dpi)
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
if __name__ == "__main__":
|
| 513 |
+
main()
|
scripts/plot_anat_cosne.py
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Disease-colored CO-SNE for RadIR/RadIR hyperbolic embeddings.
|
| 4 |
+
|
| 5 |
+
Uses the same hyperbolic CO-SNE pipeline as scripts/plot_cosne.py, then colors
|
| 6 |
+
points by disease while keeping modality markers:
|
| 7 |
+
○ Image, △ Text
|
| 8 |
+
Color = disease / class
|
| 9 |
+
|
| 10 |
+
Labeling strategies (--color_by):
|
| 11 |
+
gt ground-truth exclusive / priority labels (see --label_mode)
|
| 12 |
+
inferred disease-seeded DBSCAN on high-dim hyp image feats ("反推法")
|
| 13 |
+
cosne2d same seeded DBSCAN but on 2D CO-SNE image coords
|
| 14 |
+
cluster_csv load precomputed cluster_disease from infer_disease_clusters.py
|
| 15 |
+
|
| 16 |
+
Usage (from RadIR/RadIR):
|
| 17 |
+
# Ground-truth exclusive (clean)
|
| 18 |
+
python scripts/plot_anat_cosne.py \\
|
| 19 |
+
--load_emb outputs/cosne_emb.npz \\
|
| 20 |
+
--color_by gt --label_mode exclusive --hide_other \\
|
| 21 |
+
--out outputs/cosne_disease_clean.png
|
| 22 |
+
|
| 23 |
+
# Reverse-inference clusters on CO-SNE disk
|
| 24 |
+
python scripts/plot_anat_cosne.py \\
|
| 25 |
+
--load_emb outputs/cosne_emb.npz \\
|
| 26 |
+
--load_feats outputs/tsne_feats.npz \\
|
| 27 |
+
--color_by inferred --hide_other \\
|
| 28 |
+
--out outputs/cosne_disease_inferred.png
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import argparse
|
| 34 |
+
import os
|
| 35 |
+
import sys
|
| 36 |
+
from collections import Counter
|
| 37 |
+
|
| 38 |
+
import matplotlib.pyplot as plt
|
| 39 |
+
import numpy as np
|
| 40 |
+
import pandas as pd
|
| 41 |
+
import torch
|
| 42 |
+
|
| 43 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 44 |
+
SCRIPTS = os.path.dirname(os.path.abspath(__file__))
|
| 45 |
+
RAD_IR_ROOT = os.path.join(ROOT, "Rad_IR")
|
| 46 |
+
for p in (ROOT, RAD_IR_ROOT, SCRIPTS):
|
| 47 |
+
if p not in sys.path:
|
| 48 |
+
sys.path.insert(0, p)
|
| 49 |
+
|
| 50 |
+
import infer_disease_clusters as idc # noqa: E402
|
| 51 |
+
import plot_cosne as cosne # noqa: E402
|
| 52 |
+
|
| 53 |
+
DISEASE_NAMES = idc.DISEASE_NAMES
|
| 54 |
+
CLASS_ORDER = ["Normal"] + DISEASE_NAMES + ["Other"]
|
| 55 |
+
|
| 56 |
+
CLASS_COLORS = {
|
| 57 |
+
"Normal": "#00C853",
|
| 58 |
+
"Pneumothorax": "#AA00FF",
|
| 59 |
+
"Edema": "#00B8D4",
|
| 60 |
+
"Pneumonia": "#D50000",
|
| 61 |
+
"Effusion": "#2962FF",
|
| 62 |
+
"Cardiomegaly": "#FF6D00",
|
| 63 |
+
"Atelectasis": "#FFD600",
|
| 64 |
+
"Other": "#BDBDBD",
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
MODALITY_MARKER = {"Image": "o", "Text": "^"}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def style_for(cls: str, modality: str) -> tuple[str, str]:
|
| 71 |
+
return CLASS_COLORS.get(cls, "#333333"), MODALITY_MARKER[modality]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def load_label_table(labels_csv: str) -> pd.DataFrame:
|
| 75 |
+
df = pd.read_csv(labels_csv)
|
| 76 |
+
needed = ["dicom_id", "normal_only"] + [f"{d}_pos" for d in DISEASE_NAMES]
|
| 77 |
+
missing = [c for c in needed if c not in df.columns]
|
| 78 |
+
if missing:
|
| 79 |
+
raise KeyError(f"labels CSV missing columns: {missing}")
|
| 80 |
+
return df.drop_duplicates(subset=["dicom_id"], keep="first").reset_index(drop=True)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def build_labels(
|
| 84 |
+
df: pd.DataFrame,
|
| 85 |
+
sample_idx: np.ndarray,
|
| 86 |
+
label_mode: str,
|
| 87 |
+
) -> list[str]:
|
| 88 |
+
"""
|
| 89 |
+
Map global row indices → class name.
|
| 90 |
+
|
| 91 |
+
exclusive: single-label only; else "Other"
|
| 92 |
+
priority: Normal if normal_only, else first disease hit, else "Other"
|
| 93 |
+
"""
|
| 94 |
+
labels: list[str] = []
|
| 95 |
+
for i in sample_idx:
|
| 96 |
+
row = df.iloc[int(i)]
|
| 97 |
+
hits: list[str] = []
|
| 98 |
+
if int(row["normal_only"]) == 1:
|
| 99 |
+
hits.append("Normal")
|
| 100 |
+
for d in DISEASE_NAMES:
|
| 101 |
+
if int(row[f"{d}_pos"]) == 1:
|
| 102 |
+
hits.append(d)
|
| 103 |
+
|
| 104 |
+
if label_mode == "exclusive":
|
| 105 |
+
labels.append(hits[0] if len(hits) == 1 else "Other")
|
| 106 |
+
elif label_mode == "priority":
|
| 107 |
+
if int(row["normal_only"]) == 1 and len(hits) == 1:
|
| 108 |
+
labels.append("Normal")
|
| 109 |
+
else:
|
| 110 |
+
assigned = None
|
| 111 |
+
for d in DISEASE_NAMES:
|
| 112 |
+
if int(row[f"{d}_pos"]) == 1:
|
| 113 |
+
assigned = d
|
| 114 |
+
break
|
| 115 |
+
if assigned is None and int(row["normal_only"]) == 1:
|
| 116 |
+
assigned = "Normal"
|
| 117 |
+
labels.append(assigned if assigned is not None else "Other")
|
| 118 |
+
else:
|
| 119 |
+
raise ValueError(label_mode)
|
| 120 |
+
return labels
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _to_plot_label(name: str) -> str:
|
| 124 |
+
if name in (None, "", "Unassigned", "Other"):
|
| 125 |
+
return "Other"
|
| 126 |
+
return str(name)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def labels_from_cluster_csv(
|
| 130 |
+
cluster_csv: str, labels_csv: str, sample_idx: np.ndarray
|
| 131 |
+
) -> list[str]:
|
| 132 |
+
"""Map sample_idx → cluster_disease via dicom_id alignment."""
|
| 133 |
+
gt = pd.read_csv(labels_csv).drop_duplicates(subset=["dicom_id"], keep="first")
|
| 134 |
+
cl = pd.read_csv(cluster_csv).drop_duplicates(subset=["dicom_id"], keep="first")
|
| 135 |
+
cl = cl.set_index("dicom_id")["cluster_disease"]
|
| 136 |
+
out: list[str] = []
|
| 137 |
+
for i in sample_idx:
|
| 138 |
+
did = str(gt.iloc[int(i)]["dicom_id"])
|
| 139 |
+
if did in cl.index:
|
| 140 |
+
out.append(_to_plot_label(cl.loc[did]))
|
| 141 |
+
else:
|
| 142 |
+
out.append("Other")
|
| 143 |
+
return out
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def labels_inferred_highdim(
|
| 147 |
+
feats_npz: str,
|
| 148 |
+
labels_csv: str,
|
| 149 |
+
sample_idx: np.ndarray,
|
| 150 |
+
seed_mode: str,
|
| 151 |
+
eps: float | None,
|
| 152 |
+
min_samples: int,
|
| 153 |
+
eps_quantile: float,
|
| 154 |
+
min_cluster_size: int,
|
| 155 |
+
) -> tuple[list[str], list[dict]]:
|
| 156 |
+
"""
|
| 157 |
+
Disease-seeded DBSCAN on full-set high-dim hyp image feats, then slice
|
| 158 |
+
assignments to the CO-SNE subsample.
|
| 159 |
+
"""
|
| 160 |
+
data = np.load(feats_npz, allow_pickle=True)
|
| 161 |
+
feats = idc.prepare_image_feats(data, "hyperbolic")
|
| 162 |
+
df = pd.read_csv(labels_csv).drop_duplicates(subset=["dicom_id"], keep="first")
|
| 163 |
+
if len(df) != feats.shape[0]:
|
| 164 |
+
raise RuntimeError(
|
| 165 |
+
f"feats N={feats.shape[0]} vs labels rows={len(df)} "
|
| 166 |
+
"(expect same valid.jsonl order)"
|
| 167 |
+
)
|
| 168 |
+
normal_only = df["normal_only"].astype(int).to_numpy().astype(bool)
|
| 169 |
+
disease_mat = np.stack(
|
| 170 |
+
[df[f"{d}_pos"].astype(int).to_numpy().astype(bool) for d in DISEASE_NAMES],
|
| 171 |
+
axis=1,
|
| 172 |
+
)
|
| 173 |
+
print(f"===== Infer clusters on high-dim hyp feats ({seed_mode}) =====")
|
| 174 |
+
assigned, _uid, stats = idc.infer_disease_clusters(
|
| 175 |
+
feats,
|
| 176 |
+
normal_only,
|
| 177 |
+
disease_mat,
|
| 178 |
+
seed_mode=seed_mode,
|
| 179 |
+
eps=eps,
|
| 180 |
+
min_samples=min_samples,
|
| 181 |
+
eps_quantile=eps_quantile,
|
| 182 |
+
min_cluster_size=min_cluster_size,
|
| 183 |
+
)
|
| 184 |
+
labels = [_to_plot_label(assigned[int(i)]) for i in sample_idx]
|
| 185 |
+
return labels, stats
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def labels_inferred_cosne2d(
|
| 189 |
+
emb_img: np.ndarray,
|
| 190 |
+
df: pd.DataFrame,
|
| 191 |
+
sample_idx: np.ndarray,
|
| 192 |
+
seed_mode: str,
|
| 193 |
+
eps: float | None,
|
| 194 |
+
min_samples: int,
|
| 195 |
+
eps_quantile: float,
|
| 196 |
+
min_cluster_size: int,
|
| 197 |
+
) -> tuple[list[str], list[dict]]:
|
| 198 |
+
"""
|
| 199 |
+
Same disease-seeded DBSCAN, but distance is Euclidean in the 2D CO-SNE
|
| 200 |
+
Poincaré plane (answers: does the disk layout form disease neighborhoods?).
|
| 201 |
+
"""
|
| 202 |
+
n = emb_img.shape[0]
|
| 203 |
+
normal_only = np.zeros(n, dtype=bool)
|
| 204 |
+
disease_mat = np.zeros((n, len(DISEASE_NAMES)), dtype=bool)
|
| 205 |
+
for k, i in enumerate(sample_idx):
|
| 206 |
+
row = df.iloc[int(i)]
|
| 207 |
+
normal_only[k] = bool(int(row["normal_only"]))
|
| 208 |
+
for j, d in enumerate(DISEASE_NAMES):
|
| 209 |
+
disease_mat[k, j] = bool(int(row[f"{d}_pos"]))
|
| 210 |
+
|
| 211 |
+
# Use Euclidean metric on 2D coords via L2-normalize trick disabled:
|
| 212 |
+
# cluster_subset uses cosine on L2-normalized vectors; for 2D Poincaré
|
| 213 |
+
# points we pass raw coords and temporarily use a Euclidean DBSCAN path.
|
| 214 |
+
print(f"===== Infer clusters on CO-SNE 2D ({seed_mode}) =====")
|
| 215 |
+
assigned, _uid, stats = infer_clusters_euclidean_2d(
|
| 216 |
+
emb_img,
|
| 217 |
+
normal_only,
|
| 218 |
+
disease_mat,
|
| 219 |
+
seed_mode=seed_mode,
|
| 220 |
+
eps=eps,
|
| 221 |
+
min_samples=min_samples,
|
| 222 |
+
eps_quantile=eps_quantile,
|
| 223 |
+
min_cluster_size=min_cluster_size,
|
| 224 |
+
)
|
| 225 |
+
labels = [_to_plot_label(a) for a in assigned]
|
| 226 |
+
return labels, stats
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def infer_clusters_euclidean_2d(
|
| 230 |
+
coords: np.ndarray,
|
| 231 |
+
normal_only: np.ndarray,
|
| 232 |
+
disease_mat: np.ndarray,
|
| 233 |
+
seed_mode: str,
|
| 234 |
+
eps: float | None,
|
| 235 |
+
min_samples: int,
|
| 236 |
+
eps_quantile: float,
|
| 237 |
+
min_cluster_size: int,
|
| 238 |
+
) -> tuple[np.ndarray, np.ndarray, list[dict]]:
|
| 239 |
+
"""DBSCAN with Euclidean metric on 2D coords (within disease seeds)."""
|
| 240 |
+
from collections import defaultdict
|
| 241 |
+
|
| 242 |
+
from sklearn.cluster import DBSCAN
|
| 243 |
+
from sklearn.neighbors import NearestNeighbors
|
| 244 |
+
|
| 245 |
+
n = coords.shape[0]
|
| 246 |
+
excl = idc.exclusive_labels(normal_only, disease_mat)
|
| 247 |
+
candidates: list[dict] = []
|
| 248 |
+
class_names = ["Normal"] + DISEASE_NAMES
|
| 249 |
+
|
| 250 |
+
for name in class_names:
|
| 251 |
+
if seed_mode == "exclusive":
|
| 252 |
+
member_idx = np.where(excl == name)[0]
|
| 253 |
+
else:
|
| 254 |
+
if name == "Normal":
|
| 255 |
+
member_idx = np.where(normal_only)[0]
|
| 256 |
+
else:
|
| 257 |
+
j = DISEASE_NAMES.index(name)
|
| 258 |
+
member_idx = np.where(disease_mat[:, j])[0]
|
| 259 |
+
|
| 260 |
+
print(f" Seed {name:14s} ({seed_mode}): {member_idx.size} images", end="")
|
| 261 |
+
if member_idx.size < min_cluster_size:
|
| 262 |
+
print(" → 0 clusters")
|
| 263 |
+
continue
|
| 264 |
+
|
| 265 |
+
sub = coords[member_idx].astype(np.float64)
|
| 266 |
+
eps_used = eps
|
| 267 |
+
if eps_used is None:
|
| 268 |
+
k = min(min_samples, sub.shape[0] - 1)
|
| 269 |
+
if k < 1:
|
| 270 |
+
print(" → 0 clusters")
|
| 271 |
+
continue
|
| 272 |
+
nn = NearestNeighbors(n_neighbors=k + 1, metric="euclidean").fit(sub)
|
| 273 |
+
dists, _ = nn.kneighbors(sub)
|
| 274 |
+
eps_used = float(np.quantile(dists[:, -1], eps_quantile))
|
| 275 |
+
|
| 276 |
+
labels_db = DBSCAN(
|
| 277 |
+
eps=eps_used, min_samples=min_samples, metric="euclidean"
|
| 278 |
+
).fit_predict(sub)
|
| 279 |
+
n_kept = 0
|
| 280 |
+
for cid in sorted(set(labels_db.tolist()) - {-1}):
|
| 281 |
+
local = np.where(labels_db == cid)[0]
|
| 282 |
+
if local.size < min_cluster_size:
|
| 283 |
+
continue
|
| 284 |
+
global_idx = member_idx[local]
|
| 285 |
+
candidates.append(
|
| 286 |
+
{
|
| 287 |
+
"disease": name,
|
| 288 |
+
"global_indices": global_idx.astype(int),
|
| 289 |
+
"size": int(local.size),
|
| 290 |
+
"eps_used": float(eps_used),
|
| 291 |
+
}
|
| 292 |
+
)
|
| 293 |
+
n_kept += 1
|
| 294 |
+
print(f" → {n_kept} clusters ≥{min_cluster_size} (eps={eps_used:.4f})")
|
| 295 |
+
|
| 296 |
+
claims: dict[int, list[tuple]] = defaultdict(list)
|
| 297 |
+
for cid, c in enumerate(candidates):
|
| 298 |
+
idxs = c["global_indices"]
|
| 299 |
+
sub = coords[idxs]
|
| 300 |
+
centroid = sub.mean(axis=0)
|
| 301 |
+
dist = np.linalg.norm(sub - centroid, axis=1)
|
| 302 |
+
for k, i in enumerate(idxs):
|
| 303 |
+
is_excl = 1 if excl[i] == c["disease"] else 0
|
| 304 |
+
claims[int(i)].append(
|
| 305 |
+
(is_excl, c["size"], -float(dist[k]), cid, c["disease"])
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
assigned = np.array(["Unassigned"] * n, dtype=object)
|
| 309 |
+
cluster_uid = np.full(n, -1, dtype=int)
|
| 310 |
+
for i, opts in claims.items():
|
| 311 |
+
opts.sort(reverse=True)
|
| 312 |
+
_, _, _, cid, disease = opts[0]
|
| 313 |
+
assigned[i] = disease
|
| 314 |
+
cluster_uid[i] = cid
|
| 315 |
+
|
| 316 |
+
stats = []
|
| 317 |
+
for cid, c in enumerate(candidates):
|
| 318 |
+
idxs = c["global_indices"]
|
| 319 |
+
kept = idxs[assigned[idxs] == c["disease"]]
|
| 320 |
+
stats.append(
|
| 321 |
+
{
|
| 322 |
+
"cluster_uid": cid,
|
| 323 |
+
"disease": c["disease"],
|
| 324 |
+
"size_raw": c["size"],
|
| 325 |
+
"size_kept": int(kept.size),
|
| 326 |
+
"eps_used": c["eps_used"],
|
| 327 |
+
}
|
| 328 |
+
)
|
| 329 |
+
return assigned, cluster_uid, stats
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def print_cluster_stats(stats: list[dict], labels: list[str]) -> None:
|
| 333 |
+
print("----- kept clusters -----")
|
| 334 |
+
by_d: dict[str, int] = Counter()
|
| 335 |
+
for s in stats:
|
| 336 |
+
if s.get("size_kept", 0) > 0:
|
| 337 |
+
print(
|
| 338 |
+
f" uid={s['cluster_uid']:3d} {s['disease']:14s} "
|
| 339 |
+
f"kept={s['size_kept']:4d} raw={s['size_raw']:4d} "
|
| 340 |
+
f"eps={s['eps_used']:.4f}"
|
| 341 |
+
)
|
| 342 |
+
by_d[s["disease"]] += s["size_kept"]
|
| 343 |
+
print("----- subsample assignment -----")
|
| 344 |
+
counts = Counter(labels)
|
| 345 |
+
for cls in CLASS_ORDER:
|
| 346 |
+
if counts.get(cls, 0):
|
| 347 |
+
print(f" {cls:14s} {counts[cls]}")
|
| 348 |
+
labeled = sum(v for k, v in counts.items() if k != "Other")
|
| 349 |
+
print(f" labeled={labeled}/{len(labels)} Other={counts.get('Other', 0)}")
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def run_or_load_cosne(args) -> tuple[np.ndarray, np.ndarray, np.ndarray, float]:
|
| 353 |
+
"""
|
| 354 |
+
Returns emb_img [M,2], emb_txt [M,2], sample_idx [M], curv.
|
| 355 |
+
"""
|
| 356 |
+
if args.load_emb:
|
| 357 |
+
print(f"Loading CO-SNE coords: {args.load_emb}")
|
| 358 |
+
data = np.load(args.load_emb, allow_pickle=True)
|
| 359 |
+
emb_img = np.asarray(data["emb_img"])
|
| 360 |
+
emb_txt = np.asarray(data["emb_txt"])
|
| 361 |
+
sample_idx = np.asarray(data["sample_idx"], dtype=np.int64)
|
| 362 |
+
curv = float(data["curv"]) if "curv" in data.files else 1.0
|
| 363 |
+
if emb_img.shape[0] != emb_txt.shape[0] or emb_img.shape[0] != len(sample_idx):
|
| 364 |
+
raise ValueError("emb_img / emb_txt / sample_idx length mismatch")
|
| 365 |
+
return emb_img, emb_txt, sample_idx, curv
|
| 366 |
+
|
| 367 |
+
if not args.load_feats:
|
| 368 |
+
raise ValueError("Provide --load_emb or --load_feats")
|
| 369 |
+
|
| 370 |
+
print(f"Loading hyperbolic feats: {args.load_feats}")
|
| 371 |
+
feats = np.load(args.load_feats, allow_pickle=True)
|
| 372 |
+
if "hyp_img" not in feats.files:
|
| 373 |
+
raise KeyError("Need hyp_img / hyp_txt in feats npz")
|
| 374 |
+
hyp_img = torch.from_numpy(feats["hyp_img"])
|
| 375 |
+
hyp_txt = torch.from_numpy(feats["hyp_txt"])
|
| 376 |
+
curv = float(feats["curv"]) if "curv" in feats.files else 1.0
|
| 377 |
+
|
| 378 |
+
max_samples = None if args.max_samples <= 0 else args.max_samples
|
| 379 |
+
hyp_img, hyp_txt, sample_idx = cosne.subsample_pair(
|
| 380 |
+
hyp_img, hyp_txt, max_samples, args.seed
|
| 381 |
+
)
|
| 382 |
+
print(f" subsampled to {len(sample_idx)} pairs, curv={curv:.4f}")
|
| 383 |
+
|
| 384 |
+
with torch.no_grad():
|
| 385 |
+
poi_img = cosne.lorentz_space_to_poincare(hyp_img.float(), curv).numpy()
|
| 386 |
+
poi_txt = cosne.lorentz_space_to_poincare(hyp_txt.float(), curv).numpy()
|
| 387 |
+
|
| 388 |
+
X = np.concatenate([poi_img, poi_txt], axis=0)
|
| 389 |
+
model = cosne.COSNE(
|
| 390 |
+
n_components=2,
|
| 391 |
+
perplexity=args.perplexity,
|
| 392 |
+
n_iter=args.n_iter,
|
| 393 |
+
learning_rate=args.learning_rate,
|
| 394 |
+
learning_rate_h=args.learning_rate_h,
|
| 395 |
+
early_exaggeration=args.early_exaggeration,
|
| 396 |
+
student_t_gamma=args.student_t_gamma,
|
| 397 |
+
seed=args.seed,
|
| 398 |
+
device=args.cosne_device,
|
| 399 |
+
verbose=True,
|
| 400 |
+
)
|
| 401 |
+
Y = model.fit_transform(X)
|
| 402 |
+
n = poi_img.shape[0]
|
| 403 |
+
emb_img, emb_txt = Y[:n], Y[n:]
|
| 404 |
+
|
| 405 |
+
if args.save_emb:
|
| 406 |
+
os.makedirs(
|
| 407 |
+
os.path.dirname(os.path.abspath(args.save_emb)) or ".", exist_ok=True
|
| 408 |
+
)
|
| 409 |
+
np.savez(
|
| 410 |
+
args.save_emb,
|
| 411 |
+
emb_img=emb_img,
|
| 412 |
+
emb_txt=emb_txt,
|
| 413 |
+
sample_idx=sample_idx,
|
| 414 |
+
curv=curv,
|
| 415 |
+
)
|
| 416 |
+
print(f"Saved CO-SNE coords to {args.save_emb}")
|
| 417 |
+
|
| 418 |
+
return emb_img, emb_txt, sample_idx, curv
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def plot_disease_poincare(
|
| 422 |
+
emb_img: np.ndarray,
|
| 423 |
+
emb_txt: np.ndarray,
|
| 424 |
+
labels: list[str],
|
| 425 |
+
out_path: str,
|
| 426 |
+
title: str,
|
| 427 |
+
dpi: int,
|
| 428 |
+
hide_other: bool,
|
| 429 |
+
max_pair_lines: int,
|
| 430 |
+
) -> None:
|
| 431 |
+
n = emb_img.shape[0]
|
| 432 |
+
assert len(labels) == n
|
| 433 |
+
|
| 434 |
+
fig, ax = plt.subplots(figsize=(9, 9))
|
| 435 |
+
ax.add_patch(plt.Circle((0, 0), 1.0, color="black", fill=False, linewidth=1.2))
|
| 436 |
+
for r in (0.25, 0.5, 0.75):
|
| 437 |
+
ax.add_patch(
|
| 438 |
+
plt.Circle((0, 0), r, color="gray", fill=False, linewidth=0.4, alpha=0.35)
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
# optional pair connectors (only for non-Other if hide_other)
|
| 442 |
+
n_lines = min(max_pair_lines, n)
|
| 443 |
+
drawn = 0
|
| 444 |
+
for i in range(n):
|
| 445 |
+
if drawn >= n_lines:
|
| 446 |
+
break
|
| 447 |
+
if hide_other and labels[i] == "Other":
|
| 448 |
+
continue
|
| 449 |
+
ax.plot(
|
| 450 |
+
[emb_img[i, 0], emb_txt[i, 0]],
|
| 451 |
+
[emb_img[i, 1], emb_txt[i, 1]],
|
| 452 |
+
color="gray",
|
| 453 |
+
alpha=0.12,
|
| 454 |
+
linewidth=0.5,
|
| 455 |
+
zorder=0,
|
| 456 |
+
)
|
| 457 |
+
drawn += 1
|
| 458 |
+
|
| 459 |
+
# draw Other first (background), then each disease
|
| 460 |
+
draw_order = ["Other"] + [c for c in CLASS_ORDER if c != "Other"]
|
| 461 |
+
for cls in draw_order:
|
| 462 |
+
if hide_other and cls == "Other":
|
| 463 |
+
continue
|
| 464 |
+
mask = np.array([l == cls for l in labels], dtype=bool)
|
| 465 |
+
if not mask.any():
|
| 466 |
+
continue
|
| 467 |
+
for modality, emb in (("Image", emb_img), ("Text", emb_txt)):
|
| 468 |
+
color, marker = style_for(cls, modality)
|
| 469 |
+
is_other = cls == "Other"
|
| 470 |
+
ax.scatter(
|
| 471 |
+
emb[mask, 0],
|
| 472 |
+
emb[mask, 1],
|
| 473 |
+
c=color,
|
| 474 |
+
marker=marker,
|
| 475 |
+
s=18 if is_other else (32 if modality == "Image" else 42),
|
| 476 |
+
alpha=0.35 if is_other else 0.88,
|
| 477 |
+
edgecolors="none" if is_other else "black",
|
| 478 |
+
linewidths=0.0 if is_other else 0.3,
|
| 479 |
+
label=f"{cls}-{modality} (n={int(mask.sum())})",
|
| 480 |
+
zorder=1 if is_other else (2 if modality == "Image" else 3),
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
# radius summary on labeled (non-Other) points
|
| 484 |
+
labeled_mask = np.array([l != "Other" for l in labels], dtype=bool)
|
| 485 |
+
if labeled_mask.any():
|
| 486 |
+
r_img = np.linalg.norm(emb_img[labeled_mask], axis=1).mean()
|
| 487 |
+
r_txt = np.linalg.norm(emb_txt[labeled_mask], axis=1).mean()
|
| 488 |
+
subtitle = f"labeled mean radius Image={r_img:.3f} Text={r_txt:.3f}"
|
| 489 |
+
else:
|
| 490 |
+
subtitle = "no exclusive labels in subsample"
|
| 491 |
+
|
| 492 |
+
ax.set_title(f"{title}\n{subtitle}")
|
| 493 |
+
ax.set_xlim(-1.05, 1.05)
|
| 494 |
+
ax.set_ylim(-1.05, 1.05)
|
| 495 |
+
ax.set_aspect("equal")
|
| 496 |
+
ax.legend(loc="upper left", bbox_to_anchor=(1.02, 1.0), fontsize=8, frameon=True)
|
| 497 |
+
ax.axis("off")
|
| 498 |
+
fig.tight_layout()
|
| 499 |
+
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
|
| 500 |
+
fig.savefig(out_path, dpi=dpi, bbox_inches="tight")
|
| 501 |
+
if out_path.lower().endswith(".png"):
|
| 502 |
+
fig.savefig(out_path[:-4] + ".pdf", dpi=dpi, bbox_inches="tight")
|
| 503 |
+
plt.close(fig)
|
| 504 |
+
print(f"Saved disease CO-SNE to {out_path}")
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
def parse_args():
|
| 508 |
+
p = argparse.ArgumentParser(
|
| 509 |
+
description="Disease-colored CO-SNE (hyperbolic) for RadIR"
|
| 510 |
+
)
|
| 511 |
+
p.add_argument(
|
| 512 |
+
"--load_emb",
|
| 513 |
+
default=None,
|
| 514 |
+
help="Cached CO-SNE coords from plot_cosne.py (--save_emb)",
|
| 515 |
+
)
|
| 516 |
+
p.add_argument(
|
| 517 |
+
"--load_feats",
|
| 518 |
+
default=None,
|
| 519 |
+
help="Hyperbolic feats npz (required for --color_by inferred)",
|
| 520 |
+
)
|
| 521 |
+
p.add_argument(
|
| 522 |
+
"--labels_csv",
|
| 523 |
+
default=os.path.join(ROOT, "outputs/tsne_disease_labels.csv"),
|
| 524 |
+
)
|
| 525 |
+
p.add_argument(
|
| 526 |
+
"--cluster_csv",
|
| 527 |
+
default=os.path.join(ROOT, "outputs/tsne_cluster_disease.csv"),
|
| 528 |
+
help="Precomputed cluster_disease CSV for --color_by cluster_csv",
|
| 529 |
+
)
|
| 530 |
+
p.add_argument(
|
| 531 |
+
"--jsonl",
|
| 532 |
+
default=os.path.join(ROOT, "dataset/valid.jsonl"),
|
| 533 |
+
help="Only used to sanity-check length vs labels (optional)",
|
| 534 |
+
)
|
| 535 |
+
p.add_argument(
|
| 536 |
+
"--color_by",
|
| 537 |
+
choices=["gt", "inferred", "cosne2d", "cluster_csv"],
|
| 538 |
+
default="gt",
|
| 539 |
+
help="gt=ground-truth; inferred=反推 on high-dim; "
|
| 540 |
+
"cosne2d=反推 on CO-SNE plane; cluster_csv=load CSV",
|
| 541 |
+
)
|
| 542 |
+
p.add_argument(
|
| 543 |
+
"--label_mode",
|
| 544 |
+
choices=["exclusive", "priority"],
|
| 545 |
+
default="exclusive",
|
| 546 |
+
help="For --color_by gt only",
|
| 547 |
+
)
|
| 548 |
+
p.add_argument(
|
| 549 |
+
"--seed_mode",
|
| 550 |
+
choices=["exclusive", "inclusive"],
|
| 551 |
+
default="exclusive",
|
| 552 |
+
help="Disease seed mode for inferred / cosne2d",
|
| 553 |
+
)
|
| 554 |
+
p.add_argument("--eps", type=float, default=None, help="DBSCAN eps (auto if None)")
|
| 555 |
+
p.add_argument("--min_samples", type=int, default=5)
|
| 556 |
+
p.add_argument("--eps_quantile", type=float, default=0.5)
|
| 557 |
+
p.add_argument("--min_cluster_size", type=int, default=8)
|
| 558 |
+
p.add_argument(
|
| 559 |
+
"--hide_other",
|
| 560 |
+
action="store_true",
|
| 561 |
+
help="Do not draw Other/Unassigned points",
|
| 562 |
+
)
|
| 563 |
+
p.add_argument("--max_samples", type=int, default=500)
|
| 564 |
+
p.add_argument("--perplexity", type=float, default=30.0)
|
| 565 |
+
p.add_argument("--n_iter", type=int, default=800)
|
| 566 |
+
p.add_argument("--learning_rate", type=float, default=0.05)
|
| 567 |
+
p.add_argument("--learning_rate_h", type=float, default=5.0)
|
| 568 |
+
p.add_argument("--early_exaggeration", type=float, default=4.0)
|
| 569 |
+
p.add_argument("--student_t_gamma", type=float, default=0.1)
|
| 570 |
+
p.add_argument("--seed", type=int, default=42)
|
| 571 |
+
p.add_argument("--cosne_device", default="cpu")
|
| 572 |
+
p.add_argument("--max_pair_lines", type=int, default=40)
|
| 573 |
+
p.add_argument(
|
| 574 |
+
"--out",
|
| 575 |
+
default=os.path.join(ROOT, "outputs/cosne_disease.png"),
|
| 576 |
+
)
|
| 577 |
+
p.add_argument("--save_emb", default=None)
|
| 578 |
+
p.add_argument("--dpi", type=int, default=150)
|
| 579 |
+
return p.parse_args()
|
| 580 |
+
|
| 581 |
+
|
| 582 |
+
def main():
|
| 583 |
+
args = parse_args()
|
| 584 |
+
if args.load_emb is None and args.load_feats is None:
|
| 585 |
+
default_emb = os.path.join(ROOT, "outputs/cosne_emb.npz")
|
| 586 |
+
default_feats = os.path.join(ROOT, "outputs/tsne_feats.npz")
|
| 587 |
+
if os.path.isfile(default_emb):
|
| 588 |
+
args.load_emb = default_emb
|
| 589 |
+
elif os.path.isfile(default_feats):
|
| 590 |
+
args.load_feats = default_feats
|
| 591 |
+
else:
|
| 592 |
+
raise FileNotFoundError(
|
| 593 |
+
"Need --load_emb or --load_feats (or outputs/cosne_emb.npz)"
|
| 594 |
+
)
|
| 595 |
+
|
| 596 |
+
emb_img, emb_txt, sample_idx, curv = run_or_load_cosne(args)
|
| 597 |
+
df = load_label_table(args.labels_csv)
|
| 598 |
+
if len(df) < int(sample_idx.max()) + 1:
|
| 599 |
+
raise RuntimeError(
|
| 600 |
+
f"labels CSV has {len(df)} rows but sample_idx max={sample_idx.max()}"
|
| 601 |
+
)
|
| 602 |
+
|
| 603 |
+
stats: list[dict] = []
|
| 604 |
+
if args.color_by == "gt":
|
| 605 |
+
labels = build_labels(df, sample_idx, args.label_mode)
|
| 606 |
+
print(f"===== Label mode: gt/{args.label_mode} (n={len(labels)}) =====")
|
| 607 |
+
for cls in CLASS_ORDER:
|
| 608 |
+
c = Counter(labels).get(cls, 0)
|
| 609 |
+
if c:
|
| 610 |
+
print(f" {cls:14s} {c}")
|
| 611 |
+
title = (
|
| 612 |
+
f"CO-SNE + GT ({args.label_mode}) | hyperbolic | "
|
| 613 |
+
f"n={len(labels)} | curv={curv:.3f}"
|
| 614 |
+
)
|
| 615 |
+
elif args.color_by == "cluster_csv":
|
| 616 |
+
labels = labels_from_cluster_csv(args.cluster_csv, args.labels_csv, sample_idx)
|
| 617 |
+
print(f"===== Color by cluster CSV: {args.cluster_csv} =====")
|
| 618 |
+
for cls in CLASS_ORDER:
|
| 619 |
+
c = Counter(labels).get(cls, 0)
|
| 620 |
+
if c:
|
| 621 |
+
print(f" {cls:14s} {c}")
|
| 622 |
+
title = (
|
| 623 |
+
f"CO-SNE + inferred clusters (CSV) | hyperbolic | "
|
| 624 |
+
f"n={len(labels)} | curv={curv:.3f}"
|
| 625 |
+
)
|
| 626 |
+
elif args.color_by == "inferred":
|
| 627 |
+
feats_path = args.load_feats or os.path.join(ROOT, "outputs/tsne_feats.npz")
|
| 628 |
+
if not os.path.isfile(feats_path):
|
| 629 |
+
raise FileNotFoundError(f"--color_by inferred needs feats: {feats_path}")
|
| 630 |
+
labels, stats = labels_inferred_highdim(
|
| 631 |
+
feats_path,
|
| 632 |
+
args.labels_csv,
|
| 633 |
+
sample_idx,
|
| 634 |
+
seed_mode=args.seed_mode,
|
| 635 |
+
eps=args.eps,
|
| 636 |
+
min_samples=args.min_samples,
|
| 637 |
+
eps_quantile=args.eps_quantile,
|
| 638 |
+
min_cluster_size=args.min_cluster_size,
|
| 639 |
+
)
|
| 640 |
+
print_cluster_stats(stats, labels)
|
| 641 |
+
title = (
|
| 642 |
+
f"CO-SNE + inferred (high-dim {args.seed_mode}) | hyperbolic | "
|
| 643 |
+
f"n={len(labels)} | curv={curv:.3f}"
|
| 644 |
+
)
|
| 645 |
+
elif args.color_by == "cosne2d":
|
| 646 |
+
labels, stats = labels_inferred_cosne2d(
|
| 647 |
+
emb_img,
|
| 648 |
+
df,
|
| 649 |
+
sample_idx,
|
| 650 |
+
seed_mode=args.seed_mode,
|
| 651 |
+
eps=args.eps,
|
| 652 |
+
min_samples=args.min_samples,
|
| 653 |
+
eps_quantile=args.eps_quantile,
|
| 654 |
+
min_cluster_size=args.min_cluster_size,
|
| 655 |
+
)
|
| 656 |
+
print_cluster_stats(stats, labels)
|
| 657 |
+
title = (
|
| 658 |
+
f"CO-SNE + inferred (2D disk {args.seed_mode}) | hyperbolic | "
|
| 659 |
+
f"n={len(labels)} | curv={curv:.3f}"
|
| 660 |
+
)
|
| 661 |
+
else:
|
| 662 |
+
raise ValueError(args.color_by)
|
| 663 |
+
|
| 664 |
+
plot_disease_poincare(
|
| 665 |
+
emb_img,
|
| 666 |
+
emb_txt,
|
| 667 |
+
labels,
|
| 668 |
+
args.out,
|
| 669 |
+
title=title,
|
| 670 |
+
dpi=args.dpi,
|
| 671 |
+
hide_other=args.hide_other,
|
| 672 |
+
max_pair_lines=args.max_pair_lines,
|
| 673 |
+
)
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
if __name__ == "__main__":
|
| 677 |
+
main()
|