← All skills

multiplex-imaging-io

utility

Open 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].axes is CYX and series[0].shape[0] equals the number of FullResolution pages, 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 the ImageType filter.
  • index_of["DAPI"] is not 0, and stack[0] is not the DAPI plane — the assertion that makes positional indexing fail loudly instead of silently.
  • ResolutionUnit is 3 (centimetre), so the micrometre conversion is 1e4 / (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:

  • pages 9, FullResolution channels 8, series[0].shape (8, 1400, 1868), dtype float32.
  • 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), ResolutionUnit 3, printing as 0.498 um/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.