← All skills

cross-modal-registration

analysis

Bring a fluorescence, CODEX or IHC section into the same coordinate frame as its H&E image using ACCREDIT — building one representation both modalities share, searching orientation and scale before refining, and reading the reference-free quality score honestly enough to separate a good registration from a wrong one that scores well.

Putting two modalities in one coordinate frame

A CODEX panel and the H&E section beside it are two pictures of the same tissue in two coordinate systems. Every downstream claim — this marker is in that morphological compartment, this cell is inside that annotated region — is a claim about the map between them. Getting that map wrong does not produce an error. It produces a table.

That is what makes registration different from the steps around it. A segmentation that fails looks like nothing; a registration that fails looks like a slightly different answer. So the work splits in two, and the second half is the harder one: compute a transform, then prove it is the right transform without a ground truth to compare against.

ACCREDIT (Zhou, Zhao, Ren et al., OHSU) is the framework this page works through. It is MIT-licensed, runs six modality-specific pipelines behind one entry point — plus a senior variant for hard partial-overlap samples — scores every result with a reference-free composite, and escalates through a fixed ladder of recovery strategies when the score is low. The scoring is the interesting part and it is also where the traps are; the sections below are mostly about reading those numbers correctly.

This page is about the transform and the score. Getting the pixels and marker names out of a multiplex file is the multiplex-imaging-io skill; turning aligned pixels into a cell-by-marker table is codex-phenocycler. Both of those assume the frames already agree, which is what this page delivers.

What you have to obtain first

Nothing on the fluorescence-to-H&E path needs an account, a token or a GPU:

pip install numpy opencv-python tifffile imagecodecs SimpleITK scikit-image
git clone --depth 1 https://github.com/LeeZhou-bearway/ACCREDIT.git

imagecodecs is not in the project's own install line and you will need it. The H&E scans in the benchmark dataset used below are JPEG-compressed OME-TIFFs, and without it the pipeline's very first read fails:

ValueError: <COMPRESSION.JPEG: 7> requires the 'imagecodecs' package

openslide-python is needed only for the brightfield whole-slide path, which reads SVS through OpenSlide. The DAPI, CODEX and general-fluorescence pipelines import numpy, OpenCV, tifffile, skimage.color and SimpleITK and nothing else.

The H&E-to-IHC path additionally needs torch and LightGlue, and one of its two pretrained models is non-commercial. The project states this itself, in pipelines/he_ihc/METHODS.md, alongside the substitution:

| SuperPoint weights | MagicLeap research-only | No — use LightGlue's alternative | | LightGlue weights | Apache-2.0 | Yes |

For commercial deployment, replace SuperPoint with LightGlue's DISK backend (Apache-2.0) or use ALIKED (MIT).

So the restriction is on the feature extractor in one of six pipelines, not on the framework. Read it as three routes. Fluorescence to H&E — DAPI, CODEX, Xenium boundaries, spatial transcriptomics — never loads SuperPoint and is unrestricted. H&E to IHC as shipped loads SuperPoint and is academic-research-only. H&E to IHC with DISK or ALIKED substituted for the extractor is unrestricted and is the one to take for commercial work. The same file marks its reimplementation of the DeeperHistReg non-rigid core as "Check DHR repo (likely GPL-style) — Requires checking", which is upstream telling you the HE-IHC path has one more licence question open than the fluorescence path does. That is a second reason to lead with fluorescence where the science allows.

The rescue agent needs a metered API key that is billed separately from any subscription to a chat product. The project's README says an existing subscription covers it. It does not — API access is its own billed product, and a seat on a chat plan carries no API credit. Budget for per-token cost before you turn the rescue on. Set ANTHROPIC_API_KEY in the environment, or pass --api-key, which writes the same variable. You also need the anthropic package, which the top-level install line omits and which only pipelines/he_ihc/README.md mentions. Nothing about the deterministic pipelines requires any of this — the rescue is an optional branch and the sections below say exactly when it fires.

One representation both modalities can be compared in

Registration algorithms compare intensities. H&E is dark tissue on a light field; fluorescence is bright signal on a dark field. Nothing aligns until both are the same way up.

Two conversions, and they are not interchangeable:

import cv2, numpy as np
from skimage.color import rgb2hed

def he_inverted(rgb):
    """Whole-tissue proxy — everything stained shows up."""
    return (255 - cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)).astype(np.uint8)

def he_hematoxylin(rgb):
    """Nuclear proxy — comparable to a DAPI channel. Normalise on percentiles."""
    h = rgb2hed(rgb.astype(np.float64) / 255.0)[:, :, 0]
    lo, hi = np.percentile(h, [1, 99])
    return (np.clip((h - lo) / (hi - lo + 1e-8), 0, 1) * 255).astype(np.uint8)

Inverted grayscale is a proxy for total optical density and responds to both stains; the hematoxylin channel isolates the nuclear component, so it is the side to compare a nuclear stain against. Measured on the pair in ## Try it, at the transform that block recovers, the effect is real and small:

moving channel NCC vs inverted grayscale NCC vs hematoxylin
fluorescence red 0.2546 0.2059
fluorescence green 0.2984 0.3060
fluorescence blue 0.3074 0.3150

The nuclear-looking channels prefer hematoxylin and the non-nuclear one prefers inverted grayscale, in the direction the mechanism predicts, by a few hundredths. Treat it as a tie-breaker once a registration is working, not as the thing that makes one work.

Normalise the hematoxylin channel on percentiles, not on min and max. ACCREDIT contains both — registration/preprocess.py uses the 1st and 99th percentiles, and run_fluo2he.he_to_h_channel uses h.min() and h.max(). On the brightfield thumbnail in ## Try it the two produce images with means of 42.1 and 3.4 out of 255; the min-max version puts 0.0% of pixels above intensity 32 against 35.4% for the percentile version, because one extreme pixel sets the whole range. It survives inside register_orb only because an Otsu threshold immediately follows it and recovers most of the mask (16.4% of the frame against 18.7%). Anywhere the H channel is used as an intensity rather than as input to a threshold, the min-max form is a bug waiting for a dust particle.

The fluorescence side needs the same treatment for the same reason — stretch between percentiles of the non-zero pixels, because a fluorescence frame is mostly true zeros and including them puts the 1st percentile at 0:

def stretch(plane):
    f = plane.astype(np.float32)
    pos = f[f > 0]
    lo, hi = np.percentile(pos, [1, 99])
    return np.clip((f - lo) / (hi - lo + 1e-10) * 255, 0, 255).astype(np.uint8)

Tissue masks come from Otsu on those representations, closed and dilated so that a villus lumen or a necrotic core does not read as background:

def tissue_mask(gray):
    _, m = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
    m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, k, iterations=2)
    return cv2.morphologyEx(m, cv2.MORPH_DILATE, k, iterations=1)

