File size: 12,398 Bytes
c6be546
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
"""
MONAI-based 3D preprocessing pipeline for VoxTell grounding volumes.

Pipeline (matches VoxTell's nnUNet-style training preprocessing):
  1. EnsureChannelFirstd — add channel dim
  2. Orientationd(RAS)  — standard anatomical orientation
  3. Spacingd((1,1,1))  — isotropic 1 mm voxel spacing
  4. NormalizeIntensityd — z-score normalization (mean/std over all voxels)
  5. SpatialPadd(192)   — ensure volume is at least 192^3
  6. RandCropByPosNegLabeld(192^3, pos=1, neg=1) — foreground-biased crop

Replaces MonaiSAM2Preprocessor for the VoxTell segmentation backbone.
"""

from typing import List, Optional, Tuple

import numpy as np
import torch
from ._compat import print_log

from monai.data import MetaTensor
from monai.transforms import (
    Compose,
    EnsureChannelFirstd,
    NormalizeIntensityd,
    Orientationd,
    RandCropByPosNegLabeld,
    SpatialPadd,
    CenterSpatialCropd,
    Spacingd,
    Transposed,
    SpatialPad
)
from monai.utils import ensure_tuple


class RandCropByPosNegLabelWithRandomOffsetd(RandCropByPosNegLabeld):
    """
    Random Cropping with consistent output spacing and size, using random offset.
    Pipeline: Pad -> RandomCrop (with random offset) -> Resize to target_spacing -> CenterCrop to spatial_size
    """
    
    def randomize(
        self,
        label: torch.Tensor,
        fg_indices: np.ndarray | None = None,
        bg_indices: np.ndarray | None = None,
        image: torch.Tensor | None = None,
    ) -> None:
        # Call parent's randomize to set self.centers
        super().randomize(label, fg_indices, bg_indices, image)

        if self.cropper.centers is None:
            return

        # Vectorized random offset and clamping
        centers = np.array(self.cropper.centers)  # (num_centers, num_dims)
        spatial_size = np.array(ensure_tuple(self.cropper.spatial_size))
        spatial_shape = np.array(label.shape[1:])

        # Generate random offsets for all centers at once: [0, size) - size//2
        offsets = (self.R.rand(*centers.shape) * spatial_size).astype(np.int64) - spatial_size // 2

        # Apply offsets and clamp to valid bounds
        new_centers = centers + offsets
        min_centers = spatial_size // 2
        max_centers = spatial_shape - spatial_size + spatial_size // 2
        new_centers = np.clip(new_centers, min_centers, max_centers).astype(np.int64)

        # Convert back to tuple of tuples (required format)
        self.cropper.centers = tuple(map(tuple, new_centers.tolist()))
        
        
