seongeun-lee commited on
Commit
db46ad7
·
verified ·
1 Parent(s): c5a246d

Resample similarity maps onto the input CT grid

Browse files

- similarity_scores_to_volume(scores, model, geometry) returns the map with the input CT's shape and affine (geometry from load_ct_volume(..., return_geometry=True)); without geometry it stays on the 240x384x384 preprocessed grid
- load_ct_volume: return_affine -> return_geometry; accepts a NIfTI file or a DICOM series directory
- inference.py: saves only similarity_map.nii.gz (no --ct_output); drops the 18 CT-RATE finding scores
- README: new API, geometry, raw_spacing axis order (I, P, L), pydicom for DICOM input

Files changed (3) hide show
  1. README.md +17 -11
  2. inference.py +12 -38
  3. utils.py +69 -33
README.md CHANGED
@@ -43,11 +43,11 @@ Both examples use the bundled `inference.py` / `utils.py`; run them from the dow
43
  ```bash
44
  hf download Deepnoid/GLINT-CT --local-dir glint-ct && cd glint-ct
45
  python inference.py --volume volume.nii.gz --text "There is a lung nodule."
46
- # prints the sentence probability and the 18 CT-RATE findings,
47
- # saves similarity_map.nii.gz and ct_preprocessed.nii.gz
48
  ```
49
 
50
- Both files share one affine, so the map overlays the CT in ITK-SNAP / 3D Slicer.
 
51
 
52
  ### AutoModel
53
 
@@ -62,17 +62,23 @@ device = "cuda" if torch.cuda.is_available() else "cpu"
62
  model = AutoModel.from_pretrained(repo, trust_remote_code=True, dtype=torch.float32).to(device).eval()
63
  tokenizer = AutoTokenizer.from_pretrained(repo)
64
 
65
- pixel_values, affine = load_ct_volume("volume.nii.gz", return_affine=True) # [1, 80, 3, 384, 384]
66
  prob, scores = model_inference(pixel_values, "There is a lung nodule.", model=model, tokenizer=tokenizer)
67
- volume = similarity_scores_to_volume(scores, model) # (240, 384, 384)
68
- save_similarity_map(volume, affine, "similarity_map.nii.gz", pixel_values, "ct_preprocessed.nii.gz")
69
  ```
70
 
71
  - **Outputs:** `prob` is the sentence probability (sigmoid); `scores` is the per-token similarity in [0, 1]
72
- (40 × 24 × 24 tokens).
73
- - **Inputs:** `utils.load_ct_volume` applies the training preprocessing (IPL, HU [-1000, 1000] → [-1, 1],
74
- 1.5 × 0.75 × 0.75 mm, crop/pad to 240×480×480, resize to 240×384×384) and returns `[1, 80, 3, 384, 384]`.
75
- - **Unreliable NIfTI affine:** pass `raw_spacing=(sx, sy, sz)` to `load_ct_volume` (`--raw_spacing` in the script).
 
 
 
 
 
 
76
  - **Underlying call:** `model.compute_logits(pixel_values, [encoded_text])` returns `logits` and `similarity_scores`.
77
 
78
  Vision encoder only (`vision_only=True`):
@@ -93,7 +99,7 @@ print(out.last_hidden_state.shape) # (1, 23040, 1024)
93
 
94
  ## Requirements
95
 
96
- `transformers==4.56.1`, `torch`, `entmax`, `monai`, `nibabel`.
97
 
98
  > ⚠️ Keep `transformers==4.56.1`, the version the bundled code was validated with.
99
 
 
43
  ```bash
44
  hf download Deepnoid/GLINT-CT --local-dir glint-ct && cd glint-ct
45
  python inference.py --volume volume.nii.gz --text "There is a lung nodule."
46
+ # prints the sentence probability and saves similarity_map.nii.gz
 