The structuring element is 15 pixels at whatever resolution you hand it. ACCREDIT builds the fixed and moving masks with this same kernel at two different working scales, so the dilation is not the same physical distance on both. Resize both images to a common working size before masking, or the masks disagree by construction.

Never let it pick the modality for you

run_unified_agent.py detects the modality from file content and then, in single-pair mode, blocks on input() waiting for confirmation. An unattended agent hangs there forever. Pass -y, or use batch mode, which is non-interactive by construction.

More importantly, pass --mode explicitly, because the detector misroutes both of the commonest real inputs. Its fluorescence branch decides between the single-channel DAPI pipeline and the CODEX pipeline on len(tifffile.TiffFile(path).pages) > 5 — a page count, not a channel count. That distinction is the subject of the multiplex-imaging-io skill; here is what it costs. Four files, one detector:

file pages real channels detected
one DAPI plane, plain 2-D grayscale TIFF 1 1 AxisError — crashes
3 fluorescence channels written as a 6-page pyramid or z-stack 6 3 codex
a 40-marker CODEX panel written as one 3-D page 1 40 dapi
brightfield H&E RGB named block_2.tif 1 3 he-ihc

Three of those four are wrong and the first does not survive at all. The crash is AxisError: axis 2 is out of bounds for array of dimension 2, raised inside the thumbnail loader on any 2-D image — which is the ordinary shape of a single-plane fluorescence export.

The last row is a substring match. ck is in the detector's IHC filename keyword list, for cytokeratin, and it is tested with in, so it matches inside block, stack, check and back. Rename that file section_2.tif and the same pixels are detected as he-he. Filename-driven pipeline selection is fine when the filenames were designed for it and catastrophic when they were not.

--mode takes boundary, dapi, codex, he-ihc, he-he, st-he or senior. Choose it from what you know about the acquisition, and let detection be a cross-check you read rather than a decision you delegate.

The transform ladder

Cross-modal pairs from adjacent sections or different instruments differ in orientation, scale, translation and — after that — in local deformation. Solve them in that order, because a refinement step cannot recover an orientation error.

Orientation and scale first, by search. There is no gradient to descend from a 180° error. ACCREDIT's DAPI pipeline enumerates 4 flips × 4 rotations × 6 scale ratios and keeps the best; the CODEX pipeline searches channels instead, scoring each one by cv2.matchTemplate with TM_CCOEFF_NORMED against the inverted H&E and registering the winner. Either way, the outer loop is exhaustive and the inner one is not.

Check where in the grid the winner landed. Those six ratios are literally [0.5, 0.7, 0.8, 1.0, 1.2, 1.5], applied after both images are scaled to their own working targets. The fine similarity fit that follows can move off the winning ratio, so the grid is not a hard bound — but it is the starting point for a local optimisation, and a winner sitting on the edge of the range means the optimum may be outside it. On the pair in ## Try it the pipeline's grid bottoms out at 0.5, the fine step settles at a full-resolution scale of 0.526, and an exhaustive full-resolution search finds 0.470 with 2.4× the structural correlation — 0.3151 against 0.1289. Nothing in the output says the winner was on a boundary. Compute the expected ratio from the two pixel sizes before you start, and pre-scale one image if it lands near or outside the ends of the range.

Translation by phase correlation, per candidate. cv2.phaseCorrelate on the two masks gives a translation in one FFT, so the search grid only has to cover scale and rotation.

Then a similarity or affine fit. ACCREDIT branches on whether the two images are within 20% of each other in size — ORB features on Otsu-binarised masks if so, SimpleITK Similarity2DTransform with Mattes mutual information if not.

That branch is worth knowing about, because the quantity it tests is not the quantity it is named after. res_ratio is the mean of the two per-axis pixel-dimension ratios, and the variable it sets is called same_resolution. On the pair in ## Try it the width ratio is 1.061 and the height ratio is 0.809 — a badly mismatched pair whose mean is 0.935, inside the ORB window. Two images at the same micrometres-per-pixel but different crops fail the test; two images at wildly different micrometres-per-pixel but similar pixel counts pass it. Neither image's actual pixel size is consulted. Read it off the file yourself.

Refine last, and check what the refinement actually applied. ecc_refine runs cv2.findTransformECC with MOTION_EUCLIDEAN, which estimates a rotation and a translation — and then adds only the two translation entries back into the transform. Feed it a pair differing by a 4° rotation and the linear part comes back untouched:

before: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
after : [[1.0, 0.0, -15.8562], [0.0, 1.0, 30.2891]]

That is a translation standing in for a rotation. It pulls the tissue centroid together and pushes the periphery further apart, and the tissue-overlap metrics reward it. Only accept a refinement that improved a metric sensitive to internal structure — see below.

The twelve numbers, and what each one is actually measuring

compute_quality_metrics_v2 returns twelve keys. Five of them, weighted, form the composite quality score (QCS); two more become multipliers on it; four are computed and never read; the twelfth is the composite itself.

key what the name suggests what it computes in the composite
tissue_dice the tissue overlaps Dice of the two Otsu masks weight 0.30
boundary_dice the outlines agree Dice restricted to a ±8 px band around either outline weight 0.30
hausdorff_mean Hausdorff distance mean symmetric nearest-neighbour distance between the two largest contours weight 0.15, as 1 − hd/HD_MAX
hausdorff_95 95th-percentile Hausdorff 95th percentile of the same distance set unused
inlier_ratio the images match fraction of ORB matches kept by a freshly estimated RANSAC affine weight 0.10, saturating at 0.15
n_inliers, n_matches counts counts unused
ncc the pixels agree Pearson correlation inside the overlap unused in the default composite
nmi mutual information (H(a)+H(b))/H(a,b) over pixels where both exceed zero weight 0.15, saturating at 1.15
coverage how much tissue is covered warped mask area ÷ total frame pixels multiplier min(1, cov/0.20)
bg_coverage signal landing on background warped mask ∩ H&E background ÷ H&E background multiplier 1 − bg
qcs the composite the weighted sum × both multipliers

eval_px is a free parameter that moves the score, and it does not resize anything. It scales HD_MAX — 500 px at the default 4000 — and nothing else, so it is a promise about what resolution you warped at rather than an instruction. Pass the wrong one and the Hausdorff term silently changes. The same transform on the same pixels from ## Try it, whose images are 848 px on the long side:

eval_px HD_MAX Hausdorff sub-score QCS
512 64 0.0000 0.5250
848, the true long side 106 0.3201 0.5729
4000, the default 500 0.8559 0.6531

A 0.13 swing on identical pixels, which is more than twice the gap between the pipeline's "accept this" threshold and its "call the rescue agent" threshold.

The DAPI pipeline does not pass its own working size. It builds its masks at WORK_SIZE = 2000 on the long side and calls the scorer with eval_px=4000, so HD_MAX is twice what it should be and the Hausdorff term is systematically over-generous. On the run in ## Try it that is not academic — the pipeline's own metrics rescored at 2000 give:

