Image-Text-to-Text
Transformers
Safetensors
English
qwen3_5
medical-imaging
ct
3d-vlm
vision-language
nv-reason-ct
nvidia
conversational
custom_code
Instructions to use nvidia/NV-Reason-CT with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use nvidia/NV-Reason-CT with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("image-text-to-text", model="nvidia/NV-Reason-CT", trust_remote_code=True) messages = [ { "role": "user", "content": [ {"type": "image", "url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/p-blog/candy.JPG"}, {"type": "text", "text": "What animal is on the candy?"} ] }, ] pipe(text=messages)# Load model directly from transformers import AutoProcessor, AutoModelForImageTextToText processor = AutoProcessor.from_pretrained("nvidia/NV-Reason-CT", trust_remote_code=True) model = AutoModelForImageTextToText.from_pretrained("nvidia/NV-Reason-CT", trust_remote_code=True, device_map="auto") messages = [ { "role": "user", "content": [ {"type": "image", "url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/p-blog/candy.JPG"}, {"type": "text", "text": "What animal is on the candy?"} ] }, ] inputs = processor.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", ).to(model.device) outputs = model.generate(**inputs, max_new_tokens=40) print(processor.decode(outputs[0][inputs["input_ids"].shape[-1]:])) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use nvidia/NV-Reason-CT with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "nvidia/NV-Reason-CT" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "nvidia/NV-Reason-CT", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image in one sentence." }, { "type": "image_url", "image_url": { "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" } } ] } ] }'Use Docker
docker model run hf.co/nvidia/NV-Reason-CT
- SGLang
How to use nvidia/NV-Reason-CT with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "nvidia/NV-Reason-CT" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "nvidia/NV-Reason-CT", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image in one sentence." }, { "type": "image_url", "image_url": { "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" } } ] } ] }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "nvidia/NV-Reason-CT" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "nvidia/NV-Reason-CT", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image in one sentence." }, { "type": "image_url", "image_url": { "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" } } ] } ] }' - Docker Model Runner
How to use nvidia/NV-Reason-CT with Docker Model Runner:
docker model run hf.co/nvidia/NV-Reason-CT
| """NIfTI CT-volume preprocessing and multimodal inputs for NV-Reason-CT.""" | |
| import math | |
| import os | |
| import warnings | |
| import numpy as np | |
| import torch | |
| from scipy import ndimage | |
| from transformers import BaseImageProcessor, BatchFeature | |
| from transformers.models.qwen3_vl.processing_qwen3_vl import Qwen3VLProcessor, Qwen3VLProcessorKwargs | |
| warnings.filterwarnings("ignore", category=FutureWarning, module=r"monai\.utils\.deprecate_utils") | |
| from monai.transforms import ( # noqa: E402 | |
| CenterSpatialCropd, | |
| Compose, | |
| EnsureTyped, | |
| LoadImaged, | |
| Orientationd, | |
| Spacingd, | |
| SpatialPadd, | |
| ToTensord, | |
| Transposed, | |
| ) | |
| class AnatomySpatialCropd(CenterSpatialCropd): | |
| """Crop an ROI around chest or abdomen bounds. | |
| Lung bounds are estimated from enclosed-air components after LPS | |
| reorientation. If reliable bounds cannot be found, cropping falls back to | |
| a centered x/y crop at the superior z edge. | |
| Example: | |
| Crop an LPS-oriented, 2-mm, channel-first 4D CT tensor in HU with shape | |
| ``(C, H, W, D)``: | |
| >>> crop = AnatomySpatialCropd(keys=["image"], roi_size=(192, 192, 192), pixdim=(2.0, 2.0, 2.0)) | |
| >>> chest = crop({"image": volume, "anatomy_region": "chest"})["image"] | |
| >>> abdomen = crop({"image": volume, "anatomy_region": "abdomen"})["image"] | |
| """ | |
| VALID_REGIONS = {"chest", "abdomen"} | |
| def __init__( | |
| self, | |
| keys, | |
| roi_size, | |
| pixdim=(2.0, 2.0, 2.0), | |
| body_hu=-500.0, | |
| min_frac=0.10, | |
| min_lung_volume_ml=250.0, | |
| component_merge_gap_mm=40.0, | |
| max_chest_span_mm=500.0, | |
| abdomen_overlap_above_lung_base_mm=100.0, | |
| abdomen_length_mm=300.0, | |
| allow_missing_keys=True, | |
| ): | |
| """Initialize crop geometry and anatomy-detection thresholds.""" | |
| super().__init__(keys=keys, roi_size=roi_size, allow_missing_keys=allow_missing_keys) | |
| self.roi_size = self._normalize_roi_size(roi_size) | |
| self.pixdim = self._normalize_pixdim(pixdim) | |
| self.body_hu = float(body_hu) | |
| self.min_frac = float(min_frac) | |
| self.min_lung_volume_ml = float(min_lung_volume_ml) | |
| self.component_merge_gap_mm = float(component_merge_gap_mm) | |
| self.max_chest_span_mm = float(max_chest_span_mm) | |
| self.abdomen_overlap_above_lung_base_mm = float(abdomen_overlap_above_lung_base_mm) | |
| self.abdomen_length_mm = float(abdomen_length_mm) | |
| def _normalize_roi_size(roi_size): | |
| """Normalize an ROI size to three positive integer dimensions.""" | |
| if isinstance(roi_size, (int, np.integer)): | |
| out = (int(roi_size),) * 3 | |
| else: | |
| out = tuple(int(x) for x in roi_size) | |
| if len(out) != 3 or any(x <= 0 for x in out): | |
| raise ValueError(f"roi_size must contain three positive integers, got {roi_size!r}") | |
| return out | |
| def _normalize_pixdim(pixdim): | |
| """Normalize voxel spacing to three positive floating-point values.""" | |
| if isinstance(pixdim, (int, float, np.integer, np.floating)): | |
| out = (float(pixdim),) * 3 | |
| else: | |
| out = tuple(float(x) for x in pixdim[:3]) | |
| if len(out) != 3 or any(x <= 0 for x in out): | |
| raise ValueError(f"pixdim must contain three positive values, got {pixdim!r}") | |
| return out | |
| def resolve_region(cls, anatomy_region): | |
| """Validate and return the requested anatomy region.""" | |
| try: | |
| if anatomy_region in {None, *cls.VALID_REGIONS}: | |
| return anatomy_region | |
| except TypeError: | |
| pass | |
| raise ValueError("anatomy_region must be None, 'chest', or 'abdomen'") | |
| def _as_numpy(image): | |
| """Convert a channel-first tensor or array to a 3D NumPy array.""" | |
| if isinstance(image, torch.Tensor): | |
| data = image.detach().cpu().float().numpy() | |
| else: | |
| data = np.asarray(image, dtype=np.float32) | |
| if data.ndim == 4: | |
| data = data[0] | |
| elif data.ndim != 3: | |
| raise ValueError( | |
| "AnatomySpatialCropd expects channel-first 4D or spatial " | |
| f"3D data, got shape {image.shape}" | |
| ) | |
| return data | |
| def _component_summaries(labels, component_ids, sizes): | |
| """Summarize component voxel counts and z-axis extents.""" | |
| summaries = [] | |
| for component_id in component_ids: | |
| zs = np.where(labels == component_id)[2] | |
| if len(zs) == 0: | |
| continue | |
| summaries.append((int(component_id), int(sizes[component_id]), int(zs.min()), int(zs.max()))) | |
| return summaries | |
| def _select_superior_component_cluster(self, labels, component_ids, sizes, z_spacing_mm): | |
| """Select a superior cluster of enclosed-air components.""" | |
| summaries = self._component_summaries(labels, component_ids, sizes) | |
| if not summaries: | |
| return np.array([], dtype=int) | |
| summaries.sort(key=lambda item: (item[3], item[1]), reverse=True) | |
| # Orientationd("LPS") makes larger z indices superior. Start from the | |
| # superior large enclosed-air component and only merge nearby pieces so | |
| # abdominal bowel/stomach gas does not pull the crop inferiorly. | |
| selected_ids = [summaries[0][0]] | |
| selected_z_min = summaries[0][2] | |
| selected_z_max = summaries[0][3] | |
| for component_id, _size, z_min, z_max in summaries[1:]: | |
| gap_mm = max(0, selected_z_min - z_max - 1) * z_spacing_mm | |
| if gap_mm > self.component_merge_gap_mm: | |
| continue | |
| next_z_min = min(selected_z_min, z_min) | |
| next_span_mm = (selected_z_max - next_z_min + 1) * z_spacing_mm | |
| if next_span_mm > self.max_chest_span_mm: | |
| continue | |
| selected_ids.append(component_id) | |
| selected_z_min = next_z_min | |
| selected_z_max = max(selected_z_max, z_max) | |
| return np.array(selected_ids, dtype=int) | |
| def _detect_lung_bounds(self, image): | |
| """Estimate body and lung z-axis bounds from CT intensities.""" | |
| data = self._as_numpy(image) | |
| body = data > self.body_hu | |
| z_has_body = body.any(axis=(0, 1)) | |
| body_zs = np.where(z_has_body)[0] | |
| if len(body_zs) == 0: | |
| return None | |
| air = np.empty(body.shape, dtype=bool) | |
| for z in range(data.shape[2]): | |
| body_slice = body[:, :, z] | |
| filled = ndimage.binary_fill_holes(body_slice) | |
| air[:, :, z] = filled & ~body_slice | |
| labels, components = ndimage.label(air) | |
| if components == 0: | |
| return { | |
| "body_z_min": int(body_zs.min()), | |
| "lung_z_min": None, | |
| "lung_z_max": None, | |
| } | |
| sizes = np.bincount(labels.ravel()) | |
| sizes[0] = 0 | |
| largest_component_voxels = int(sizes.max()) | |
| if largest_component_voxels <= 0: | |
| return { | |
| "body_z_min": int(body_zs.min()), | |
| "lung_z_min": None, | |
| "lung_z_max": None, | |
| } | |
| kept_ids = np.where(sizes >= self.min_frac * largest_component_voxels)[0] | |
| kept_ids = kept_ids[kept_ids != 0] | |
| selected_ids = self._select_superior_component_cluster(labels, kept_ids, sizes, self.pixdim[2]) | |
| if len(selected_ids) == 0: | |
| return { | |
| "body_z_min": int(body_zs.min()), | |
| "lung_z_min": None, | |
| "lung_z_max": None, | |
| } | |
| lung = np.isin(labels, selected_ids) | |
| z_has_lung = lung.any(axis=(0, 1)) | |
| lung_zs = np.where(z_has_lung)[0] | |
| if len(lung_zs) == 0: | |
| lung_z_min = None | |
| lung_z_max = None | |
| else: | |
| lung_z_min = int(lung_zs.min()) | |
| lung_z_max = int(lung_zs.max()) | |
| max_chest_slices = max(1, int(math.ceil(self.max_chest_span_mm / self.pixdim[2]))) | |
| capped_z_min = max(lung_z_min, lung_z_max - max_chest_slices + 1) | |
| if capped_z_min > lung_z_min: | |
| lung[:, :, :capped_z_min] = False | |
| z_has_lung = lung.any(axis=(0, 1)) | |
| lung_zs = np.where(z_has_lung)[0] | |
| lung_z_min = int(lung_zs.min()) if len(lung_zs) else None | |
| lung_z_max = int(lung_zs.max()) if len(lung_zs) else None | |
| lung_volume_ml = float(lung.sum()) * self.pixdim[0] * self.pixdim[1] * self.pixdim[2] / 1000.0 | |
| if lung_volume_ml < self.min_lung_volume_ml: | |
| lung_z_min = None | |
| lung_z_max = None | |
| return { | |
| "body_z_min": int(body_zs.min()), | |
| "lung_z_min": lung_z_min, | |
| "lung_z_max": lung_z_max, | |
| } | |
| def _region_bounds(self, image, region): | |
| """Return z-axis bounds for the requested chest or abdomen crop.""" | |
| bounds = self._detect_lung_bounds(image) | |
| if not bounds or bounds["lung_z_min"] is None or bounds["lung_z_max"] is None: | |
| return None | |
| if region == "chest": | |
| return bounds["lung_z_min"], bounds["lung_z_max"] | |
| z_size = int(image.shape[-1]) | |
| overlap_slices = int(math.ceil(self.abdomen_overlap_above_lung_base_mm / self.pixdim[2])) | |
| length_slices = int(math.ceil(self.abdomen_length_mm / self.pixdim[2])) | |
| z_max = min(z_size - 1, int(bounds["lung_z_min"]) + overlap_slices) | |
| z_min = max(int(bounds["body_z_min"]), z_max - length_slices + 1) | |
| if z_min > z_max: | |
| return None | |
| return z_min, z_max | |
| def _axis_start(self, size, roi, center): | |
| """Compute a clamped crop start index centered on one axis.""" | |
| start = int(round(float(center) - roi / 2.0)) | |
| start = max(0, min(start, size - roi)) | |
| return start | |
| def _crop_one(self, image, region): | |
| """Crop one volume to the configured ROI for an anatomy region.""" | |
| spatial_shape = tuple(int(x) for x in image.shape[-3:]) | |
| for dim, roi in zip(spatial_shape, self.roi_size): | |
| if dim < roi: | |
| raise ValueError( | |
| f"AnatomySpatialCropd input spatial shape {spatial_shape} is smaller than roi_size {self.roi_size}" | |
| ) | |
| bounds = self._region_bounds(image, region) | |
| x_start = self._axis_start(spatial_shape[0], self.roi_size[0], (spatial_shape[0] - 1) / 2.0) | |
| y_start = self._axis_start(spatial_shape[1], self.roi_size[1], (spatial_shape[1] - 1) / 2.0) | |
| if bounds is None: | |
| filename = getattr(image, "meta", {}).get("filename_or_obj", "<unknown>") | |
| warnings.warn( | |
| f"AnatomySpatialCropd could not determine {region} z bounds for {filename}; " | |
| "using a centered x/y crop at the superior z edge.", | |
| RuntimeWarning, | |
| stacklevel=2, | |
| ) | |
| z_start = spatial_shape[2] - self.roi_size[2] | |
| else: | |
| z_min, z_max = bounds | |
| z_center = (float(z_min) + float(z_max)) / 2.0 | |
| z_start = self._axis_start(spatial_shape[2], self.roi_size[2], z_center) | |
| slices = ( | |
| slice(None), | |
| slice(x_start, x_start + self.roi_size[0]), | |
| slice(y_start, y_start + self.roi_size[1]), | |
| slice(z_start, z_start + self.roi_size[2]), | |
| ) | |
| if len(image.shape) == 3: | |
| slices = slices[1:] | |
| return image[slices] | |
| def __call__(self, data, lazy=None): | |
| """Apply anatomy-aware cropping to the configured dictionary keys.""" | |
| d = dict(data) | |
| region = self.resolve_region(d.get("anatomy_region")) | |
| if region is None: | |
| return super().__call__(d, lazy=lazy) | |
| for key in self.key_iterator(d): | |
| d[key] = self._crop_one(d[key], region) | |
| return d | |
| class ImageLoader3D(BaseImageProcessor): | |
| """Load and preprocess NIfTI CT volumes for the 3D vision tower. | |
| Volumes are reoriented to LPS, resampled to ``pixdim``, padded and cropped | |
| to ``spatial_size``, and normalized from Hounsfield units by default. | |
| ``normalize_mode="ct"`` clips values to [-1000, 1000] and scales them to | |
| [-1, 1]. Use ``0`` or ``"none"`` to leave intensities unchanged for | |
| custom normalization. | |
| Example: | |
| Load the same NIfTI image with chest and abdomen crops: | |
| >>> loader = ImageLoader3D(pixdim=(2.0, 2.0, 2.0), spatial_size=(192, 192, 192)) | |
| >>> chest = loader.load_image("scan.nii.gz", normalize_mode="ct", anatomy_region="chest") | |
| >>> abdomen = loader.load_image("scan.nii.gz", normalize_mode="ct", anatomy_region="abdomen") | |
| Notes: | |
| If ``anatomy_region`` is not specified, a centered crop is used; it may | |
| not include the desired anatomy. Set ``normalize_mode`` to ``0`` or | |
| ``"none"`` to leave intensities unchanged for custom normalization. | |
| """ | |
| def __init__( | |
| self, | |
| pixdim=(2.0, 2.0, 2.0), | |
| spatial_size=(192, 192, 192), | |
| final_grid_size=(24, 24, 24), | |
| merge_size=1, | |
| normalize_mode="ct", | |
| **kwargs, | |
| ): | |
| """Configure CT resampling, cropping, grid, and normalization.""" | |
| super().__init__(**kwargs) | |
| self.pixdim = list(pixdim) | |
| self.spatial_size = list(spatial_size) | |
| self.final_grid_size = list(final_grid_size) | |
| self.merge_size = merge_size | |
| self.normalize_mode = normalize_mode | |
| self.keys = ["image"] | |
| self.transforms = None | |
| def _resolve_normalize_mode(normalize_mode): | |
| """Map normalization aliases to raw-HU mode 0 or scaled mode 1.""" | |
| if normalize_mode is None: | |
| return 0 | |
| if isinstance(normalize_mode, str): | |
| mode = normalize_mode.strip().lower() | |
| if mode in {"", "0", "none", "raw", "off"}: | |
| return 0 | |
| if mode in {"1", "ct", "ct_hu", "hu"}: | |
| return 1 | |
| if isinstance(normalize_mode, (int, np.integer)) and not isinstance(normalize_mode, bool): | |
| mode = int(normalize_mode) | |
| if mode in {0, 1}: | |
| return mode | |
| raise ValueError( | |
| f"Invalid normalize_mode={normalize_mode!r}; expected 0/raw or 1/ct" | |
| ) | |
| def _normalize_modes_for_batch(cls, normalize_mode, batch_size): | |
| """Expand and validate normalization modes for a volume batch.""" | |
| if isinstance(normalize_mode, (list, tuple)): | |
| if len(normalize_mode) != batch_size: | |
| raise ValueError( | |
| f"normalize_mode list length ({len(normalize_mode)}) must match " | |
| f"number of images ({batch_size})" | |
| ) | |
| return [cls._resolve_normalize_mode(mode) for mode in normalize_mode] | |
| return [cls._resolve_normalize_mode(normalize_mode)] * batch_size | |
| def _anatomy_regions_for_batch(anatomy_region, batch_size): | |
| """Expand and validate anatomy regions for a volume batch.""" | |
| if isinstance(anatomy_region, (list, tuple)): | |
| if len(anatomy_region) != batch_size: | |
| raise ValueError( | |
| f"anatomy_region list length ({len(anatomy_region)}) must match " | |
| f"number of images ({batch_size})" | |
| ) | |
| return [AnatomySpatialCropd.resolve_region(region) for region in anatomy_region] | |
| region = AnatomySpatialCropd.resolve_region(anatomy_region) | |
| return [region] * batch_size | |
| def _normalize_ct_hu(image): | |
| """Clip HU values to [-1000, 1000] and scale them to [-1, 1].""" | |
| image = torch.nan_to_num(image.float(), nan=-1000.0, posinf=1000.0, neginf=-1000.0) | |
| return torch.clamp(image, min=-1000.0, max=1000.0) / 1000.0 | |
| def _apply_normalization(self, image, normalize_mode): | |
| """Apply the requested CT normalization mode to one volume.""" | |
| mode = self._resolve_normalize_mode(normalize_mode) | |
| if mode == 0: | |
| return image | |
| if mode == 1: | |
| return self._normalize_ct_hu(image) | |
| raise AssertionError(f"unreachable normalize_mode={mode}") | |
| def load_image(self, filename, normalize_mode=None, anatomy_region=None): | |
| """Load one volume using the configured normalization by default.""" | |
| anatomy_region = AnatomySpatialCropd.resolve_region(anatomy_region) | |
| if normalize_mode is None: | |
| normalize_mode = self.normalize_mode | |
| if self.transforms is None: | |
| self.transforms = self.get_transforms() | |
| image = self.transforms({"image": filename, "anatomy_region": anatomy_region})["image"] | |
| image = image.as_subclass(torch.Tensor).contiguous() | |
| return self._apply_normalization(image, normalize_mode) | |
| def get_transforms(self): | |
| """Build the deterministic MONAI volume-preprocessing pipeline.""" | |
| keys = self.keys | |
| pixdim = self.pixdim | |
| spatial_size = self.spatial_size | |
| transforms = [] | |
| transforms.append( | |
| LoadImaged( | |
| keys=keys, | |
| ensure_channel_first=True, | |
| dtype=None, | |
| allow_missing_keys=True, | |
| image_only=True, | |
| ) | |
| ) | |
| transforms.append(EnsureTyped(keys=keys, data_type="tensor", dtype=torch.float, allow_missing_keys=True)) | |
| # The crop heuristic assumes LPS orientation, where larger z indices are superior. | |
| transforms.append(Orientationd(keys=keys, axcodes="LPS")) | |
| transforms.append(Spacingd( | |
| keys=keys, pixdim=pixdim, mode=("bilinear"), dtype=torch.float, | |
| min_pixdim=np.array(pixdim) * 0.9, max_pixdim=np.array(pixdim) * 1.1, | |
| allow_missing_keys=True, | |
| )) | |
| transforms.append(SpatialPadd(keys=keys, spatial_size=spatial_size, value=-1000)) | |
| transforms.append(AnatomySpatialCropd(keys=keys, roi_size=spatial_size, pixdim=pixdim)) | |
| transforms.append(Transposed(keys=keys, indices=(0, 3, 2, 1))) | |
| transforms.append(ToTensord(keys=keys)) | |
| return Compose(transforms) | |
| def __call__(self, images, *, normalize_mode=None, anatomy_region=None, **kwargs) -> BatchFeature: | |
| """Preprocess volume paths into model-ready batch features.""" | |
| return self.preprocess(images, normalize_mode=normalize_mode, anatomy_region=anatomy_region, **kwargs) | |
| def preprocess(self, images, *, normalize_mode=None, anatomy_region=None, **kwargs) -> BatchFeature: | |
| """Load a volume batch and return pixel tensors with grid metadata.""" | |
| if self.transforms is None: | |
| self.transforms = self.get_transforms() | |
| if normalize_mode is None: | |
| normalize_mode = self.normalize_mode | |
| normalize_modes = self._normalize_modes_for_batch(normalize_mode, len(images)) | |
| anatomy_regions = self._anatomy_regions_for_batch(anatomy_region, len(images)) | |
| images_list = [] | |
| image_grid_thws = [] | |
| for filename, image_normalize_mode, image_anatomy_region in zip(images, normalize_modes, anatomy_regions): | |
| images_list.append( | |
| self.load_image( | |
| filename, | |
| normalize_mode=image_normalize_mode, | |
| anatomy_region=image_anatomy_region, | |
| ) | |
| ) | |
| image_grid_thws.append(torch.tensor(self.final_grid_size)) | |
| pixel_values = torch.stack(images_list, dim=0) | |
| image_grid_thws = torch.stack(image_grid_thws, dim=0) | |
| return {"pixel_values": pixel_values, "image_grid_thw": image_grid_thws} | |
| def to_dict(self): | |
| """Serialize configuration without the runtime MONAI pipeline.""" | |
| # The runtime-only MONAI pipeline is not JSON-serializable and is rebuilt lazily after loading. | |
| output = super().to_dict() | |
| output.pop("transforms", None) | |
| return output | |
| def save_pretrained(self, save_directory: str | os.PathLike, push_to_hub: bool = False, **kwargs): | |
| """Save configuration after discarding the runtime MONAI pipeline.""" | |
| self.transforms = None | |
| return super().save_pretrained(save_directory, push_to_hub, **kwargs) | |
| class VLM3D_Processor(Qwen3VLProcessor): | |
| """Qwen3.5 tokenizer/chat template plus the NV-Reason-CT 3D loader. | |
| CT volumes must be supplied with `images3d=`. The inherited stock image | |
| and video processors are retained only for Transformers reconstruction. | |
| Example: | |
| Prepare text and a chest CT volume with the public processor interface: | |
| >>> from transformers import AutoProcessor | |
| >>> processor = AutoProcessor.from_pretrained("nvidia/NV-Reason-CT", trust_remote_code=True) | |
| >>> messages = [{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": "Describe this CT."}]}] | |
| >>> prompt = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) | |
| >>> inputs = processor(text=prompt, images3d=["scan.nii.gz"], anatomy_region="chest", return_tensors="pt") | |
| """ | |
| def get_attributes(cls): | |
| """Return subcomponents handled by the inherited processor loader.""" | |
| # image_processor_3d is reconstructed explicitly in from_pretrained. | |
| return ["image_processor", "tokenizer", "video_processor"] | |
| def __init__( | |
| self, | |
| image_processor=None, | |
| tokenizer=None, | |
| video_processor=None, | |
| image_processor_3d=None, | |
| chat_template=None, | |
| **kwargs, | |
| ): | |
| """Initialize tokenizer, standard processors, and the 3D loader.""" | |
| super().__init__( | |
| image_processor=image_processor, | |
| tokenizer=tokenizer, | |
| video_processor=video_processor, | |
| chat_template=chat_template, | |
| **kwargs, | |
| ) | |
| self.image_processor_3d = image_processor_3d | |
| def from_pretrained(cls, pretrained_model_name_or_path, **kwargs): | |
| """Load the composite processor and reconstruct its 3D loader.""" | |
| processor = super().from_pretrained(pretrained_model_name_or_path, **kwargs) | |
| image_processor_3d_kwargs = { | |
| key: kwargs[key] | |
| for key in ("cache_dir", "force_download", "local_files_only", "token", "revision") | |
| if key in kwargs | |
| } | |
| processor.image_processor_3d = ImageLoader3D.from_pretrained( | |
| pretrained_model_name_or_path, | |
| subfolder="image_processor_3d", | |
| **image_processor_3d_kwargs, | |
| ) | |
| return processor | |
| def save_pretrained(self, save_directory, **kwargs): | |
| """Save the composite processor and dedicated 3D loader metadata.""" | |
| saved_files = super().save_pretrained(save_directory, **kwargs) | |
| if self.image_processor_3d is not None: | |
| self.image_processor_3d.save_pretrained( | |
| os.path.join(save_directory, "image_processor_3d") | |
| ) | |
| return saved_files | |
| def __call__( | |
| self, | |
| images=None, | |
| text=None, | |
| videos=None, | |
| images3d=None, | |
| *, | |
| normalize_mode=None, | |
| anatomy_region=None, | |
| **kwargs, | |
| ): | |
| """Tokenize text and optionally preprocess NIfTI volumes from ``images3d``. | |
| Normalization modes and anatomy regions may be scalars or lists aligned | |
| with the volume batch. Volume inputs add ``pixel_values`` and | |
| ``image_grid_thw`` to the returned batch. | |
| """ | |
| if images is not None or videos is not None: | |
| raise ValueError( | |
| "NV-Reason-CT accepts NIfTI CT volumes through `images3d=`; " | |
| "ordinary `images=` and `videos=` inputs are not supported." | |
| ) | |
| if normalize_mode is not None and images3d is None: | |
| raise ValueError("`normalize_mode` is only supported with `images3d`") | |
| if anatomy_region is not None and images3d is None: | |
| raise ValueError("`anatomy_region` is only supported with `images3d`") | |
| output_kwargs = self._merge_kwargs( | |
| Qwen3VLProcessorKwargs, | |
| tokenizer_init_kwargs=self.tokenizer.init_kwargs, | |
| **kwargs, | |
| ) | |
| if images3d is not None: | |
| images3d_kwargs = output_kwargs["images_kwargs"].copy() | |
| image_inputs = self.image_processor_3d( | |
| images=images3d, | |
| normalize_mode=normalize_mode, | |
| anatomy_region=anatomy_region, | |
| **images3d_kwargs, | |
| ) | |
| image_grid_thw = image_inputs["image_grid_thw"] | |
| image_merge_size = self.image_processor_3d.merge_size | |
| else: | |
| image_inputs = {} | |
| image_grid_thw = None | |
| image_merge_size = None | |
| if not isinstance(text, list): | |
| text = [text] | |
| text = text.copy() | |
| if image_grid_thw is not None: | |
| merge_length = image_merge_size**2 | |
| index = 0 | |
| # Expand each image marker to the visual-token count represented | |
| # by its grid. | |
| for i in range(len(text)): | |
| while self.image_token in text[i]: | |
| num_image_tokens = image_grid_thw[index].prod() // merge_length | |
| text[i] = text[i].replace(self.image_token, "<|placeholder|>" * num_image_tokens, 1) | |
| index += 1 | |
| text[i] = text[i].replace("<|placeholder|>", self.image_token) | |
| return_tensors = output_kwargs["text_kwargs"].pop("return_tensors", None) | |
| return_mm_token_type_ids = output_kwargs["text_kwargs"].pop("return_mm_token_type_ids", None) | |
| text_inputs = self.tokenizer(text, **output_kwargs["text_kwargs"]) | |
| self._check_special_mm_tokens(text, text_inputs, modalities=["image"]) | |
| if return_mm_token_type_ids: | |
| text_inputs["mm_token_type_ids"] = self.create_mm_token_type_ids(text_inputs["input_ids"]) | |
| return BatchFeature(data={**text_inputs, **image_inputs}, tensor_type=return_tensors) | |