47
  ```
48
 
49
+ `--volume` takes a NIfTI file or a DICOM series directory. The map has the input CT's shape and affine, so it
50
+ overlays the input in ITK-SNAP / 3D Slicer.
51
 
52
  ### AutoModel
53
 
 
62
  model = AutoModel.from_pretrained(repo, trust_remote_code=True, dtype=torch.float32).to(device).eval()
63
  tokenizer = AutoTokenizer.from_pretrained(repo)
64
 
65
+ pixel_values, geometry = load_ct_volume("volume.nii.gz", return_geometry=True) # [1, 80, 3, 384, 384]
66
  prob, scores = model_inference(pixel_values, "There is a lung nodule.", model=model, tokenizer=tokenizer)
67
+ similarity_map = similarity_scores_to_volume(scores, model, geometry) # input CT shape, e.g. (512, 512, 205)
68
+ save_similarity_map(similarity_map, geometry["affine"], "similarity_map.nii.gz")
69
  ```
70
 
71
  - **Outputs:** `prob` is the sentence probability (sigmoid); `scores` is the per-token similarity in [0, 1]
72
+ (40 × 24 × 24 tokens); `similarity_scores_to_volume` resamples it onto the input CT grid (0 outside the 360 mm
73
+ preprocessing crop), or keeps the 240×384×384 preprocessed grid without `geometry`.
74
+ - **`geometry`:** returned by `load_ct_volume(..., return_geometry=True)`: `shape` and `affine` of the input CT (the
75
+ map's NIfTI header) and `to_model`, the 4×4 input-voxel → preprocessed-voxel mapping used for the resampling; for
76
+ array input `affine` is `None` and the map stays on the preprocessed grid.
77
+ - **Inputs:** a NIfTI file or a DICOM series directory (one series); `utils.load_ct_volume` applies the training
78
+ preprocessing (IPL, HU [-1000, 1000] → [-1, 1], 1.5 × 0.75 × 0.75 mm, crop/pad to 240×480×480, resize to
79
+ 240×384×384) and returns `[1, 80, 3, 384, 384]`.
80
+ - **Unreliable affine:** pass the voxel spacing along the reoriented I, P, L axes, e.g. `raw_spacing=(1.5, 0.7, 0.7)`,
81
+ to `load_ct_volume` (`--raw_spacing` in the script).
82
  - **Underlying call:** `model.compute_logits(pixel_values, [encoded_text])` returns `logits` and `similarity_scores`.
83
 
84
  Vision encoder only (`vision_only=True`):
 
99
 
100
  ## Requirements
101
 
102
+ `transformers==4.56.1`, `torch`, `entmax`, `monai`, `nibabel`; `pydicom` for DICOM series input.
103
 
104
  > ⚠️ Keep `transformers==4.56.1`, the version the bundled code was validated with.
105
 
inference.py CHANGED
@@ -1,4 +1,4 @@
1
- """Score a sentence against a chest CT volume, save its similarity map as NIfTI and score the 18 CT-RATE findings.
2
 
3
  python inference.py --volume volume.nii.gz --text "There is a lung nodule."
4
  """
@@ -13,7 +13,6 @@ from transformers import AutoModel, AutoTokenizer
13
  from utils import (
14
  load_ct_volume,
15
  model_inference,
16
- model_inference_multiple_text,
17
  save_similarity_map,
18
  similarity_scores_to_volume,
19
  )
@@ -22,27 +21,6 @@ warnings.filterwarnings("ignore", category=UserWarning)
22
 
23
  REPO_DIR = os.path.dirname(os.path.abspath(__file__))
24
 
25
- CT_RATE_LABELS = [
26
- "Medical material",
27
- "Arterial wall calcification",
28
- "Cardiomegaly",
29
- "Pericardial effusion",
30
- "Coronary artery wall calcification",
31
- "Hiatal hernia",
32
- "Lymphadenopathy",
33
- "Emphysema",
34
- "Atelectasis",
35
- "Lung nodule",
36
- "Lung opacity",
37
- "Pulmonary fibrotic sequela",
38
- "Pleural effusion",
39
- "Mosaic attenuation pattern",
40
- "Peribronchial thickening",
41
- "Consolidation",
42
- "Bronchiectasis",
43
- "Interlobular septal thickening",
44
- ]
45
-
46
 
47
  def load_model(device=None, dtype=torch.float32):
48
  device = device or ("cuda" if torch.cuda.is_available() else "cpu")
@@ -53,30 +31,26 @@ def load_model(device=None, dtype=torch.float32):
53
 
54
  def main():
55
  parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
56
- parser.add_argument("--volume", default="volume.nii.gz", help="NIfTI CT volume")
57
  parser.add_argument("--text", default="There is a lung nodule.")
58
- parser.add_argument("--output", default="similarity_map.nii.gz", help="similarity map (NIfTI)")
59
- parser.add_argument(
60
- "--ct_output", default="ct_preprocessed.nii.gz", help="preprocessed CT on the same grid (NIfTI)"
61
- )
62
  parser.add_argument(
63
- "--raw_spacing", type=float, nargs=3, default=None, help="voxel spacing if the NIfTI affine is unreliable"
 
 
 
 
64
  )
65
  args = parser.parse_args()
66
 
67
  models = load_model()
68
- pixel_values, affine = load_ct_volume(args.volume, raw_spacing=args.raw_spacing, return_affine=True)
69
 
70
  prob, scores = model_inference(pixel_values, args.text, **models)
71
  print(f"{args.text!r}: probability {prob.item():.3f}")
72
- volume = similarity_scores_to_volume(scores, models["model"])
73
- save_similarity_map(volume, affine, args.output, pixel_values, args.ct_output)
74
- print(f"similarity map saved to {args.output} (CT: {args.ct_output})")
75
-
76
- prompts = [f"There is {label}" for label in CT_RATE_LABELS] # zero-shot evaluation prompts
77
- probs, _ = model_inference_multiple_text(pixel_values, prompts, **models)
78
- for label, p in zip(CT_RATE_LABELS, probs.flatten().tolist()):
79
- print(f" {label:42s} {p:.3f}")
80
 
81
 
82
  if __name__ == "__main__":
 
1
+ """Score a sentence against a chest CT volume and save its similarity map as NIfTI.
2
 
3
  python inference.py --volume volume.nii.gz --text "There is a lung nodule."
4
  """
 
13
  from utils import (
14
  load_ct_volume,
15
  model_inference,
 
16
  save_similarity_map,
17
  similarity_scores_to_volume,
18
  )
 
21
 
22
  REPO_DIR = os.path.dirname(os.path.abspath(__file__))
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  def load_model(device=None, dtype=torch.float32):
26
  device = device or ("cuda" if torch.cuda.is_available() else "cpu")
 
31
 
32
  def main():
33
  parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
34
+ parser.add_argument("--volume", default="volume.nii.gz", help="NIfTI CT volume or DICOM series directory")
35
  parser.add_argument("--text", default="There is a lung nodule.")
36
+ parser.add_argument("--output", default="similarity_map.nii.gz", help="similarity map on the input CT grid (NIfTI)")
 
 
 
37
  parser.add_argument(
38
+ "--raw_spacing",
39
+ type=float,
40
+ nargs=3,
41
+ default=None,
42
+ help="voxel spacing along the reoriented I, P, L axes (e.g. 1.5 0.7 0.7) if the input affine is unreliable",
43
  )
44
  args = parser.parse_args()
45
 
46
  models = load_model()
47
+ pixel_values, geometry = load_ct_volume(args.volume, raw_spacing=args.raw_spacing, return_geometry=True)
48
 
49
  prob, scores = model_inference(pixel_values, args.text, **models)
50
  print(f"{args.text!r}: probability {prob.item():.3f}")
51
+ similarity_map = similarity_scores_to_volume(scores, models["model"], geometry)
52
+ save_similarity_map(similarity_map, geometry["affine"], args.output)
53
+ print(f"similarity map {tuple(similarity_map.shape)} saved to {args.output}")
 
 
 
 
 
54
 
55
 
56
  if __name__ == "__main__":
utils.py CHANGED
@@ -83,21 +83,41 @@ def normalize_pixel_values_videos(pixel_values_videos):
83
  return pixel_values_videos
84
 
85
 
86
- def load_ct_volume(volume, orientd=True, raw_spacing=None, return_affine=False):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  """Load and preprocess a CT volume.
88
 
89
- volume : path to a NIfTI (.nii/.nii.gz) file, or a (240, 384, 384) tensor/ndarray in HU that is
90
- already on the preprocessed grid (IPL, 1.5/0.75/0.75 mm resampled, cropped/padded, resized);
91
- only the HU window is applied to arrays. Returns pixel_values of shape
92
- (1, num_frames, 3, H, W), plus the 4x4 world affine of the preprocessed grid if
93
- ``return_affine`` (None for array input).
 
94
  """
95
- affine = None
96
  if isinstance(volume, str):
97
  transform = build_ct_transform(orientd=orientd, raw_spacing=raw_spacing)
98
  out = transform({"image": volume})
99
  image = out["image"].squeeze(0) # (1, D, H, W) -> (D, H, W)
100
- affine = np.asarray(out["image"].affine, dtype=np.float64)
101
  else:
102
  image = torch.as_tensor(np.asarray(volume), dtype=torch.float32)
103
  if tuple(image.shape) != tuple(CT_MONAI_CONFIG["final_size"]):
@@ -106,45 +126,61 @@ def load_ct_volume(volume, orientd=True, raw_spacing=None, return_affine=False):
106
  )
