Creating an NXtomo file#
HTTomo can automatically find tomography data in a NeXus NXtomo file. This tutorial shows how to pack either NumPy arrays or stacks of TIFF images into that format.
Download the complete create_nxtomo.py script before following the examples. The script is
self-contained and may also be imported as a Python module.
Install the packages used by the script in the environment where the packing will run:
python -m pip install numpy h5py tifffile
tifffile is only needed for the TIFF example.
Input layout#
Projection, flat-field, and dark-field data must have the axis order
(frames, detector_y, detector_x). For projections, frames is the
rotation-angle axis. The angle array must be one-dimensional, measured in
degrees, and contain exactly one value per projection.
A single flat or dark image may be supplied as a two-dimensional array. The script adds its frame axis automatically. Flats and darks are optional, but operations that perform flat/dark correction need meaningful calibration images.
From NumPy arrays#
Import write_nxtomo() from the downloaded script. Arrays can come from
numpy.load, another Python library, or calculations performed in the same
program:
import numpy as np
from create_nxtomo import write_nxtomo
projections = np.load("projections.npy") # (n_angles, detector_y, detector_x)
angles = np.load("angles.npy") # (n_angles,), in degrees
flats = np.load("flats.npy") # (n_flats, detector_y, detector_x)
darks = np.load("darks.npy") # (n_darks, detector_y, detector_x)
write_nxtomo(
"scan.nxs",
projections,
angles,
flats=flats,
darks=darks,
sample_name="my sample",
compression="gzip",
)
Use overwrite=True only when an existing output file should be replaced.
Set compression=None for faster writing and a larger file, or
compression="lzf" for lightweight compression. The default is
compression="gzip".
From TIFF stacks#
Keep each image type in its own directory, for example:
scan/
├── projections/
│ ├── projection_0000.tif
│ ├── projection_0001.tif
│ └── ...
├── flats/
│ ├── flat_0000.tif
│ └── ...
├── darks/
│ ├── dark_0000.tif
│ └── ...
└── angles.npy
Then run the script. Quote each glob so that the script, rather than the shell, receives and sorts the complete file list:
python create_nxtomo.py scan.nxs \
--projections "scan/projections/*.tif" \
--angles scan/angles.npy \
--flats "scan/flats/*.tif" \
--darks "scan/darks/*.tif" \
--sample-name "my sample"
The angles may instead be stored as a one-column .txt file or a
comma-separated .csv file. Omit --flats or --darks when that image
type is unavailable. Use --overwrite to replace an existing output file.
The TIFF loader sorts filenames naturally, so projection_2.tif precedes
projection_10.tif. The filenames still need to encode the acquisition
order correctly. Every matched TIFF must be a single two-dimensional grayscale
image, and all images must have the same height and width.
What the script writes#
The output stores darks, then flats, then projections in one three-dimensional
detector dataset. The image_key gives every frame its NXtomo meaning:
0– projection;1– flat field; and2– dark field.
Calibration frames are assigned the first projection angle because NXtomo
requires one rotation angle per frame. HTTomo uses image_key to select only
the projection frames and their corresponding angles.
The important part of the resulting file is:
/entry NXentry
├── definition "NXtomo"
├── instrument NXinstrument
│ └── detector NXdetector
│ ├── data (frames, detector_y, detector_x)
│ └── image_key (frames,)
├── sample NXsample
│ └── rotation_angle (frames,), units="deg"
└── data NXdata
├── data link to detector/data
├── image_key link to detector/image_key
└── rotation_angle link to sample/rotation_angle
The links in /entry/data do not duplicate the arrays. They expose the
standard NXtomo paths that HTTomo’s automatic discovery uses.
Loading the result in HTTomo#
Use the standard tomography loader and set all discoverable paths to
auto:
- method: standard_tomo
module_path: httomo.data.hdf.loaders
parameters:
data_path: auto
image_key_path: auto
rotation_angles: auto
Pass scan.nxs as the input data file when running HTTomo. If the file has
no flats or darks, HTTomo supplies dummy calibration arrays. Alternatively,
set flats: ignore or darks: ignore explicitly when the corresponding
correction should not use stored calibration images; see Darks and flats.
Reusable NeXus writer code#
The core NumPy writer is included below for reference. The downloadable script also contains TIFF loading, validation, filename sorting, and its command-line interface.
Reusable NeXus writer code
1def write_nxtomo(
2 output_file: str | Path,
3 projections: np.ndarray,
4 angles: np.ndarray,
5 *,
6 flats: np.ndarray | None = None,
7 darks: np.ndarray | None = None,
8 sample_name: str = "sample",
9 compression: str | None = "gzip",
10 overwrite: bool = False,
11) -> Path:
12 """Write NumPy arrays to an HTTomo-compatible NXtomo file.
13
14 Parameters
15 ----------
16 output_file
17 Destination ``.nxs`` or ``.h5`` file.
18 projections
19 Projection stack with shape ``(angles, detector_y, detector_x)``.
20 angles
21 One rotation angle in degrees per projection.
22 flats, darks
23 Optional calibration stacks. A single 2D image is also accepted.
24 sample_name
25 Descriptive sample name stored in the NXsample group.
26 compression
27 HDF5 compression: ``"gzip"``, ``"lzf"``, or ``None``.
28 overwrite
29 Replace ``output_file`` if it already exists.
30 """
31 projections = _as_frame_stack(projections, "projections")
32 angles = np.asarray(angles)
33 if angles.ndim != 1 or len(angles) != len(projections):
34 raise ValueError("angles must be 1D with one value per projection")
35 if (
36 not (
37 np.issubdtype(angles.dtype, np.integer)
38 or np.issubdtype(angles.dtype, np.floating)
39 )
40 or not np.isfinite(angles).all()
41 ):
42 raise ValueError("angles must contain finite numeric values")
43
44 optional_stacks = []
45 for name, stack in (("darks", darks), ("flats", flats)):
46 if stack is None:
47 optional_stacks.append(None)
48 continue
49 stack = _as_frame_stack(stack, name)
50 if stack.shape[1:] != projections.shape[1:]:
51 raise ValueError(
52 f"{name} frame shape {stack.shape[1:]} does not match "
53 f"projection frame shape {projections.shape[1:]}"
54 )
55 optional_stacks.append(stack)
56 darks, flats = optional_stacks
57
58 compression = None if compression == "none" else compression
59 if compression not in (None, "gzip", "lzf"):
60 raise ValueError("compression must be 'gzip', 'lzf', or None")
61
62 stacks = [stack for stack in (darks, flats, projections) if stack is not None]
63 output_dtype = np.result_type(*(stack.dtype for stack in stacks))
64 dark_count = 0 if darks is None else len(darks)
65 flat_count = 0 if flats is None else len(flats)
66 projection_count = len(projections)
67 frame_count = dark_count + flat_count + projection_count
68 detector_y, detector_x = projections.shape[1:]
69
70 image_key = np.concatenate(
71 (
72 np.full(dark_count, 2, dtype=np.int8),
73 np.full(flat_count, 1, dtype=np.int8),
74 np.zeros(projection_count, dtype=np.int8),
75 )
76 )
77 # NXtomo requires an angle for every frame. Calibration frames use the
78 # first projection angle; HTTomo selects projection angles via image_key.
79 frame_angles = np.concatenate(
80 (np.full(dark_count + flat_count, angles[0]), angles)
81 ).astype(np.float32, copy=False)
82
83 output_file = Path(output_file)
84 output_file.parent.mkdir(parents=True, exist_ok=True)
85 mode = "w" if overwrite else "w-"
86 now = datetime.now(timezone.utc).isoformat()
87
88 with h5py.File(output_file, mode) as nexus_file:
89 nexus_file.attrs.update(
90 default="entry", file_name=output_file.name, file_time=now
91 )
92
93 entry = _nx_group(nexus_file, "entry", "NXentry")
94 entry.attrs["default"] = "data"
95 entry.create_dataset("definition", data="NXtomo")
96 entry.create_dataset("title", data=sample_name)
97 entry.create_dataset("start_time", data=now)
98
99 instrument = _nx_group(entry, "instrument", "NXinstrument")
100 detector = _nx_group(instrument, "detector", "NXdetector")
101 data = detector.create_dataset(
102 "data",
103 shape=(frame_count, detector_y, detector_x),
104 dtype=output_dtype,
105 chunks=(1, min(detector_y, 256), min(detector_x, 256)),
106 compression=compression,
107 )
108 data.attrs.update(
109 interpretation="image",
110 axes="frame,detector_y,detector_x",
111 )
112 key = detector.create_dataset("image_key", data=image_key)
113 key.attrs["meaning"] = "0=projection; 1=flat; 2=dark"
114
115 first = 0
116 for stack in (darks, flats, projections):
117 if stack is not None:
118 data[first : first + len(stack)] = stack
119 first += len(stack)
120
121 sample = _nx_group(entry, "sample", "NXsample")
122 sample.create_dataset("name", data=sample_name)
123 rotation_angle = sample.create_dataset("rotation_angle", data=frame_angles)
124 rotation_angle.attrs["units"] = "deg"
125
126 # NXdata contains hard links, not copies. These paths are also the
127 # locations used by HTTomo's automatic NXtomo discovery.
128 nx_data = _nx_group(entry, "data", "NXdata")
129 nx_data.attrs["signal"] = "data"
130 nx_data.attrs["axes"] = np.asarray(
131 ["rotation_angle", ".", "."], dtype=h5py.string_dtype()
132 )
133 nx_data["data"] = data
134 nx_data["image_key"] = key
135 nx_data["rotation_angle"] = rotation_angle
136
137 entry.create_dataset("end_time", data=datetime.now(timezone.utc).isoformat())
138
139 return output_file
140
141