eval_px QCS
4000, as the pipeline passes 0.6177
2000, its actual working size 0.5893

QCS_DONE is 0.60, so its own result moves from accepted to below threshold. Set eval_px to the long side you actually evaluated at, and do not compare a published HE-IHC score against a published DAPI one.

Four places the number is not what its name says

Each of these is reproduced by the Tier 0 block in ## Try it, which needs no download.

A perfect boundary match is scored as the worst possible one. The composite reads the Hausdorff term as metrics.get('hausdorff_mean', HD_MAX) or HD_MAX. Python's or treats 0.0 as absent, so the one value that means the contours coincide exactly is replaced by the value that means they could not be further apart. Holding everything else fixed:

hausdorff_mean QCS
0.0 0.75
0.005 0.90
1.0 0.8977
500.0 0.75

The better of the two — the one whose contours coincide exactly — scores 0.15 lower than one that misses by a hundredth of a pixel. hausdorff_mean is rounded to two decimals before this, so anything under 0.005 px trips it, which in practice means an identity transform. That is exactly the self-registration sanity check people run first, and it reports 0.75 where the arithmetic says 0.90.

Only the largest contour of each mask is measured. Both Hausdorff values come from max(contours, key=len) on each mask, so a specimen with two tissue fragments is scored on one of them. Move the smaller fragment 340 px and leave the larger one identical: tissue_dice correctly drops to 0.9120 while hausdorff_mean and hausdorff_95 both report 0.0 — a perfect boundary match, which then hits the falsy-zero above and is scored as the worst one. Two defects in series, and the visible symptom is a good registration losing 0.15.

The inlier ratio does not measure your transform. orb_inlier_ratio detects ORB features in both images and runs cv2.estimateAffinePartial2D with RANSAC — estimating a new transform from scratch and reporting what fraction of matches it keeps. RANSAC absorbs whatever translation is present, so the number barely moves as you displace the result. Measured across the twelve shifted and rotated variants the ## Try it block scores — every shift from 0 to 160 px and every rotation up to 180° — it ranges from 0.326 to 0.565, and because it saturates at 0.15 its sub-score is 1.000 for all twelve. A tenth of the composite is a constant. On smooth synthetic images ORB finds no matches at all and it is a constant 0.000 instead. Neither constant is a measurement.

ncc is computed and then discarded. The module docstring calls normalized cross-correlation "most discriminating metric" and the default composite does not read it. Set it to −1.0, 0.0 or 1.0 with everything else fixed and the QCS is 0.6467 all three times. It is used only by composite_quality_score_cycif, the full-field-of-view variant, at weight 0.20 and normalised by a ceiling of 0.40 — so even there, the NCC of 0.3151 that the correct registration below achieves contributes 0.158 of 1.00, and an NCC of 0.11 contributes 0.055. If you want NCC to gate anything, read it directly.

The finding that matters — a silhouette is not an alignment

Four of the five weighted terms are computed on binary tissue masks. Masks describe the outline. Two outlines can agree while the structure inside them is displaced by tens of micrometres, which is the whole failure mode a cell-resolution claim depends on excluding.

Measured on the brightfield-versus-fluorescence pair in ## Try it — same section, two modalities, the fluorescence covering part of the brightfield field at a different scale. Three transforms, all scored at eval_px = 848, the images' true long side:

transform scale tissue_dice ncc QCS what it looks like
the grid point with the best tissue_dice 0.860 0.8283 −0.0035 0.5620 structure visibly offset from structure
the grid point with the best ncc 0.470 0.5925 0.3151 0.5729 every crypt on its own crypt
ACCREDIT's dapi pipeline, 597 s 0.526 0.6760 0.1289 0.5951 correct, slightly loose

Two things to take from that table. The metric carrying the single largest weight ranks the visibly wrong transform first, by 40%. And the composite puts all three inside a band of 0.033, ordering them in a way uncorrelated with structural agreement — it correctly puts the worst last, and it inverts the top two, preferring the transform whose NCC is less than half the other's. NCC spans −0.0035 to 0.3151 over the same three.

Which grid point comes out on top depends on the search bounds, and so does the composite's ordering of the first two rows; narrow the grid and the wrong transform scores higher instead. The 2-to-3% margin is the finding. The ranking is not stable enough to be one.

The mechanism is not subtle once stated. The moving image covers only part of the fixed one, so Dice compares a small warped mask against the whole fixed tissue mask and every uncovered pixel counts against it. Scaling the moving image up covers more fixed tissue and raises Dice, while destroying the internal correspondence. Dice rewards covering more tissue. Correct registration does not always cover more tissue. ACCREDIT's coverage multiplier is aimed at the opposite failure — a warp that collapses — and does nothing about this one.

That mechanism also says where the problem stops, and the sweep at the end of ## Try it confirms it: crop the fixed image to the region the moving one covers, so both describe the same tissue, and tissue_dice on the same transform goes from 0.5925 to 0.9725 and the composite becomes decisive. The composite is valid on full-field pairs and unreliable on partial-field ones, and nothing in its output tells you which you have — which is what the null control below is for.

What the composite does do is separate gross failure from success. Displace the correct transform and it falls monotonically:

shift 0 px 5 px 10 px 20 px 40 px 80 px 160 px
QCS 0.5729 0.5317 0.4998 0.4608 0.4073 0.3236 0.1909
ncc 0.3151 0.1213 0.0391 −0.0494 −0.0280 0.0168 −0.0280

So it works as a catastrophe detector and not as a ranking. Notice where the pipeline's own thresholds sit against that table: QCS_DONE is 0.60, the rescue gate is 0.65 and the give-up threshold is 0.25. A 10-pixel displacement costs 0.073 — larger than the entire gap between "accept this" and "call the rescue agent". The operating thresholds are inside the noise band.

The null control — the cheapest honest test on this page

Score the registration you obtained. Then score the same registration with the moving image rotated 180°, and compare.

M180 = (np.vstack([M, [0, 0, 1]])
        @ np.vstack([cv2.getRotationMatrix2D((w / 2, h / 2), 180, 1.0), [0, 0, 1]]))[:2]

A 180° flip is the largest orientation error possible and the commonest gross failure in this domain. Whatever metric you are gating on must separate the answer from it by a wide margin. On the ## Try it pair:

correct rotated 180° ratio
QCS 0.5729 0.4031 1.4×
ncc 0.3151 0.0255 12×
tissue_dice 0.5925 0.4754 1.2×

A compact specimen overlaps itself under rotation, so silhouette metrics cannot see orientation at all. Run this control on your pair before you trust any threshold on it. If your score ranks the answer within 1.5× of a 180° flip, it is not measuring alignment on your data and it must not be the gate. On a full-field version of the same pair the composite clears that bar by 90×, so this is a question about your framing rather than about the tool, and one warp answers it.