107
  (a_min, a_max), (b_min, b_max) = CT_MONAI_CONFIG["hu_range"], CT_MONAI_CONFIG["normalize_range"]
108
  image = (image.clamp(a_min, a_max) - a_min) / (a_max - a_min) * (b_max - b_min) + b_min
 
109
 
110
  pixel_values_videos = normalize_pixel_values_videos(image).unsqueeze(0) # (1, num_frames, 3, H, W)
111
- return (pixel_values_videos, affine) if return_affine else pixel_values_videos
112
 
113
 
114
- def similarity_scores_to_volume(similarity_scores, model, target_shape=CT_MONAI_CONFIG["final_size"]):
115
- """Per-token scores ``[1, 1, num_tokens]`` -> (D, H, W) map on the preprocessed grid (trilinear)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  vc = model.config.vision_config
117
  patch_size = getattr(vc, "patch_size", None) or 16
118
  frame_length = vc.num_channels * (getattr(vc, "tubelet_size", None) or 2)
119
  grid = (vc.input_slices // frame_length, vc.img_size // patch_size, vc.img_size // patch_size)
120
 
121
- volume = similarity_scores.float().reshape(grid)
122
- if target_shape is not None:
123
- volume = torch.nn.functional.interpolate(
124
- volume[None, None], size=tuple(target_shape), mode="trilinear", align_corners=False
125
- )[0, 0]
126
- return volume
127
-
128
 
129
- def save_similarity_map(similarity_volume, affine, save_path, pixel_values=None, ct_save_path=None):
130
- """Save the similarity map (and optionally the preprocessed CT) as NIfTI in world space.
131
 
