Resampling¶
In plain words
Change how Thyra converts explains the shared m/z axis in plain words. This page has every detail.
Mass axis resampling maps every spectrum in a dataset onto one shared m/z axis. It is enabled by default, and by default every parameter is chosen automatically from the acquisition metadata.
This page explains what that automatic choice is, how it is made, and how to override it. For the list of flags alone, see the CLI Reference.
Why resampling exists¶
An imzML file comes in one of two storage modes:
| Mode | m/z arrays | Consequence |
|---|---|---|
| continuous | one shared array for the whole dataset | pixels are already comparable bin-for-bin |
| processed | a separate array per spectrum | pixel 1's column 500 and pixel 2's column 500 are different masses |
For processed data -- which includes most centroided vendor exports -- you
cannot build a pixel x m/z matrix at all without first agreeing on a common
axis. Thyra reports this as needs_resampling, and it is the reason resampling
defaults to on rather than off.
Even for continuous data, resampling is often still what you want: it lets you impose a physically sensible bin spacing, restrict the mass range, or match the axis convention of another tool.
To keep the original per-spectrum axes untouched, disable it:
Note that with --no-resample on processed data, each pixel keeps its own m/z
values, and most downstream tools will not be able to treat the table as a
matrix of comparable features.
How the automatic choice is made¶
Three things are decided: the method (how intensity moves onto the new axis), the axis type (how bin widths scale with m/z), and the bin count.
acquisition metadata
│
▼
DataCharacteristics storage mode, spectrum type, instrument
│ name/type/manufacturer, peak density
▼
InstrumentDetectorChain first matching detector wins
│
├──────────────► resampling method (nearest_neighbor | tic_preserving)
├──────────────► axis type (constant | *_tof | orbitrap | fticr)
└──────────────► bin count from a target width at a reference m/z
What Thyra looks at¶
DataCharacteristics collects, from the metadata:
- whether there is a shared mass axis (continuous) or not (processed)
- spectrum type --
centroid spectrum(MS:1000127) orprofile spectrum(MS:1000128) - instrument name, instrument type, and manufacturer
- average peaks per spectrum; above 5000 the data is recorded as high-density profile
- format flags for Rapiflex, timsTOF, PHI, and Waters
Spectrum type is whatever the file declares -- MS:1000127 or
MS:1000128 -- read from the file description first and then from the rest of
the document. Only when a file declares neither does Thyra fall back to
guessing centroid from processed storage mode, and it says so in a warning
when it does. Processed and centroid are independent: processed means each
spectrum carries its own m/z array, which says nothing about whether its peaks
are centroided.
Peak density is reported but does not select a method or an axis type. It describes how finely the spectra were sampled, not what acquired them, and a dense profile spectrum can come from a MALDI-TOF, a TOF-SIMS, or an Orbitrap run in profile mode.
The timsTOF flag is a case-insensitive substring match on the instrument name,
because Bruker names the family many ways (timsTOF fleX MALDI-2,
timsTOF Pro 2, timsTOF SCP, and so on). The name comes from the Bruker
.d's GlobalMetadata when there is one, and otherwise from the instrument
model the imzML itself declares -- so a timsTOF exported to imzML rides the
same match, as long as the export names the instrument (see the info box
below for the one common exporter that does not).
Which detector wins¶
Detectors are tried in a fixed priority order and the first match wins: timsTOF, Rapiflex, FT-ICR, Orbitrap, PHI, Waters, generic centroid, then a catch-all default. This table is the actual observed behaviour of that chain:
| Metadata | Detector | Method | Axis type |
|---|---|---|---|
| timsTOF, centroid | timsTOF | nearest_neighbor |
reflector_tof |
| timsTOF, profile high-density | timsTOF | nearest_neighbor |
reflector_tof |
| Rapiflex, profile | Rapiflex MALDI-TOF | tic_preserving |
constant |
| Bruker MALDI-TOF | Rapiflex MALDI-TOF | tic_preserving |
constant |
| imzML or mzPeak declaring an FT-ICR analyzer or model | FT-ICR | nearest_neighbor |
fticr |
solariX .d (native, peaks.sqlite) |
FT-ICR | nearest_neighbor |
fticr |
| imzML or mzPeak declaring an Orbitrap analyzer or model | Orbitrap | nearest_neighbor |
orbitrap |
PHI SmartSoft-TOF .raw |
PHI SmartSoft-TOF (ToF-SIMS) | nearest_neighbor |
tof |
Waters MassLynx .raw, profile trace (the SELECT SERIES MRT default) |
Waters MassLynx (profile trace) | tic_preserving |
linear_tof |
Waters MassLynx .raw, SELECT SERIES MRT vendor centroid |
Waters SELECT SERIES MRT (vendor centroid) | nearest_neighbor |
tof |
Waters MassLynx .raw, vendor centroid or undeclared, other instruments |
Waters MassLynx | nearest_neighbor |
reflector_tof |
| unknown vendor, profile (any density) | Unknown (default) | nearest_neighbor |
constant |
| unknown, centroid | Centroid, instrument unknown | nearest_neighbor |
reflector_tof |
| no usable metadata | Unknown (default) | nearest_neighbor |
constant |
Why PHI needs its own row
Without it PHI reaches the catch-all default and is reported as
constant. Thyra's own answer would still be nearest_neighbor, so
nothing breaks inside Thyra -- but preview_msi hands that axis type
to callers, and a caller that maps constant to profile-MALDI
conventions arrives at tic_preserving onto an equidistant axis. That
combination is destructive here: a PHI pixel holds a median of 44
measured points across m/z 0.5--1850, and interpolating between them
fabricates intensity in every bin in the gaps, with the TIC rescale
hiding it behind a total that still balances.
Why PHI's target axis is tof and its source law is linear_tof
These are two different laws on this instrument, and the row above is
the target axis. PhiMassAxis steps at a constant flight time, so its
channels are spaced as sqrt(m/z) -- that is linear_tof, and it is
what source_grid_law reports. The peaks do not follow that law:
measured resolving power climbs from about 2,200 at m/z 10 and levels
off near 4,250 above m/z 60, where linear_tof would have it climbing
as sqrt(m) without end and reflector_tof would have it flat from the
start. Only the peak width has any business setting a bin width, so the
target axis takes the two-term law fitted to it.
The generic linear_tof default of 17 mDa at m/z 300 would put 42% of
the measured peaks under two bins per peak width. The measurements
behind this choice, against the instrument's own peak-image export, are
in Design Decisions.
Resampling stays on by default for PHI
It is reasonable to ask whether PHI should default to --no-resample,
since the native channel grid is the instrument's own measurement and
nothing is interpolated either way. On the tuned axis it should not:
the resampled store is 22% smaller, has 8.4x fewer columns, opens about
1.8x faster (0.61 s against 1.08 s, medians of four alternating warm
reads), and comes out better correlated with the vendor's peak images
than the unresampled one. Its remaining recovery spread is
window-edge quantisation -- the vendor's windows span 3 to 10 bins, and
their edges fall mid-bin -- not lost peak shape; the windows with the
fewest bins are exactly the ones with the worst recovery. Use
--no-resample when you need var["tof_us"] or the raw channel grid.
tic_preserving is gated on the source and target axis laws matching
A detector may only ask for tic_preserving if it knows the spacing law
of the grid the spectra arrive on, and that law is the one it is asking
the target axis to use. Two routes qualify. RapiflexReader lays every
spectrum out with np.linspace, so the source is uniform in m/z, and the
axis it requests is constant -- the same law. The Waters profile trace
is the digitiser's own record, sampled at a fixed clock rate and so
spaced as sqrt(m/z) (measured (m/z)^0.494 on a SELECT SERIES MRT and
(m/z)^0.498 on a Synapt G2-Si), and the axis it requests is
linear_tof -- again the same law.
Anything else is refused and gets nearest_neighbor instead, with a log
line saying so. The gate is there because Thyra's interpolate-then-rescale
operator is exact only when the two laws match -- see the !!! danger box
below for what it costs off the diagonal.
How an imzML reaches the Orbitrap and FT-ICR rows
Both detectors match on an instrument_type of "Orbitrap" or
"FT-ICR", which the imzML metadata extractor resolves from the file's
own instrumentConfiguration, in declaration-strength order: the
<analyzer> component cvParam (MS:1000484 orbitrap, MS:1000079
FT-ICR) first, then a recognised instrument-model term (the solariX,
apex, LTQ FT, and Orbitrap/Exactive/Exploris families), then a
family-identifying product name in free-form model text. A file that
declares none of the three stays untyped and falls through to the
generic rows below -- an unstated analyzer stays unstated.
The native solariX .d route needs none of this: its extractor stamps
instrument_type = "FT-ICR" directly from the instrument identity in
peaks.sqlite, so the same FT-ICR row is reached without an export
step (see Supported Formats).
One known gap: SCiLS Lab-lineage imzML exports of timsTOF data stamp the
generic MS:1001534 Bruker Daltonics flex series term rather than a
timsTOF model term. That term genuinely covers the axial flex series
too, so Thyra does not attribute it to a timsTOF; a centroid export
still lands on the right pair via the centroid row, but a profile export
falls to the default. --mass-axis-type remains the explicit override
for files whose metadata is silent or too generic.
tic_preserving is selected for profile MALDI-TOF, not for high resolution
It is easy to assume the high-resolution analysers get the more elaborate
method. They do not. tic_preserving is chosen for profile data, where
a peak is spread over many points and rebinning would otherwise change the
total ion count. Orbitrap and FT-ICR data is normally centroided, so it gets
nearest_neighbor, which is the correct choice for discrete peaks. Keep
nearest_neighbor for profile Orbitrap or FT-ICR data too: Thyra
cannot tell whether an imzML's points follow the analyser's own spacing
law, and tic_preserving onto the orbitrap or fticr axis of a source
that does not follow it distorts intensity ratios by 7x or more (see the
box below).
Do not combine tic_preserving with an axis your data does not follow
tic_preserving interpolates onto the target axis and then applies a
single scaling factor to the whole spectrum. That is exact only when the
target axis is spaced the way the source's own points are. Otherwise a
single factor cannot account for bin widths that vary across the mass
range differently from the source spacing. On a source sampled evenly in
m/z, pairing it with linear_tof, reflector_tof, orbitrap or fticr
suppresses high-m/z ions relative to low-m/z ones: across 300-1100 m/z,
two ions of equal abundance come back with their ratio distorted by 1.9x
on linear_tof, 3.7x on reflector_tof, 7.0x on orbitrap and 13.4x
on fticr -- the ratio of the bin widths at the two masses.
Auto-selection cannot produce these pairings. tic_preserving is only
ever chosen alongside an axis whose law the source grid itself follows --
constant for the Rapiflex, linear_tof for the Waters profile trace --
which is what makes it exact; the detector chain enforces that, and it
re-checks once the axis is settled, so overriding the axis with
--mass-axis-type downgrades an auto-selected tic_preserving to
nearest_neighbor rather than interpolating onto an axis the source
does not follow.
You therefore have to ask for a mismatched combination explicitly, by
naming --resample-method tic_preserving yourself, and Thyra takes you
at your word -- with a warning when it contradicts the detector.
If your data does not follow the axis you want, use nearest_neighbor,
which moves each peak into a single bin and is unaffected by bin width.
The chosen detector, method, and axis type are all logged:
INFO - Detected instrument type: timsTOF
INFO - Selected resampling method: NEAREST_NEIGHBOR
INFO - Selected axis type: REFLECTOR_TOF
Overriding the detector¶
--resample-method overrules the detector, and the detector is still asked
what it would have picked. When the two disagree, the conversion says so and
carries on:
WARNING - Resampling method TIC_PRESERVING was given explicitly, but this
source's detector chose NEAREST_NEIGHBOR for it. Interpolating a source the
detector reads as sparse fills the whole axis: [...] Pass
--resample-gap-tolerance to discard bins no measured m/z vouches for, or drop
the override.
Nothing about the output changes -- the method you asked for is the method
used. The warning exists because the failure it points at is invisible in the
totals: on a 713-frame PASEF acquisition, --resample-method tic_preserving
stored 423,386,757 non-zeros against 302,106 for the detector's choice, a
583 MB table against 7.6 MB, and per-pixel TIC identical to the last digit.
Adding --resample-gap-tolerance 0.01 to the same command brings it down to
4,801,946. See Gaps in the source m/z values.
Methods¶
nearest_neighbor¶
Each target bin takes the intensity of the nearest original m/z value. Peaks
stay sharp and stay put, and no intensity is invented between them. This is the
right choice for centroid data, where a peak is a single discrete mass and
smearing it across neighbouring bins would be wrong. The operator is
NearestNeighborStrategy in thyra.resampling.strategies.
The bin a peak lands in is computed, not searched for: every axis type is
laid uniformly in one analytic coordinate (m/z itself, sqrt, ln,
1/sqrt, 1/m, or the tof law's own cumulative), so the nearest bin is
the peak's position in that coordinate rounded, checked against its two
neighbours. It is the same bin the binary search found, for every peak --
design decision D21
has the argument and the measurements -- and a quarter to a half of the
conversion time less on a centroid dataset of a few thousand peaks per
spectrum.
Consequence worth knowing: on a target axis finer than the source spacing, most bins receive nothing. A dataset resampled from 4,000 source points onto 190,000 bins has exactly 4,000 populated bins per spectrum, and the rest are zero. This is why you should extract ion images by summing over an m/z window rather than picking the single nearest bin -- see the tutorial.
tic_preserving¶
Linear interpolation onto the target axis, followed by rescaling so the
spectrum's total ion current matches the original. This is the right choice for
profile data: a peak spans many points, interpolation reconstructs its shape
on the new grid, and the rescaling step stops rebinning from quietly changing
quantitation. The operator is TICPreservingStrategy in
thyra.resampling.strategies.
Use it whenever the total ion count per pixel has to stay comparable before and after conversion.
Interpolating at a bin's centre reads only the two source samples either side of it. Once bins are wide enough that one holds a sample neither of those is, that sample's ion current would be lost. Such a spectrum is rebinned instead, wherever a bin holds two or more samples: each sample stands for the stretch of m/z halfway to its neighbours, and every bin takes the share of that stretch it overlaps. A peak narrower than a bin then keeps its ion current wherever it falls. The spectrum's other bins are scaled by bin width over sample spacing, so both kinds of bin count ions on one scale. On an evenly sampled source this starts at about two bins per sample; below that, spectra are resampled by interpolation alone, as described above. See D50 for the measurements.
The total that is preserved is the share of the spectrum inside the axis range.
If you crop with --resample-min-mz or --resample-max-mz, intensity outside
the window is dropped rather than redistributed over the bins you kept, so the
per-pixel total falls by whatever you cropped out -- a cropped window should not
claim ions from the parts that were cut away. That share is measured by area, so
a window narrower than the spacing between source points still keeps a
proportionate amount instead of collapsing to zero.
With the default axis, which spans the dataset's own mass range, nothing is dropped and the total is preserved exactly.
Gaps in the source m/z values¶
Interpolation has no notion of a gap. Between the last source point before an
empty stretch and the first one after it, np.interp draws a straight line,
and every target bin in between gets an intensity that was never measured. On
sparse or thresholded m/z arrays that is most of the axis: forcing
tic_preserving onto bellini.imzML -- 2,222 points per spectrum, median
source spacing 0.0072 Da, largest gap 1,269 Da -- puts 80.6% of the output
total ion current more than 0.5 Da from any measured point.
--resample-gap-tolerance sets how far a target bin may sit from the nearest
source m/z before its interpolated value is discarded instead of trusted. It is
the same parameter Cardinal calls tolerance and matter calls tol.
Masking happens before the TIC rescale, so the intensity returns to the bins that do have measurements behind them rather than being deleted; the per-pixel total is still preserved. A rebinned bin holds measured samples, and the tolerance never removes it.
Sizing it: on a source grid of uniform step s, no target bin is ever more
than s/2 from a source point, so any tolerance above half the widest gap in
your source m/z values changes nothing. Continuous profile data is dense and
evenly sampled, which is why the default is no limit -- there is nothing to
refuse. Set it when your m/z arrays are sparse or peak-picked and you are
asking for tic_preserving anyway.
nearest_neighbor needs no such parameter: it only ever fills a bin some peak
was snapped into.
What "in range" means¶
Both methods keep a peak when it lies inside the declared mass range --
--resample-min-mz and --resample-max-mz, or the source's own range when you
set neither. Peaks outside it are discarded, not folded into the first or
last bin.
The declared range is not quite the same as the span of the axis points. Every
physics axis type lays its bins as target_bins + 1 edges across the range and
stores the centres, so the first and last centre sit half a bin inside the
range you asked for: a source declaring 50-1000 m/z builds an axis running
50.0001 to 999.9975. A peak at exactly 50.0 still belongs in the first
bin, and Thyra keeps it there. That matters most for sources whose declared
range is their first and last sample: PHI ToF-SIMS takes its mass range from
the first and last detector channel, so dropping boundary peaks would lose both
channels in every pixel.
constant axes are unaffected either way: they are laid out with
np.linspace(min_mz, max_mz, n), whose end points already are the declared
bounds. --no-resample is unaffected too -- the axis there is the source's own
values.
The mass-mobility heatmap, the mobility table and the MS/MS table keep the same range, so they hold the same edge peaks as the summed table.
Dropping is deliberate and is reported once per conversion:
WARNING - Dropping peaks that fall outside the target mass range
[250.0000, 1200.0000] m/z -- 2 of 20 in the first spectrum affected. They are
discarded, not folded into the edge bins. Widen the resampling range to keep
them.
Folding them in instead would be worse: clamping every out-of-range peak onto
the nearest edge bin puts 654,158 counts in bin 0 of pea.imzML cropped to
400-800 m/z, where a real peak there is around 80. The total is conserved
exactly, so no TIC check would see it.
Axis types¶
The axis type sets how bin width grows with m/z. Each corresponds to the physics of a mass analyser, so that bins track the instrument's real resolving power instead of over-sampling the low end and under-sampling the high end.
| Axis type | Name in SCiLS Lab | Bin width scales as | Rationale |
|---|---|---|---|
constant |
Constant (equidistant) | constant Da | Equidistant bins |
linear_tof |
Axial TOF | sqrt(m/z) |
Linear TOF: flight time t ∝ sqrt(m/z), so equal time bins give sqrt(m/z) mass bins |
reflector_tof |
Orthogonal TOF | m/z |
Constant relative resolution R = m/Δm; bins are uniform in ln(m/z) |
tof |
-- | sqrt(A m + B m^2) |
A measured TOF peak width; linear_tof and reflector_tof are its two limits. See The two-term TOF law |
orbitrap |
Orbitrap | m/z^1.5 |
Orbitrap frequency f ∝ 1/sqrt(m/z), so equal frequency bins give m/z^1.5 mass bins |
fticr |
MRMS (Fourier-transform) | m/z^2 |
Cyclotron frequency f ∝ 1/(m/z), so equal frequency bins give m/z^2 mass bins |
SCiLS Lab offers the same five laws for its common mass axis, under the names
in the second column, so an axis can be matched between the two. Its earlier
versions called linear_tof and reflector_tof "Linear TOF" and "Reflector
TOF".
reflector_tof is the most broadly useful of these: constant relative
resolution means constant relative mass accuracy across the whole range, which
is what most MS workflows assume.
The two-term TOF law¶
A time-of-flight peak's width in flight time has a constant part (detector and digitiser response, pusher timing) and a part proportional to the flight time (energy spread, turnaround time), added in quadrature. In m/z:
A is in mDa2/Da and B is dimensionless. linear_tof is the
B = 0 limit (width grows as sqrt(m)), reflector_tof the A = 0 limit
(width grows as m, constant ppm, with 1/sqrt(B) the resolving power). Real
instruments sit between, and where they sit is measurable from a few hundred
isolated peaks by least squares on FWHM^2 = A m + B m^2:
| Instrument | A |
B |
1/sqrt(B) |
Fitted from |
|---|---|---|---|---|
| SELECT SERIES MRT | 0.0185 | 9.1e-6 | 331,000 | 229 peaks, m/z 300-1000 (R2 0.36 on FWHM2: the peaks scatter, the trend does not) |
| timsTOF fleX | 0.0877 | 8.74e-4 | 34,000 | 180 peaks, m/z 300-1000 (R2 0.89) |
| PHI nanoTOF | 0.454 | 0.0284 | 5,900 | 311 peaks, m/z 12-377, across 12 acquisitions (2017-2026, both polarities) -- quantile regression at q = 0.05, not least squares |
The MRT pair reproduces the measured 2.97 / 3.79 / 4.54 mDa at m/z 400 / 600 /
800; a log-log fit of the same peaks gives an exponent of 0.67, between the
0.5 and 1.0 the two single-term laws allow, which is why neither of them fits
an MRT centroid list exactly. The timsTOF pair is within 10% of the
reflector_tof shape over m/z 400-1000 (7% at 400, 3% at 600; the constant
term shows below that and the gap reaches 10% at m/z 300), so nothing changes
for timsTOF by default and the pair is opt-in.
The nanoTOF pair is fitted differently, and the difference is worth
understanding if you fit a law for an instrument of your own. Least squares
runs the law through the middle of a corpus, so half of every acquisition's
peaks come out narrower than it predicts and get fewer bins than intended --
on this corpus, 12% of peaks would have landed under two bins per peak width.
The two errors are not symmetric: a peak broader than the law simply gets
more bins than it needs, costing store and nothing else, while a peak
narrower than the law is the one case that loses shape. So the fit is
placed at the narrow edge instead -- quantile regression at q = 0.05 on
FWHM^2 = A m + B m^2, which puts 94% of the 311 measured peaks at three bins
per width or better and none below two. It costs 20% more bins than a
least-squares fit of the same data, and buys a 6% failure rate instead of
70%. ToF-SIMS resolving power
varies by a factor of three between tunes, and a C60 primary beam resolves
about five times worse again (R 280-1,200 against 1,700-7,100 for Bi3);
those acquisitions are deliberately oversampled by this law rather than
averaged into it.
The axis lays bins at FWHM(m) / k for k bins per peak width (default 3).
The cumulative bin count has a closed form, (2/sqrt(B)) asinh(sqrt(B m / A)),
so the axis is a uniform grid in that variable and the count is exact.
# An MRT centroid conversion: this is the default, no flags needed
thyra mrt_run.raw out.zarr --waters-spectrum centroid
# A timsTOF opting in to its measured pair (the default stays reflector_tof)
thyra run.d out.zarr --mass-axis-type tof
# A TOF instrument Thyra has no pair for, fitted from its own peaks
thyra run.imzML out.zarr --mass-axis-type tof --tof-law 0.0185 9.1e-6
# Finer bins: the width at the reference m/z sets k, as on any other axis
thyra run.d out.zarr --mass-axis-type tof \
--resample-width-at-mz 0.005 --resample-reference-mz 1000
--mass-axis-type tof takes the pair the detected instrument declares (MRT
centroid, timsTOF, PHI nanoTOF); --tof-law A B supplies one for an
instrument that has none, and it is an error to have neither. There is no separate flag for the
bins per peak width: --resample-width-at-mz at --resample-reference-mz
fixes it, since k is whatever puts a bin of that width at the reference m/z.
The Python API's ResamplingConfig also accepts bins_per_fwhm directly, on
a tof axis only and never beside a width or a bin count: either of those
fixes the bin count too, so the pair is refused.
Centroid axes follow peak width; profile axes follow the sample grid. The
law describes how wide a peak is, which is what the bins of a centroid list
should track. A profile trace is a set of samples on the digitiser's own
grid, and its bins should track that grid instead -- the Waters profile
default uses linear_tof because that is how its samples are spaced (see
Supported Formats), not because of how
wide its peaks are. The two-term law is never applied to profile data, nor to
FT-ICR or Orbitrap data, whose widths are not time-of-flight quantities.
Bin count¶
You can set the bin count directly, or specify a target bin width at a reference m/z and let Thyra derive the count from the axis physics. The two are mutually exclusive.
When you specify neither, the detected instrument may declare a width; failing that, the axis type's default applies:
| Source | Default width | At reference m/z |
|---|---|---|
| Waters SELECT SERIES MRT vendor centroid | tof law at 3 bins per FWHM (1.75 mDa) |
1000 |
| Waters vendor centroid, other instruments | 2 mDa | 1000 |
| Waters SELECT SERIES MRT profile trace | 1.3 mDa | 1000 |
| Waters profile trace, other instruments | 1.14 x the run's predicted sample spacing | 1000 |
| PHI SmartSoft-TOF (ToF-SIMS) | tof law at 3 bins per FWHM (about 57 mDa) |
1000 |
any other source, linear_tof |
17 mDa | 300 |
| any other source, everything else | 5 mDa | 1000 |
The Waters figures are measured, not conventional. 2 mDa is the coarsest
centroid setting that clears two bins per measured peak width at m/z 800 on an
MRT (2.8 bins per FWHM of 4.48 mDa; the earlier 5 mDa gave 1.1). 1.3 mDa is
about 1.14 times the MRT digitiser's sample spacing at m/z 1000, pinned rather
than derived so every MRT run with the same mass range shares one axis; see
Supported Formats. An instrument's
width applies only on the fully automatic path -- set --mass-axis-type and
the width defaults revert to the axis type's, since a width tuned for one law
is not a sensible default for another.
The 17 mDa / m/z 300 pairing for linear_tof is a working default, close to
the axis SCiLS Lab produces for FlexImaging data but not a claim of exact
equivalence. To match a particular SCiLS project, set --resample-width-at-mz
and --resample-reference-mz from that project's own axis.
The count is then derived per axis type:
| Axis type | Bin count |
|---|---|
reflector_tof |
ln(max/min) * (ref_mz / width) |
linear_tof |
(2/k) * (sqrt(max) - sqrt(min)), k = width / sqrt(ref_mz) |
orbitrap |
2 * (1/sqrt(min) - 1/sqrt(max)) * (ref_mz^1.5 / width) |
fticr |
(1/min - 1/max) * (ref_mz^2 / width) |
constant |
(max - min) / width |
Each is the integral of 1 / width(m) across the mass range, so the bin width
the axis actually realizes at your reference m/z is the width you asked for.
A count derived this way is floored at 100 bins; a count given with
--resample-bins is used as it is. When the floor or a given count sets the
axis, its bins are not the width asked for, and the stored resampling step
records the width (and, on a tof axis, the bins per FWHM) the axis has.
A worked example, from the tutorial's synthetic dataset --
250-1200 Da, constant axis, default 5 mDa width:
which is what the log reports:
INFO - Calculating bins for 5.0 mDa width at m/z 1000.0
INFO - Calculated 190000 bins for constant axis type
INFO - Building resampled mass axis: 250.00 - 1200.00 m/z, 190000 bins
190,000 bins from 4,000 source points sounds extravagant, and on the
nearest_neighbor path -- which is what auto picks for this dataset -- it
very nearly is free. Each source point lands in exactly one bin, so the
non-zero count does not depend on the bin width at all and only the index
arrays lengthen: the tutorial store comes out at roughly 31 MB, and going from
4,000 bins to 190,000 costs about +0.8% on disk.
That only holds for nearest_neighbor, and for zero-suppressed profiles
tic_preserving interpolates, so on a continuous profile it populates
essentially every bin. The matrix comes out 99.998% dense and the store
becomes n_pixels x target_bins x ~7.5 bytes -- independent of how sparse
the source was. Measured on this same 4,000-point source: at 190,000 bins
it costs 1.44 MB per pixel, so 400 pixels take 574 MB against 6.93 MB at
4,000 bins, an 82.9x blowup, and the 1,728-pixel tutorial dataset would
take roughly 2.5 GB.
The exception is a source that stores explicit zeros around its peaks and nothing in between, like the Waters profile trace. Interpolation between two zeros is zero, so only the bins under the stored clusters are populated -- and only those are evaluated, which is what keeps the MRT default's conversion time in line with nearest-neighbour binning.
On that path the default bin count is a decision rather than a detail.
Pass --resample-bins or --no-resample if you would rather not upsample.
The 10 million bin cap¶
--no-resample on a processed-mode imzML does not build a physics axis at all.
It builds the raw axis: every distinct m/z value in the whole dataset, one
column each. When the peak lists genuinely share m/z values that is small. When
they do not -- which is the normal case for centroided peak picking, where two
pixels almost never report a peak at the same double -- it grows to roughly one
column per peak in the entire dataset, and there is no natural stopping point.
Thyra gives up once that axis passes 10 million unique m/z values, and says what to do instead:
Common mass axis exceeded 10,000,000 unique m/z values after
12,400 of 918,855 spectra (10,004,112 so far). The peak lists in this dataset
do not share m/z values, so a raw axis grows to roughly one column per peak,
which is not usable downstream. Convert with resampling instead (it is the
default; --no-resample disables it), or raise max_mass_axis_length.
10 million bins is also the limit SCiLS Lab sets on its common mass axis, so a store within the cap stays within what that software can load.
The cap is Python-API only, through reader_options, and applies to the imzML
reader:
from thyra import convert_msi
convert_msi(
"data.imzML",
"out.zarr",
reader_options={"max_mass_axis_length": 50_000_000}, # or None for no cap
)
It has no effect when resampling is on, which is the default: a resampled axis has the bin count you asked for.
The value is a count, so it must be a positive integer or None. Anything
else is refused before the file is opened.
Overriding the automatic choice¶
Every part is independently overridable; anything you leave alone stays automatic.
# Method and axis type. nearest_neighbor is the pairing to use with an
# axis your data does not follow -- see the danger box under
# "Which detector wins" above.
thyra input.imzML output.zarr \
--resample-method nearest_neighbor \
--mass-axis-type orbitrap
# Fixed bin count
thyra input.imzML output.zarr --resample-bins 50000
# Target width at a reference m/z instead of a bin count
thyra input.imzML output.zarr \
--resample-width-at-mz 0.01 \
--resample-reference-mz 500
# Restrict the mass range
thyra input.imzML output.zarr \
--resample-min-mz 400 --resample-max-mz 1000
# Off entirely
thyra input.imzML output.zarr --no-resample
The Python API does not resample by default
convert_msi() called without resampling_config keeps the original mass
axis, whereas the thyra command-line tool resamples by default. Pass a
resampling_config explicitly if you want the CLI's behaviour from Python.
One axis for several data sets¶
To compare data sets bin for bin, they need the same mass axis. Plan the axis once, then give the plan to every conversion.
from thyra import convert_msi, plan_mass_axis
plan = plan_mass_axis(["a.imzML", "b.imzML"])
print(plan.origins) # where each setting came from
print(plan.warnings) # what the plan costs each source
plan.save("axis.json")
convert_msi("a.imzML", "a.zarr", resampling_config=plan)
convert_msi("b.imzML", "b.zarr", resampling_config=plan)
On the command line, pass the saved file: --mass-axis-plan axis.json. The
plan fixes the axis, so the other axis options cannot be combined with it.
The plan takes the range all sources share and, on most axes, the widest bin
width any source declares. Each source keeps its own resampling method. A
conversion refuses to run when the axis it builds is not the planned one.
An imzML range is moved out by the last digit its header states, because the header rounds it.
Pass axis_type, min_mz, max_mz or width_at_mz to set a value yourself.
Checking what was used¶
The decision is recorded in the output store, so a converted dataset is self-describing:
import spatialdata as sd
sdata = sd.read_zarr("output.zarr")
table = sdata.tables["msi_dataset_z0"]
mz = table.var["mz"].values
print(f"{len(mz)} bins, {mz.min():.2f} -- {mz.max():.2f} m/z")
# Bin width across the range tells you the axis type in practice:
# flat -> constant, growing linearly -> reflector_tof, and so on.
import numpy as np
d = np.diff(mz)
print(f"bin width: {d.min()*1000:.3f} -- {d.max()*1000:.3f} mDa")
For the tutorial dataset this prints a flat 5.000 mDa across the range,
confirming a constant axis at the default width.
Practical guidance¶
- Leave it alone unless you have a reason. The detected settings are right for the common Bruker and imzML cases.
- Disable it (
--no-resample) when you want the untouched vendor axis -- your own peak picking, centroiding, or calibration downstream. - Leave the method on
autofor profile data too. Thyra pickstic_preservingonly where it knows the spectra follow the target axis (Bruker rapifleX and other Bruker MALDI-TOF data, and the Waters profile trace); anywhere else interpolation would distort intensity ratios, as the danger box above measures. - Set
--resample-binsany time you need to match another tool's axis exactly. - Narrow the mass range (
--resample-min-mz/--resample-max-mz) before increasing bin counts; it is usually the cheaper way to get the resolution you need where you need it.
See also¶
- CLI Reference -- the flags
- Tutorial -- resampling in the context of a full conversion
- Output Format -- where the m/z axis lives in the store
- API Reference --
ResamplingConfig,ResamplingDecisionTree