pidgeon is a multimodal whole-slide registration package inspired by
Palom's block-wise local shift
estimation and VALIS-style smooth
non-rigid registration. Unlike Palom seam/piecewise mosaic implementation,
accepted local shifts are regularized into one composed affine-plus-field
transform.
- Quickstart
- Install
- Learned matcher weights and licensing
- Method
- Use
- How to interpret RegistrationResult
- Single-source registration, multi-channel output
- CLI
- Matching points/cells between two registered images
- QC plots
- Method and Source References
- Logging
- Troubleshooting
- Python and parallelism
License: MIT - see LICENSE.
Install one matcher extra from the Install table, then run a minimal registration:
import pidgeon as px
ref = px.open_pyramid("reference.ome.tif")
mov = px.open_pyramid("moving.ome.tif")
result = px.Aligner(smoothing=4.0).register(
ref,
mov,
ref_channel=0,
moving_channel=0,
qc_dir="qc",
)
if result.status is not px.CoarseStatus.OK:
raise RuntimeError(f"Registration status is {result.status.value}; inspect qc/ before output.")
px.write_registered_pyramid_tiled(ref, mov, result, "registered.ome.tif", output_level=0)open_pyramid(...) is the public reader entry point. It opens local TIFF
pyramids, ordinary bitmap images, and OME-Zarr pyramids, and can wrap in-memory
SpatialData images when the optional spatialdata extra is installed. Concrete
backend classes (TiffPyramidReader, OmeZarrPyramidReader,
SpatialDataPyramidReader) are available for direct use and tests.
The rest of this README covers install options, matcher licensing, the method, Python and CLI usage, QC outputs, and lower-level API details.
Why use pidgeon:
- One reusable transform. Estimate one affine-plus-field transform from one reference/moving registration image pair, then reuse it for all moving channels and point coordinates.
- Whole-slide aware. Register at a memory-bounded working pyramid level and stream full-resolution OME-TIFF output tile by tile.
- Automatic quality gates. Score thumbnail matches by RANSAC inliers and spatial coverage, then surface fallback states instead of silently trusting bad alignments.
- Local refinement without tile seams. Convert confidence-filtered block shifts into one smooth radial basis function (RBF) displacement field.
- Inspection first. Write thumbnail and region QC plots when
qc_diris supplied, before committing to downstream analysis.
Core I/O is torch-free. Install at least one primary matcher extra (sift or
glue) for full registration. Add dense only when you want the optional RoMa
fallback to rescue weak sparse thumbnail matches.
| Install target | Purpose | pip | uv |
|---|---|---|---|
| core | Readers, transforms, tiled writing; no matcher extra | pip install -e . |
uv sync |
sift |
Classical OpenCV SIFT matcher | pip install -e ".[sift]" |
uv sync --extra sift |
glue |
LightGlue matchers over ALIKED/DISK/SIFT/SuperPoint features | pip install -e ".[glue]" |
uv sync --extra glue |
dense |
RoMa detector-free fallback; combine with sift or glue |
pip install -e ".[sift,dense]" |
uv sync --extra sift --extra dense |
plot |
Matplotlib QC plots | pip install -e ".[plot]" |
uv sync --extra plot |
spatialdata |
In-memory SpatialData image adapter | pip install -e ".[spatialdata]" |
uv sync --extra spatialdata |
all |
All optional runtime features, heavy | pip install -e ".[all]" |
uv sync --extra all |
dev |
Ruff, mypy, and pytest | pip install -e ".[dev]" |
uv sync --extra dev |
Format, lint, and type-check:
ruff format src tests
ruff check src tests
mypyThe learned matcher is optional. When the glue extra is installed,
default_matcher() uses LightGlueMatcher(features="aliked"); otherwise use
the classical sift extra. Matcher choice affects the coarse thumbnail stage
described in Method.
For most research-institute use, start with ALIKED:
import pidgeon as px
matcher = px.LightGlueMatcher(features="aliked", max_num_keypoints=4096, ransac_seed=42)
result = px.Aligner(matcher=matcher, smoothing=4.0).register(ref, mov)Set ransac_seed on a matcher, or pass ransac_seed=... to Aligner when
using the default matcher, to make RANSAC affine verification reproducible.
Other LightGlue feature choices are available when the glue extra is
installed: DISK,
SIFT, and
SuperPoint.
px.LightGlueMatcher(features="disk")
px.LightGlueMatcher(features="sift")
px.LightGlueMatcher(features="superpoint") # restrictive upstream licenseTo explicitly run SuperPoint+LightGlue, install the glue extra and pass a
SuperPoint matcher into the aligner:
import pidgeon as px
# Use SuperPoint only after confirming the upstream noncommercial research terms.
matcher = px.LightGlueMatcher(features="superpoint")
aligner = px.Aligner(matcher=matcher, smoothing=4.0)
result = aligner.register(
ref,
mov,
ref_channel=0,
moving_channel=0,
qc_dir="qc",
)The first run may need network access to download upstream pretrained weights into the local torch/model cache. For controlled environments, pre-populate that cache according to your institute's software and license review process.
These upstream downloads are controlled by Torch Hub's cache location rather
than by a pidgeon-specific setting: set TORCH_HOME before running pidgeon
to choose where SuperPoint, LightGlue, DISK, ALIKED, and RoMa weights are stored.
TORCH_HOME=/path/to/model-cache uv run ...Torch then stores downloaded weights under
/path/to/model-cache/hub/checkpoints. If TORCH_HOME is unset, the default is
$XDG_CACHE_HOME/torch/hub/checkpoints, or ~/.cache/torch/hub/checkpoints
when XDG_CACHE_HOME is unset. RoMa's tiny model may also cache the Torch Hub
XFeat repository under /path/to/model-cache/hub.
License summary:
- LightGlue code and LightGlue pretrained weights are Apache-2.0 upstream.
- DISK follows the LightGlue/Apache-2.0-compatible path upstream.
- ALIKED is published upstream under BSD-3-Clause.
- SuperPoint is different: upstream LightGlue documents it as restrictive, and Magic Leap's SuperPoint license is for academic or non-profit noncommercial research use only. It also includes restrictions on sublicensing, redistribution, confidentiality, and derivatives.
- Classical
SiftMatcheruses OpenCV SIFT and no learned detector weights; use it when learned weights are not installed or not acceptable for the project, and install thesiftextra for this path.
Practical guidance:
- Use
features="aliked"by default for internal academic/non-profit research unless your institution prefers DISK or SIFT after license review. - Use
features="superpoint"only after your institute confirms that the slide analysis is noncommercial research and the upstream SuperPoint terms are acceptable. - Record the matcher in your provenance, for example:
lightglue:aliked,lightglue:disk,lightglue:sift, orsift. - This README is not legal advice; check the upstream licenses for your exact deployment, collaborations, and data-sharing model.
Upstream references: LightGlue license notes, SuperPoint license.
This section expands the registration workflow from thumbnail matching through block refinement, reusable transforms, and tiled output.
In simple terms, the method does this:
- find a rough global alignment from small thumbnails;
- check whether that rough alignment is believable;
- refine local shifts on blocks at a memory-safe working level;
- smooth those local shifts into one non-rigid field;
- write registered pixels or transform point coordinates with the same composed affine-plus-field transform.
pidgeon borrows Palom-style block-shift evidence, but regularizes accepted
shifts into one continuous affine-plus-field transform rather than a piecewise
seam mosaic. It is closer to VALIS in spirit because it keeps a smooth
non-rigid model, QC/fallback states, and point-coordinate warping, while staying
focused on a smaller memory-bounded pipeline.
-
Open image pyramids.
open_pyramid(...)dispatches to a backend reader for local TIFF/bitmap, local OME-Zarr, or in-memory SpatialData images. Each reader records pyramid levels, axes, channel names, pixel size, and(y, x)downsample factors. This lets pidgeon choose useful lower-resolution levels without loading level 0.Compared with Palom and VALIS: all three need pyramid-aware whole-slide I/O. pidgeon keeps the reader interface small so the same registration code can work with TIFF, OME-Zarr, SpatialData, and future reader backends.
-
Choose a safe working level.
working_level="auto"selects the finest common pyramid level whose largest single image plane fits withinmax_working_megapixels. If you force a level that is too large, pidgeon raises before reading it.This is mostly a memory-safety improvement: pidgeon keeps refinement in memory at a controlled pyramid level and reserves tiled processing for final output streaming.
-
Make coarse thumbnails. The coarse stage starts with a 2000 px long-edge thumbnail target. Each thumbnail is converted to one normalized grayscale image and bright-background images are automatically inverted so likely tissue/structure is bright on dark background. If matching is weak, pidgeon first retries with CLAHE contrast enhancement before moving to a larger thumbnail.
This is the cheap-first part of the method. It avoids jumping straight to high-resolution matching when a contrast adjustment is enough.
-
Try plausible orientations. By default, pidgeon tries the moving thumbnail as-is plus 90, 180, and 270 degree rotations. With
coarse_orientation_search="all", it also tries mirrored variants.This is useful for slide exports where orientation or mirroring is not guaranteed. The winning orientation is folded back into the final affine, so users do not need to rotate the input image manually.
-
Find matching keypoints. The matcher proposes corresponding points between the reference thumbnail and moving thumbnail. With the
glueextra, this is usually ALIKED+LightGlue. Without learned dependencies, thesiftextra provides OpenCV SIFT.The block-refinement idea pidgeon borrows does not depend on learned keypoint matching. VALIS has a broad registration stack. pidgeon uses learned or classical feature matching plus geometric verification so any channel pair can be accepted when its inlier coverage is convincing.
-
Fit a coarse affine with RANSAC. RANSAC estimates a 3x3 affine transform from moving-thumbnail coordinates into reference-thumbnail coordinates. It also marks which matches are inliers.
RANSAC is the first protection against bad matches. A match can have many raw points and still be bad; pidgeon trusts inliers, not raw keypoint count.
-
Score the coarse match. pidgeon checks inlier count, inlier ratio, and how widely the inliers cover the thumbnail. Well-spread but sparse matches are treated as resolution-limited, so pidgeon can climb to a larger thumbnail. Clustered or low-coverage matches are treated as unsafe, because a bigger thumbnail usually will not fix missing tissue or a repeated pattern.
This is one of the main additions over a plain block-refinement workflow: the package decides when to continue, climb resolution, try dense fallback, or mark the result as tentative or needing review.
-
Write thumbnail QC before large reads. If
qc_diris provided, pidgeon writes the thumbnail matched-keypoint plot, aligned side-by-side/overlap plot, and optional valid-mask plot before it reads working-level image planes.This is deliberate. You can inspect whether the rough affine is sane before spending memory and time on block refinement. VALIS also emphasizes QC; pidgeon makes the coarse QC happen early.
-
Scale the affine to the working level. The thumbnail affine is lifted to the chosen working level. pidgeon uses the reference and moving pyramid downsample factors separately, because the two images may choose different coarse levels.
This prevents a common pyramid bug: assuming both image pyramids have the same downsample schedule.
-
Read working-level planes and make a coarse-warped moving image. Only now does pidgeon read the selected reference and moving planes. It applies the coarse affine to the moving plane so local block refinement starts from an already roughly aligned image.
This intermediate coarse warp is used for estimating block shifts. The final output still uses the composed affine-plus-field transform so pixels are not repeatedly resampled by separate tools.
-
Estimate local block shifts. pidgeon splits the reference working image and coarse-warped moving image into blocks. Each block estimates a local translation by phase correlation, using scikit-image's
phase_cross_correlationimplementation of efficient subpixel translation registration. The phase-correlation response is stored as a confidence value.This is the Palom-inspired part of pidgeon. The difference is that block shifts are treated as evidence for a later smooth field, not as independent seam-delimited corrections.
-
Drop unsafe blocks. If the caller supplies
valid_mask, blocks majority-outside that mask are excluded before shift estimation. Without a mask, all grid blocks are attempted. Blocks with low phase-correlation response, extreme shift magnitude, or robust median absolute deviation (MAD) outlier behavior are marked invalid after shift estimation.The working-level images use the same foreground-bright grayscale conversion as coarse matching, so block phase correlation sees comparable contrast.
Missing, damaged, or low-evidence tissue should not be stretched to fit the reference. VALIS has stronger residual-based validation; pidgeon currently provides mask-aware filtering and block-level confidence QC.
-
Smooth the accepted shifts into one field. Valid block shifts are interpolated with a thin-plate radial basis function (RBF) into a dense displacement field. Higher
smoothingmakes the field stiffer; lowersmoothingallows more local deformation.This is where pidgeon differs from hard per-tile correction. Instead of applying separate local transforms with possible tile boundaries, pidgeon builds one smooth field. That is closer in spirit to VALIS non-rigid registration.
-
Return one reusable registration result.
RegistrationResultstores the scaled affine, optional displacement field, block shifts, status, valid block count, working shape, working level, and coarse-match metadata. Save it withresult.save("registration_result.npz").This result can be reused without rerunning matching. Use
result.apply_to_points(points_xy)for points already in the moving working-level grid, orresult.apply_to_points_at_level(...)for centroids stored at level 0 or another pyramid level. -
Apply the transform to pixels. For small outputs, read a moving plane at
result.working_leveland usepx.warp_moving(...). For whole-slide output, usepx.write_registered_pyramid_tiled(...), which reads only the moving source region needed for each output tile and writes a pyramidal OME-TIFF.This keeps final output memory-bounded through tiled streaming without making the transform piecewise. The registered OME-TIFF contains image data, not the red/blue QC overlay.
-
Report fallback states clearly. If coarse matching misses the policy, pidgeon first tries a RoMa-backed dense matcher when the
denseextra is installed. If dense matching satisfies the policy, the result status isOK. If the best available affine still misses the policy, pidgeon returnsDENSE_FALLBACKand logs why the registration is tentative. If no usable affine exists, it returnsNEEDS_REVIEWwithout reading working-level images. Fallback logs use structured markers such asWORKING_LEVEL_FALLBACK,COARSE_ALIGNMENT_FALLBACK, andPARALLEL_BACKEND_FALLBACK.These states are intended to stop bad slides from silently producing a convincing-looking but biologically wrong warp.
import pidgeon as px
ref = px.open_pyramid("reference.ome.tif")
mov = px.open_pyramid("moving.ndpi")
ref_channel = 0
moving_channel = 1
aligner = px.Aligner(
matcher=px.LightGlueMatcher(features="superpoint"),
smoothing=4.0,
coarse_max_size=8000,
coarse_orientation_search="all",
)
result = aligner.register(
ref,
mov,
ref_channel=ref_channel,
moving_channel=moving_channel,
qc_dir="qc",
)
result.save("qc/registration_result.npz")
print(f"Result: {result.status}. Valid blocks: {result.n_valid_blocks}")
if result.status is not px.CoarseStatus.OK:
raise RuntimeError(f"Registration status is {result.status.value}; inspect qc/ before output.")ref_channel and moving_channel are the simple path: pass either integer
indices or exact names from each reader's channel_names, and pidgeon reads one
2D plane from each image before applying its internal grayscale normalization,
polarity check, and optional CLAHE.
result = aligner.register(
ref,
mov,
ref_channel="hematoxylin",
moving_channel="DAPI",
qc_dir="qc",
)Name resolution happens at the register(...) boundary. Lower-level reader
calls such as reader.read_level(level, channel) still expect integer channel
indices. For derived registration images, pass ref_preprocessor and/or
moving_preprocessor. A preprocessor is called as processor(reader, level) and
must return a 2D numeric image for that exact pyramid level. Always read from the
reader argument; with moving_roi="auto" the moving reader may be an ROI view
during candidate screening.
For example, build the moving registration image from an RGB hematoxylin component:
import numpy as np
from skimage.color import rgb2hed
from skimage.exposure import rescale_intensity
def moving_hematoxylin(reader, level):
rgb = np.dstack([reader.read_level(level, c) for c in (0, 1, 2)])
rgb = rescale_intensity(rgb.astype("float32"), out_range=(0.0, 1.0))
return rgb2hed(rgb)[..., 0]
result = aligner.register(
ref,
mov,
ref_channel=0,
moving_preprocessor=moving_hematoxylin,
qc_dir="qc",
)Or combine two moving channels before registration:
from pidgeon.preprocess import percentile_normalize
def moving_dapi_membrane(reader, level):
dapi = percentile_normalize(reader.read_level(level, 0))
membrane = percentile_normalize(reader.read_level(level, 2))
return 0.7 * dapi + 0.3 * membrane
result = aligner.register(
ref,
mov,
ref_channel=0,
moving_preprocessor=moving_dapi_membrane,
qc_dir="qc",
)Preprocessors affect transform estimation and QC images only. Saved registration
results contain the estimated transform, not the Python preprocessor function.
Tiled output still writes raw moving image channels selected with
moving_channels=....
The superpoint matcher requires the glue extra and downloads learned
weights; use default_matcher() or a SIFT-backed matcher when that is a better
fit for your environment.
Low-confidence regions should be down-weighted or excluded before downstream
per-cell analysis. Use evidence such as valid_mask, block confidence in
result.shifts, or region_confidence_map(...) to filter matches where the
registration was weak. Border points can also have larger residuals because the
non-rigid field is zero-padded outside its support, so those points effectively
receive affine-only correction.
Coarse matching starts at a 2000 px thumbnail long edge and stops at 8000 px by default. Lower the ceiling when CPU learned matching is too expensive:
result = px.Aligner(
smoothing=4.0,
coarse_max_size=4000,
).register(ref, mov)Use moving_roi="auto" when the moving image has multiple tissue pieces or a
large empty canvas and pidgeon should pick the best moving tissue box before
registration. It works out of the box with the default tissue-detection policy;
when you need custom control, pass TissueDetectionPolicy to the aligner and
request automatic ROI selection when calling register(...):
aligner = px.Aligner(
smoothing=4.0,
tissue_policy=px.TissueDetectionPolicy(
detection_long_edge_px=2500,
min_area_fraction=0.001,
support_threshold_fraction=0.12,
support_density_threshold=0.01,
box_margin_fraction=0.08,
max_candidates=16,
),
)
result = aligner.register(
ref,
mov,
ref_channel=0,
moving_channel=0,
moving_roi="auto",
qc_dir="qc",
)moving_roi="auto" segments likely tissue on a moving thumbnail, screens up to
max_candidates candidate boxes with coarse matching, and keeps the candidate
with the strongest sufficient geometric assessment. The selected level-0 box is
stored in result.moving_roi; all screened candidates and their coarse-match
metadata are stored in result.moving_roi_candidates. Increase
detection_long_edge_px for small or faint tissue pieces, lower
min_area_fraction or support_density_threshold to admit weaker components,
increase box_margin_fraction to keep more context around each box, and lower
max_candidates when candidate screening is too slow. When qc_dir is set,
pidgeon writes qc/tissue_candidates.png by default; pass
qc_tissue_candidates=None to skip it.
Registration/refinement is currently in-memory at one pyramid level. By default,
working_level="auto" chooses the finest common pyramid level whose largest
single image plane is at most max_working_megapixels=32. This avoids accidental
level-0 reads of whole-slide images that can be tens of gigabytes. To force a
specific level, pass working_level=...; pidgeon raises a clear MemoryError if
that level exceeds the guard. Set max_working_megapixels=None only when the
machine has enough RAM for the full working images and warp grids.
To write full-resolution output without loading level 0 into memory, keep registration at an automatic/coarse working level and stream the final transform application with the tiled writer:
px.write_registered_pyramid_tiled(
ref,
mov,
result,
"registered_multichannel.ome.tif",
moving_channels=list(range(len(mov.channel_names))),
output_level=0, # reference level 0 shape
output_canvas="reference",
tile=1024,
levels=5,
progress=True,
workers=8,
prefetch=16,
dtype=mov.level_dtype(0),
compression="zlib", # use None for fastest writes if file size is acceptable
codec_workers=8,
)This writes registered moving channels on the reference output_level grid by
default. For example, output_level=0 with output_canvas="reference" produces
the same spatial shape as ref.level_shape(0) while keeping memory bounded to
output tiles and their needed moving-source windows. Set
output_canvas="moving" to keep the full affine-transformed moving canvas
instead of clipping to the reference canvas; pixels outside the estimated
reference field use the affine transform only. Tile warping is computed in
parallel and yielded to the TIFF writer in order. By default workers=None uses
the CPU cores
available to the current process, respecting os.sched_getaffinity(0) when
available. If affinity is unavailable or fails, pidgeon logs
TILE_WORKER_FALLBACK and uses os.cpu_count(). prefetch=None queues up to
twice the worker count. codec_workers=None uses the same CPU detector for TIFF
compression workers, avoiding tifffile's lower internal auto cap on some
systems. Pass progress=False to silence the tqdm progress bar in batch jobs, or
workers=1 to force serial tile computation.
compression is passed through to
tifffile and encoded by the installed
imagecodecs package. Use compression=None for uncompressed output. Practical
values for OME-TIFF output include:
"zlib"/"deflate"/"adobe_deflate": lossless Deflate compression; the default and a good compatibility-first choice."lzw": lossless and broadly supported, often larger or slower than Deflate."zstd"or"lzma": lossless codecs that can be useful for size/speed tradeoffs, but are less universally supported by TIFF viewers."jpeg": lossy 8-bit JPEG compression; use only when lossy output is acceptable and the dtype/viewer combination is suitable."jpeg2000": JPEG 2000 compression; can be useful for whole-slide storage, but reader support and lossless/lossy behavior depend on the codec settings."webp"or"jpegxl": newer codecs exposed by recentimagecodecsversions; check downstream viewer support before using them for exchange."packbits": simple lossless run-length compression, usually only helpful for very sparse or flat images.
Additional TIFF compression names supported by your installed tifffile build
may also work, but many are legacy, vendor-specific, or poorly supported by
OME-TIFF viewers. Pass compressionargs={...} when a codec needs quality,
level, or lossless-mode options.
If writing is still slow, enable INFO logs and inspect
TILED_WRITE_LEVEL_TIMING. A high tile_wait_s points to image reads/warping;
a high writer_wait_s points to TIFF encoding or disk writes. For a quick
throughput test, write uncompressed output with compression=None; if that is
much faster, tune codec_workers or choose a faster codec.
You can inspect the source storage dtype without reading pixels:
mov.level_dtype(0) # e.g. dtype('uint16')
mov.read_region(0, 0, 0, 16, 16).dtypewrite_registered_pyramid_tiled(...) defaults to the moving image dtype at the
requested source level. Pass dtype=... only when you deliberately want to
convert the output. Output channel names default to the selected moving channel
names unchanged; pass channel_names=... only when you want custom names.
The registered OME-TIFF is image data, not a red/blue QC overlay. Some viewers assign green/red/yellow lookup tables to OME channels by default; change the viewer LUT to grayscale if a single-channel registered output appears colored.
When supplied by the caller, valid_mask is interpreted on the
block-refinement grid: the same coordinate space as the reference working image
and the coarse-aligned moving working image. Pidgeon does not generate this mask
internally. Thumbnail QC resizes the supplied mask onto the aligned moving
thumbnail so the valid regions can be checked before the large working-level
reads.
The saved .npz records the selected working_level, working_shape, and
estimated transform, not any ref_preprocessor or moving_preprocessor
callable used to estimate it. For in-memory application, read the moving image
at loaded.working_level and use loaded.affine with loaded.field. For
full-resolution or finer-level output, pass the loaded result to
px.write_registered_pyramid_tiled(...); the tiled writer scales the
working-level transform to the requested output_level. loaded.coarse.affine
remains the thumbnail-level affine used during coarse matching.
Save and reuse a registration transform without repeating matching/refinement:
saved = result.save("qc/registration_result.npz")
loaded = px.load_registration_result(saved)
moving_work = mov.read_level(loaded.working_level, moving_channel)
registered = px.warp_moving(
moving_work,
loaded.affine,
loaded.field,
loaded.working_shape,
)
canvas = px.moving_output_canvas(moving_work.shape, loaded.affine)
registered_full_moving_canvas = px.warp_moving(
moving_work,
loaded.affine,
loaded.field,
canvas.shape,
output_origin_xy=canvas.origin_xy,
)registration_result.npz is a compressed NumPy archive with JSON metadata and
numeric arrays. Load it with px.load_registration_result(...); treat the
individual keys as an inspection/debugging format rather than a separate public
interchange API. Its structure is:
metadata_json: JSON string withformat,format_version,status,working_shape,working_level, block counts,has_affine,has_field, optionalmoving_roi, screenedmoving_roi_candidates, and nested coarse metadata.metadata_json["coarse"]: coarse-match status, reference and moving pyramid levels, thumbnail target size, CLAHE flag, winning orientation, fallback trail, assessment metrics (n_inliers,inlier_ratio,coverage,sufficient,resolution_limited), and match backend/keypoint counts.affine: working-level moving-to-reference affine used for pixel and point warping. Empty when no affine was available.coarse_affine: thumbnail-level affine from the coarse matcher, kept for provenance rather than direct full-resolution warping.field_dyandfield_dx: optional dense displacement-field grids in(y, x)order overworking_shape. Empty when no non-rigid field was built.shifts: one row per refinement block:[row, col, center_y, center_x, shift_y, shift_x, response, valid].match_src,match_dst,match_affine,match_inliers,match_scores: coarse matcher correspondences, RANSAC affine, inlier mask, and match scores.
RegistrationResult is the reusable registration artifact returned by
Aligner.register(...) and loaded by load_registration_result(...). Treat it
as the provenance and transform object for one moving-to-reference registration.
Start with these fields:
result.status: the coarse-registration trust state.CoarseStatus.OKmeans the pipeline found a usable coarse affine.CoarseStatus.DENSE_FALLBACKmeans the best available affine did not satisfy policy and downstream output needs review.CoarseStatus.NEEDS_REVIEWmeans no trusted affine was available.result.affine: the working-level moving-to-reference affine used by pixel and point warping. It isNonewhen no usable affine was available.result.field: the optional non-rigid displacement field.Nonemeans the final transform is affine-only.result.working_levelandresult.working_shape: the pyramid level and reference-grid shape where the transform was estimated. Useapply_to_points_at_level(...)orwrite_registered_pyramid_tiled(...)when working with level-0 data so level scaling is handled for you.result.n_valid_blocksandresult.shifts: the local refinement evidence. Few valid blocks or erratic shifts are a reason to inspectregion_confidence.pngbefore using the transform downstream.result.coarse: thumbnail-level provenance. It records the selected coarse levels, target thumbnail size, orientation, CLAHE use, assessment metrics, fallback trail, and winning matcher result.
Dense fallback provenance is recorded in result.coarse.match.backend and
result.coarse.trail. If dense matching rescued the coarse alignment,
result.status is still OK; check for a dense backend such as roma:tiny or
for dense=True entries in the trail:
used_dense_fallback = (
result.coarse.match is not None
and (
result.coarse.match.backend.startswith(("roma:", "dense:"))
or any("dense=True" in item for item in result.coarse.trail)
)
)A compact user-facing summary can be built without opening the saved .npz
directly:
summary = {
"status": result.status.value,
"working_level": result.working_level,
"working_shape": result.working_shape,
"has_affine": result.affine is not None,
"has_field": result.field is not None,
"valid_blocks": result.n_valid_blocks,
"total_blocks": len(result.shifts),
"coarse_backend": None if result.coarse.match is None else result.coarse.match.backend,
"coarse_inliers": (
None if result.coarse.assessment is None else result.coarse.assessment.n_inliers
),
"coarse_coverage": (
None if result.coarse.assessment is None else result.coarse.assessment.coverage
),
}
print(summary)For QC, prefer the generated files when qc_dir was supplied:
thumbnail_matches.png explains the coarse correspondences,
thumbnail_overlap_before_after.png shows the affine improvement, and
region_confidence.png plus region_confidence.json summarize block evidence,
residuals, deformation diagnostics, and scorecard metrics.
The same registration result can be applied to points and annotations:
centroids_in_ref = result.apply_to_points_at_level(
cell_centroids_xy_level0,
moving_reader=mov,
ref_reader=ref,
from_level=0,
to_level=0,
)
# Project reference annotations back onto the moving image. With a non-rigid
# field, this uses the same first-order inverse approximation as pixel warping;
# pass affine_only=True to ignore the field.
annotation_in_moving = result.apply_inverse_to_points_at_level(
annotation_xy_level0,
moving_reader=mov,
ref_reader=ref,
from_level=0,
to_level=0,
)Choose raw channels with ref_channel/moving_channel as integer indices or
exact channel names, or build derived registration images with
ref_preprocessor/moving_preprocessor, from the signals with the strongest
shared geometry. Once estimated, the same result can be applied to any number of
raw moving channels. For whole-slide output, use the tiled writer example above
and pass
moving_channels=list(range(len(mov.channel_names))) or another explicit
channel list.
For small images or deliberately coarse output, the older in-memory path is
still available: read mov.read_level(result.working_level, channel), call
px.warp_moving(...), then write the registered moving channel with
px.write_pyramid(...).
The command-line entry point runs the same registration pipeline and logs at
INFO by default:
pidgeon REFERENCE MOVING -o OUTPUT [options]Common flags:
--ref-channel CHANNELand--moving-channel CHANNEL: integer indices or exact channel names used to estimate the transform.--moving-channels all|A,B,C: moving channels to write in the registered output. Select by integer index or exact moving channel name; defaults to the single--moving-channelregistration channel.--working-level auto|N: pyramid level for in-memory refinement.--output-level working|N: useworkingfor legacy in-memory output or an integer such as0for tiled output at that reference pyramid level.--output-canvas reference|moving: clip to the reference canvas or keep the full transformed moving canvas.--moving-roi autoor--moving-roi x,y,width,height: restrict the moving image before registration.--qc-dir qc: write thumbnail, tissue-candidate, and region QC outputs.--save-registration qc/registration_result.npz: save the reusable transform archive.--log-level DEBUG: emit detailed fallback and timing logs.
Example:
pidgeon ref.ome.tif moving.ome.tif -o registered.ome.tif \
--coarse-max-size 4000 \
--coarse-orientation-search all \
--moving-roi auto \
--output-level 0 \
--output-canvas reference \
--output-tile-workers 8 \
--output-tile-prefetch 16 \
--output-codec-workers 8 \
--qc-dir qc \
--save-registration qc/registration_result.npz \
--ransac-seed 42 \
--log-level DEBUGTiled output shows a progress bar by default; add --no-progress to silence it.
Use --output-tile-workers 1 if a particular image backend behaves poorly with
threaded reads. Use --output-compression none to test whether zlib compression
is the bottleneck. Otherwise, pass the same tifffile compression strings accepted
by write_registered_pyramid_tiled(...), such as zlib, lzw, zstd, jpeg,
or jpeg2000. Use --output-codec-workers N to override the automatic
compression worker count. For container inputs with multiple image elements,
pass --ref-element and --moving-element.
Single-cell correspondence is only meaningful when the moving and reference images are the same physical section imaged twice. For adjacent serial sections, the nearest cell is a physically different cell.
Use the registration result to warp moving centroids into the reference frame,
then call match_points with thresholds chosen for your assay and point
spacing:
import numpy as np
import pidgeon as px
import scipy.spatial
ref = px.open_pyramid("imageA.ome.tif")
mov = px.open_pyramid("imageB.ome.tif")
# mov is warped into ref's frame.
result = px.Aligner(smoothing=4.0, ransac_seed=0).register(
ref=ref,
moving=mov,
ref_channel=0,
moving_channel=0,
qc_dir="qc",
)
assert result.status is px.CoarseStatus.OK
# Moving centroids are usually stored in level-0 pixels. This method handles
# pyramid-level scaling before and after the working-level transform.
moving_in_ref_xy = result.apply_to_points_at_level(
moving_centroids_xy_level0,
moving_reader=mov,
ref_reader=ref,
from_level=0,
to_level=0,
)
# Choose this from your point density, not from pidgeon defaults. A common
# starting point is a fraction of the median nearest-neighbor spacing in ref.
nn_distances, _ = scipy.spatial.cKDTree(ref_centroids_xy_level0).query(
ref_centroids_xy_level0,
k=2,
)
median_spacing_px = np.median(nn_distances[:, 1])
max_distance_px = 0.25 * median_spacing_px
matches = px.match_points(
ref_centroids_xy_level0,
moving_in_ref_xy,
max_distance_px=max_distance_px,
ratio_threshold=0.7,
require_mutual=True,
ref_labels=ref_label_mask, # optional; label ids map to ref point index + 1
)
print(matches.as_summary())
print(np.percentile(matches.distances_px, [50, 90, 99]))max_distance_px should be tied to the median inter-point spacing or to a known
assay tolerance. After matching, inspect matches.distances_px: a tight peak
near zero means the registration cleared the point-spacing bar, while a broad
smear means the per-point matches are suspect even if many points were paired.
For synthetic accuracy checks, evaluate target registration error (TRE) on a checkerboard of landmarks when the deformation is known:
def known_transform(points_xy):
out = points_xy.copy()
out[:, 0] += 2.0
out[:, 1] -= 1.0
return out
report = px.synthetic_tre_report(
transform_points=result.apply_to_points,
known_transform=known_transform,
shape_yx=result.working_shape,
spacing_px=256,
pixel_size_um=0.2125,
)
print(report.as_dict()) # median_tre_um and p90_tre_umThe coarse matcher handles cropped fields of view through feature matching and
RANSAC as long as there is enough overlap. It also searches rotated moving
thumbnails by default before lifting the winning thumbnail affine to full
working resolution. Use coarse_orientation_search="all" if mirrored scans are
possible:
result = px.Aligner(
smoothing=4.0,
coarse_orientation_search="all", # none, rotations, or all
).register(ref, mov)QC plotting requires the plot extra.
For a quick look at any opened pyramid before registration, use
preview_reader(...). It reads a low-resolution pyramid level directly rather
than loading level 0 and downsampling it in memory.
import pidgeon as px
from matplotlib import pyplot as plt
ref = px.open_pyramid("reference.ome.tif")
mov = px.open_pyramid("moving.ome.tif")
# Show a 3-channel RGB composite at an overview level.
px.preview_reader(ref, channels=[0, 1, 2], target_long_edge_px=1024)
# In headless runs, save the preview without opening a GUI window.
fig = px.preview_reader(
mov,
channels=[0],
target_long_edge_px=1024,
dpi=220,
output_path="qc/moving_preview.png",
show=False,
)
plt.close(fig)QC files are not written unless you pass qc_dir=... to register(...) or
--qc-dir ... to the CLI. When a QC directory is supplied, the standard plot
filenames are enabled by default; pass None in Python or none on the CLI for
any individual QC output you want to skip.
Thumbnail QC figures use a capped default figure size so large coarse-match
thumbnails do not produce enormous PNG files. Pass figsize=(width, height) or
dpi=... to preview_reader(...); other plotting helpers accept figsize, and
save_coarse_match_qc(...) can pass it through when you need a larger
manual-inspection raster.
ref_channel = 0
moving_channel = 0
qc_paths = px.save_coarse_match_qc(
"qc",
ref,
mov,
result.coarse,
ref_channel=ref_channel,
moving_channel=moving_channel,
valid_mask=valid_mask, # optional; enables the mask plot and mask panel
qc_matched_keypoints="thumbnail_matches.png",
qc_aligned_side_by_side="thumbnail_matches_aligned_side_by_side.png",
qc_overlap_before_after="thumbnail_overlap_before_after.png",
qc_moving_footprint="thumbnail_moving_footprint.png",
qc_valid_mask="thumbnail_valid_mask.png",
)This writes thumbnail-level QC before any working-level image reads:
qc/thumbnail_matches.png: matched keypoints with inlier lines.qc/thumbnail_matches_aligned_side_by_side.png: reference thumbnail, aligned moving thumbnail, and red/blue overlap panels. Low-intensity background is suppressed in the overlap so the colored signal mostly marks tissue. Thumbnail QC uses the same foreground-bright grayscale and CLAHE setting as coarse matching. Whenvalid_maskis supplied, this becomes a four-panel figure with an additional aligned moving thumbnail tinted green where the mask is valid.qc/thumbnail_overlap_before_after.png: red/blue overlap before and after applying the coarse affine. The before panel places the moving thumbnail on the reference canvas with no transform; the after panel uses the accepted coarse affine.qc/thumbnail_moving_footprint.png: full moving thumbnail with the reference thumbnail canvas mapped back into moving-thumbnail coordinates. This shows the region that can appear in the aligned thumbnail; moving pixels outside that footprint are clipped by the default reference output canvas, not removed before matching.qc/thumbnail_valid_mask.png: valid mask over the aligned moving thumbnail. This is written only whenvalid_maskis supplied; otherwise pidgeon logsTHUMBNAIL_QC_SKIP output=valid_mask reason=no_mask_providedand does not create the file.qc/tissue_candidates.png: moving-thumbnail tissue boxes detected formoving_roi="auto", including the candidate selected for registration. This is written whenmoving_roi="auto"andqc_dirare used, unlessqc_tissue_candidates=None.
Working-level region QC is written after refinement when
register(..., qc_dir=...) is used. It is block-level QC from result.shifts,
not matched-nuclei/cell residual QC. Columns are labeled A, B, ... and rows
are labeled 1, 2, ... so a block can be referenced as, for example, C4.
qc/region_confidence.png: overview figure containing the working overlap, edge composite, checkerboard, block confidence, gradient similarity, residual improvement, raw residual, Jacobian, displacement vectors, and scorecard.qc/region_confidence.json: machine-readable scorecard with status, coarse status, block counts, rejection counts, inlier ratio, TRE values when available, field displacement/folding summaries, and affine decomposition.qc/region_confidence_working_overlap.png: red/blue overlay of the reference working image and registered moving image; magenta indicates overlap.qc/region_confidence_edge_composite.png: false-color edge overlay; magenta marks reference edges, green marks moving edges, and white marks shared edges.qc/region_confidence_checkerboard.png: checkerboard alternating reference and registered moving tiles so local discontinuities or residual offsets are easier to see.qc/region_confidence_block_confidence.png: per-block phase-correlation response overlaid on the reference image; rejected blocks are marked.qc/region_confidence_gradient_similarity.png: per-block edge normalized cross-correlation, useful when intensity residuals are hard to interpret across modalities.qc/region_confidence_residual_improvement.png: per-block improvement from affine-only moving to final affine-plus-field moving; red improves and blue worsens.qc/region_confidence_raw_residual.png: per-block mean absolute difference between the reference and final registered moving image.qc/region_confidence_jacobian.png: displacement-field Jacobian determinant; red indicates expansion, blue compression, and black non-positive determinant folds.qc/region_confidence_displacement_vectors.png: accepted block-shift vectors drawn over the reference image.
Pass None for any output you want to suppress. The returned qc_paths
dictionary contains only the plots that were actually written.
These use result.coarse.level and result.coarse.moving_level; they do not
read level 0 unless coarse matching itself selected level 0.
# Or recreate the thumbnail planes used by coarse matching and customize the plot.
ref_level = result.coarse.level
moving_level = result.coarse.moving_level
ref_thumb = ref.read_level(ref_level, ref_channel)
mov_thumb = mov.read_level(moving_level, moving_channel)
fig = px.plot_matched_keypoints(ref_thumb, mov_thumb, result.coarse.match)
fig.savefig("matches.png", dpi=180)To recreate the post-refinement block QC explicitly:
work_level = result.working_level
ref_work = ref.read_level(work_level, ref_channel)
mov_work = mov.read_level(work_level, moving_channel)
mov_aligned = px.warp_moving(mov_work, result.affine, result.field, result.working_shape)
px.save_region_confidence_qc(
"qc/region_confidence.png",
ref_work,
mov_aligned,
result,
)For custom working-level overlay QC after registration, build the red/blue array directly and plot it however you prefer:
work_level = result.working_level
ref_img = ref.read_level(work_level, ref_channel).astype(float)
mov_img = mov.read_level(work_level, moving_channel).astype(float)
warped_moving_img = px.warp_moving(
mov_img,
result.affine,
result.field,
result.working_shape,
)
overlay_rgb = px.red_blue_overlay(ref_img, warped_moving_img)Upstream systems and design neighbors:
- Palom: piecewise alignment for layers of mosaics; pidgeon borrows the block-shift evidence idea but deliberately smooths accepted shifts into one field instead of producing a seam mosaic.
- VALIS and its documentation: whole-slide rigid and non-rigid pathology registration, point-data warping, and OME-TIFF output.
Feature matching and coarse geometry:
- LightGlue paper and
repository: adaptive sparse local feature
matcher used when the
glueextra is installed. - ALIKED, DISK, SuperPoint, and SIFT: local feature detectors/descriptors that LightGlue or the classical fallback can use.
- SuperGlue paper and repository: useful background for the learned matching family; pidgeon uses LightGlue directly.
- RANSAC: robust affine estimation from matched keypoints.
Preprocessing, refinement, and warping:
- CLAHE in scikit-image and adaptive histogram equalization: contrast enhancement used in the thumbnail escalation ladder.
phase_cross_correlationand the Guizar-Sicairos et al. subpixel registration paper: local block-shift estimation.RBFInterpolatorand thin-plate splines: smooth field interpolation from sparse valid block shifts.skimage.transform.warpandscipy.ndimage.map_coordinates: inverse-map pixel and field sampling used by the composed warp.
Image formats and storage:
- OME-TIFF specification: interoperable microscopy metadata plus TIFF pixel storage.
- tifffile: TIFF/BigTIFF/OME-TIFF read and write support used for pyramidal output.
- Zarr: chunked array access used by
tifffile.aszarr()and the reader path.
Library code uses the standard logging package and does not configure handlers
on import. Enable logs in scripts or notebooks when you want progress details:
import logging
logging.basicConfig(level=logging.INFO)
logging.getLogger("pidgeon").setLevel(logging.DEBUG) # optional, for more detailFor concise status in notebooks without INFO-log output, pass
progress=True to Aligner.register(...). This uses a tqdm.auto stage
progress bar and is independent of logging.
Fallbacks are logged with explicit markers and consequences:
WORKING_LEVEL_FALLBACK: pidgeon chose a coarser working pyramid level, or could not apply the memory guard from metadata.COARSE_ALIGNMENT_FALLBACK: the coarse matcher did not meet the acceptance policy; any continued registration is tentative and QC should be inspected.MATCHER_FALLBACK: learned matcher dependencies were unavailable, so pidgeon selected the SIFT matcher path. Install thesiftextra for that fallback.DENSE_MATCHER_FALLBACK_UNAVAILABLE: sparse matching reachedDENSE_FALLBACK, but the optional RoMa dependencies were not installed.DENSE_MATCHER_FALLBACK_FAILED: RoMa fallback raised unexpectedly, so pidgeon kept the best sparse fallback instead.PARALLEL_BACKEND_FALLBACK: a requested backend is unsafe or unavailable, so pidgeon routed block work to a safer backend.THUMBNAIL_QC_FALLBACK: only partial thumbnail QC could be written.
- If the output looks wrong, start with
qc/thumbnail_matches*.pngto check the coarse affine before trusting working-level refinement. - If matches are clustered or sparse, increase
coarse_max_size, try a stronger matcher extra, or install thedenseextra so RoMa can be attempted. - If the moving image has multiple tissue pieces or a large empty canvas, use
moving_roi="auto"and inspectqc/tissue_candidates.png. - If regions stretch implausibly, provide a
valid_mask, increasesmoothing, or exclude low-confidence blocks usingregion_confidence_map(...). - If output is unexpectedly clipped, use
output_canvas="moving"instead of the default reference canvas. - pidgeon does not currently bundle sample slide data; test with any supported TIFF, bitmap, OME-TIFF, OME-Zarr, or SpatialData image pair that shares visible morphology.
pidgeon currently requires Python 3.12 or newer. On Python 3.14
free-threaded/no-GIL builds, block phase correlation dispatches through
parallel.MapEngine using real threads with shared memory. On stock GIL builds,
it uses processes. Where the platform supports it, pidgeon explicitly uses the
fork process start method for block workers; this avoids Python 3.14's
forkserver/spawn path re-importing an unguarded top-level script and
starting registration again inside worker bootstrapping. Logs will show this as
process/fork x....
If fork is unavailable, or if you deliberately request spawn/forkserver,
wrap script entry points in the standard multiprocessing guard:
def main():
...
if __name__ == "__main__":
main()Python sub-interpreters are not used for the NumPy/scipy block path because current NumPy C extensions may fail when imported inside sub-interpreters.