132
- With the affine from ``load_ct_volume(..., return_affine=True)`` the map overlays the
133
- original CT in ITK-SNAP / 3D Slicer. ``pixel_values`` + ``ct_save_path`` also save the
134
- preprocessed volume (HU window [-1000, 1000]) on the same grid.
135
- """
136
  import nibabel as nib
137
 
138
- sim = similarity_volume.detach().float().cpu().numpy() if torch.is_tensor(similarity_volume) else similarity_volume
139
  nib.save(nib.Nifti1Image(sim.astype(np.float32), affine), save_path)
140
 
141
- if pixel_values is not None and ct_save_path is not None:
142
- mean = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1)
143
- std = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1)
144
- frames = pixel_values[0].detach().float().cpu() * std + mean # (num_frames, 3, H, W) in [0, 1]
145
- hu = (frames.reshape(-1, *frames.shape[-2:]) * 2 - 1) * 1000.0
146
- nib.save(nib.Nifti1Image(hu.numpy().astype(np.float32), affine), ct_save_path)
147
-
148
 
149
  def _as_pixel_values(volume, raw_spacing):
150
  if torch.is_tensor(volume) and volume.dim() == 5: # already preprocessed by load_ct_volume
@@ -159,7 +195,7 @@ def model_inference(volume, text, model, tokenizer, raw_spacing=None):
159
  similarity_prob : scalar sigmoid probability that the phrase applies.
160
  similarity_scores : per-token similarity in [0, 1] over the V-JEPA video tokens
161
  (shape [1, 1, num_tokens]); ``similarity_scores_to_volume``
162
- maps it onto the preprocessed CT grid.
163
 
164
  volume may also be the ``pixel_values`` returned by ``load_ct_volume``.
165
  """
 
