Tutorial¶
This is a complete, step-by-step walkthrough of the Thyra pipeline: from a raw vendor dataset to a SpatialData/Zarr store you can query, plot, and hand to any tool in the scverse ecosystem.
It comes in two parts, and they are independent:
| What you need | Time | Runs on | |
|---|---|---|---|
| Part 1 -- synthetic data | Nothing beyond pip install thyra |
~1 minute | Windows, macOS, Linux |
| Part 2 -- published example dataset | 19 GB download, Bruker SDK | Hours | Windows (see caveat) |
Start with Part 1 to confirm your installation works and to see the shape of the output. Move to Part 2 when you want to run the real acquisition from the paper.
Before you start¶
Thyra requires Python 3.12 or 3.13. Check what you have:
Every figure below was last re-verified against thyra 3.0.0 with
spatialdata 0.8.0, anndata 0.13.2, zarr 3.1.6 and pandas 2.3.2 --
the set pyproject.toml pins. To check the spatialdata version too:
Part 1: A five-minute check with synthetic data¶
No download, no vendor SDK, no configuration. Thyra ships a generator that writes a small synthetic imzML dataset, so you can exercise the whole pipeline immediately.
Step 1: Generate a dataset¶
Wrote example_data/synthetic_brain.imzML
Wrote example_data/synthetic_brain.ibd
1728 pixels (48 x 36), 4000 m/z bins, 25.0 um, 30.4 MB total
This is a brain-like phantom: an elliptical "section" containing a distinct inner structure, over a m/z range of 250-1200. Three groups of peaks are written into it -- one shared across the whole section, one confined to the outer region, one confined to the inner region, plus low-mass matrix ions covering the entire slide. That gives the images below real spatial structure to show.
The output is deterministic: the same --seed always produces byte-identical
data, so your numbers should match the ones printed in this tutorial exactly.
This is synthetic data
The phantom is for verifying the software and learning the output layout. The peak positions are in a plausible phospholipid range but the intensities are invented. For real data, see Part 2.
Step 2: Convert it¶
Nothing else is required -- no format flag, no pixel size. Thyra detects the format from the file, reads the pixel size out of the imzML metadata, picks a resampling strategy, and writes the store. The interesting lines in the log:
INFO - Detected format: imzml
INFO - Using reader: ImzMLReader
INFO - Attempting automatic pixel size detection...
INFO - Detected pixel size: 25.0 um
INFO - Selected resampling method: NEAREST_NEIGHBOR
INFO - Building resampled mass axis: 250.00 - 1200.00 m/z, 190000 bins
INFO - Converted sparse matrix for msi_dataset_z0: 6,885,838 non-zero entries (CSC)
INFO - Conversion completed successfully
Conversion takes a few seconds and produces a roughly 31 MB .zarr directory.
Why 190,000 m/z bins from 4,000 input points?
Resampling is on by default and builds a uniform axis with 5 mDa bins
across the mass range, which is finer than this phantom's 0.24 Da spacing.
The result is correct and stays compact because it is stored sparsely --
only 4,000 bins per spectrum are populated. Pass --no-resample to keep
the original axis instead. See Resampling for how the axis
type and bin count are chosen.
Step 3: Open the output¶
import spatialdata as sd
sdata = sd.read_zarr("example_data/synthetic_brain.zarr")
print("Tables:", list(sdata.tables.keys()))
print("Images:", list(sdata.images.keys()))
print("Shapes:", list(sdata.shapes.keys()))
Three elements, all named after the dataset id (msi_dataset by default,
settable with --dataset-id, and _z0 marking the first z-slice):
- the table, an AnnData object holding the intensity matrix
- the TIC image, total ion current per pixel
- the pixel shapes, one polygon per acquired pixel
Confirm the pixel size survived the round trip:
print(sdata.attrs["pixel_size_x_um"], sdata.attrs["pixel_size_units"])
print(sdata.attrs["pixel_size_detection_info"]["method"])
Step 4: The intensity matrix¶
table = sdata.tables["msi_dataset_z0"]
print(f"Shape: {table.shape} (pixels x m/z bins)")
print(f"m/z range: {table.var['mz'].min():.1f} -- {table.var['mz'].max():.1f}")
print(f"Sparse: {table.X.nnz:,} non-zero of {table.X.shape[0] * table.X.shape[1]:,}")
Shape: (1728, 190000) (pixels x m/z bins)
m/z range: 250.0 -- 1200.0
Sparse: 6885838 non-zero of 328320000
1728 pixels matches the 48 x 36 grid. The matrix is a csc_matrix; keep it
sparse and never call .toarray() on the whole thing for a real dataset.
Step 5: The TIC image¶
import numpy as np
import matplotlib.pyplot as plt
tic = np.asarray(sdata.images["msi_dataset_z0_tic"])[0] # (c, y, x) -> (y, x)
print(f"Shape: {tic.shape}, range {tic.min():.0f} -- {tic.max():.0f}")
plt.imshow(tic, cmap="viridis")
plt.colorbar(label="TIC")
plt.title("Total ion current")
plt.show()
You should see a bright ellipse -- the phantom "section" -- against a dim background of matrix signal.
Step 6: The average spectrum¶
mz = table.var["mz"].values
avg = np.asarray(table.uns["average_spectrum"])
plt.plot(mz, avg, linewidth=0.6)
plt.xlim(740, 920)
plt.xlabel("m/z")
plt.ylabel("Average intensity")
plt.show()
peak_mz = mz[np.argmax(avg)]
print(f"Most intense average peak: m/z {peak_mz:.3f}")
The average is computed over non-empty pixels only, so empty grid positions do not dilute it.
Step 7: Ion images¶
To map one ion, sum the matrix columns inside an m/z window and scatter the result back onto the pixel grid:
def ion_image(table, target_mz, tol=0.25):
"""Sum intensities in [target-tol, target+tol] and lay them out on the grid."""
mz = table.var["mz"].values
lo, hi = np.searchsorted(mz, [target_mz - tol, target_mz + tol])
values = np.asarray(table.X[:, lo:hi].sum(axis=1)).ravel()
x = table.obs["x"].values.astype(int)
y = table.obs["y"].values.astype(int)
image = np.zeros((y.max() + 1, x.max() + 1))
image[y, x] = values
return image
for target, label in [(760.6, "whole section"),
(772.5, "outer region"),
(888.6, "inner region")]:
plt.figure()
plt.imshow(ion_image(table, target), cmap="inferno")
plt.colorbar(label="Intensity")
plt.title(f"m/z {target} -- {label}")
plt.show()
The three images differ: m/z 760.6 covers the whole section, 772.5 covers the outer region with the inner structure punched out as a hole, and 888.6 lights up only the inner structure. That contrast is what makes MSI worth doing, and seeing it here confirms intensities landed on the right pixels.
Always integrate over a window, never a single bin
On a resampled axis most bins are empty by construction. Picking the single
nearest bin to a target m/z -- np.abs(mz - target).argmin() -- will
usually land on an empty one and return an all-zero image. Sum over a
tolerance window, as ion_image does above.
That is the full pipeline. Continue to the notebook for optical overlays, per-pixel spectra, z-slices, and metadata, or to Part 2 for real data.
Part 2: The published example dataset¶
The dataset behind the paper is archived on Zenodo under 10.5281/zenodo.18326569, licensed CC-BY-4.0.
| File | Size | MD5 |
|---|---|---|
MALDI-MSI_Sagittal_Mouse_Brain.tar.gz |
19.1 GB | 1dbd2ea14dcb6bd99baf7af6ca843018 |
Xenium_Sagittal_Mouse_Brain.tar.gz |
13.0 GB | 14416994287008b691d43de9dbe34ca3 |
he_mouse_brain_adjacent.tar.gz |
851.5 MB | 8393a3ee8ba59219fe1c9f7f21acb710 |
Only the first is needed to run Thyra. It is a sagittal mouse brain section acquired on a Bruker timsTOF fleX: MALDI, positive mode, 250-1200 Da, 5 um raster, 1007 x 1469 pixels. The other two files are the Xenium spatial transcriptomics and H&E histology of the same specimen, included for cross-modal work and not used by Thyra.
Platform requirements¶
Bruker data needs the vendor SDK
This is a Bruker .d dataset, so reading it requires the Bruker SDK. The
DLLs are bundled for Windows. On Linux and macOS the vendor's
libtimsdata.so / libtimsdata.dylib must be installed separately -- see
Troubleshooting.
Hosted notebook services such as Colab and Binder cannot run this part.
If you only need to verify that Thyra works, Part 1 is platform-independent and requires no SDK.
You will also need roughly 40 GB of free disk: 19 GB for the archive, 19 GB extracted, plus the output store.
Step 1: Download¶
Or directly:
curl -L -O "https://zenodo.org/api/records/18326569/files/MALDI-MSI_Sagittal_Mouse_Brain.tar.gz/content"
Verify the download before spending time on conversion:
It must print 1dbd2ea14dcb6bd99baf7af6ca843018. On Windows use
CertUtil -hashfile MALDI-MSI_Sagittal_Mouse_Brain.tar.gz MD5.
Step 2: Extract¶
This gives you:
MALDI-MSI Sagittal Mouse Brain/
├── 20240826_Xenium_0040000.tiff optical image
├── 20240826_Xenium_0040001.tiff optical image, high resolution
├── 20240826_Xenium_0041899.bak
└── 20240826_Xenium_0041899.d/ the MSI acquisition
└── 250-1200Pos_Maldi_5um.m/ acquisition method
The folder name contains spaces
Quote the path in every command below, or the shell will split it.
Step 3: Convert¶
Same command as Part 1 -- only the input path changes. Thyra detects the Bruker
format, reads the 5 um pixel size from the acquisition metadata, and finds the
optical images next to the .d folder.
Because this dataset is far over the 10 GB threshold, Thyra switches to streaming mode automatically: spectra are processed in chunks and written incrementally, so peak memory stays roughly flat instead of scaling with the dataset. The output is identical either way. Expect a long run; add logging so you can watch progress and keep a record:
thyra "MALDI-MSI Sagittal Mouse Brain/20240826_Xenium_0041899.d" mouse_brain.zarr \
-v INFO --log-file conversion.log
If memory is tight, reduce the resampling bin count:
thyra "MALDI-MSI Sagittal Mouse Brain/20240826_Xenium_0041899.d" mouse_brain.zarr \
--resample-bins 20000
Step 4: Optical alignment¶
For Bruker data Thyra also writes the microscopy images and aligns the MSI to them using the teaching points from the FlexImaging metadata. After conversion you will see additional image elements:
import spatialdata as sd
sdata = sd.read_zarr("mouse_brain.zarr")
print([k for k in sdata.images if "optical" in k])
The TIC image carries an affine transform mapping it into the optical image's pixel space, so overlays line up without manual registration. The notebook shows how to read that transform and plot the overlay, and Coordinate Systems documents the contract.
What just happened¶
Each stage of the pipeline maps onto something you can observe in the steps above:
input thyra output
───── ───── ──────
.imzML ──┐ ┌── format detection ──┐ ┌── table (pixels x m/z)
.d ──┼──────┼── pixel size ┼──────┼── TIC image
.raw ──┘ ├── mass resampling │ ├── optical images + transform
└── optical alignment ─┘ └── pixel shapes
+ metadata in .attrs
| Stage | Where you saw it |
|---|---|
| Format detection | Detected format: imzml / Using reader: ImzMLReader |
| Pixel size detection | Detected pixel size: 25.0 um, then sdata.attrs["pixel_size_detection_info"] |
| Mass resampling | Building resampled mass axis: ... 190000 bins |
| Sparse table | table.X.nnz of 6,885,838 |
| TIC image | Step 5 |
| Optical alignment | Part 2, Step 4 |
| Metadata preservation | sdata.attrs, table.uns |
The result is a plain SpatialData store. Nothing in it is Thyra-specific --
spatialdata.read_zarr is the only Thyra-aware step, and after that napari,
squidpy, and scanpy work on it directly.
Where to go next¶
- Explore the output notebook -- optical overlays, per-pixel spectra, z-slices, metadata
- Output Format -- the full element and metadata layout
- CLI Reference -- every option
- Coordinate Systems -- how MSI, optical, and global coordinate spaces relate
- Getting Started -- troubleshooting