Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.7.0] - 2026-07-02

### Changed (BREAKING)

- `lif_mosaic="auto-stitch"` (still the default) no longer means "use
bioio-lif's 1-pixel-overlap M-scan-order stitcher"; it now dispatches a
per-scene cascade — try `stage-stitch` when the scene has per-tile
`PosX`/`PosY` and both physical pixel sizes, fall back to `grid-stitch`
when the `FieldX`/`FieldY` grid is complete, and only fall through to
bioio-lif's built-in stitcher when no `<Tile>` layout is present. Existing
scripts passing `--lif-mosaic auto-stitch` (or the API `lif_mosaic=
"auto-stitch"`) will now produce different — and, per the validation done
under #41 against a real Leica LIF, better — pixels. To restore the
pre-v0.7.0 behavior for a specific run, pass the new
`lif_mosaic="bioio-lif"` value; that path still fires
`MosaicStitchingWarning`. Under `layout="plate"`, the cascade skips
`stage-stitch` (not wired to the plate writer) and lands on
`grid-stitch` or `bioio-lif`. (#41)
- `MosaicStitchingWarning` text no longer suggests `lif_mosaic="grid-stitch"`
or `"stage-stitch"` as alternatives — the cascade already tried them
before falling through to this branch. The warning still names
`lif_mosaic="per-tile"` as the remaining zarrmony alternative.

### Added

- `lif_mosaic="bioio-lif"` — explicit value that opts back into bioio-lif's
1-pixel-overlap M-scan-order stitcher. The cascade will also select this
automatically for scenes with no `<Tile>` metadata. (#41)
- `mosaic.cascade_selected: true` on the audit block whenever the
`auto-stitch` cascade picked the concrete stitcher (as opposed to the
user requesting one explicitly). Pairs with the existing
`mosaic.stitcher` field so downstream analysis can distinguish
"grid-stitch was picked because stage was ineligible" from "user
explicitly asked for grid-stitch". (#41)
- Non-throwing `is_stage_layout_complete()` and `is_grid_layout_complete()`
predicates in `zarrmony.metadata.lif_tiles`, plus a
`select_auto_stitch_cascade()` selector that composes them. Both writer
paths (`_convert_per_scene` and `write_plate`) use the same selector so
the cascade decision stays consistent. (#41)

## [0.6.0] - 2026-07-02

### Added
Expand Down
59 changes: 53 additions & 6 deletions src/zarrmony/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
grid_shape,
reassemble_grid,
reassemble_stage,
select_auto_stitch_cascade,
stage_overlap_discrepancy,
)
from zarrmony.naming import resolve_scene_dirnames, sanitize_scene_name
Expand All @@ -59,14 +60,22 @@
ResolvedLayout = Literal["per-scene", "bf2raw", "plate"]
_VALID_LAYOUTS: tuple[Layout, ...] = ("auto", "per-scene", "bf2raw", "plate")

LifMosaic = Literal["auto-stitch", "per-tile", "grid-stitch", "stage-stitch"]
LifMosaic = Literal[
"auto-stitch", "per-tile", "grid-stitch", "stage-stitch", "bioio-lif"
]
_VALID_LIF_MOSAIC: tuple[LifMosaic, ...] = (
"auto-stitch",
"per-tile",
"grid-stitch",
"stage-stitch",
"bioio-lif",
)

# The concrete stitcher the cascade dispatches to under lif_mosaic="auto-stitch".
# "per-tile" is not a cascade output — users request it explicitly because it
# changes the on-disk shape (N sub-stores per scene, not one canvas).
_CascadeChoice = Literal["stage-stitch", "grid-stitch", "bioio-lif"]

# Meters → micrometers (OME convention for <Plane> PositionX/Y/Z units).
_METERS_TO_UM = 1_000_000.0

Expand Down Expand Up @@ -418,11 +427,14 @@ def convert(
"is one image by spec; convert as flat (layout='per-scene' or "
"layout='auto' with a flat reader) to get per-tile stores"
)
# Plate + stage-stitch is not yet wired end-to-end (the plate writer
# only knows how to swap in grid-stitch's reassembly today). Reject
# Plate + explicit stage-stitch is not yet wired end-to-end (the plate
# writer only knows how to swap in grid-stitch's reassembly today). Reject
# explicitly so users don't silently get bioio-lif auto-stitched plate
# FOVs when they asked for stage-based placement; convert flat to get
# a stage-stitched canvas per scene, or use grid-stitch under plate.
# a stage-stitched canvas per scene, or use grid-stitch under plate. The
# auto-stitch cascade already knows to skip stage-stitch under plate mode
# and land on grid-stitch instead, so this rejection is scoped to the
# explicit request only.
if effective_layout == "plate" and lif_mosaic == "stage-stitch":
raise LayoutMismatchError(
"stage-stitch is not yet supported under plate layout; convert "
Expand Down Expand Up @@ -716,8 +728,35 @@ def _convert_per_scene(
store_audits.extend(tile_audits)
continue

grid_stitch_mode = lif_mosaic == "grid-stitch" and reassembly_eligible
stage_stitch_mode = lif_mosaic == "stage-stitch" and reassembly_eligible
# Resolve the effective stitcher. Under the cascade default
# (lif_mosaic="auto-stitch"), per-scene metadata decides which of
# stage-stitch → grid-stitch → bioio-lif runs; explicit values pass
# through unchanged. When the scene isn't mosaic-reassembly-eligible
# (e.g. a _Merged sibling or a scalar scene) there's nothing to
# cascade and the reader's normal xarray_dask_data path handles it.
effective_stitcher = lif_mosaic
cascade_selected = False
if reassembly_eligible and lif_mosaic == "auto-stitch":
cascade_tiles_xarr = reader.tiles_xarray_dask_data
cascade_m_size = int(cascade_tiles_xarr.sizes["M"])
cascade_scene_xml = find_scene_xml(reader)
cascade_tile_layout = (
extract_tile_layout(cascade_scene_xml)
if cascade_scene_xml is not None
else None
)
cascade_px_y, cascade_px_x = _stage_pixel_sizes_um(reader)
effective_stitcher = select_auto_stitch_cascade(
cascade_tile_layout,
m_size=cascade_m_size,
pixel_size_x_um=cascade_px_x,
pixel_size_y_um=cascade_px_y,
plate_mode=False,
)
cascade_selected = True

grid_stitch_mode = effective_stitcher == "grid-stitch" and reassembly_eligible
stage_stitch_mode = effective_stitcher == "stage-stitch" and reassembly_eligible
# Either reassembly mode consumes the raw M-intact tiles surface and
# replaces the auto-stitch mosaic_summary with a mode-specific block;
# `write_scene`'s auto-stitch summary would mislabel the record with
Expand Down Expand Up @@ -785,6 +824,14 @@ def _convert_per_scene(
elif stage_stitch_mode and stage_mosaic_summary is not None:
scene_record["mosaic"] = stage_mosaic_summary

# Distinguish cascade-selected from user-explicit in the audit so
# downstream analysis can tell "grid-stitch was picked because stage
# metadata was incomplete" apart from "user explicitly asked for
# grid-stitch". The mosaic block already carries stitcher=... for the
# concrete choice; this flag adds the intent.
if cascade_selected and "mosaic" in scene_record and scene_record["mosaic"]:
scene_record["mosaic"]["cascade_selected"] = True

metadata_warnings: list[dict] = []
ome_image: Image | None = None
if lif_extracted is not None:
Expand Down
31 changes: 19 additions & 12 deletions src/zarrmony/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,26 +114,33 @@ def _parse_chunk_shape(
)
@click.option(
"--lif-mosaic",
type=click.Choice(["auto-stitch", "per-tile", "grid-stitch", "stage-stitch"]),
type=click.Choice(
["auto-stitch", "per-tile", "grid-stitch", "stage-stitch", "bioio-lif"]
),
default="auto-stitch",
show_default=True,
help=(
"LIF-specific. How to write mosaic scenes with no vendor _Merged "
"sibling. 'auto-stitch' (default) uses bioio-lif's 1-pixel-overlap "
"stitcher (one image per scene; emits MosaicStitchingWarning). "
"'per-tile' writes one OME-Zarr per tile under "
"<OUTPUT>/<scene>/tile_X{f:02d}Y{f:02d}.ome.zarr/ with stage "
"positions in each tile's OME-XML <Plane> for external stitchers "
"(ASHLAR, m2stitch, BigStitcher); incompatible with --layout plate. "
"'grid-stitch' reassembles one canvas per scene by placing tile M=i "
"at (field_y[i]*tile_H, field_x[i]*tile_W) from LIF FieldX/FieldY "
"(butt joints, no overlap); fixes M-scan-order placement while "
"preserving one-store-per-scene; raises on incomplete tile metadata. "
"sibling. 'auto-stitch' (default) runs a per-scene cascade: "
"stage-stitch when the scene has per-tile PosX/PosY and physical "
"pixel size (both X and Y), else grid-stitch when tile FieldX/FieldY "
"form a complete rectangular grid, else bioio-lif's built-in "
"M-scan-order stitcher (which emits MosaicStitchingWarning). "
"'stage-stitch' places each tile at its PosX/PosY stage µm position "
"(converted via the scene's pixel size); honours the LIF-declared "
"intended overlap; raises on missing PosX/PosY or scene pixel size "
"and warns (MosaicPlacementWarning) when observed vs intended overlap "
"diverges past 20%. Other readers ignore this flag. See ADR-0005."
"diverges past 20%. 'grid-stitch' reassembles one canvas per scene by "
"placing tile M=i at (field_y[i]*tile_H, field_x[i]*tile_W) from LIF "
"FieldX/FieldY (butt joints, no overlap); raises on incomplete tile "
"metadata. 'per-tile' writes one OME-Zarr per tile under "
"<OUTPUT>/<scene>/tile_X{f:02d}Y{f:02d}.ome.zarr/ with stage positions "
"in each tile's OME-XML <Plane> for external stitchers (ASHLAR, "
"m2stitch, BigStitcher); incompatible with --layout plate. 'bioio-lif' "
"opts back into bioio-lif's 1-pixel-overlap M-scan-order stitcher "
"(the pre-v0.7.0 default); the cascade will pick this automatically "
"when a scene has no <Tile> layout metadata. Other readers ignore this "
"flag. See ADR-0005."
),
)
def convert_cmd(
Expand Down
73 changes: 73 additions & 0 deletions src/zarrmony/metadata/lif_tiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,79 @@ def grid_shape(tiles: list[dict]) -> tuple[int | None, int | None]:
return max(ys) + 1, max(xs) + 1


def select_auto_stitch_cascade(
tile_layout: dict | None,
*,
m_size: int,
pixel_size_x_um: float | None,
pixel_size_y_um: float | None,
plate_mode: bool,
) -> str:
"""Resolve ``lif_mosaic="auto-stitch"`` to a concrete stitcher per scene.

Cascade order:

1. ``"stage-stitch"`` — every tile has ``pos_x_m``/``pos_y_m`` and the scene
has both physical pixel sizes. Skipped under ``plate_mode=True`` since
the plate writer does not yet wire stage-stitch (see api.py rejection).
2. ``"grid-stitch"`` — every tile has ``field_x``/``field_y`` forming a
complete rectangular grid.
3. ``"bioio-lif"`` — no tile layout (or neither of the above eligible);
falls back to bioio-lif's built-in M-scan-order stitcher.

Returned value is one of ``"stage-stitch"``, ``"grid-stitch"``,
``"bioio-lif"`` — always a concrete stitcher, never ``"per-tile"`` (which
users request explicitly because it changes the on-disk shape) and never
``"auto-stitch"`` (the value that triggered this call).
"""
if (
not plate_mode
and is_stage_layout_complete(tile_layout)
and pixel_size_x_um is not None
and pixel_size_y_um is not None
):
return "stage-stitch"
if is_grid_layout_complete(tile_layout, m_size):
return "grid-stitch"
return "bioio-lif"


def is_stage_layout_complete(tile_layout: dict | None) -> bool:
"""True iff every tile in ``tile_layout`` has finite ``pos_x_m`` AND ``pos_y_m``.

Used by the ``lif_mosaic="auto-stitch"`` cascade to pre-check whether a
scene is stage-stitch-eligible before invoking the (strict, raising) stage
path. Returns ``False`` on ``None`` layout, empty tiles, or any tile
missing a stage position — the cascade then falls through to grid-stitch.
"""
if tile_layout is None:
return False
tiles = tile_layout.get("tiles") or []
if not tiles:
return False
return all(
t.get("pos_x_m") is not None and t.get("pos_y_m") is not None for t in tiles
)


def is_grid_layout_complete(tile_layout: dict | None, m_size: int) -> bool:
"""True iff ``tile_layout`` covers a complete rectangular grid of ``m_size`` tiles.

Non-raising sibling of :func:`validate_grid_layout` for the auto-stitch
cascade's grid-eligibility pre-check. Returns ``False`` on ``None`` layout
or any invariant failure that would make :func:`validate_grid_layout`
raise.
"""
if tile_layout is None:
return False
tiles = tile_layout.get("tiles") or []
try:
validate_grid_layout(tiles, m_size)
except ValueError:
return False
return True


def validate_grid_layout(tiles: list[dict], m_size: int) -> tuple[int, int]:
"""Assert ``tiles`` covers a complete rectangular grid; return ``(rows, cols)``.

Expand Down
36 changes: 19 additions & 17 deletions src/zarrmony/readers/lif.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,17 @@ def is_mosaic_reassembly_eligible(self) -> bool:
def _stitching_warning_text(self, scene_name: str, tile_count: int) -> str:
"""Compose the :class:`MosaicStitchingWarning` body.

Names both known auto-stitch pathologies — the 1-pixel overlap seam
(quoted against the LIF-declared intended overlap when extractable) and
the M-scan-order tile placement — then lists both zarrmony escape
hatches (per-tile first: pixel-correct; grid-stitch second: fixes
arrangement on a single canvas), then external stitchers as a last
resort. Ordering is deliberate: per-tile is the honest correctness
answer, grid-stitch preserves the one-store-per-scene invariant when
the user's downstream tooling needs a single canvas.
Fires only when bioio-lif's own stitcher actually runs — under the
v0.7.0 cascade default (``lif_mosaic="auto-stitch"``) that's either
the cascade fallback (no ``<Tile>`` layout metadata to feed
stage-stitch or grid-stitch) or an explicit
``lif_mosaic="bioio-lif"`` request. The warning names both known
pathologies — the 1-pixel overlap seam (quoted against the
LIF-declared intended overlap when extractable) and the M-scan-order
placement that ignores ``FieldX``/``FieldY`` — and points at
``lif_mosaic="per-tile"`` as the remaining zarrmony alternative
(stage-stitch and grid-stitch aren't listed because the cascade
already tried them or the user opted out of both).
"""
layout = self._tile_layout()
overlap_x = layout.get("intended_overlap_x_pct") if layout else None
Expand All @@ -192,15 +195,14 @@ def _stitching_warning_text(self, scene_name: str, tile_count: int) -> str:
f"used, and tiles are placed by M-scan order — not by their "
f"declared FieldX/FieldY grid indices, so visual layout may not "
f"match acquisition. {overlap_clause}. No vendor-stitched sibling "
f"('{scene_name}{_MERGED_SUFFIX}') was found; consider re-running "
f'with lif_mosaic="stage-stitch" to place tiles at their true '
f"stage µm positions (honours declared overlap; one canvas per "
f'scene), lif_mosaic="grid-stitch" to fix arrangement on a butt-'
f"jointed canvas by FieldX/FieldY indices (seams remain if the "
f'acquisition had overlap), lif_mosaic="per-tile" to write each '
f"tile as its own OME-Zarr (pixel-correct, no seams — external "
f"stitcher must reassemble), or an external stitcher (ASHLAR, "
f"m2stitch, BigStitcher) if boundary correctness matters."
f"('{scene_name}{_MERGED_SUFFIX}') was found. Under the default "
f'lif_mosaic="auto-stitch" cascade this fallback runs only when '
f"neither stage-stitch nor grid-stitch is eligible (no per-tile "
f"<Tile> layout in the scene XML); consider "
f'lif_mosaic="per-tile" to write each tile as its own OME-Zarr '
f"(pixel-correct, no seams — external stitcher must reassemble), "
f"or an external stitcher (ASHLAR, m2stitch, BigStitcher) if "
f"boundary correctness matters."
)

@property
Expand Down
45 changes: 40 additions & 5 deletions src/zarrmony/writers/plate.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
extract_tile_layout,
grid_shape,
reassemble_grid,
select_auto_stitch_cascade,
)
from zarrmony.readers.plate import PlateField, PlateLayout
from zarrmony.writers.scene import write_scene
Expand Down Expand Up @@ -315,18 +316,50 @@ def write_plate(
channels = _channels_for_current_scene(reader, channel_colors)
image_name = f.field_name or reader.scenes[f.scene_index]

grid_stitch_this_fov = lif_mosaic == "grid-stitch" and bool(
# Cascade under plate mode: auto-stitch resolves per FOV to either
# grid-stitch (when tile grid metadata is complete) or bioio-lif
# (fallback). Stage-stitch is unreachable here — the plate writer
# hasn't wired stage-stitch's canvas swap, and select_auto_stitch_
# cascade() honours plate_mode=True by skipping it. Explicit
# lif_mosaic="grid-stitch" from the caller still routes directly.
reassembly_eligible_fov = bool(
getattr(reader, "is_mosaic_reassembly_eligible", lambda: False)()
)
effective_stitcher = lif_mosaic
cascade_selected = False
grid_tile_layout: dict | None = None
grid_tile_count = 0
if reassembly_eligible_fov and lif_mosaic == "auto-stitch":
cascade_tiles_xarr = reader.tiles_xarray_dask_data
grid_tile_count = int(cascade_tiles_xarr.sizes["M"])
cascade_scene_xml = find_scene_xml(reader)
grid_tile_layout = (
extract_tile_layout(cascade_scene_xml)
if cascade_scene_xml is not None
else None
)
effective_stitcher = select_auto_stitch_cascade(
grid_tile_layout,
m_size=grid_tile_count,
pixel_size_x_um=None,
pixel_size_y_um=None,
plate_mode=True,
)
cascade_selected = True

grid_stitch_this_fov = (
effective_stitcher == "grid-stitch" and reassembly_eligible_fov
)
if grid_stitch_this_fov:
grid_tiles_xarr = reader.tiles_xarray_dask_data
grid_tile_count = int(grid_tiles_xarr.sizes["M"])
scene_xml = find_scene_xml(reader)
grid_tile_layout = (
extract_tile_layout(scene_xml) if scene_xml is not None else None
)
if grid_tile_layout is None:
scene_xml = find_scene_xml(reader)
grid_tile_layout = (
extract_tile_layout(scene_xml)
if scene_xml is not None
else None
)
grid_xarr = reassemble_grid(grid_tiles_xarr, grid_tile_layout)
else:
grid_xarr = None
Expand Down Expand Up @@ -357,6 +390,8 @@ def write_plate(
tile_count=grid_tile_count,
reader=reader,
)
if cascade_selected and "mosaic" in scene_record and scene_record["mosaic"]:
scene_record["mosaic"]["cascade_selected"] = True
field_records.append(scene_record)

if ome_image_for_field is not None:
Expand Down
Loading
Loading