83
  return pixel_values_videos
84
 
85
 
86
+ def _input_geometry(image, orientd, raw_spacing):
87
+ """Input voxel grid (shape, affine) and its 4x4 voxel mapping onto the preprocessed grid."""
88
+ import nibabel as nib
89
+
90
+ affine = np.asarray(image.meta["original_affine"], dtype=np.float64)
91
+ shape = tuple(int(s) for s in image.meta["spatial_shape"])
92
+ ref_affine = affine
93
+ if raw_spacing is not None: # the pipeline replaced the affine with diag(raw_spacing) after reorientation
94
+ to_oriented = np.eye(4)
95
+ if orientd:
96
+ ornt = nib.orientations.ornt_transform(
97
+ nib.orientations.io_orientation(affine),
98
+ nib.orientations.axcodes2ornt(CT_MONAI_CONFIG["orientation"]),
99
+ )
100
+ to_oriented = np.linalg.inv(nib.orientations.inv_ornt_aff(ornt, shape))
101
+ ref_affine = np.diag([*map(float, raw_spacing), 1.0]) @ to_oriented
102
+ to_model = np.linalg.inv(np.asarray(image.affine, dtype=np.float64)) @ ref_affine
103
+ return {"shape": shape, "affine": affine, "to_model": to_model}
104
+
105
+
106
+ def load_ct_volume(volume, orientd=True, raw_spacing=None, return_geometry=False):
107
  """Load and preprocess a CT volume.
108
 
109
+ volume : path to a NIfTI (.nii/.nii.gz) file or a DICOM series directory, or a (240, 384, 384)
110
+ tensor/ndarray in HU that is already on the preprocessed grid (IPL, 1.5/0.75/0.75 mm
111
+ resampled, cropped/padded, resized); only the HU window is applied to arrays. Returns
112
+ pixel_values of shape (1, num_frames, 3, H, W), plus the input grid if ``return_geometry``
113
+ (``shape``, ``affine`` (None for array input) and ``to_model``, its voxel mapping onto the
114
+ preprocessed grid), which ``similarity_scores_to_volume`` maps the scores back onto.
115
  """
 
116
  if isinstance(volume, str):
117
  transform = build_ct_transform(orientd=orientd, raw_spacing=raw_spacing)
118
  out = transform({"image": volume})
119
  image = out["image"].squeeze(0) # (1, D, H, W) -> (D, H, W)
120
+ geometry = _input_geometry(out["image"], orientd, raw_spacing)
121
  else:
122
  image = torch.as_tensor(np.asarray(volume), dtype=torch.float32)
123
  if tuple(image.shape) != tuple(CT_MONAI_CONFIG["final_size"]):
 
126
  )
127
  (a_min, a_max), (b_min, b_max) = CT_MONAI_CONFIG["hu_range"], CT_MONAI_CONFIG["normalize_range"]
128
  image = (image.clamp(a_min, a_max) - a_min) / (a_max - a_min) * (b_max - b_min) + b_min
129
+ geometry = {"shape": tuple(image.shape), "affine": None, "to_model": np.eye(4)}
130
 
131
  pixel_values_videos = normalize_pixel_values_videos(image).unsqueeze(0) # (1, num_frames, 3, H, W)
132
+ return (pixel_values_videos, geometry) if return_geometry else pixel_values_videos
133
 
134
 