What to gate on instead

Read ncc on the overlap directly and require it to clear a floor you set from the null control. Report the composite alongside as context, not as the decision. The overlap mask is one line and the call is already in the library:

from quality_metrics import ncc, compute_quality_metrics_v2

overlap = ((fixed_mask > 0) & (warped_mask > 0)).astype(np.uint8) * 255
structure = ncc(fixed_inverted, warped_moving, mask=overlap)
metrics = compute_quality_metrics_v2(fixed_mask, warped_mask, fixed_inverted, warped_moving,
                                     eval_px=max(fixed_mask.shape))

Constrain the overlap when you search on NCC, or the optimiser will find a tiny high-correlation patch and stop. Requiring the overlap to be at least half the warped tissue's own area is enough, and it is what the ## Try it search does.

Two honest limits on this recommendation. NCC assumes a monotone intensity relationship between the modalities, which cross-modal pairs only approximately have — a realistic good value here is 0.32, not 0.9, and the floor has to be calibrated per pair rather than carried over. And nmi is the more principled cross-modal choice, except that the composite normalises it as (nmi − 1.0)/0.15 and saturates at 1.15, so anything genuinely informative is clipped to the same number; if you use NMI, use the raw value, not the sub-score.

The CODEX pipeline mis-scales the transform it scores

Verified against commit 9293473 on 2026-08-29, and worth re-checking before you read any CODEX QCS.

pipelines/codex/run_codex_pipeline.py holds two functions that convert a full-resolution transform down to the working resolution. _save_overlay multiplies the linear part by he_scale / fl_scale. _compute_qcs, in the same file, multiplies it by fl_scale / he_scale. They are reciprocals, so they cannot both be right, and the derivation says the overlay is: a point at he_scale · (A · p + t) in the working frame is (he_scale / fl_scale) · A · p_work + he_scale · t.

The consequence only appears when the H&E and the CODEX image differ in pixel dimensions, which is the normal case. Build a pair that is exactly a 2× rescaling of itself — a 6000 × 4500 H&E and a 3000 × 2250 CODEX — so the true transform is known to be [[2,0,0],[0,2,0]], and warp the nuclear mask both ways:

linear factor applied mask Dice against the H&E
he_scale / fl_scale = 0.667, as _save_overlay uses 0.9609
fl_scale / he_scale = 1.500, as _compute_qcs uses 0.0822

Handed the exact correct transform, _compute_qcs returns QCS 0.1972. The same call with the ratio cancelled returns 0.5163. All six QCS evaluations in the CODEX decision cascade go through that function, and every threshold in it — 0.60 to proceed, 0.55, 0.35, 0.25 to give up — is read from that number.

On real data it changes the verdict, and the pipeline's own log shows it. Tier 2 of ## Try it runs the shipped CODEX pipeline on a 43-channel GBM panel against its name-matched H&E. At step 4 the region-of-interest module registers the pair, scores its own warp in its own frame, and prints:

  QCS=0.842  td=0.874  bd=0.874  ncc=0.555  hd=17.4
  Final dice: 0.8739  QCS: 0.8416
  ROI: QCS=0.2209

Two lines apart, one registration, 0.8416 and 0.2209. The first is the module scoring the warp it actually performed; the second is the parent re-scoring the same transform through _compute_qcs. The pipeline keeps the second, and four steps later prints STEP 6: All steps failed (QCS=0.2209 < 0.25) and writes a user_guidance.txt telling the reader to check orientation and scale by hand.

Putting the same transform back through the same function with the ratio cancelled gives QCS 0.6908, tissue_dice 0.9013, ncc 0.4475 — above the pipeline's own 0.60 acceptance threshold — and the overlay it wrote shows the CODEX signal sitting on the H&E tissue. The ROI module's own 0.8416 is itself inflated, by the eval_px mismatch described above, so 0.6908 is the number to believe. Both are more than three times the verdict the pipeline recorded.

Three practical consequences. Check whether it bites you: the applied factor is (fl_scale / he_scale)² times the correct one, where each scale is min(1, 4000 / long side). That is 1.0 in exactly two situations — the two long sides are equal, or both are at or below 4000 px so both scales clamp to 1.0. Whole-slide pairs are neither. In the synthetic pair above the factor is 2.25; where both images exceed 4000 px it is (L_fixed / L_moving)². Do not compare a CODEX QCS to a DAPI or HE-IHC one — they are not the same quantity. And score the CODEX result yourself with compute_quality_metrics_v2, warping the moving image into the fixed frame with your own scaling, which is what the ## Try it Tier 2 block does. The overlay image the pipeline writes is scaled correctly, so a good-looking overlay next to a poor QCS is the signature of this and not of a bad registration.

When the score is low

The CODEX pipeline escalates through a fixed ladder: register the best protein channel, then a nuclear-channel mutual-information rescue, then a rotation-and-phase-correlation rescue on the protein channel, then a region-of-interest registration with a channel and scale search, then the exhaustive DAPI orientation search as a fallback, and finally a user_guidance.txt file if the score is still under 0.25. Each step is entered on a threshold and the best result across all of them is kept, so a later step never makes things worse.

Above that sits an optional LLM rescue agent, gated in run_unified_agent.py on qcs < 0.65 and qcs > 0, for the dapi, codex, boundary and st-he modalities. Four things to know before you rely on it.

It cannot fire on a total failure. The qcs > 0 half of the condition means a registration scoring exactly zero — the worst outcome the pipeline can produce — skips the rescue entirely.

Its failures are swallowed. The call sits inside a bare except Exception that logs one line and continues. With no key, no package, or a stale model identifier, the run completes normally and returns the unrescued result. The only evidence is that line, which on a host without the SDK reads exactly:

Rescue agent failed: No module named 'anthropic'

Nothing about this reaches result.json or pipeline_summary.json. If you are consuming those files programmatically, grep the log for Rescue agent before concluding the rescue ran.

The model identifier is hard-coded, in two places in agent/agent.py. Read it out of that file rather than assuming it — model names are retired on the provider's schedule, and a stale one fails at the first API call and lands in the swallowed-exception path above.

It is a search, not an oracle. The agent selects among the same enhancement, rotation, channel-selection and re-registration tools the deterministic ladder already exposes, and it decides using the composite score. Everything in the sections above about that score applies to its decisions too. A rescue that improves the composite without improving the overlap NCC has improved the score, not the registration.

When a fetch returns 403 or 429, vary the request before concluding anything

This belongs in a registration page because assembling a cross-modal pair means fetching from several hosts, and a wrong conclusion here silently narrows what you will attempt.

assets.hubmapconsortium.org answers 403 to any User-Agent beginning curl/ or Wget/ and reaches the application for every other client string. Confirmed on 2026-08-29:

