SteveZeyuZhang commited on
Commit
b91f38a
·
1 Parent(s): 1cc4fea

Add source files and streaming backup restore instructions

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +2 -0
  2. BACKUP_RESTORE.md +27 -0
  3. Rad_IR/radir.egg-info/PKG-INFO +26 -0
  4. Rad_IR/radir.egg-info/SOURCES.txt +10 -0
  5. Rad_IR/radir.egg-info/dependency_links.txt +1 -0
  6. Rad_IR/radir.egg-info/requires.txt +20 -0
  7. Rad_IR/radir.egg-info/top_level.txt +1 -0
  8. Rad_IR/radir/RadIR.py +984 -0
  9. Rad_IR/radir/__init__.py +1 -0
  10. Rad_IR/radir/__pycache__/RadIR.cpython-313.pyc +0 -0
  11. Rad_IR/radir/__pycache__/RadIR.cpython-39.pyc +0 -0
  12. Rad_IR/radir/__pycache__/__init__.cpython-313.pyc +0 -0
  13. Rad_IR/radir/__pycache__/__init__.cpython-39.pyc +0 -0
  14. Rad_IR/radir/__pycache__/lorentz.cpython-313.pyc +0 -0
  15. Rad_IR/radir/__pycache__/lorentz.cpython-39.pyc +0 -0
  16. Rad_IR/radir/__pycache__/transformer_decoder.cpython-313.pyc +0 -0
  17. Rad_IR/radir/__pycache__/transformer_decoder.cpython-39.pyc +0 -0
  18. Rad_IR/radir/data/__pycache__/conditional_dataset_train.cpython-313.pyc +0 -0
  19. Rad_IR/radir/data/__pycache__/conditional_dataset_train.cpython-39.pyc +0 -0
  20. Rad_IR/radir/data/__pycache__/hmclip_mimic_box.cpython-313.pyc +0 -0
  21. Rad_IR/radir/data/__pycache__/hmclip_mimic_box.cpython-39.pyc +0 -0
  22. Rad_IR/radir/data/bpe_simple_vocab_16e6.txt +0 -0
  23. Rad_IR/radir/data/build_mimic_h5.py +0 -0
  24. Rad_IR/radir/data/conditional_dataset_train.py +141 -0
  25. Rad_IR/radir/data/hmclip_mimic_box.py +358 -0
  26. Rad_IR/radir/data/utils.py +18 -0
  27. Rad_IR/radir/lorentz.py +312 -0
  28. Rad_IR/radir/transformer_decoder.py +154 -0
  29. Rad_IR/setup.py +32 -0
  30. backup-manifest.json +20 -0
  31. restore_backup.py +29 -0
  32. sbatch/stage1.sh +59 -0
  33. sbatch/stage1_and_stage2.sh +61 -0
  34. sbatch/stage2.sh +45 -0
  35. scripts/README.md +3 -0
  36. scripts/RadIR_Trainer_CXR_CT.py +482 -0
  37. scripts/__pycache__/RadIR_Trainer_CXR_CT.cpython-39.pyc +0 -0
  38. scripts/__pycache__/image_traversals_compare.cpython-313.pyc +0 -0
  39. scripts/__pycache__/infer_disease_clusters.cpython-39.pyc +0 -0
  40. scripts/__pycache__/plot_cosne.cpython-39.pyc +0 -0
  41. scripts/__pycache__/plot_tsne.cpython-313.pyc +0 -0
  42. scripts/__pycache__/scheduler.cpython-39.pyc +0 -0
  43. scripts/build_traversal_concepts.py +620 -0
  44. scripts/checkdata.py +87 -0
  45. scripts/conditional_dataset_evaluate.py +584 -0
  46. scripts/conditional_dataset_train.py +825 -0
  47. scripts/data_process.py +177 -0
  48. scripts/image_traversals_compare.py +1186 -0
  49. scripts/infer_disease_clusters.py +513 -0
  50. 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()