class SmartRandCropByPosNegLabeld(RandCropByPosNegLabelWithRandomOffsetd):
    """
    Random Cropping with consistent output spacing and size and random offset.
    Pipeline: Pad -> RandomCrop -> Resize to target_spacing -> CenterCrop to spatial_size
    """
    
    def __init__(
        self,
        keys,
        label_key,
        spatial_size,
        target_spacing,
        pad_mode="constant",
        resize_mode=('bilinear', 'nearest'),
        **kwargs
    ):
        """
        Args:
            keys: keys of the corresponding items to be transformed.
            label_key: key for label used for positive/negative sampling.
            spatial_size: final output spatial size after all transforms.
            target_spacing: target spacing to resample to (e.g., [1.0, 1.0, 1.0]).
            pad_mode: padding mode for SpatialPadd.
            resize_mode: interpolation mode for Spacingd ("bilinear", "nearest", etc.).
            **kwargs: additional arguments for RandCropByPosNegLabeld.
        """
        self.spatial_size = np.array(spatial_size)
        self.target_spacing = np.array(target_spacing)
        self.pad_mode = pad_mode
        
        # Initialize parent with dummy spatial_size, will be computed per-image
        super().__init__(keys=keys, label_key=label_key, spatial_size=(1, 1, 1), **kwargs)
        
        self.spacer = Spacingd(
            keys=self.keys,
            pixdim=self.target_spacing.tolist(),
            mode=resize_mode
        )
        self.center_cropper = CenterSpatialCropd(keys=self.keys, roi_size=self.spatial_size.tolist())
    
    def _get_crop_size(self, label):
        """Compute crop size in voxels based on physical size and image spacing."""        
        if isinstance(label, MetaTensor):
            spacing = torch.norm(label.affine[:3, :3], dim=0)
        else:
            print_log(f"Image has no affine, using default spacing of 1.0", logger='current')
            spacing = self.target_spacing
        
        # Compute crop voxels: physical_extent / current_spacing
        # physical_extent = spatial_size[i] * target_spacing[i]
        crop_size = [torch.ceil(self.spatial_size[i] * (self.target_spacing[i] / spacing[i])).int().item() for i in range(3)]
        crop_size = [s + 5 for s in crop_size]
        
        return tuple(crop_size)
    
    def __call__(self, data, lazy=None):
        d = dict(data)
        label = d[self.label_key]
        
        # Step 1: Compute crop size based on image spacing
        crop_size = self._get_crop_size(label)
        self.cropper.spatial_size = crop_size # This is used for random offset sampling
        
        # Step 2: Pad if volume is smaller than crop_size
        for key in self.key_iterator(d):
            img = d[key]
            if self.pad_mode == 'min_value' or self.pad_mode == 'constant':
                pad_mode = 'constant'
                
                constant_values = img.min() if self.pad_mode == 'min_value' else 0
                
                padder = SpatialPad(spatial_size=crop_size, mode=pad_mode, constant_values=constant_values)
            else:
                pad_mode = self.pad_mode
                
                padder = SpatialPad(spatial_size=crop_size, mode=pad_mode)
            
            d[key] = padder(img)
        
        # Step 3: Random crop (uses parent's __call__) with random offset
        cropped_list = super().__call__(d, lazy=lazy)
        
        # From list of dicts to dict of lists
        stacked_list = []
        for sample in cropped_list:
            # Step 4: Resample to target spacing
            sample = self.spacer(sample)
            # Step 5: Center crop to exact spatial size
            sample = self.center_cropper(sample)
            stacked_list.append(sample)
            
        return stacked_list
            