135
+ def _resample_to_input(volume, to_model, shape, chunk=32):
136
+ """Trilinearly sample a preprocessed-grid volume at every input voxel; 0 outside the preprocessing crop."""
137
+ if np.allclose(to_model, np.eye(4)) and tuple(shape) == tuple(volume.shape):
138
+ return volume
139
+ device = volume.device
140
+ M = torch.as_tensor(to_model, dtype=torch.float32, device=device)
141
+ size = torch.tensor(volume.shape, dtype=torch.float32, device=device)
142
+ ni, nj, nk = shape
143
+ jk = torch.stack(torch.meshgrid(torch.arange(nj), torch.arange(nk), indexing="ij"), -1).reshape(-1, 2).to(device)
144
+ out = torch.empty(tuple(shape), dtype=torch.float32, device=device)
145
+ for i0 in range(0, ni, chunk):
146
+ ii = torch.arange(i0, min(i0 + chunk, ni), device=device)
147
+ ijk = torch.cat([ii.repeat_interleave(len(jk))[:, None], jk.repeat(len(ii), 1)], 1).float()
148
+ src = ijk @ M[:3, :3].T + M[:3, 3] # input voxel -> preprocessed voxel
149
+ grid = (2 * src / (size - 1) - 1).flip(-1) # (d, h, w) -> grid_sample's (x, y, z)
150
+ out[i0 : i0 + len(ii)] = torch.nn.functional.grid_sample(
151
+ volume[None, None], grid.view(1, -1, 1, 1, 3), mode="bilinear", padding_mode="zeros", align_corners=True
152
+ ).view(len(ii), nj, nk)
153
+ return out
154
+
155
+
156
+ def similarity_scores_to_volume(similarity_scores, model, geometry=None):
157
+ """Per-token scores ``[1, 1, num_tokens]`` -> similarity map on the input CT grid.
158
+
159
+ The token grid is upsampled (trilinear) to the 240x384x384 preprocessed grid and, with ``geometry`` from
160
+ ``load_ct_volume``, resampled onto the input voxels so the map has the input's shape and affine (0 outside
161
+ the 360 mm preprocessing crop). Without ``geometry`` the map stays on the preprocessed grid.
162
+ """
163
  vc = model.config.vision_config
164
  patch_size = getattr(vc, "patch_size", None) or 16
165
  frame_length = vc.num_channels * (getattr(vc, "tubelet_size", None) or 2)
166
  grid = (vc.input_slices // frame_length, vc.img_size // patch_size, vc.img_size // patch_size)
167
 
168
+ similarity_map = similarity_scores.float().reshape(grid)
169
+ similarity_map = torch.nn.functional.interpolate(
170
+ similarity_map[None, None], size=CT_MONAI_CONFIG["final_size"], mode="trilinear", align_corners=False
171
+ )[0, 0]
172
+ if geometry is None:
173
+ return similarity_map
174
+ return _resample_to_input(similarity_map, geometry["to_model"], geometry["shape"])
175
 
 
 
176
 
177
+ def save_similarity_map(similarity_map, affine, save_path):
178
+ """Save the similarity map as NIfTI; with ``geometry["affine"]`` it overlays the input CT voxel for voxel."""
 
 
179
  import nibabel as nib
180
 
181
+ sim = similarity_map.detach().float().cpu().numpy() if torch.is_tensor(similarity_map) else similarity_map
182
  nib.save(nib.Nifti1Image(sim.astype(np.float32), affine), save_path)
183
 
 
 
 
 
 
 
 
184
 
185
  def _as_pixel_values(volume, raw_spacing):
186
  if torch.is_tensor(volume) and volume.dim() == 5: # already preprocessed by load_ct_volume
 
195
  similarity_prob : scalar sigmoid probability that the phrase applies.
196
  similarity_scores : per-token similarity in [0, 1] over the V-JEPA video tokens
197
  (shape [1, 1, num_tokens]); ``similarity_scores_to_volume``
198
+ maps it onto the input CT grid.
199
 
200
  volume may also be the ``pixel_values`` returned by ``load_ct_volume``.
201
  """