for ua in "curl/8.7.1" "Wget/1.21.4" "Mozilla/5.0"; do
  printf "%-16s " "$ua"
  curl -s -o /dev/null -w "%{http_code}\n" -I -A "$ua" "https://assets.hubmapconsortium.org/"
done
# curl/8.7.1       403
# Wget/1.21.4      403
# Mozilla/5.0      404

The 403 is a client-string block. The 404 is the application answering. Reported as-is, the first reads as "this data is not publicly accessible", which is false and would have been recorded as fact. A 429 from a data portal is usually a bot check on the landing page rather than a limit on the file host, and the same applies.

So: on 403 or 429, change the User-Agent, try the direct file URL rather than the landing page, and try one other client before writing anything down. Set -A on any curl aimed at a HuBMAP host. This is not about being clever with headers — it is that "I could not reach it" and "it is not available" are different findings, and only one of them is worth acting on.

What to write into the project

The transform is the artefact. Everything downstream is a re-derivation from it, so it has to be recoverable without re-running anything:

import json, numpy as np

np.save("moving_to_fixed_affine.npy", M)        # 2x3, maps moving pixels -> fixed pixels
json.dump({
    "fixed": {"path": fixed_path, "width": W, "height": H},
    "moving": {"path": moving_path, "width": w, "height": h},
    "transform_maps": "moving pixel coordinates -> fixed pixel coordinates",
    "search": {"scale": float(scale), "angle_deg": float(angle)},
    "metrics": {k: float(v) for k, v in metrics.items()},
    "ncc_overlap": float(structure),
    "ncc_overlap_rotated_180": float(structure_180),
}, open("registration.json", "w"), indent=2)

Alongside it, an overlay PNG. Every number on this page can be produced by a transform that is visibly wrong, and the two minutes it takes to look at the overlay is the only check that never misreports. Save it at a resolution where individual structures are distinguishable — a 2000 px long side is enough to see whether crypts land on crypts, and a 500 px thumbnail is not.

Write down which of the two H&E representations you used, which channel drove the registration, the search bounds, the working resolution, and the NCC floor you set from the null control. None of those is recoverable from the transform matrix, and each of them changes the answer.

Where this stops

Resolving a channel to a marker name, reading a region instead of a whole slide, and recovering the pixel size from a file's own tags are the multiplex-imaging-io skill. Choosing segmentation channels, running a segmenter over the aligned stack, measuring per cell and assembling the AnnData are codex-phenocycler. The spatial statistics that follow are spatial-phenotyping.

This page hands over one 2×3 matrix and an honest statement of how well it holds. It does not do non-rigid deformation beyond what the pipelines apply internally, it does not propagate a transform to segmentation masks or point sets, and it does not register three or more modalities into a common frame — for the last, register each moving image to the same fixed H&E rather than chaining, because chained transforms compound their errors and the composite score cannot see the compounding.

Try it

Three tiers. Tier 0 needs no data at all and reproduces the four metric defects. Tier 1 adds 0.2 MB and does a real cross-modal registration with the null control. Tier 2 is 849 MB and runs ACCREDIT's own CODEX pipeline on a published benchmark pair.

Data. Tier 1 is DAB-thumbnail.jpg and Fluo-thumbnail.jpg from Test Dataset for Whole Slide Image Registration (EPFL BIOP, Zenodo 10.5281/zenodo.5675686), CC BY 4.0, no account. They are one mouse duodenum section imaged twice — 848 × 484 brightfield with a DAB chromogen, and 799 × 598 fluorescence covering part of the same field at a different magnification. They are thumbnails, not the dataset: the record's 1,075 MB warpy-demo-project.zip holds the real slides. So Tier 1 exercises the representation, search, scoring and null control, and does not exercise OME-TIFF pyramid loading, channel selection or memory behaviour.

Tier 2 is ZH811_INF_v6.ome.tif (718 MB CODEX) and its 1:1 name-matched ZH811_INF_v6_HE.ome.tif (6.2 MB, inside the 131 MB HE_scans.zip) from Glioma spatialomics dataset (Zenodo 10.5281/zenodo.12624860), CC BY 4.0, no account. That is the CODEX-plus-H&E resource behind Greenwald, Galili-Darnell, Hoefflin et al., Cell 187(10) 2485–2501, 2024. Zenodo does not honour range requests on these files, so a dropped connection restarts the download from zero — use a retry loop rather than curl -C -. All four URLs confirmed reachable 2026-08-29.

Run. Cold, in an empty directory.

python3 -m venv .venv
. .venv/bin/activate
pip install numpy opencv-python tifffile imagecodecs SimpleITK scikit-image
git clone --depth 1 https://github.com/LeeZhou-bearway/ACCREDIT.git

Tier 0 — the twelve numbers and the four defects, no download

import sys, numpy as np, cv2
sys.path.insert(0, "ACCREDIT/quality")
from quality_metrics import (compute_quality_metrics_v2, composite_quality_score,
                             hausdorff_distance, tissue_dice)

H = W = 512
fixed = np.zeros((H, W), np.uint8)
cv2.ellipse(fixed, (256, 250), (170, 115), 20, 0, 360, 210, -1)
cv2.circle(fixed, (430, 440), 42, 210, -1)          # a second tissue fragment
fixed = cv2.GaussianBlur(fixed, (0, 0), 3)
fixed_mask = ((fixed > 0) * 255).astype(np.uint8)

def shift(img, d):
    return cv2.warpAffine(img, np.float32([[1, 0, d], [0, 1, d]]), (W, H))

print("displacement -> the twelve numbers, and the composite")
print(f"{'px':>4} {'dice':>7} {'bnd':>7} {'ncc':>8} {'hd_mean':>8} {'IR':>6} {'nmi':>7} {'cov':>7} {'bg':>7} {'QCS':>7}")
for d in (0, 5, 20, 60, 150):
    mv, mm = shift(fixed, d), shift(fixed_mask, d)
    m = compute_quality_metrics_v2(fixed_mask, mm, fixed, mv, eval_px=512)
    print(f"{d:>4} {m['tissue_dice']:>7.4f} {m['boundary_dice']:>7.4f} {m['ncc']:>8.4f} "
          f"{m['hausdorff_mean']:>8.2f} {m['inlier_ratio']:>6.3f} {m['nmi']:>7.4f} "
          f"{m['coverage']:>7.4f} {m['bg_coverage']:>7.4f} {m['qcs']:>7.4f}")
print("keys returned:", len(compute_quality_metrics_v2(fixed_mask, fixed_mask, fixed, fixed, eval_px=512)))

base = dict(tissue_dice=1.0, boundary_dice=1.0, inlier_ratio=0.0, nmi=2.0,
            coverage=0.5, bg_coverage=0.0)
print("\nA. a perfect boundary match is scored as the worst possible one")
for hd in (0.0, 0.005, 0.01, 1.0, 500.0):
    print(f"   hausdorff_mean={hd:<7} QCS={composite_quality_score({**base, 'hausdorff_mean': hd}, eval_px=512)}")

