|
| 1 | +from pathlib import Path |
| 2 | + |
| 3 | +import nrrd |
| 4 | +import numpy as np |
| 5 | + |
| 6 | +from vidata.registry import register_loader, register_writer |
| 7 | +from vidata.utils.affine import build_affine |
| 8 | + |
| 9 | + |
| 10 | +@register_writer("image", ".nrrd", backend="nrrd") |
| 11 | +@register_writer("mask", ".nrrd", backend="nrrd") |
| 12 | +def save_nrrd(data: np.ndarray, file: str | Path, metadata: dict | None = None) -> list[str]: |
| 13 | + """Save a NumPy array as a nrrd using pynrrd. |
| 14 | +
|
| 15 | + Args: |
| 16 | + data (np.ndarray): Image data array (z, y, x) or (y, x). |
| 17 | + file (Union[str, Path]): Output file path (.nrrd). |
| 18 | + metadata (Optional[dict]): Optional metadata dictionary containing: |
| 19 | + - "spacing" (list or np.ndarray): Physical spacing per axis. |
| 20 | + - "origin" (list or np.ndarray): Origin of the image. |
| 21 | + - "direction" (list or np.ndarray): Flattened or matrix-form direction. |
| 22 | + """ |
| 23 | + ndims = data.ndim |
| 24 | + |
| 25 | + if ndims == 3: |
| 26 | + data = data.transpose(2, 1, 0) # (X,Y,Z) → (Z,Y,X) |
| 27 | + elif ndims == 2: |
| 28 | + data = data.transpose(1, 0) |
| 29 | + |
| 30 | + # if metadata is not None: |
| 31 | + if metadata is not None and "spacing" in metadata: |
| 32 | + spacing = np.array(metadata["spacing"][::-1]) |
| 33 | + else: |
| 34 | + spacing = np.ones(ndims) |
| 35 | + |
| 36 | + if metadata is not None and "origin" in metadata: |
| 37 | + origin = np.array(metadata["origin"][::-1]) |
| 38 | + else: |
| 39 | + origin = np.zeros(ndims) |
| 40 | + |
| 41 | + if metadata is not None and "direction" in metadata: |
| 42 | + direction = np.array(metadata["direction"]).flatten()[::-1].reshape(ndims, ndims) |
| 43 | + else: |
| 44 | + direction = np.eye(ndims) |
| 45 | + |
| 46 | + space_dirs = direction * spacing[:, None] |
| 47 | + |
| 48 | + header = { |
| 49 | + "type": "short", |
| 50 | + "dimension": ndims, |
| 51 | + "space origin": origin.tolist(), |
| 52 | + "space directions": space_dirs.tolist(), |
| 53 | + "encoding": "gzip", # compressed NRRD |
| 54 | + } |
| 55 | + nrrd.write(str(file), data, header) |
| 56 | + return [str(file)] |
| 57 | + |
| 58 | + |
| 59 | +@register_loader("image", ".nrrd", backend="nrrd") |
| 60 | +@register_loader("mask", ".nrrd", backend="nrrd") |
| 61 | +def load_nrrd(file: str | Path) -> tuple[np.ndarray, dict]: |
| 62 | + """Load a nrrd file using pynrrd and return data and metadata. This function is consistent with |
| 63 | + vidata.io.sitk_io.load_sitk |
| 64 | +
|
| 65 | + Args: |
| 66 | + file (Union[str, Path]): Path to the medical image file (.nrrd). |
| 67 | +
|
| 68 | + Returns: |
| 69 | + tuple[np.ndarray, dict[str, np.ndarray]]: A tuple containing: |
| 70 | + - Image data as a NumPy array (z, y, x or y, x). |
| 71 | + - Metadata dictionary with: |
| 72 | + - "spacing": voxel spacing (np.ndarray) |
| 73 | + - "origin": image origin (np.ndarray) |
| 74 | + - "direction": orientation matrix (np.ndarray) |
| 75 | + - "affine": computed affine matrix (np.ndarray) |
| 76 | + """ |
| 77 | + array, header = nrrd.read(str(file)) |
| 78 | + ndims = array.ndim |
| 79 | + |
| 80 | + # Convert the array to z,y,x axis ordering (numpy) |
| 81 | + if ndims == 3: |
| 82 | + array = array.transpose(2, 1, 0) # (X,Y,Z) → (Z,Y,X) |
| 83 | + elif ndims == 2: |
| 84 | + array = array.transpose(1, 0) # (X,Y) → (Y,X) |
| 85 | + |
| 86 | + # Extract the metadata |
| 87 | + origin = np.array(header["space origin"]) if "space origin" in header else np.zeros(ndims) |
| 88 | + spacing = ( |
| 89 | + np.linalg.norm(np.array(header["space directions"]), axis=1) |
| 90 | + if "space directions" in header |
| 91 | + else np.ones(ndims) |
| 92 | + ) |
| 93 | + |
| 94 | + if "space directions" in header: |
| 95 | + norms = np.linalg.norm(header["space directions"], axis=1, keepdims=True) |
| 96 | + direction = header["space directions"] / (norms + 1e-12) |
| 97 | + else: |
| 98 | + direction = np.eye(ndims) |
| 99 | + direction = direction.flatten() # Flattened SITK style |
| 100 | + |
| 101 | + # Convert Metadata from (X,Y,Z) → (Z,Y,X) |
| 102 | + spacing = spacing[::-1] |
| 103 | + origin = origin[::-1] |
| 104 | + direction = np.array(direction[::-1]).reshape(ndims, ndims) |
| 105 | + |
| 106 | + affine = build_affine(ndims, spacing, origin, direction) |
| 107 | + |
| 108 | + metadata = { |
| 109 | + "spacing": spacing, |
| 110 | + "origin": origin, |
| 111 | + "direction": direction, |
| 112 | + "affine": affine, |
| 113 | + "header": header, |
| 114 | + } |
| 115 | + |
| 116 | + return array, metadata |
0 commit comments