multiplex-imaging-io
utilityOpen multiplex imaging files — Akoya QPTIFF from PhenoCycler and Vectra, CODEX multichannel TIFF, OME-TIFF, and folders of single-channel TIFFs — resolving channels by marker name rather than position, reading regions instead of whole slides, and recovering pixel size in micrometres from the file's own tags.
Opening a multiplex image
Everything downstream — segmentation, per-cell measurement, phenotyping — inherits
whatever this step gets wrong, and this step gets things wrong quietly. A channel read
by position instead of by name produces a full table of measurements attributed to the
wrong antibody. A whole-slide read at full resolution produces a MemoryError at best
and swap-thrashing at worst. A pixel size nobody recovered produces areas in pixels that
cannot be compared to any other dataset.
Nothing here needs an account, an API key, or a GPU. The one thing to obtain is the sample file itself, which is public and stated where it is used.
What these files actually are
All four formats below are TIFF underneath. What differs is where the channel names live and whether a resolution pyramid is present.
| Format | Produced by | Channel names live in | Pyramid |
|---|---|---|---|
| QPTIFF | Akoya PhenoCycler, Vectra / Polaris | per-page PerkinElmer-QPI-ImageDescription XML |
yes, on scans |
| Component TIFF | Akoya inForm unmixing | same per-page QPI XML | no |
| OME-TIFF | converters, most pipelines | one OME-XML block for the whole file | sometimes |
| ImageJ TIFF | CODEX processor, Fiji exports | imagej_metadata["Labels"], or nowhere |
no |
| Folder of TIFFs | many CODEX and MIBI pipelines | the file names | no |
tifffile reads all of them, is BSD-3-Clause, and is the only hard dependency of
anything below.
pip install tifffile imagecodecs numpy zarr pillow
imagecodecs is not optional in practice. Every page of all four Akoya sample files is
LZW-compressed — including all 33 pages of the whole-slide scan — and without it
asarray() raises on exactly the files you care about:
ValueError: <COMPRESSION.LZW: 5> requires the 'imagecodecs' package
Akoya also writes JPEG-compressed scans — the vendor's own sample readme labels one of the whole-slide QPTIFFs "RGB, JPG compression" — which need it for the same reason.
Never index channels by position
This is the failure that survives review, because nothing about it looks wrong. Read the canonical public Vectra 7-colour sample and ask where DAPI is:
import tifffile
import xml.etree.ElementTree as ET
path = "LuCa-7color_1x1component_data.tif"
with tifffile.TiffFile(path) as tf:
names = [(ET.fromstring(p.description).findtext("Name") or "").strip() for p in tf.pages]
kinds = [ET.fromstring(p.description).findtext("ImageType") for p in tf.pages]
print(names)
# ['PDL1 (Opal 520)', 'CD8 (Opal 540)', 'FoxP3 (Opal 570)', 'CD68 (Opal 620)',
# 'PD1 (Opal 650)', 'CK (Opal 690)', 'DAPI', 'Autofluorescence', '']
DAPI is channel 6. Not 0. Akoya orders component pages by emission wavelength and
appends DAPI and the autofluorescence estimate at the end. Every tutorial that opens with
nuclei = img[0] gets PD-L1 and segments an immune checkpoint marker as if it were
chromatin.
Note the ninth entry: an empty string. That is the thumbnail page, which carries no Name.
It is the reason the map below is built from the pages the file calls FullResolution
rather than from tf.pages — see The page count is not the channel count immediately
below, and the tiled file there, where two more non-channel pages appear in the middle of
the list rather than politely at the end.
Build the map once and index through it everywhere:
channels = [n for n, k in zip(names, kinds) if k == "FullResolution"]
index_of = {n: i for i, n in enumerate(channels)}
nuclei = index_of["DAPI"] # 6
tumour = index_of["CK (Opal 690)"] # 5
And on a raw scan there are no marker names at all. The same specimen's whole-slide
LuCa-7color_Scan1.qptiff names its five channels for the filters they were acquired
through:
# component file, after inForm unmixing
['PDL1 (Opal 520)', 'CD8 (Opal 540)', 'FoxP3 (Opal 570)', 'CD68 (Opal 620)',
'PD1 (Opal 650)', 'CK (Opal 690)', 'DAPI', 'Autofluorescence'] # DAPI is index 6
# raw scan, same specimen, before unmixing
['DAPI', 'FITC', 'CY3', 'Texas Red', 'CY5'] # DAPI is index 0
Two files from one slide, eight channels against five, and DAPI at index 6 in one and index 0 in the other. Hardcoding either position is wrong for the other, which is the argument for the name map in one line.
It is also the limit of the name map: on the raw scan index_of["CD8 (Opal 540)"] cannot
succeed, because the marker-to-fluorophore assignment is not in the file. It lives in the
run's panel table, and a seven-colour panel does not fit in five filters — that is what the
spectral unmixing step resolves. If you are handed a scan rather than component data and
asked for CD8, the answer is not a channel index; it is that the file has to go through
inForm, or its equivalent, first.
Two more things to notice about the component name list, both of which bite when you try to match against a panel table.
The names carry the fluorophore, not just the marker. CD8 (Opal 540) will not
match a panel row that says CD8. Split on the parenthesis when you need the bare
marker, and keep the full string as the key into the file — they are different
identifiers and conflating them silently drops channels.
Autofluorescence is not a marker. inForm's unmixing emits it as a component
alongside the real ones. Include it in a clustering run and it behaves like a
high-variance marker present on every cell. Exclude it explicitly, by name.
The page count is not the channel count
with tifffile.TiffFile(path) as tf:
print(len(tf.pages)) # 9
print(len(tf.series)) # 2
print(tf.series[0].shape, tf.series[0].axes, tf.series[0].dtype)
# (8, 1400, 1868) CYX float32
print(tf.series[1].shape, tf.series[1].axes, tf.series[1].dtype)
# (350, 467, 3) YXS uint8
Nine pages, eight channels. Page 8 is a 350x467 RGB thumbnail that Akoya writes into
the same file. Iterating tf.pages and treating each as a channel gives you a ninth
"marker" that is a downsampled colour preview.
And "channels equals pages minus one" is not a rule you can lean on either. A tiled
2x2 field from the same sample directory has five pages for two channels: two
FullResolution, one Thumbnail and two ReducedResolution pyramid levels. The
difference is invisible in the page count and stated plainly in each page's XML.
Read through tf.series, not tf.pages. tifffile has already grouped the pages
into logical images and labelled the axes; series[0] is the multichannel stack and
series[1] is the thumbnail. On a Vectra scan the same mechanism separates the pyramid
levels from the label and macro images.
If you do need the pages — the per-channel XML only exists there — filter on what the XML says the page is:
with tifffile.TiffFile(path) as tf:
channels = [p for p in tf.pages
if ET.fromstring(p.description).findtext("ImageType") == "FullResolution"]
print(len(channels)) # 8
The values ImageType takes across the Akoya sample set are FullResolution,
ReducedResolution, Thumbnail, Overview and Label. Filter to the first and the count
is right whatever else the file happens to carry — and on a whole-slide scan it carries a
lot. The 2.09 GB LuCa-7color_Scan1.qptiff has 33 pages across 4 series for 5
channels: 5 FullResolution, 25 ReducedResolution pyramid pages, plus one each of
Thumbnail, Overview and Label.
Label is the slide label photograph. On clinical material that image routinely carries
a specimen barcode, an accession number, or a handwritten name. It travels inside the same
file as the pixel data, so anything you copy, share or deposit takes it along. Read it
deliberately, and check it before the file leaves your institution.
Pixel size, in micrometres, from the file
A whole-slide area in pixels is a property of the objective, not the biology. Two datasets acquired at 10x and 20x are not comparable in pixels and are comparable in square micrometres, so convert once and put the unit in the column name.
TIFF stores resolution as a rational, in pixels per ResolutionUnit:
with tifffile.TiffFile(path) as tf:
page = tf.pages[0]
num, den = page.tags["XResolution"].value
unit = page.tags["ResolutionUnit"].value # 3 = centimetre, 2 = inch
pixels_per_cm = num / den # 20080.54
um_per_pixel = 1e4 / pixels_per_cm # 0.4980
Three traps in those four lines.
The numerator here is 4294967295 — that is 2^32 - 1, the largest value a TIFF
RATIONAL can hold. Akoya saturates the numerator and puts the precision in the
denominator. Code that assumes den == 1 and reads the numerator as the resolution
gets a number four billion times too large.
ResolutionUnit is 3, not the default 2. TIFF's default is inches. Dividing by
2.54 when the file already says centimetres, or not dividing when it says inches, is a
factor-of-2.54 error in every area you report — large enough to matter, small enough to
look like biology.
The thumbnail has its own resolution. tf.pages[8] reports 1.9920 um/pixel, exactly
4x the full-resolution 0.4980. Read the tag from the page you actually measured on.
For OME-TIFF, prefer the OME-XML, which states the unit outright rather than encoding it:
with tifffile.TiffFile("image.ome.tif") as tf:
if tf.is_ome:
root = ET.fromstring(tf.ome_metadata)
ns = {"ome": root.tag.split("}")[0].strip("{")}
px = root.find(".//ome:Pixels", ns)
print(px.get("PhysicalSizeX"), px.get("PhysicalSizeXUnit"))
Reading a region instead of a slide
Whole-slide scans are large enough that reading level 0 is not a slow path but a path that
does not complete. The public LuCa-7color_Scan1.qptiff is a modest one — 34,560 x 24,960
pixels across 5 channels, uint8 — and that is already 4.3 GB decompressed. A
PhenoCycler run with dozens of channels at 16 bits is one to two orders of magnitude past
it. Measured on that file:
| Read | Result | Time |
|---|---|---|
series[0].asarray() — level 0 |
4.3 GB | do not |
series[0].levels[-1].asarray() — level 5 |
4.2 MB, (5, 1080, 780) |
under 0.1 s |
| a 512 x 512 region of all 5 channels at level 0, via Zarr | (5, 512, 512) |
under 0.1 s |
The last two are the whole technique: the overview costs nothing, and a full-resolution region costs nothing provided you slice rather than load.
Two mechanisms, both driven through tifffile. The second needs zarr as well, which is
why it is in the install line above.
Pick a pyramid level. A pyramidal file's series[0] carries levels, each a halving.
The whole-slide scan has six:
with tifffile.TiffFile("LuCa-7color_Scan1.qptiff") as tf:
for i, lv in enumerate(tf.series[0].levels):
print(i, lv.shape)
# 0 (5, 34560, 24960)
# 1 (5, 17280, 12480)
# 2 (5, 8640, 6240)
# 3 (5, 4320, 3120)
# 4 (5, 2160, 1560)
# 5 (5, 1080, 780)
The same code on the tiled 2x2 field from the same public directory, which has two, so you can exercise it without the 2 GB download:
curl -L -o HandEcompressed_2x2.tif \
"https://downloads.openmicroscopy.org/images/Vectra-QPTIFF/perkinelmer/PKI_fields/HandEcompressed_%5B11004%2C54205%5D_2x2component_data.tif"
with tifffile.TiffFile("HandEcompressed_2x2.tif") as tf:
s = tf.series[0]
for i, lv in enumerate(s.levels):
print(i, lv.shape)
# 0 (2, 2784, 3728)
# 1 (2, 1392, 1864)
overview = s.levels[-1].asarray() # smallest level — safe to hold in memory
A PhenoCycler scan has more of them, on the same halving pattern. Use the smallest level for anything you are going to look at rather than measure: tissue detection, a QC figure, choosing where to crop. Never measure on a downsampled level and report the numbers as if they came from level 0.
len(s.levels) is 1 on a file with no pyramid, so the same code path works either way and
levels[-1] degrades to the full-resolution image rather than raising. That is convenient
and it is also how a "fast overview" silently becomes a full-resolution read on a scan
someone exported without a pyramid — check the shape you got back.
Slice a region out of a Zarr view. aszarr() exposes the file as a chunked store that
reads only the tiles a slice touches:
import zarr
with tifffile.TiffFile("HandEcompressed_2x2.tif") as tf:
store = tf.series[0].aszarr(level=0)
z = zarr.open(store, mode="r")
print(z.shape, z.dtype, z.chunks) # (2, 2784, 3728) float32 (1, 512, 512)
tile = z[:, 1000:1512, 2000:2512] # (2, 512, 512)
store.close()
The chunk shape is the file's own tile grid — (1, 512, 512) here — so a slice aligned to
it reads exactly the tiles it needs. Read the chunk shape rather than assuming it: on the
whole-slide scan, levels 0 to 4 are tiled 512 x 512 but the smallest level is written as
strips, so the chunking is not uniform across the pyramid even within one file. A slice that straddles tile boundaries reads the
straddled tiles in full and discards the remainder, which is correct but not free.
The order of the axes in that slice is whatever series.axes said — check it, do not
assume CYX. A file written YXC accepts the same slice and returns a different region
without error.
mxtifffile, and what it does and does not do
mxtifffile wraps tifffile with format detection and name-based reads. It is worth
knowing about because the API it advertises is exactly what this page is about — and on
the canonical public Vectra file, three of its four headline calls fail. Verified against
version 0.0.2, the current release, on 25 Aug 2026:
pip install mxtifffile # not needed for anything above; only for this section
from mxtifffile import MxTiffFile
f = MxTiffFile("LuCa-7color_1x1component_data.tif")
print(f.format_id) # 'qptiff' — detection works
print(f.get_fluorophores()) # all eight names — works
print(f.get_markers()) # ['PDL1', None, None, None, None, None, None, None]
for label, call in [("print_channel_summary", lambda: f.print_channel_summary()),
("read_region by name", lambda: f.read_region("DAPI", pos=(0, 0),
shape=(256, 256)))]:
try:
call()
print(label, "-> worked")
except Exception as e:
print(label, "->", type(e).__name__, e)
# print_channel_summary -> TypeError unsupported format string passed to NoneType.__format__
# read_region by name -> ValueError Biomarker 'DAPI' not found in this file
The two failures are wrapped because the first one would otherwise end the block: written as
plain statements, print_channel_summary() raises and the read_region line never runs, so
you would see one of the two defects and assume the other was fine.
The cause is a mapping choice, not a bug in the file. mxtifffile resolves a channel's
marker from an XML element named Marker, and in Akoya's per-page XML that element only
appears inside the ScanProfile block — which Akoya writes on page 0 only. Page 0's
description is 14,823 characters against about 1,970 for the seven other channel pages
(and 668 for the thumbnail). So channel 0 gets a
marker and channels 1-7 get None, read_region by name resolves against that list, and
print_channel_summary formats a None description.
The library's own extension mechanism fixes it. Point the biomarker field at the Name
element that every page does carry:
import json, importlib.resources as resources
from pathlib import Path
from mxtifffile import MxTiffFile
cfg = json.loads(resources.files("mxtifffile").joinpath("formats.json").read_text())
for fmt in cfg["formats"]:
if fmt["id"] == "qptiff":
fmt["channel_fields"]["biomarker"] = ".//Name"
Path("vectra-formats.json").write_text(json.dumps(cfg, indent=2))
f = MxTiffFile("LuCa-7color_1x1component_data.tif", formats_config="vectra-formats.json")
f.get_markers()
# ['PDL1 (Opal 520)', 'CD8 (Opal 540)', 'FoxP3 (Opal 570)', 'CD68 (Opal 620)',
# 'PD1 (Opal 650)', 'CK (Opal 690)', 'DAPI', 'Autofluorescence']
region = f.read_region(["DAPI", "CD8 (Opal 540)"], pos=(200, 300), shape=(128, 128), level=0)
print_channel_summary() still raises, because no Vectra page carries a Description
element for it to format. Print your own table.
read_region mixes two axis conventions in one call, and this is the part to get
right. Measured against a full-array reference on the sample file:
region = f.read_region(6, pos=(300, 100), shape=(60, 50), level=0)
region.shape # (50, 60) — that is (height, width)
pos is (x, y). shape is (width, height). The array that comes back is row-major,
so it is (height, width) for a single layer and (height, width, channels) when you ask
for a list — a scalar layers argument drops the trailing axis, so code that always indexes
[..., 0] breaks on one form and code that never does breaks on the other. Passing a NumPy-style
(rows, cols) to shape silently returns a transposed region wherever the region is
not square, and raises ValueError: Requested region exceeds image dimensions wherever
the transposed extent runs off the edge — which is the lucky case, because it is the one
you notice.
On licensing, mxtifffile contradicts itself three ways. Its pyproject.toml,
setup.cfg, README badge and PyPI classifiers all declare MIT. The LICENSE file in the
repository is the GNU General Public License v3, 35,823 bytes of it. And the LICENSE
shipped inside the installed wheel, at
mxtifffile-0.0.2.dist-info/licenses/LICENSE, is zero bytes — so the artifact you
actually install states no terms at all.
Running it is unaffected by any of that. If you intend to vendor or redistribute it, the
contradiction is unresolved upstream and worth an issue before you rely on any one reading.
qptifffile is the same author's earlier package and its LICENSE is empty too;
mxtifffile supersedes it.
Given all of the above, the tifffile route earlier on this page is the one to reach for
first. It is BSD-3-Clause, unambiguous, has no name-resolution layer to go wrong, and is
already a dependency of everything downstream.
CODEX ImageJ TIFFs and folders of single-channel files
The CODEX processor writes a multichannel ImageJ TIFF whose channel names, when present, live in the ImageJ metadata rather than in any XML:
with tifffile.TiffFile("codex_stack.tif") as tf:
meta = tf.imagej_metadata or {}
labels = meta.get("Labels") # often one label per plane; often absent
stack = tf.series[0].asarray()
When Labels is absent there is nothing in the file to recover names from, and the
channel order is defined by the run's own cycle-and-channel table. Do not guess it. Ask
for the table, write it next to the image, and index through it.
A folder of single-channel TIFFs is the same problem with the names in the file paths:
import re, glob, numpy as np, tifffile
paths = sorted(glob.glob("cycle*/*.tif"))
if not paths:
raise SystemExit("no TIFFs matched cycle*/*.tif — check the directory layout")
pattern = re.compile(r"_([A-Za-z0-9+-]+)\.tif{{BODY}}quot;)
names, keep = [], []
for path in paths:
m = pattern.search(path)
if m is None:
raise ValueError(f"cannot read a marker name out of {path!r} — fix the pattern "
"rather than letting this file be dropped or misnamed")
names.append(m.group(1))
keep.append(path)
stack = np.stack([tifffile.imread(p) for p in keep]) # (C, Y, X)
Raise on a filename the pattern does not match, rather than skipping it. A skipped file
shifts every channel after it by one, which is the same silent mislabelling this whole page
is about — and re.search(...).group(1) on a non-match raises AttributeError: 'NoneType' object has no attribute 'group', which is a confusing way to learn it.
Sort the paths explicitly. Directory order is filesystem order, and cycle10 sorts
before cycle2 under every default. The pairing of names to stack is only as good as
that sort.
Write the channel table into the project
The channel table is the provenance record for everything measured afterwards. Write it to disk next to the image, not into a notebook cell.
import csv
import xml.etree.ElementTree as ET
import tifffile
path = "LuCa-7color_1x1component_data.tif"
rows = []
with tifffile.TiffFile(path) as tf:
num, den = tf.pages[0].tags["XResolution"].value
um_px = 1e4 / (num / den)
series = tf.series[0]
for i, page in enumerate(tf.pages):
meta = ET.fromstring(page.description)
if meta.findtext("ImageType") != "FullResolution":
continue
name = (meta.findtext("Name") or "").strip()
rows.append({
"index": i,
"channel_name": name,
"marker": name.split(" (")[0],
"fluorophore": name[name.find("(") + 1:name.rfind(")")] if "(" in name else "",
"is_marker": name not in ("DAPI", "Autofluorescence"),
"height": page.shape[0],
"width": page.shape[1],
"dtype": str(page.dtype),
"um_per_pixel": round(um_px, 4),
"levels": len(series.levels),
})
with open("channels.csv", "w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=list(rows[0]))
w.writeheader()
w.writerows(rows)
Alongside it, write small PNG crops so a person can confirm the mapping is right before anything expensive runs on it. Segmenting a slide and discovering at the phenotyping stage that the nuclear channel was PD-L1 costs a day; looking at eight thumbnails costs a minute.
import numpy as np, tifffile
from PIL import Image
with tifffile.TiffFile(path) as tf:
stack = tf.series[0].asarray() # small file — a scan would use a pyramid level
for i, name in enumerate(names[:stack.shape[0]]):
crop = stack[i, :512, :512].astype(np.float32)
lo, hi = np.percentile(crop, [1, 99.5])
scaled = np.clip((crop - lo) / max(hi - lo, 1e-9), 0, 1) * 255
Image.fromarray(scaled.astype(np.uint8)).save(f"qc_{i}_{name.split(' (')[0]}.png")
Stretch to percentiles rather than to min/max. A single hot pixel sets the max and renders every real structure black, which reads as a dead channel.
Dtype, and what the numbers mean
Do not assume a bit depth. Across the four Akoya files on this page: inForm's unmixed
components are float32 in normalised counts — the component file runs 0 to 29.36 on the
DAPI plane, not 0 to 65535 — while the whole-slide scan from the same specimen is uint8.
CODEX acquisitions are commonly uint16. One vendor, one specimen, three different ranges.
Two consequences. Anything that assumes a 16-bit range — img / 65535, a uint8 cast, a
threshold of 1000 — is wrong on component data by three orders of magnitude. And an
intensity is comparable across cells within one image, comparable across images only after
the cross-slide correction the analysis stage does, and never comparable across markers.
Check the range per channel, not over the stack. The two are different numbers and the stack-wide one is the less useful of them:
import numpy as np
print(stack.dtype, float(stack.min()), float(stack.max()))
# float32 0.0 77.25 <- the whole stack: this is PD1, not DAPI
for name, i in index_of.items():
plane = stack[i]
print(f"{name:22s} max {plane.max():6.2f} median {np.median(plane):6.3f}")
# PDL1 (Opal 520) max 55.04 ...
# CD8 (Opal 540) max 32.93 ...
# FoxP3 (Opal 570) max 17.14 ...
# CD68 (Opal 620) max 10.99 ...
# PD1 (Opal 650) max 77.25 ...
# CK (Opal 690) max 34.10 ...
# DAPI max 29.36 ...
# Autofluorescence max 21.31 ...
Seven-fold between the dimmest channel and the brightest, on one field, from one exposure
series. That spread is the reason a threshold chosen on one marker is meaningless on
another, and the reason a stack-wide max tells you about whichever channel happens to be
hottest rather than about the one you are working on.
Check the range before you threshold on it, every time. It costs one line and it is the difference between a threshold that selects nuclei and one that selects nothing.
Try it
Data. The PerkinElmer Vectra 7-colour lung carcinoma sample, LuCa-7color, from the
Open Microscopy public sample-image set. Released by PerkinElmer under
CC-BY-4.0 (see COPYING beside the file); no account, no token. The 1x1 component
field used here is 85 MB and was confirmed reachable on 25 Aug 2026.
Two neighbours in the same directory are worth knowing about. HandEcompressed_[11004,54205]_2x2component_data.tif
(117 MB) is the tiled, pyramidal file the Reading a region section is verified against —
use it to exercise levels and aszarr, which a single 1x1 field cannot. And
PKI_scans/LuCa-7color_Scan1.qptiff is a 2.09 GB whole-slide QPTIFF, which is the realistic
shape of PhenoCycler output; the host serves it at a few hundred KB/s, so budget for that
rather than putting it in a quick test.
Run. Self-contained, no other setup:
pip install tifffile imagecodecs numpy zarr pillow
curl -L -o LuCa-7color_1x1component_data.tif \
"https://downloads.openmicroscopy.org/images/Vectra-QPTIFF/perkinelmer/PKI_fields/LuCa-7color_%5B13860%2C52919%5D_1x1component_data.tif"
import xml.etree.ElementTree as ET
import numpy as np
import tifffile
path = "LuCa-7color_1x1component_data.tif"
with tifffile.TiffFile(path) as tf:
pages = tf.pages
names = [(ET.fromstring(p.description).findtext("Name") or "").strip() for p in pages]
kinds = [ET.fromstring(p.description).findtext("ImageType") for p in pages]
series = tf.series[0]
stack = series.asarray()
num, den = pages[0].tags["XResolution"].value
unit = pages[0].tags["ResolutionUnit"].value
um_px = 1e4 / (num / den)
channels = [n for n, k in zip(names, kinds) if k == "FullResolution"]
index_of = {n: i for i, n in enumerate(channels)}
print("pages", len(pages), "| full-resolution channels", len(channels))
print("series0", series.shape, series.axes, series.dtype)
print("channels", channels)
print("DAPI index", index_of["DAPI"], "| CK index", index_of["CK (Opal 690)"])
print("resolution unit", unit, "| um/pixel", round(um_px, 4))
dapi = stack[index_of["DAPI"]]
print("DAPI range", float(dapi.min()), round(float(dapi.max()), 3))
# A region read, and the axis order that comes back with it.
region = stack[:, 100:150, 300:360]
print("region", region.shape)
# The invariant that makes name-lookup worth doing: position and name disagree.
assert index_of["DAPI"] != 0, "DAPI would be channel 0 only if the file were reordered"
assert not np.array_equal(stack[0], dapi)
# The channel axis is the FullResolution page count -- not the page count.
assert stack.shape[0] == len(channels)
assert [k for k in kinds if k != "FullResolution"] == ["Thumbnail"]
print("OK")
Expect.
Invariants — a failure here means the skill is wrong, not that upstream moved:
series[0].axesisCYXandseries[0].shape[0]equals the number ofFullResolutionpages, so the channel axis is first and the two spatial axes follow. This is the relationship that holds; "pages minus one" holds only on files with exactly one non-channel page, which is why the assertion pairs it with theImageTypefilter.index_of["DAPI"]is not0, andstack[0]is not the DAPI plane — the assertion that makes positional indexing fail loudly instead of silently.ResolutionUnitis3(centimetre), so the micrometre conversion is1e4 / (num/den); reading it as inches gives a value 2.54x wrong.- A NumPy slice
stack[:, 100:150, 300:360]returns(channels, 50, 60)— rows then columns.mxtifffile.read_region(pos=(300, 100), shape=(60, 50))returns the same pixels as(50, 60, channels). Same region, two conventions.
Observed values, from a run on 25 Aug 2026 against tifffile 2026.8.23 — these move only if PerkinElmer republishes the sample:
pages9,FullResolutionchannels 8,series[0].shape(8, 1400, 1868), dtypefloat32.- Channel order
PDL1 (Opal 520),CD8 (Opal 540),FoxP3 (Opal 570),CD68 (Opal 620),PD1 (Opal 650),CK (Opal 690),DAPI,Autofluorescence. DAPI at index 6, CK at 5. XResolution(4294967295, 213887),ResolutionUnit3, printing as0.498um/pixel.- DAPI range 0.0 to 29.357 — float32 normalised counts, not a 16-bit range.
Across other files in the same set. The reader above was re-run against the two H&E component files in the same directory, which differ from the 7-colour field on every axis that could break it:
| File | Pages | ImageType values |
Channels | Layout | um/px |
|---|---|---|---|---|---|
LuCa-7color_[13860,52919]_1x1component_data.tif |
9 | 8 x FullResolution, Thumbnail | 8 | strips | 0.4980 |
HnE_3_1x1component_data.tif |
3 | 2 x FullResolution, Thumbnail | 2 | strips | 0.4989 |
HandEcompressed_[11004,54205]_2x2component_data.tif |
5 | 2 x FullResolution, Thumbnail, 2 x ReducedResolution | 2 | tiled | 0.4989 |
PKI_scans/LuCa-7color_Scan1.qptiff |
33 | 5 x FullResolution, 25 x ReducedResolution, Thumbnail, Overview, Label | 5 | tiled 512 (levels 0-4), strips at level 5 | 0.4980 |
The ImageType filter, the XResolution conversion and the series-versus-pages
distinction hold on all four. Four things do not generalise, and each is why the code above
is written the way it is: the channel count; the position of any given marker — the H&E
files are ['Eosin', 'Hematoxylin'] with no DAPI at all, and the scan puts DAPI at 0 where
the component file puts it at 6; the page count, which the scan inflates from 5 channels to
33 pages; and the dtype, which is float32 on the component files and uint8 on the scan.
Nothing about a vendor's format is constant except that its own XML will tell you, if you
read it.