class VoxTellPreprocessor:
    """
    3D preprocessing for VoxTell grounding input.

    Takes a raw CT volume + binary mask (any orientation, any spacing)
    and produces one or more 192^3 patches ready for VoxTell's encoder.

    In **eval mode** (set via ``set_eval_mode()``), returns the full
    preprocessed volume (orient → z-score → resample 1mm → transpose)
    without any cropping, for sliding window inference.

    Args:
        patch_size: Spatial size of each output patch.
        pos: Weight for positive (foreground) patches.
        neg: Weight for negative (background) patches.
        num_samples: Number of 192^3 patches to sample per volume.
    """

    def __init__(
        self,
        patch_size: Tuple[int, int, int] = (192, 192, 192),
        pos: float = 1.0,
        neg: float = 1.0,
        num_samples: int = 1,
    ):
        self.patch_size = patch_size
        self.num_samples = num_samples
        self._eval_mode = False

        self.transform = Compose([
            # 1. Add channel dimension: (W,H,D) → (1,W,H,D)
            EnsureChannelFirstd(
                keys=['image', 'label'],
                channel_dim='no_channel',
            ),
            # 2. Reorient to RAS (Right-Anterior-Superior)
            Orientationd(
                keys=['image', 'label'],
                axcodes='RAS',
            ),
            # 3. Z-score normalization over all voxels
            NormalizeIntensityd(
                keys=['image']
            ),
            # 4. Random crop with foreground bias
            SmartRandCropByPosNegLabeld(
                keys=['image', 'label'],
                label_key='label',
                spatial_size=patch_size,
                target_spacing=(1.0, 1.0, 1.0),
                resize_mode=('bilinear', 'nearest'),
                pos=pos,
                neg=neg,
                num_samples=num_samples,
            ),
            # 5. Reorient to RAS (Right-Anterior-Superior)
            Transposed(
                keys=['image', 'label'],
                indices=[0, 3, 2, 1] # CWHD to CDHW
            ),
        ])

        # Eval pipeline: same as training but no cropping.
        # orient → normalize → resample 1mm → transpose
        self.eval_transform = Compose([
            EnsureChannelFirstd(
                keys=['image', 'label'],
                channel_dim='no_channel',
            ),
            Orientationd(
                keys=['image', 'label'],
                axcodes='RAS',
            ),
            NormalizeIntensityd(
                keys=['image'],
            ),
            Spacingd(
                keys=['image', 'label'],
                pixdim=(1.0, 1.0, 1.0),
                mode=('bilinear', 'nearest'),
            ),
            Transposed(
                keys=['image', 'label'],
                indices=[0, 3, 2, 1],  # CWHD to CDHW
            ),
        ])

    def set_eval_mode(self):
        """Switch to eval mode: full-volume preprocessing without cropping."""
        self._eval_mode = True

    def apply_image(
        self,
        volume: np.ndarray,
        mask: np.ndarray,
        affine: Optional[np.ndarray] = None,
        data_source: Optional[str] = None,
    ) -> Optional[Tuple[torch.Tensor, torch.Tensor]]:
        """
        Preprocess volume + mask and return VoxTell-ready 3D patches.

        In eval mode, returns the full preprocessed volume (no cropping)
        for sliding window inference.  In training mode, returns one or
        more foreground-biased 192^3 patches.

        Args:
            volume: Raw CT volume, any axis order (matching affine).
            mask: Binary segmentation mask, same shape as volume.
            affine: 4x4 affine matrix (required for orientation/spacing).
            data_source: Unused (kept for interface compatibility).

        Returns:
            Tuple of ``(volume_tensor, mask_tensor)`` where shapes are:
              - Training: ``(1, D, H, W)`` with D=H=W=192.
              - Eval: ``(1, D', H', W')`` at 1 mm isotropic spacing.
            If ``num_samples > 1`` (training only), returns a list of
            such tuples.  Returns None on failure.
        """
        try:
            assert affine is not None, 'Affine matrix is required'

            img_tensor = MetaTensor(
                volume.astype(np.float32), affine=affine,
            )
            lbl_tensor = MetaTensor(
                mask.astype(np.float32), affine=affine,
            )

            data_dict = {
                'image': img_tensor,
                'label': lbl_tensor,
            }

            if self._eval_mode:
                result = self.eval_transform(data_dict)
                vol = result['image'].as_tensor()  # (1, D', H', W')
                msk = result['label'].as_tensor()
                return vol, msk

            result = self.transform(data_dict)

            # RandCropByPosNegLabeld returns a list of dicts
            if isinstance(result, list):
                if self.num_samples == 1:
                    r = result[0]
                    vol_patch = r['image'].as_tensor()  # (1, D, H, W)
                    msk_patch = r['label'].as_tensor()
                    return vol_patch, msk_patch
                else:
                    patches = []
                    for r in result:
                        vol_patch = r['image'].as_tensor()
                        msk_patch = r['label'].as_tensor()
                        patches.append((vol_patch, msk_patch))
                    return patches
            else:
                vol_patch = result['image'].as_tensor()
                msk_patch = result['label'].as_tensor()
                return vol_patch, msk_patch

        except Exception as e:
            print_log(
                f'VoxTellPreprocessor error: {e}', logger='current',
            )
            import traceback
            print_log(traceback.format_exc(), logger='current')
            return None