print("\nB. ncc is computed and then discarded by the default composite")
b = dict(tissue_dice=0.8, boundary_dice=0.6, hausdorff_mean=10.0, inlier_ratio=0.05,
         nmi=1.08, coverage=0.5, bg_coverage=0.02)
for v in (-1.0, 0.0, 1.0):
    print(f"   ncc={v:<5} QCS={composite_quality_score({**b, 'ncc': v}, eval_px=512)}")

print("\nC. only the largest contour is measured")
moved = fixed_mask.copy()
moved[380:500, 380:500] = 0                                    # delete the small fragment
cv2.circle(moved, (90, 450), 42, 255, -1)                      # and put it 340 px away
print(f"   tissue_dice          {tissue_dice(fixed_mask, moved):.4f}")
print(f"   hausdorff_mean, _95  {hausdorff_distance(fixed_mask, moved)}")

assert composite_quality_score({**base, 'hausdorff_mean': 0.0}, eval_px=512) \
     < composite_quality_score({**base, 'hausdorff_mean': 1.0}, eval_px=512)
assert (composite_quality_score({**b, 'ncc': -1.0}, eval_px=512)
        == composite_quality_score({**b, 'ncc': 1.0}, eval_px=512))
assert hausdorff_distance(fixed_mask, moved) == (0.0, 0.0) and tissue_dice(fixed_mask, moved) < 0.95
print("\nOK")

Expect — Tier 0. Invariants; a failure here means this page is wrong:

  • Twelve keys come back from compute_quality_metrics_v2.
  • hausdorff_mean = 0.0 scores strictly lower than hausdorff_mean = 1.0, everything else held fixed.
  • The composite is identical for ncc = −1.0 and ncc = +1.0.
  • The two-fragment mask reports (0.0, 0.0) for both Hausdorff values while tissue_dice is below 0.95 — the displaced fragment is invisible to one metric and visible to the other.
  • inlier_ratio is 0.000 on every row. ORB finds no features on smooth synthetic shapes, so the 0.10 inlier term contributes nothing here — the mirror image of the real-data case below, where it is pinned at its ceiling instead.

Observed on 2026-08-29 with OpenCV 5.0.0 and numpy 2.5.2; these move if OpenCV changes its contour or ORB implementations:

  px    dice     bnd      ncc  hd_mean     IR     nmi     cov      bg     QCS
   0  1.0000  1.0000   1.0000     0.00  0.000  2.0000  0.2939  0.0000  0.7500
   5  0.9659  0.7845   0.7897     3.97  0.000  1.1986  0.2939  0.0142  0.8042
  20  0.8645  0.5289   0.2090    16.05  0.000  1.0335  0.2939  0.0564  0.5321
  60  0.6598  0.4834   0.0845    47.63  0.000  1.0200  0.2786  0.1271  0.3503
 150  0.3586  0.4615  -0.1121   103.21  0.000  1.0168  0.2191  0.1800  0.2155

The first two rows are the falsy-zero in the wild: a 5-pixel misalignment scores 0.8042 and perfect alignment scores 0.7500.

Tier 1 — a real cross-modal registration and the null control

curl -sL -o DAB-thumbnail.jpg  "https://zenodo.org/records/5675686/files/DAB-thumbnail.jpg?download=1"
curl -sL -o Fluo-thumbnail.jpg "https://zenodo.org/records/5675686/files/Fluo-thumbnail.jpg?download=1"
import sys, numpy as np, cv2
sys.path.insert(0, "ACCREDIT/quality")
from quality_metrics import compute_quality_metrics_v2, ncc, tissue_dice

def tissue(gray):
    _, m = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
    m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, k, iterations=2)
    return cv2.morphologyEx(m, cv2.MORPH_DILATE, k, iterations=1)

def norm8(a):
    f = a.astype(np.float32); pos = f[f > 0]
    lo, hi = np.percentile(pos, [1, 99])
    return np.clip((f - lo) / (hi - lo + 1e-10) * 255, 0, 255).astype(np.uint8)

dab = cv2.cvtColor(cv2.imread("DAB-thumbnail.jpg"), cv2.COLOR_BGR2RGB)
flu = cv2.cvtColor(cv2.imread("Fluo-thumbnail.jpg"), cv2.COLOR_BGR2RGB)
H, W = dab.shape[:2]; h, w = flu.shape[:2]
print(f"fixed  brightfield DAB  {W}x{H}")
print(f"moving fluorescence     {w}x{h}")
print(f"per-axis size ratios: width {W/w:.3f} height {H/h:.3f} -> mean {(W/w + H/h)/2:.3f}")

he_inv = (255 - cv2.cvtColor(dab, cv2.COLOR_RGB2GRAY)).astype(np.uint8)
mv = norm8(cv2.cvtColor(flu, cv2.COLOR_RGB2GRAY))
he_m, mv_m = tissue(he_inv), tissue(mv)
cy, cx = h / 2, w / 2
mv_area = float((mv_m > 0).sum())

def build(s, th, dx, dy):
    M = cv2.getRotationMatrix2D((cx, cy), th, s); M[0, 2] += dx; M[1, 2] += dy; return M
def warp(M):
    return cv2.warpAffine(mv_m, M, (W, H)), cv2.warpAffine(mv, M, (W, H))
def score(M):
    wm, wf = warp(M)
    return compute_quality_metrics_v2(he_m, wm, he_inv, wf, eval_px=max(H, W))
def ncc_of(M):
    wm, wf = warp(M)
    ov = ((he_m > 0) & (wm > 0)).astype(np.uint8) * 255
    s2 = (M[0, 0] ** 2 + M[1, 0] ** 2)
    return ncc(he_inv, wf, mask=ov) if (ov > 0).sum() > 0.5 * mv_area * s2 else -1.0

cands = []
for s in np.arange(0.42, 0.90, 0.01):
    for th in np.arange(-6, 10.1, 2.0):
        R = cv2.getRotationMatrix2D((cx, cy), th, s)
        (dx, dy), _ = cv2.phaseCorrelate(np.float32(cv2.warpAffine(mv_m, R, (W, H))) / 255.,
                                         np.float32(he_m) / 255.)
        cands.append((s, th, dx, dy))

by_dice = max(cands, key=lambda p: tissue_dice(he_m, warp(build(*p))[0]))
by_ncc  = max(cands, key=lambda p: ncc_of(build(*p)))

print()
print(f"{'chosen by':>11} {'scale':>6} {'angle':>6} {'dice':>7} {'ncc':>8} {'IR':>6} {'nmi':>7} {'cov':>6} {'QCS':>7}")
for label, p in (("tissue dice", by_dice), ("ncc", by_ncc)):
    m = score(build(*p))
    print(f"{label:>11} {p[0]:>6.3f} {p[1]:>6.1f} {m['tissue_dice']:>7.4f} {m['ncc']:>8.4f} "
          f"{m['inlier_ratio']:>6.3f} {m['nmi']:>7.4f} {m['coverage']:>6.4f} {m['qcs']:>7.4f}")

