imzML Parser Notes¶
Thyra reads imzML through pyimzML 1.5.5. This page is the written-down half of a survey of everything Thyra relies on from that library: hazards that are real and verified, but that correctly get no code today — because they are unreachable from Thyra's current call graph, because the safe thing is already what Thyra does, or because the surface in question is one Thyra deliberately does not use.
Nothing here is a bug report against the shipped converter. It is the set of things that would cost somebody a day if they had to rediscover them, listed where a future reader will look before making the change that reaches them.
ImzMLParser is not thread-safe¶
ImzMLParser.get_spectrum_as_string does two unsynchronised seek-then-read
pairs on a single shared file handle:
self.m.seek(offsets[0])
mz_string = self.m.read(lengths[0])
self.m.seek(offsets[1])
intensity_string = self.m.read(lengths[1])
Two threads interleaving between a seek and its read get each other's bytes.
Measured over real pea.imzML, eight threads sharing one parser, 2000
getspectrum calls:
| corrupt reads | 108 (5.4%) |
| exceptions raised | 0 |
| corrupt at the correct declared length | 84 of 108 |
corrupt with mz.size == 0 |
24 |
corrupt reads with a threading.Lock added |
0 of 2000 |
The 84 are the dangerous ones: the array comes back the length the header promised, holding another pixel's masses. No length check, emptiness check or dtype check can see it.
This is not a live defect. Thyra's imzML reads are strictly serial — a grep
for concurrency primitives across thyra/ finds three hits, all of them on the
class registry and the Bruker buffer pool, none on the imzML read path, and a
non-blocking-lock proxy over two real conversions confirmed no contention ever
occurs.
It is written down because parallelising the imzML read is an obvious future
optimisation, and this trap is laid directly underneath it. The sanctioned
route is parser.portable_spectrum_reader() with one open file object per
worker, never a shared ImzMLParser.
Do not "fix" the .ibd resolution to match pyimzml¶
There are two rules for finding the .ibd beside an imzML, and Thyra's is the
more robust of the two. This section exists because the asymmetry looks, at a
glance, like Thyra being sloppy.
pyimzml's re.IGNORECASE is decorative: the f.stem == imzml_path.stem
comparison sitting beside it is a plain case-sensitive ==. So the flag buys
nothing, and three ordinary situations behave worse under pyimzml's rule:
| Situation | pyimzml | Thyra |
|---|---|---|
SAMPLE.IBD beside sample.imzML |
IndexError: list index out of range |
resolves — Path.exists() is case-insensitive on Windows |
A parent directory named proj.ibd |
the unanchored regex matches the full path, so every same-stem sibling qualifies — including the imzML itself, which is what gets returned | unaffected; only the .ibd suffix is ever constructed |
A half-finished download sample.ibdtmp |
.+\.ibd matches it, so it competes with the real file |
unaffected |
Do not change with_suffix('.ibd').exists() to match pyimzml
It would be a regression on all three rows. If the resolution rule ever does
need to change, it should get stricter, not more like _infer_bin_filename.
A related consequence worth knowing when writing tests: production always passes
ibd_file= explicitly, so it never reaches _infer_bin_filename at all. Tests
that construct a bare ImzMLParser(path) are exercising a rule the shipped code
does not use, which means they cannot catch a resolution bug in it. Use
tests.fixtures.imzml_parser.production_parser instead; it mirrors
ImzMLReader._initialize_parser exactly.
Two smaller traps on the same handle:
ibd_file=Noneis not the same as omitting the argument. Omitting it infers the path; passingNonegives a parser that raisesAttributeError: 'NoneType' object has no attribute 'seek'on the first read. A caller writingibd_file=maybe_handlegets the unreadable parser whenever the handle isNone.__enter__/__exit__close a handle the parser does not own. Withibd_file=fsupplied,parser.m is f, and leaving awithblock closes the caller's file. Thyra does not usewithon a parser;ImzMLReader.close()already closes the same aliased handle twice, which is idempotent in CPython and load-bearing on nothing.
Dead pyimzml surface, for anyone tempted to adopt it¶
None of the following is reachable from Thyra today. All of it looks useful, and all of it is broken in a way that would not be obvious on first use.
_bisect_spectrum returns the first peak for any query below the range¶
ix_l, ix_u = bisect_left(mzs, mz_value - tol), bisect_right(mzs, mz_value + tol) - 1
if ix_l == len(mzs):
return len(mzs), len(mzs)
if ix_u < 1:
return 0, 0 # <-- "nothing matched" and "peak 0 matched" are the same answer
Query below the mass range and bisect_right returns 0, so ix_u is -1, so the
function reports the half-open range (0, 0) — which the caller reads as peak
zero matched. Measured: getionimage(bellini, mz=0.5, tol=0.05) returns a
128x128 image with 16384 of 16384 pixels non-zero, where the truth is a
blank image.
getionimage also constructs a UserWarning about z=0 and never raises it —
UserWarning("...") on a line by itself is just an allocated object.
browse() hands back bare map objects¶
_SpectrumMetaDataBrowser._find_referenceable_param_groups ends with
ids = map(...); return ids. Anything that indexes the result, checks its
length, or iterates it a second time sees an exhausted iterator rather than an
error.
check_peaks_overlap is not a centroid/profile tiebreaker¶
Two independent reasons not to adopt it:
- It calls
random.seed(42)on the globalrandommodule, clobbering the caller's RNG stream as a side effect of asking a question about a file.calc_statisticsdoes the same. - On real
bellini.imzML— which declaresMS:1000128 profile spectrum— it returns 0.01% overlap, i.e. it says centroid. Adopting it would put Thyra in disagreement with a shipped file's own declaration on day one.
include_spectra_metadata='full' is infeasible, not merely expensive¶
It appends a SpectrumData object per spectrum. Measured at +147 MiB per
12,737 spectra, which extrapolates to roughly 10.6 GiB on the 918,855-spectrum
xenium file — on top of the parse. The list mode is cheaper and does not work
either: it never follows referenceableParamGroupRef, so it returns None for
every spectrum, with no signal that the lookup was structurally incapable of
succeeding. That is exactly where SCiLS puts MS:1000127.
The list mode also matches on the accession alone. pyimzml's
ACCESSION_FIX_MAPPING turns a mislabelled pair such as MS:1000128 named
"positive scan" into MS:1000130, but only where it parses terms itself:
fileContent and the parameter groups. On a spectrum the pair stated no
polarity at all. Thyra's _pyimzml_compat.ensure_spectrum_term_fix applies
the same correction to the per-spectrum values (D55).
Two more constraints worth not rediscovering¶
choose_iterparsevalidates nothing."LXML","elementtree","expat"and""all fall through to ElementTree without complaint. Thyra'sparse_lib="ElementTree"pin is therefore protected by exact string equality and nothing else — a typo would be silent, and here it would be harmless because the fallback happens to be the pinned choice. Do not rely on that staying true. The reason for the pin is in the comment block atImzMLReader._initialize_parser.SIZE_DICTand numpy disagree about'l'.PRECISION_DICT['64-bit integer']is'l'andSIZE_DICT['l']is 8, butnp.dtype('l').itemsizeis 4 on Windows and 8 on Linux, so the same file decodes differently on the two platforms. If this ever needs fixing, patch the module globals — setPRECISION_DICT['64-bit integer'] = 'q'andSIZE_DICT['q'] = 8. Patching the per-instanceparser.sizeDictis not enough:PortableSpectrumReaderreads the module-level dict, so the same parser would report one size fromgetspectrumand another from its ownportable_spectrum_reader.
The coordinate base is not a constant¶
The specification numbers x and y from 1, and pyimzml passes through whatever
the document holds. Exports numbered from 0 exist, so subtracting a
constant 1 would put the first column of one at x = -1. A negative index is a
legal negative numpy index, so the converter's grid guard would drop those
spectra rather than crash: a 3x3 acquisition at coordinates 0..2 previewed as
grid (2, 2), warned that "5 spectra sat outside the declared 2x2x1 grid",
stored 4 rows and exited 0.
The base is now measured, by thyra.utils.imzml_coordinate_base, and the rule
is deliberately not the one z uses:
| axis | rule | why |
|---|---|---|
| x, y | min(observed_minimum, 1) |
0 folds down; 1 and anything above it keep the spec base |
| z | observed_minimum |
z has no physical origin to preserve |
Rebasing x and y on their observed minimum would move a legitimately cropped
acquisition. A file whose leftmost occupied column is x = 5 is a region of a
larger slide, not a 0-based export, and nothing in the document distinguishes
the two; sliding it to the origin would change its grid width, every
obs["spatial_x"], the TIC image extent and its pixel footprint. Folding only
a 0 down leaves every such file exactly where it was.
The declared IMS:1000042 / IMS:1000043 pixel counts were the third
candidate and are still unread here. mzpeak_extractor is the only place in
Thyra that reads them at all, and it carries a comment about a declared extent
disagreeing with the coordinates shipped beside it.
Whatever is subtracted is reported as
EssentialMetadata.coordinate_offsets and written to
coordinate_systems.global.coordinate_offsets_px, so a store can say where its
origin came from. Before this the imzML extractor set no offsets at all.
One exception, on z. pyimzml gives a spectrum without IMS:1000052 the z of
1. That 1 is subtracted, so the spectra sit on plane 0, but the store records
a z offset of 0, as every format without z does
(D30).
When every z is 1, the first spectrum is read again to tell a stated 1 from
pyimzml's.
Pinned by tests/unit/readers/test_imzml_zero_based.py, which converts a
0-based file, a 1-based one and a cropped 1-based one -- the last being the
file the rejected alternative would have moved.
Fixed in Thyra, still true of pyimzml¶
One finding was real enough to grow code: imzmldict discards
unitAccession. convert_cv_param(accession, value) takes no unit argument,
so __readimzmlmeta cannot carry one even though ParamGroup.cv_params
preserves it. imzmldict['pixel size x'] is a bare number in whatever unit the
vendor declared, so reading it as micrometres is wrong: a file declaring its
pixel as 4406.25 nanometre (UO:0000018) would land on disk with
obs/spatial_x a thousand times too large, and convert_msi would still
return True.
The information is never lost from the document, only from the dict, so
ImzMLMetadataExtractor reads the unit-bearing path — each cv_params
tuple is (name, accession, value, raw_name, raw_value, unit_name,
unit_accession) — and converts: UO:0000016 mm x1000, UO:0000017 um x1,
UO:0000018 nm /1000. Any other declared unit is refused with a
ConversionRefused, which fails the conversion and prints the one sentence
naming the unit rather than a traceback; a cvParam with no unit at all keeps
the historical micrometre reading, because real vendor files (the IONTOF class
among them) write IMS:1000046 unitless and were being read correctly.
Corroboration that the ambiguity was genuine rather than theoretical: pyimzml's
own get_physical_coordinates docstring says it returns nanometers while
multiplying by the same unlabelled pixel size x that Thyra treats as
micrometres. The library's author disagreed with Thyra about the unit.
The behaviour is pinned by
tests/unit/readers/test_hand_authored_fixtures.py::TestUnitNanometre, whose
assertions now state the correct conversion — including the refusal — rather
than characterise the error.
IMS:1000046 was an area until 2017¶
Until commit 421481e of the imzML vocabulary (2017-09-07) IMS:1000046 was
named "pixel size" and gave the area of a pixel, in an area unit.
IMS:1000047 was "image shape" and had no value type. Since that commit the
two are "pixel size (x)" and "pixel size y", both lengths, and "image shape"
is IMS:1000058.
Public files still use the old form. In a header audit of public imzML
files, 70 give IMS:1000046 and no IMS:1000047, and all 70 name it "pixel
size". 39 of them also give a pixel count and an extent. In all 39,
sqrt(value) x count is the extent exactly: value 10000, count 174, extent
17400 is a 100 um pixel. None of the 39 carries a unit. The other 31 declare
UO:0000015, centimetre, and name it "micrometer".
pyimzml renames the term while parsing. It warns Accession IMS:1000046 found
with incorrect name "pixel size", and puts the value in
imzmldict['pixel size x']. The area is then a bare number under the name
of a length. The name the file wrote survives as raw_name, the fourth
field of the cv_params tuple.
Both routes to a pixel size ask for both terms, so a file with the lone term
gave none. One form got through: the old name with a number on
IMS:1000047. It was read as two lengths, 10000 um by 10000 um for a 100 um
pixel, and convert_msi returned True. No such file was found. 28 public
files that give both terms were read, one per deposit and writer, standing
for 408, and the four real files at hand. All use the current names.
Thyra now reads raw_name, and does not read IMS:1000046 under the name
"pixel size". The file gives no pixel size, the log says why, and the
conversion asks for --pixel-size. The preview does the same, because the
header-only extractor shares _extract_pixel_size_fast.
_extract_pixel_size_from_xml has the same guard. It is never reached on a
real file: pyimzml 1.5.5 keeps no root on its Metadata, so the method
returns at its first check.
The mzPeak path goes further and tests the value against the pixel count and the extent, see design decision D27.
Pinned by tests/unit/metadata/extractors/test_imzml_old_pixel_size.py.
How public files state the pixel size¶
The headers of the 479 public imzML files of that audit that state a pixel size were read again on 2026-09-29, for the unit. They come from two repositories and 15 deposits, and 380 of them from one writer. So the table says which forms occur, not how common each is.
| The header gives | Files | Deposits | Thyra takes |
|---|---|---|---|
| both terms, unit micrometre | 381 | 9 | the values |
| both terms, no unit | 28 | 4 | the values, as micrometres |
IMS:1000046 alone, named "pixel size", no unit |
39 | 4 | none, and asks for --pixel-size |
IMS:1000046 alone, named "pixel size", unit accession UO:0000015, unit name "micrometer" |
31 | 1 | none, and asks for --pixel-size |
67 of the 479 give no unit. Reading a value without a unit as micrometres is an assumption. The file does not confirm it.
UO:0000015 is the centimetre. In the 31 files the accession and the name of
the unit disagree, and nothing else in the header settles which one is meant.
Five synthetic test files hold these forms, one per row and one more for the
area without an extent. Each is the same acquisition of 3 by 2 pixels of
50 um. They are in
tests/data/fixtures/,
with the script that writes them. From each of them Thyra takes 50 um or no
pixel size, on the imzML path and on the mzPeak path.
Pinned by tests/unit/metadata/extractors/test_pixel_size_fixtures.py.
IMS:1000080 is never checked against the .ibd header¶
The imzML specification puts the binary file's UUID in the first 16 bytes of
the .ibd and the same value in the XML as IMS:1000080. pyimzml reads
neither against the other -- it never touches the header bytes at all.
That pair is the only thing that can tell an .imzML apart from a different
acquisition's .ibd renamed to sit beside it. Every other check Thyra makes
(ImzMLReader._validate_parser_state) reads the XML's own offsets and lengths
against the binary's size, which a wrong-but-similarly-shaped file satisfies.
Thyra compares them and warns, naming both values and both filenames,
rather than refusing. That is measured, not cautious: of the three real files
in the corpus, pea and the Xenium export match byte for byte, while
bellini -- an IONTOF SurfaceLab 7.5 export -- declares
{FC37F303-A9C0-4CD3-A28E-1D18E523C269} and begins its .ibd with
3ad1bacd-dcc3-4f7b-aea7-f9b375dbf731. Its first spectrum starts at byte 16,
so the header slot is genuinely populated; the writer just filled the two
places independently. That file converts correctly, so refusing would reject
data Thyra reads right today. See design decision D17.
Only the 32 hex digits are compared. IONTOF writes the registry braces and
SCiLS does not, case varies, and the hyphens are positional rather than data,
so a file re-spelling its own UUID in the other convention must not read as a
disagreement with itself. Pinned by
tests/unit/readers/test_imzml_parser_state_validation.py::TestIbdUuid.
A typed cvParam with no value aborts the metadata parse¶
pyimzml.ontology.ontology.convert_xml_value converts each cvParam value to
the type its ontology entry declares. It catches ValueError (an empty
value="" on a float-typed term becomes None) but not TypeError, so a
term with no value attribute at all raises float(None) out of
ImzMLParser.__init__, before any spectrum is read.
That is what the pyimzML fork behind TIMSCONVERT and TIMSImaging writes for
the ion mobility array it adds: a mobilityArray param group whose
MS:1003006 mean inverse reduced ion mobility array term carries a unit and
no value, and pyimzml types MS:1003006 as xsd:float. Upstream pyimzml
therefore cannot open any of those exports:
Thyra installs a patch as narrow as the defect before building a parser
(thyra/readers/imzml/_pyimzml_compat.py): a missing value on a typed term
converts to None, the same result an empty value already produces. Nothing
else changes. tests/data/fixtures/mobility_continuous.imzML reproduces the
layout, and the reader then goes on to read the third array itself, since
__iter_read_spectrum_meta matches only the m/z and intensity group ids and
ignores every other binaryDataArray -- see
Output Format: Ion mobility.
The fixtures behind this page¶
Several of the shapes described here are only reachable from
tests/data/fixtures/,
a small corpus of hand-authored imzML/.ibd pairs. They exist because every
other imzML in the test suite was written by pyimzml's own ImzMLWriter, and a
parser and a writer from one codebase agree on each other's mistakes.
Read that directory's README before editing any of them; two of the ways they break are invisible to the test suite.
The same directory holds the five pixel size test files, see How public files state the pixel size. Those are written by pyimzml, because their subject is the geometry terms and nothing else.