---
name: codex-phenocycler
description: Take a CODEX or PhenoCycler image from pixels to a cell-by-marker table — choosing segmentation channels, running Cellpose over a multiplex stack, measuring per-cell intensity in micrometres, and building the AnnData that spatial analysis starts from. Includes the licence and token gates on the tools this workflow is usually built with.
category: analysis
license: CC-BY-4.0
author: Heureka Labs
version: 1.0.0
tags: [microscopy, multiplex-imaging, segmentation, spatial, single-cell]
datasets: [https://downloads.openmicroscopy.org/images/Vectra-QPTIFF/perkinelmer/PKI_fields/LuCa-7color_%5B13860%2C52919%5D_1x1component_data.tif]
allowed-tools: Read, Write, Edit, Bash
verified:
  date: 2026-08-26
  against: cellpose 4.2.1.1 (Cellpose-SAM) on MPS / tifffile 2026.8.23 / scikit-image 0.26.x / anndata 0.13.x / scanpy 1.12.3 / squidpy 1.8.3 / numpy 2.5.2 / Python 3.12.8 / PerkinElmer Vectra LuCa-7color component field
  executed: 12
  unverified: 0
---

# From a CODEX image to a cell table

A PhenoCycler or CODEX run produces one enormous multichannel image and the expectation
that a table of typed, positioned cells will come out the other end. The chain is:
read the file, choose which channels define a cell, segment, measure every marker inside
every mask, assemble an `AnnData`, cluster, and confirm the cell types by eye before
anybody builds on them.

Each step has one decision that carries the result and several that do not. This page is
about the ones that do.

Reading the file — channel names, pixel size, regions rather than slides — is its own
problem, and the `multiplex-imaging-io` skill covers it. The spatial statistics that come
after the table are covered by `spatial-phenotyping`. This page is the middle.

## Before you start: two gates on the obvious tools

The published workflow for this task is **SPACEc** (Nat Commun 2025), and the CLI
alternative is **multiplex-imaging-pipeline** with DeepCell's Mesmer. Both are worth
knowing about, and neither is the route this page takes.

**SPACEc is licensed for academic non-commercial use only.** Its `LICENSE.txt` is a
Stanford Academic Software License Agreement, under which the recipient

> agrees to use the Software solely for internal academic non-commercial purposes and
> shall not distribute or transfer it to another location or to any other person without
> prior written permission from STANFORD. In particular, no article in this license grants
> commercial use rights to RECIPIENT.

The same agreement also states that the recipient "may NOT make modifications to the
Software or integrate Software into RECIPIENT's own software". If you are at an academic
institution and staying inside it, that is a route you can take, and the paper is the
reference for the method. If you are anywhere else, it is not, and no amount of tooling
changes that. Read the licence yourself before installing it.

**DeepCell's Mesmer is non-commercial too, and the token is not the reason.** The
`deepcell-tf` code ships a `LICENSE` whose text is unmodified Apache 2.0, though the
project's own README badge and licence section call it "modified APACHE2". The weights are
a separate question and the documentation answers it directly:

> DeepCell models and training datasets are licensed under a modified Apache license for
> non-commercial academic use only. An API key for accessing datasets and models can be
> obtained at https://users.deepcell.org/login/

So the `DEEPCELL_ACCESS_TOKEN` is an access mechanism sitting on top of a use restriction.
Registering is easy and does not change what you may do with the model afterwards. For
commercial work that puts Mesmer in the same category as SPACEc rather than in the
"ordinary requirement" category, which is worth knowing before you build a pipeline around
it. `multiplex-imaging-pipeline` (MIT) wraps it as `segment-ome`; its last release was
7 May 2024, before numpy 2.0.0 landed that June, so expect to pin an environment for it.

**Cellpose is BSD-3-Clause, on PyPI, and downloads its weights anonymously.** That is why
the worked path below uses it. It is not a claim that it segments better than Mesmer — for
membrane-based whole-cell segmentation on multiplex data, Mesmer is the stronger published
result — only that it is a path every reader can actually take.

The one thing to budget for is size: the weights come from the ungated public
`mouseland/cellpose-sam` repository on Hugging Face, under BSD-3-Clause, and the current
checkpoint is **1.2 GB**. It is fetched on first use into `~/.cellpose/models` and cached
there, with no token — but a first run on a fresh machine is a large download before
anything segments.

```bash
pip install cellpose tifffile imagecodecs scikit-image anndata scanpy squidpy igraph
```

A GPU helps and is not required: `cellpose.core.use_gpu()` reports what it found, and CUDA
and Apple MPS both count. A 700x700 two-channel crop segments in about 4 seconds on MPS.

## Choose the segmentation channels deliberately

This is the decision that determines every number downstream, and it is usually made by
whatever the tutorial happened to use.

**A nuclear channel is mandatory.** DAPI, Hoechst, or the equivalent. It is what tells the
segmenter where cells are.

**A membrane or cytoplasmic channel is optional and changes what you measure.** Nuclei-only
segmentation gives you nuclear masks; any marker that lives in the membrane or cytoplasm is
then measured over the wrong compartment, and CD8 on a T cell will read as background.
Adding a membrane channel gives you whole-cell masks and the marker intensities mean what
their names suggest.

There is rarely one membrane channel that covers every cell type — a pan-cytokeratin marks
epithelium and says nothing about lymphocytes. The usual compromise is a sum of two or
three broad markers. State which, because the segmentation is not reproducible without it.

Measured on the 700x700 crop of the Vectra field used below:

| Segmentation input | Objects found |
|---|---|
| DAPI alone | 1424 |
| DAPI + CK (Opal 690) | 1334 |

Both are defensible; they are not the same set of cells, and the second is the one where a
cytoplasmic measurement means something. Never pick these channels by index — resolve them
by marker name, because the position of DAPI is a property of the vendor's export and not
of your panel.

### The first segmentation in a process differs from every one after it

Run the identical two-channel input through the identical model four times in one process,
and the result is not what you would guess:

| Device | Object count | eval 1 vs 2 | eval 1 vs 3 | eval 1 vs 4 | evals 2, 3, 4 |
|---|---|---|---|---|---|
| CPU | 1335 throughout | 587 px differ | 587 | 587 | byte-identical |
| Apple MPS | 1334 throughout | 7,203 px differ | 7,203 | 7,203 | byte-identical |

**The model is deterministic. The first call in a process is not.** Whichever device runs
first in a fresh process produces a mask that every subsequent call disagrees with, by an
identical amount each time, after which the result never moves again. Object counts are
unaffected throughout — this changes boundaries, not detections.

The warm-up is a property of the **process**, not of the device or the model object. Run two
CPU calls first and *then* build an MPS model, and MPS's first call already returns the
value its second call would have given:

| Process | first call | second call | differ |
|---|---|---|---|
| MPS from cold | `e10850f6…` | `b97be840…` | 7,203 px |
| CPU from cold, then MPS | MPS gives `b97be840…` | `b97be840…` | **0 px** |

Those hashes reproduce exactly across sessions on the same machine, which is what makes
this a mechanism rather than an anecdote — and it is why a run that happened to start on one
device will report the *other* device as the unstable one.

**Building a fresh model does not reset it.** Instantiate a second `CellposeModel` halfway
through a process and its first call returns the warmed-up value, not the cold one. Nor does
`batch_size` change it — 4 and 8 give the same two hashes and the same difference. Whatever
is being warmed lives in the process, not in the model object, so the obvious workarounds do
not work and saving the mask remains the answer.

That single fact explains a set of observations that otherwise look contradictory, and it is
worth stating because the obvious summaries are both wrong. "MPS is unreliable" is wrong;
"use the CPU for reproducibility" is wrong. What is true is that a **one-shot script gives a
different answer from the same code called twice**, on either device.

The two devices also disagree with each other, and more than they disagree with themselves:
1334 objects against 1335, and 145,341 differing pixels. One object in 1335 is 0.07% and
nothing for a composition estimate; 145,341 pixels is plenty for a per-cell measurement.

So: **segment once, save the label image, and measure from the saved labels.** That habit
makes all of the above moot, which is why it is the recommendation rather than a warm-up
call. Re-running segmentation inside a downstream script means the first number you ever
computed is the one you can never reproduce. Record the device alongside the model version,
and treat any per-cell measurement as reproducible only against a saved mask.

```python
import numpy as np, tifffile
import xml.etree.ElementTree as ET
from cellpose import models

path = "LuCa.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]
    stack = tf.series[0].asarray()
    num, den = tf.pages[0].tags["XResolution"].value

um_px = 1e4 / (num / den)             # 0.498 — needed for every area and coordinate below
channels = [n for n, k in zip(names, kinds) if k == "FullResolution"]
index_of = {n: i for i, n in enumerate(channels)}

crop = stack[:, :700, :700]           # every count on this page is from this crop
seg_input = np.stack([crop[index_of["DAPI"]],
                      crop[index_of["CK (Opal 690)"]]])         # (C, Y, X)
labels = models.CellposeModel(gpu=True).eval(seg_input, channel_axis=0, batch_size=8)[0]
print(labels.shape, labels.dtype, int(labels.max()))            # (700, 700) uint16 1334
```

The crop is deliberate and every number quoted on this page comes from it. `stack` here is
`(8, 1400, 1868)`; segmenting all of it is 3.5x the work and gives a different count, so if
you drop the crop, drop the expected numbers with it.

`channel_axis=0` is what tells Cellpose the leading axis is channels rather than the first
spatial axis. Omit it on a `(2, Y, X)` array and it interprets the stack as a two-row image.

## Tile a slide; do not feed it whole

A whole PhenoCycler slide is orders of magnitude larger than anything a segmenter will hold.
Read a pyramid level or a region for anything exploratory, and tile with overlap for the
real run.

The overlap is not optional and the deduplication is the part people get wrong. Cells
straddling a tile boundary are segmented twice, once partially in each tile. Discard the
objects that touch a tile edge rather than trying to merge them — a partial mask has a
smaller area and a truncated intensity profile, so keeping both halves is worse than
keeping neither, and the cells you lose are recovered from the neighbouring tile where they
sit in the interior.

```python
from skimage.segmentation import clear_border

tile_labels = clear_border(labels)           # drops objects touching this tile's edge
```

On the 700x700 crop below that drops 1335 objects to 1242 on CPU and 1334 to 1241 on MPS —
**7% of the cells sit on the border of a single tile**, either way.

**Count the labels, not the maximum, after this step.** `clear_border` zeroes the border
objects but does not renumber the survivors, so `.max()` becomes the highest label that
happens to remain — a number with no relation to how many objects are left:

```python
print(int(labels.max()))                            # 1335 — consecutive, so this is the count
print(int(tile_labels.max()))                       # 1334 — highest survivor, NOT the count
print(len(set(np.unique(tile_labels)) - {0}))       # 1242 — the objects that remain
```

On this crop label 1335 sat on the border and was removed, and 1334 did not, so `.max()`
drops by exactly one while 93 objects disappear. It could as easily have dropped by nothing
or by fifty; the value is whatever the highest surviving label happens to be.

Every other block on this page reports `int(labels.max())`, which is correct only while the
labels are consecutive — they are, straight out of Cellpose, and they are not after this.
Follow that convention past `clear_border` and you overcount by 92 cells, 7%, in the
direction that hides the border loss you just performed. That is the fraction you must recover from neighbouring tiles,
and it is why the overlap has to be real rather than nominal: with no overlap you would
simply have lost them.

Choose the overlap from the largest cell you expect — two cell diameters is a safe default —
and keep a global offset so coordinates stay in slide space rather than tile space.

## Measure inside the masks, on the original data

```python
import pandas as pd
from skimage.measure import regionprops_table

markers = [n for n in channels if n not in ("DAPI", "Autofluorescence")]

props = regionprops_table(labels, properties=("label", "centroid", "area"))
X = np.column_stack([
    regionprops_table(labels, intensity_image=crop[index_of[m]],
                      properties=("intensity_mean",))["intensity_mean"]
    for m in markers
])
```

`intensity_image` must be the same array the labels were derived from — `crop`, not
`stack`. Pass the uncropped plane and skimage raises `ValueError: Label and intensity image
shapes must match`, which at least fails loudly; pass a *differently* cropped plane of the
same shape and it does not fail at all, and every intensity is measured in the wrong place.

**Exclude the autofluorescence component and the nuclear stain from the marker matrix.**
Autofluorescence is an unmixing artefact, not an antibody; DAPI is present on every cell
by construction. Both behave like high-variance markers in a clustering run and both will
drive clusters that mean nothing.

**Mean, not sum.** Total intensity scales with cell area, so a summed matrix clusters
primarily on size. If you want the size, carry it as a column in `obs`, where it is a
covariate you can look at rather than a hidden component of every marker.

**Convert to micrometres at this point**, using the pixel size from the file. Areas in
pixels are not comparable to anything from another instrument, and the coordinates feed
distance-based statistics downstream where the units matter.

```python
import anndata as ad

a = ad.AnnData(
    X.astype(np.float32),
    obs=pd.DataFrame({"area_um2": props["area"] * um_px**2},
                     index=[f"cell_{i}" for i in props["label"]]),
    var=pd.DataFrame(index=[m.split(" (")[0] for m in markers]),
)
a.obsm["spatial"] = np.column_stack([props["centroid-1"],      # x = column
                                     props["centroid-0"]]) * um_px   # y = row
a.obs["image_id"] = pd.Categorical(["LuCa-7color-field-1"] * a.n_obs)
a.layers["raw"] = a.X.copy()
```

`regionprops` returns `centroid-0` as the **row** and `centroid-1` as the **column**, while
`obsm["spatial"]` is conventionally `(x, y)`. Swapping them transposes your tissue, which
leaves every neighbourhood statistic valid-looking and wrong.

Set `image_id` even for a single field — the spatial tools need it the moment a second
image arrives, and adding it later means rewriting the object. **Make it a `Categorical`,
not a string.** `a.obs["image_id"] = "field-1"` produces a string column and squidpy
refuses it later with `TypeError: Expected adata.obs['image_id'] to be categorical, found
string` — several steps after the mistake, in a function that has nothing to do with it.

Sanity-check the areas against the biology before going further. A median of 47.7 um^2 on
this field is a small nucleus-plus-rim, which is what a DAPI+CK segmentation of dense
carcinoma should give. A median in the hundreds means the masks have merged cells.

## Cluster, then confirm the cell types by hand

Transform, cluster, and then — this is the step that matters — **look at the marker means
before naming anything**.

```python
import scanpy as sc

a.X = np.arcsinh(a.X / 5.0)
sc.pp.scale(a, max_value=10)
sc.pp.neighbors(a, n_neighbors=15, random_state=0)
sc.tl.leiden(a, resolution=0.5, key_added="cluster",
             flavor="igraph", n_iterations=2, random_state=0)

means = (pd.DataFrame(np.asarray(a.layers["raw"]), index=a.obs_names, columns=a.var_names)
           .groupby(a.obs["cluster"].values, observed=True).mean())
print(means.round(2).to_string())
```

On the Vectra field, resolution 0.5 gives 11 or 12 clusters depending on the run, and the
table contains rows like these — quoted by what they contain rather than by cluster number,
because neither the numbering nor the count is stable:

```
    PDL1   CD8  FoxP3  CD68    PD1    CK
    1.12  0.14   0.19  0.11   3.03   6.76      <- CK-high: tumour
    1.14  0.13   0.20  0.09   3.05   9.14      <- CK-high: tumour, indistinguishable from the row above
    4.60  0.11   0.17  0.50  19.44   0.72      <- PD1-high
    3.90  3.75   0.37  0.24   5.81   1.13      <- CD8-high: cytotoxic T cell
    4.66  0.13   2.75  0.54  10.46   0.60      <- FoxP3-high: regulatory T cell
```

**Read that table as one run, not as the answer.** Because the segmentation upstream is not
reproducible, nothing downstream of it is either. Across three runs of this same pipeline —
two on MPS and one on CPU — the outcome moved like this:

| | clusters | PDL1 | CD8 | FoxP3 | CD68 | PD1 | CK |
|---|---|---|---|---|---|---|---|
| spread across three runs | 11-12 | 9.42-10.08 | 3.71-3.75 | 2.75-3.29 | 1.21-1.30 | 19.44-19.45 | 9.14-9.27 |

What survives is what you should be willing to report: CD8 separates into one cluster at
roughly fifteen times its baseline elsewhere, FoxP3 separates into one, PD1 is the brightest
channel in the panel, and CD68 never rises above about 1.3 in any cluster in any run. What
does not survive is the cluster count, the ordering of the ids, and the second decimal place
of any mean. Quote the first kind; date and version-stamp the second.

Three things that table tells you and the clustering does not.

**Some clusters are namable and some are not.** The CD8-high and FoxP3-high rows have one
marker an order of magnitude above every other cluster. The two CK-high rows differ in
nothing you can name. Leave the second kind unnamed. `cluster_6` in a results table is
honest; `Tumour subtype B` is a claim nobody made.

**A marker in the panel is not a cell type in the data.** CD68's highest cluster mean here
is 1.21 in this run and never above 1.30 in any, against a 99th percentile of **per-cell
mean** CD68 intensity of 1.65 across all
1,334 cells — the dimmest channel in the panel. (Compare like with like here: the 99th
percentile of raw CD68 *pixels* in the same crop is 2.7. A cluster mean is an average of
per-cell averages, so it is only ever comparable to the per-cell distribution.) No
macrophage population separates at resolution 0.2, 0.5 or 1.0. That is a real result about
this field and this panel, and it must be reported rather than papered over.

**Never take the argmax.** Every cluster has a highest marker whether or not any marker is
elevated, so `means.idxmax(axis=1)` returns a full set of confident labels from a table
that supports two of them. It is the single fastest way to publish invented cell types.

Then write the annotation as an explicit, auditable map — and surface it for a person to
confirm, because on this data it is a proposal, not a measurement:

```python
# Resolve ids from the marker table, never by typing them in — they move between runs.
naming = {means["CD8"].idxmax(): "CD8 T cell",
          means["FoxP3"].idxmax(): "Treg"}
for cid in means.index[means["CK"] > 5]:            # the CK-high compartment
    naming.setdefault(cid, "Tumour")

a.obs["cell_type"] = (a.obs["cluster"]
    .map(naming).astype("object").fillna("unassigned").astype("category"))
print(naming)
print(a.obs.cell_type.value_counts().to_dict())
```

Writing `{"0": "Tumour", "3": "CD8 T cell"}` by hand is the obvious thing to do and it is
wrong on this data for the reason the section above gives: cluster numbering is not stable
between runs, so a map typed from one run silently mislabels the next. Derive the ids from
the marker table and the thresholds you chose, and print the map so the run records which
ids it actually used.

## Neighborhoods, and where this hands off

With cell types on the object, the first spatial question is which types sit together:

```python
import squidpy as sq

sq.gr.spatial_neighbors(a, library_key="image_id", coord_type="generic", delaunay=True)
sq.gr.nhood_enrichment(a, cluster_key="cell_type", seed=0, show_progress_bar=False)
```

Pass `library_key` from the start. On one image it changes nothing; the moment the object
holds two, omitting it builds edges between cells on different slides, and the result stays
plausible. The `spatial-phenotyping` skill covers that failure, the permutation nulls,
Ripley's L, cellular neighborhoods, and the aggregation to patients that any group
comparison needs.

## What to write into the project

```python
a.write_h5ad("cells.h5ad")                       # the object everything downstream reads
tifffile.imwrite("labels.tif", labels)           # the mask, so measurements are re-derivable
means.round(3).to_csv("cluster_marker_means.csv")
```

Plus a written summary — not comments in a notebook — recording which channels were used
for segmentation and why, the pixel size and where it came from, the arcsinh cofactor, the
Leiden resolution, which clusters were named and on what marker evidence, and which were
left unassigned. Every one of those is a decision that changes the cell counts, and none of
them is recoverable from the `.h5ad`.

## Try it

**Data.** The PerkinElmer Vectra 7-colour lung carcinoma component field, `LuCa-7color`,
from the Open Microscopy public sample set — 8 unmixed component layers including DAPI,
CD8, FoxP3, CD68, PD1, PD-L1 and pan-cytokeratin. Released by PerkinElmer under
**CC-BY-4.0** (see `COPYING` beside the file), 85 MB, no account. Confirmed reachable on
25 Aug 2026. It is a Vectra field rather than a PhenoCycler slide, which makes it small
enough to run in a minute while carrying the same structure: named channels, a nuclear
stain that is not channel 0, a real membrane marker, and a per-page pixel size.

**Run.** Cellpose downloads its weights on first use, from a public host, without an
account. About a minute on a GPU or Apple MPS, a few minutes on CPU.

```bash
pip install cellpose tifffile imagecodecs scikit-image anndata scanpy squidpy igraph
curl -L -o LuCa.tif \
  "https://downloads.openmicroscopy.org/images/Vectra-QPTIFF/perkinelmer/PKI_fields/LuCa-7color_%5B13860%2C52919%5D_1x1component_data.tif"
```

```python
import xml.etree.ElementTree as ET
import numpy as np, pandas as pd, tifffile, anndata as ad, scanpy as sc
from cellpose import models
from skimage.measure import regionprops_table

with tifffile.TiffFile("LuCa.tif") 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]
    stack = tf.series[0].asarray()
    num, den = tf.pages[0].tags["XResolution"].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)}
markers = [n for n in channels if n not in ("DAPI", "Autofluorescence")]
print("channels", channels)
print("DAPI index", index_of["DAPI"], "| um/pixel", round(um_px, 4), "| markers", len(markers))

crop = stack[:, :700, :700]
model = models.CellposeModel(gpu=True)
nuc_only = model.eval(crop[index_of["DAPI"]], batch_size=8)[0]
labels = model.eval(np.stack([crop[index_of["DAPI"]], crop[index_of["CK (Opal 690)"]]]),
                    channel_axis=0, batch_size=8)[0]
print("objects: DAPI alone", int(nuc_only.max()), "| DAPI+CK", int(labels.max()))

props = regionprops_table(labels, properties=("label", "centroid", "area"))
X = np.column_stack([
    regionprops_table(labels, intensity_image=crop[index_of[m]],
                      properties=("intensity_mean",))["intensity_mean"]
    for m in markers])

a = ad.AnnData(X.astype(np.float32),
               obs=pd.DataFrame({"area_um2": props["area"] * um_px**2},
                                index=[f"cell_{i}" for i in props["label"]]),
               var=pd.DataFrame(index=[m.split(" (")[0] for m in markers]))
a.obsm["spatial"] = np.column_stack([props["centroid-1"], props["centroid-0"]]) * um_px
a.obs["image_id"] = pd.Categorical(["LuCa-7color-field-1"] * a.n_obs)
a.layers["raw"] = a.X.copy()
print("AnnData", a.shape, "| median area um2", round(float(np.median(a.obs.area_um2)), 1))

a.X = np.arcsinh(a.X / 5.0)
sc.pp.scale(a, max_value=10)
sc.pp.neighbors(a, n_neighbors=15, random_state=0)
sc.tl.leiden(a, resolution=0.5, key_added="cluster",
             flavor="igraph", n_iterations=2, random_state=0)
means = (pd.DataFrame(np.asarray(a.layers["raw"]), index=a.obs_names, columns=a.var_names)
           .groupby(a.obs["cluster"].values, observed=True).mean())
print("clusters", a.obs.cluster.nunique())
print(means.round(2).to_string())
print("highest cluster mean per marker:", means.max().round(2).to_dict())

# DAPI is not channel 0, and the marker matrix excludes it and autofluorescence.
assert index_of["DAPI"] != 0
assert "DAPI" not in list(a.var_names) and "Autofluorescence" not in list(a.var_names)
assert a.n_vars == len(channels) - 2
# One row per label, and labels are 1..N with no gaps.
assert a.n_obs == int(labels.max()) == len(set(props["label"]))
# Coordinates are inside the crop, in micrometres.
assert a.obsm["spatial"].min() >= 0
assert a.obsm["spatial"].max() <= 700 * um_px
# CD8 separates into a cluster; CD68 does not rise above the noise anywhere.
assert means["CD8"].max() > 5 * means["CD8"].median()
assert means["CD68"].max() < means["CD8"].max()
print("OK")
```

**Expect.**

Invariants — a failure means the skill is wrong:

- `index_of["DAPI"]` is not 0, so any positional segmentation-channel choice is wrong on
  this file.
- The marker matrix has exactly `len(channels) - 2` columns: DAPI and Autofluorescence are
  excluded by name, not by position.
- `a.n_obs` equals `labels.max()` — Cellpose labels are consecutive from 1, so one row per
  object with no gaps. A mismatch means objects were dropped between segmentation and
  measurement.
- Coordinates lie within the crop, in micrometres: `0 <= spatial <= 700 * um_px`. A
  transposed or unconverted coordinate breaks this.
- `centroid-0` is the row and `centroid-1` is the column, so `obsm["spatial"]` takes them
  in the order `(centroid-1, centroid-0)`.
- The CD8 cluster mean is several times the median cluster mean, while CD68's peak stays
  below CD8's — the panel contains a marker that does not resolve, which is the case the
  annotation step has to survive.

Observed values, from a run on 25 Aug 2026 against cellpose 4.2.1.1 on Apple MPS — these
move with the Cellpose model version, with the device, and, per the determinism section
above, between a process's first segmentation and its later ones:

- 8 channels; DAPI at index 6; 0.498 um/pixel; 6 markers after exclusions.
- 700x700 crop: **1424** objects from DAPI alone, **1334** from DAPI + CK. On CPU the
  DAPI + CK count is 1335.
- Median cell area 47.7 um^2.
- Leiden at 0.5: 11 clusters in this run. Highest cluster mean per marker — PDL1 9.42,
  CD8 3.75, FoxP3 2.75, CD68 1.21, PD1 19.44, CK 9.14. Across three runs the count was 11
  or 12 and each mean moved in the second significant figure, to the spread tabulated
  above, while the ordering never changed.
- One CD8-high cluster at 3.75 against 0.11-0.22 elsewhere; one FoxP3-high cluster; three
  CK-high clusters that are mutually indistinguishable. Cluster *numbers* are not stable
  between runs — match clusters by their marker profile, not by id.

**Across other inputs.** The channel-resolution, measurement and AnnData-assembly code was
re-run on the two H&E component files in the same sample directory, whose channels are
`['Eosin', 'Hematoxylin']` with no DAPI at all. It fails at exactly the right place — the
`index_of["DAPI"]` lookup raises `KeyError` — rather than silently segmenting on
Hematoxylin. That is the behaviour to preserve when you adapt this: resolve segmentation
channels by name and let a missing one raise, because the alternative is a full cell table
built on the wrong compartment.