M = build(*by_ncc); M3 = np.vstack([M, [0, 0, 1]])
print("\ndegrading the ncc-chosen transform by a pure shift")
print(f"{'px':>5} {'dice':>7} {'ncc':>8} {'QCS':>7}")
for d in (0, 5, 10, 20, 40, 80, 160):
    Md = M.copy(); Md[0, 2] += d; Md[1, 2] += d
    m = score(Md); print(f"{d:>5} {m['tissue_dice']:>7.4f} {m['ncc']:>8.4f} {m['qcs']:>7.4f}")

print("\nthe null control: rotate the moving image and re-score")
print(f"{'deg':>5} {'dice':>7} {'ncc':>8} {'QCS':>7}")
rot = {}
for th in (0, 10, 45, 90, 180):
    Mr = (M3 @ np.vstack([cv2.getRotationMatrix2D((cx, cy), th, 1.0), [0, 0, 1]]))[:2]
    rot[th] = score(Mr)
    print(f"{th:>5} {rot[th]['tissue_dice']:>7.4f} {rot[th]['ncc']:>8.4f} {rot[th]['qcs']:>7.4f}")

m0, m180 = rot[0], rot[180]
print(f"\nseparation between the answer and a 180-degree flip")
print(f"   QCS {m0['qcs']:.4f} / {m180['qcs']:.4f} = {m0['qcs']/m180['qcs']:.2f}x")
print(f"   NCC {m0['ncc']:.4f} / {abs(m180['ncc']):.4f} = {abs(m0['ncc']/m180['ncc']):.0f}x")

wf = warp(M)[1]; ovl = dab.copy(); k = wf > 30
ovl[k] = (ovl[k] * 0.55 + np.array([0, 255, 0], np.uint8) * 0.45).astype(np.uint8)
cv2.imwrite("overlay_ncc.png", cv2.cvtColor(ovl, cv2.COLOR_RGB2BGR))
np.save("M_moving_to_fixed.npy", M)
print("\nwrote overlay_ncc.png and M_moving_to_fixed.npy")

d_dice, d_ncc = score(build(*by_dice)), score(build(*by_ncc))
assert d_dice['tissue_dice'] > 1.3 * d_ncc['tissue_dice']          # dice prefers the wrong transform
assert abs(d_dice['qcs'] - d_ncc['qcs']) / d_ncc['qcs'] < 0.10     # the composite cannot separate them
assert d_ncc['ncc'] > 10 * abs(d_dice['ncc'])                      # ncc separates them by an order of magnitude
assert m0['ncc'] > 10 * abs(m180['ncc'])                           # ncc sees a 180-degree flip
assert m0['qcs'] / m180['qcs'] < 1.5                               # the composite barely does
assert m0['inlier_ratio'] > 0.15                                   # the inlier term sits on its ceiling
print("OK")

Expect — Tier 1. About ten seconds. Invariants:

  • tissue_dice prefers the transform with essentially zero structural correlation, by more than 30%.
  • The composite separates those two transforms by less than 10% — it cannot rank them.
  • ncc separates them by more than an order of magnitude, and separates the answer from a 180° flip by more than 10×, while the composite stays under 1.5×.
  • inlier_ratio exceeds 0.15 on the correct transform, so its sub-score is saturated at 1.000 and the 0.10 inlier term is constant.
  • Open overlay_ncc.png. Every crypt cross-section in the green channel sits on its own brightfield counterpart. That is what an NCC of 0.32 looks like on a cross-modal pair, and it is the calibration the number has no meaning without.

Observed on 2026-08-29:

fixed  brightfield DAB  848x484
moving fluorescence     799x598
per-axis size ratios: width 1.061 height 0.809 -> mean 0.935

  chosen by  scale  angle    dice      ncc     IR     nmi    cov     QCS
tissue dice  0.860    8.0  0.8283  -0.0035  0.281  1.0222 0.4850  0.5620
        ncc  0.470    0.0  0.5925   0.3151  0.559  1.0584 0.2070  0.5729

degrading the ncc-chosen transform by a pure shift
   px    dice      ncc     QCS
    0  0.5925   0.3151  0.5729
    5  0.5783   0.1213  0.5317
   10  0.5609   0.0391  0.4998
   20  0.5304  -0.0494  0.4608
   40  0.4801  -0.0280  0.4073
   80  0.3947   0.0168  0.3236
  160  0.1888  -0.0280  0.1909

the null control: rotate the moving image and re-score
  deg    dice      ncc     QCS
    0  0.5925   0.3151  0.5729
   10  0.5698   0.0511  0.5089
   45  0.4997   0.0010  0.4089
   90  0.4792  -0.0205  0.3908
  180  0.4754   0.0255  0.4031

separation between the answer and a 180-degree flip
   QCS 0.5729 / 0.4031 = 1.42x
   NCC 0.3151 / 0.0255 = 12x

Here the composite happens to put the right transform first, by 0.0109. On a narrower search grid — 0.55 to 0.75 in steps of 0.005 — it puts the wrong one first instead, by 0.011. The margin is what holds; the ordering is not stable enough to be a finding, and a gate whose sign flips with a search bound is not a gate.

Across other inputs. The same reader was run against the ACCREDIT dapi pipeline end-to-end on this pair, and against a synthetic 6000 × 4500 H&E paired with a 3000 × 2250 CODEX stack:

input what it exercised outcome
this pair, through the shipped entry point in --mode dapi -y the orientation search, ECC and BSpline steps 597 s on a 0.4-megapixel pair; scale 0.526, correct overlay; QCS reported as 0.6177 at eval_px=4000, 0.5893 at its true 2000 px working size, 0.5951 when its transform is rescored in the frame above; rescue gate fired at qcs < 0.65 and failed on the missing SDK
synthetic 2× rescaled pair, through _compute_qcs the CODEX full-to-work transform scaling true transform scored 0.1972; warping with the overlay function's ratio instead gives mask Dice 0.9609 against 0.0822
four TIFFs differing only in page layout and filename detect_modality one crash, two misroutes, one filename-driven misroute
a JPEG-compressed OME-TIFF H&E without imagecodecs the first read in every pipeline ValueError: <COMPRESSION.JPEG: 7> requires the 'imagecodecs' package

The three code defects generalised — the falsy-zero, the largest-contour restriction and the discarded NCC are properties of the source and appeared identically on synthetic and real data. The silhouette problem did not, and how it varies is the most useful thing in this section. Running the same transform and the same null control across four variants of the pair:

variant tissue_dice ncc QCS QCS vs its own 180° flip NCC vs 180°
as published, 848 × 484 0.5925 0.3151 0.5729 1.42× 12×
both downsampled 2×, 424 × 242 0.5870 0.4374 0.5494 1.34× 57×
both downsampled 4×, 212 × 121 0.5556 0.4856 0.5281 1.17× 23×
moving cast to uint16 0.5925 0.3151 0.5729 1.42× 12×
fixed cropped to the moving's footprint 0.9725 0.3117 0.8146 90× 14×

Three conclusions, and the last one is the operative one:

Bit depth is a non-event. Percentile stretching over the non-zero pixels makes 8-bit and 16-bit inputs produce byte-identical scores. Nothing to guard against.

The composite discriminates worse at lower resolution. The 15-pixel morphology kernel, the 8-pixel boundary band and HD_MAX are all fixed pixel counts, so downsampling shrinks the real errors relative to them while leaving the tolerances put. NCC, having no length scale, sharpens instead. Do not validate a registration on a downsampled proxy.

The silhouette problem is a partial-field problem. Crop the fixed image to the region the moving one actually covers and both images describe the same tissue — tissue_dice goes to 0.9725 and the composite separates the answer from a 180° flip by 90× instead of 1.4×. So the composite is not broken; it is valid on full-field pairs and unreliable on partial-field ones, and nothing in its output says which you have. ## Try it's null control is how you find out, and it takes one warp.

One trap in the other direction. composite_quality_score_cycif exists for full-field pairs and is the right variant there — but on a partial-field pair its stricter coverage floor and area-ratio penalty both reward over-scaling. Feeding this pair to it, the score rises monotonically from 0.0055 at the correct transform to 0.1033 at 40% too large — nineteen times better for a registration whose NCC has collapsed from 0.3151 to 0.0614. Choose the variant from the framing, not from the modality name.

Tier 2 — ACCREDIT's own CODEX pipeline on a published benchmark pair

849 MB and roughly an hour of download at the rate Zenodo serves these files.

curl -sL --retry 5 --retry-delay 15 -o HE_scans.zip \
  "https://zenodo.org/records/12624860/files/HE_scans.zip?download=1"
unzip -q HE_scans.zip
until [ "$(stat -f%z ZH811_INF_v6.ome.tif 2>/dev/null || stat -c%s ZH811_INF_v6.ome.tif 2>/dev/null || echo 0)" -ge 718135498 ]; do
  curl -sL --retry 5 --retry-delay 15 -o ZH811_INF_v6.ome.tif \
    "https://zenodo.org/records/12624860/files/ZH811_INF_v6.ome.tif?download=1"
done
. .venv/bin/activate
cat > tier2.py <<'EOF'
import json, os, sys, numpy as np, cv2, tifffile
for sd in ("", "quality", "registration", "pipelines/codex",
           "pipelines/fluo_general", "pipelines/dapi"):
    sys.path.insert(0, os.path.join("ACCREDIT", sd))
from run_codex_pipeline import run_codex_pipeline, _compute_qcs
from quality_metrics import compute_quality_metrics_v2, ncc

HE, MV = "HE/ZH811_INF_v6_HE.ome.tif", "ZH811_INF_v6.ome.tif"
with tifffile.TiffFile(HE) as t:
    he_shape, he_levels = t.series[0].shape, len(t.series[0].levels)
with tifffile.TiffFile(MV) as t:
    mv_shape, mv_pages = t.series[0].shape, len(t.pages)
print("fixed  H&E  ", he_shape, "pyramid levels", he_levels)
print("moving CODEX", mv_shape, "pages", mv_pages)

he_long, mv_long = max(he_shape[:2]), max(mv_shape[-2:])
he_scale, mv_scale = min(1.0, 4000 / he_long), min(1.0, 4000 / mv_long)
print(f"long sides {he_long} and {mv_long}")
print(f"_compute_qcs applies {mv_scale/he_scale:.4f}; the correct factor is {he_scale/mv_scale:.4f}")
print(f"the scaling defect bites here: {abs(mv_scale/he_scale - he_scale/mv_scale) > 1e-9}")

res = run_codex_pipeline(HE, MV, "out_codex")
summary = json.load(open("out_codex/pipeline_summary.json"))
print("\nreported stage", summary["final_stage"], "reported QCS", summary["final_qcs"])

# Re-score the same transform with the correct full-to-work ratio.
M = np.array(res["M_full"] if "M_full" in res else json.load(open("out_codex/result.json"))["M_full"])
M_fixed = M.copy(); M_fixed[:, :2] *= (he_scale / mv_scale) / (mv_scale / he_scale)
qcs_corrected, m_corrected = _compute_qcs(HE, MV, M_fixed, "out_codex")
print("QCS with the ratio cancelled  ", qcs_corrected)
print("ncc on the overlap            ", m_corrected["ncc"])
print("tissue_dice                   ", m_corrected["tissue_dice"])
EOF
python tier2.py

Expect — Tier 2. About 18 minutes and a peak of roughly 9 GB of resident memory on ten cores; the 43-channel × 7-ratio scan in step 4 alone takes 694 s. The pipeline reads the whole 43-channel stack into memory, so budget for it. Nothing is printed until the process exits if you redirect stdout, because Python buffers it.

Invariants — a failure here means this page is wrong:

  • The pipeline writes result.json, pipeline_summary.json, pipeline.log, user_guidance.txt and overlay_final.png into out_codex, and pipeline_summary.json carries final_stage, final_qcs and the six thresholds.
  • The two images do not share a long-side pixel count, so the scaling defect applies: the block prints True for that line, and the two ratios it prints are reciprocals.
  • The QCS printed with the ratio cancelled is more than three times the QCS the pipeline reports, and the ncc that comes with it is above 0.4 — a registration the pipeline declared a failure.
  • overlay_final.png is scaled by the correct ratio and shows the CODEX signal filling the H&E tissue outline. Where the overlay looks right and the reported QCS looks bad, believe the overlay.

Observed on 2026-08-29, first and last lines of the run:

fixed  H&E   (10275, 8334, 3) pyramid levels 3
moving CODEX (43, 8885, 7194) pages 43
long sides 10275 and 8885
_compute_qcs applies 1.1564; the correct factor is 0.8647
the scaling defect bites here: True
...
STEP 6: All steps failed  (QCS=0.2209 < 0.25)
Total time: 1047s

reported stage failed reported QCS 0.2209
QCS with the ratio cancelled   0.6908
ncc on the overlap             0.4475
tissue_dice                    0.9013

Two more observed values worth knowing, both from the middle of the log. Channel selection scores all 43 channels by template match and picks ch39 at 0.5482; the region-of-interest step in the ladder independently picks ch31 by its own QCS scan. Different channel, same panel, two selection criteria — so "the best channel" is a property of the scoring rule, not of the panel, and it belongs in whatever you write down about the run.