From 826d528234539eb19c06e660fb4bc07bf58729f6 Mon Sep 17 00:00:00 2001 From: Max Ferrin Date: Thu, 2 Jul 2026 11:47:17 -0700 Subject: [PATCH] =?UTF-8?q?feat(lif)!:=20auto-stitch=20is=20now=20a=20casc?= =?UTF-8?q?ade=20default=20(stage=20=E2=86=92=20grid=20=E2=86=92=20bioio-l?= =?UTF-8?q?if)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: lif_mosaic="auto-stitch" (still the default) no longer means "use bioio-lif's 1-pixel-overlap M-scan-order stitcher". It now runs a per-scene cascade that picks the best available stitcher from the tile metadata surface: 1. stage-stitch — per-tile PosX/PosY + scene physical pixel size (X and Y) 2. grid-stitch — complete rectangular FieldX/FieldY grid 3. bioio-lif — no layout (final fallback) Under layout="plate" the cascade skips stage-stitch (not wired to the plate writer today) and lands on grid-stitch or bioio-lif per FOV. Validated against a real Leica LIF (Julia's astrocytes acquisition, 48 scenes, 3x3 mosaics, 10% declared overlap). The cascade lands on stage-stitch for every scene; visual comparison against the acquisition software rendering showed stage-stitch and grid-stitch both faithful, stage-stitch marginally better on seam overlap. auto-stitch (old behavior) produced wrong tile arrangement. To opt back into the pre-v0.7.0 default: pass the new lif_mosaic="bioio-lif" value. It's also what the cascade automatically picks on tile-metadata-less scenes. Audit block gains mosaic.cascade_selected=True whenever the cascade picked the concrete stitcher, so downstream analysis can distinguish "grid-stitch was picked because stage was ineligible" from "user asked for grid-stitch". Explicit values (--lif-mosaic stage-stitch, etc.) omit the flag. Implementation: - Cascade selector select_auto_stitch_cascade() in metadata/lif_tiles.py, composed from two new non-throwing predicates (is_stage_layout_complete, is_grid_layout_complete). Shared by _convert_per_scene (api.py) and write_plate (writers/plate.py). - MosaicStitchingWarning text updated: no longer suggests grid-stitch or stage-stitch (cascade already tried them), still names per-tile as the remaining zarrmony alternative. - CLI --lif-mosaic gains "bioio-lif" as a fifth choice; default string stays "auto-stitch" (only the meaning changed). - Tests: new tests/test_lif_cascade.py locks the decision matrix (7 cases: each of the 3 cascade arms + plate skip + 2 explicit-value bypasses). Two prior default-behavior tests rewritten to reflect the new default. Closes #41. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 40 +++++ src/zarrmony/api.py | 59 ++++++- src/zarrmony/cli.py | 31 ++-- src/zarrmony/metadata/lif_tiles.py | 73 ++++++++ src/zarrmony/readers/lif.py | 36 ++-- src/zarrmony/writers/plate.py | 45 ++++- tests/test_lif_cascade.py | 272 +++++++++++++++++++++++++++++ tests/test_lif_grid_stitch.py | 37 ++-- tests/test_lif_per_tile.py | 10 +- tests/test_lif_stage_stitch.py | 16 +- tests/test_readers.py | 18 +- 11 files changed, 570 insertions(+), 67 deletions(-) create mode 100644 tests/test_lif_cascade.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f8dee8c..1987ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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 `` 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 diff --git a/src/zarrmony/api.py b/src/zarrmony/api.py index 00a33cd..5e23212 100644 --- a/src/zarrmony/api.py +++ b/src/zarrmony/api.py @@ -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 @@ -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 PositionX/Y/Z units). _METERS_TO_UM = 1_000_000.0 @@ -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 " @@ -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 @@ -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: diff --git a/src/zarrmony/cli.py b/src/zarrmony/cli.py index 983b398..1dc759a 100644 --- a/src/zarrmony/cli.py +++ b/src/zarrmony/cli.py @@ -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 " - "//tile_X{f:02d}Y{f:02d}.ome.zarr/ with stage " - "positions in each tile's OME-XML 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 " + "//tile_X{f:02d}Y{f:02d}.ome.zarr/ with stage positions " + "in each tile's OME-XML 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 layout metadata. Other readers ignore this " + "flag. See ADR-0005." ), ) def convert_cmd( diff --git a/src/zarrmony/metadata/lif_tiles.py b/src/zarrmony/metadata/lif_tiles.py index ff2e7a9..802a7d6 100644 --- a/src/zarrmony/metadata/lif_tiles.py +++ b/src/zarrmony/metadata/lif_tiles.py @@ -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)``. diff --git a/src/zarrmony/readers/lif.py b/src/zarrmony/readers/lif.py index da8a1bd..06e8251 100644 --- a/src/zarrmony/readers/lif.py +++ b/src/zarrmony/readers/lif.py @@ -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 ```` 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 @@ -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" 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 diff --git a/src/zarrmony/writers/plate.py b/src/zarrmony/writers/plate.py index 6539031..a45ee5e 100644 --- a/src/zarrmony/writers/plate.py +++ b/src/zarrmony/writers/plate.py @@ -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 @@ -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 @@ -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: diff --git a/tests/test_lif_cascade.py b/tests/test_lif_cascade.py new file mode 100644 index 0000000..bcf08dc --- /dev/null +++ b/tests/test_lif_cascade.py @@ -0,0 +1,272 @@ +"""End-to-end tests for the LIF ``lif_mosaic="auto-stitch"`` cascade (v0.7.0, #41). + +The default value picks a concrete stitcher per scene: + +1. ``stage-stitch`` when every tile has ``PosX``/``PosY`` and the scene has + both physical pixel sizes. +2. ``grid-stitch`` when ``FieldX``/``FieldY`` form a complete rectangular grid. +3. ``bioio-lif`` (M-scan-order fallback) otherwise. + +These tests exercise each decision arm plus the two escape hatches +(``bioio-lif`` opt-in and ``stage-stitch`` explicit override) so a future +refactor can't silently change which branch fires for which metadata shape. +""" + +from __future__ import annotations + +import warnings +from pathlib import Path + +import pytest + +from tests.conftest import FakePhysicalPixelSizes, FakeReader, TileScene +from zarrmony import api as api_module +from zarrmony import convert +from zarrmony.errors import MosaicPlacementWarning +from zarrmony.readers.plugin import ReaderPlugin + + +def _fake_plugin(name: str = "bioio-lif") -> ReaderPlugin: + return ReaderPlugin( + name=name, + match=lambda _p: 100, + open=lambda _p: object(), + distribution=name, + source="builtin", + ) + + +@pytest.fixture +def patched_reader(monkeypatch: pytest.MonkeyPatch): + def installer(reader: FakeReader): + plugin_obj = _fake_plugin() + monkeypatch.setattr( + api_module, "get_reader", lambda _path: (reader, plugin_obj, 100) + ) + + return installer + + +TILE_YX = (20, 20) +_STEP_M = TILE_YX[1] * 0.9 / 1_000_000.0 # 10% overlap in µm → m + + +def _tiles_with(*, stage: bool, grid: bool) -> list[dict]: + """3x3 tile dicts with per-axis toggles for stage-vs-grid completeness.""" + tiles = [] + for fy in range(3): + for fx in range(3): + tiles.append( + { + "field_x": fx if grid else None, + "field_y": fy if grid else None, + "pos_x_m": (0.04 + fx * _STEP_M) if stage else None, + "pos_y_m": (0.017 + fy * _STEP_M) if stage else None, + "pos_z_m": 0.01170, + } + ) + return tiles + + +def _cascade_reader( + *, + stage: bool = True, + grid: bool = True, + pixel_sizes: FakePhysicalPixelSizes | None = None, + include_tile_metadata: bool = True, +) -> FakeReader: + """Reassembly-eligible reader whose tile metadata surface is controllable. + + ``include_tile_metadata=False`` simulates a mosaic scene with no ```` + entries in the scene XML — the "cascade lands on bioio-lif" case. + """ + if include_tile_metadata: + scene = TileScene(tiles=_tiles_with(stage=stage, grid=grid), tile_yx=TILE_YX) + per_tile_scenes = {0: scene} + else: + scene = TileScene(tiles=[], tile_yx=TILE_YX) + per_tile_scenes = {0: scene} + return FakeReader( + scenes=["Position 1"], + dims="TCZYX", + shape=(1, 1, 1, TILE_YX[0], TILE_YX[1]), + channel_names=["DAPI"], + pixel_sizes=pixel_sizes or FakePhysicalPixelSizes(Y=1.0, X=1.0), + per_tile_scenes=per_tile_scenes, + ) + + +def _get_mosaic(result: dict) -> dict: + return result["stores"][0]["per_scene"][0]["mosaic"] + + +# ---------- 1. Full metadata → stage ---------- + + +def test_cascade_lands_on_stage_when_all_metadata_present( + tmp_path: Path, patched_reader +) -> None: + reader = _cascade_reader(stage=True, grid=True) + patched_reader(reader) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", MosaicPlacementWarning) + result = convert("/tmp/x.lif", tmp_path / "out", pyramid_min_size=4) + + mosaic = _get_mosaic(result) + assert mosaic["stitcher"] == "zarrmony-stage" + assert mosaic["cascade_selected"] is True + + +# ---------- 2. Missing pixel size + complete grid → grid ---------- + + +def test_cascade_falls_back_to_grid_when_pixel_size_missing( + tmp_path: Path, patched_reader +) -> None: + reader = _cascade_reader( + stage=True, grid=True, pixel_sizes=FakePhysicalPixelSizes(Y=None, X=None) + ) + patched_reader(reader) + + result = convert("/tmp/x.lif", tmp_path / "out", pyramid_min_size=4) + + mosaic = _get_mosaic(result) + assert mosaic["stitcher"] == "zarrmony-grid" + assert mosaic["cascade_selected"] is True + + +# ---------- 3. Missing per-tile positions + complete grid → grid ---------- + + +def test_cascade_falls_back_to_grid_when_tile_positions_missing( + tmp_path: Path, patched_reader +) -> None: + reader = _cascade_reader(stage=False, grid=True) + patched_reader(reader) + + result = convert("/tmp/x.lif", tmp_path / "out", pyramid_min_size=4) + + mosaic = _get_mosaic(result) + assert mosaic["stitcher"] == "zarrmony-grid" + assert mosaic["cascade_selected"] is True + + +# ---------- 4. No metadata at all → bioio-lif ---------- + + +def test_cascade_falls_all_the_way_to_bioio_lif_without_tile_metadata( + tmp_path: Path, patched_reader +) -> None: + # FakeReader doesn't wrap through _MosaicAwareLifReader so it can't emit + # MosaicStitchingWarning — that path is exercised in test_readers.py. + # Here we assert the cascade DECISION: neither zarrmony reassembler ran, + # which is the observable difference between "cascade landed on + # bioio-lif" and "cascade landed on grid/stage". + reader = _cascade_reader(include_tile_metadata=False) + patched_reader(reader) + + result = convert("/tmp/x.lif", tmp_path / "out", pyramid_min_size=4) + + scene_record = result["stores"][0]["per_scene"][0] + mosaic = scene_record.get("mosaic") + if mosaic is not None: + assert mosaic.get("stitcher") not in ("zarrmony-stage", "zarrmony-grid") + + +# ---------- 5. Plate mode → grid (stage plate-rejected under cascade) ---------- + + +def test_cascade_under_plate_layout_skips_stage_and_lands_on_grid( + tmp_path: Path, patched_reader +) -> None: + from zarrmony.readers.plate import Acquisition, PlateField, PlateLayout + + plate_layout = PlateLayout( + name="p", + rows=["A"], + columns=["01"], + acquisitions=[Acquisition(id=1, name="acq", maximumfieldcount=1)], + fields=[ + PlateField( + scene_index=0, + row="A", + column="01", + field_name="A01", + acquisition_id=1, + ), + ], + ) + scene = TileScene(tiles=_tiles_with(stage=True, grid=True), tile_yx=TILE_YX) + reader = FakeReader( + scenes=["Position 1"], + dims="TCZYX", + shape=(1, 1, 1, TILE_YX[0], TILE_YX[1]), + channel_names=["DAPI"], + pixel_sizes=FakePhysicalPixelSizes(Y=1.0, X=1.0), + layout_hint="plate", + plate_layout=plate_layout, + per_tile_scenes={0: scene}, + ) + patched_reader(reader) + + audit = convert("/tmp/x.lif", tmp_path / "out", pyramid_min_size=4, layout="plate") + + field = audit["fields"][0] + assert field["mosaic"]["stitcher"] == "zarrmony-grid" + assert field["mosaic"]["cascade_selected"] is True + + +# ---------- 6. Explicit bioio-lif → routes there regardless ---------- + + +def test_explicit_bioio_lif_bypasses_cascade_even_with_full_metadata( + tmp_path: Path, patched_reader +) -> None: + """Passing lif_mosaic='bioio-lif' opts back into the pre-v0.7.0 default + (M-scan-order stitcher + 1-px overlap). The cascade does NOT run — the + audit must not carry cascade_selected=True for this branch, even though + the fixture has enough metadata to have picked stage under the default. + """ + reader = _cascade_reader(stage=True, grid=True) + patched_reader(reader) + + result = convert( + "/tmp/x.lif", + tmp_path / "out", + pyramid_min_size=4, + lif_mosaic="bioio-lif", + ) + + scene_record = result["stores"][0]["per_scene"][0] + mosaic = scene_record.get("mosaic") + if mosaic is not None: + assert mosaic.get("cascade_selected") is not True + assert mosaic.get("stitcher") not in ("zarrmony-stage", "zarrmony-grid") + + +# ---------- 7. Explicit stage-stitch → still stage; cascade flag absent ---------- + + +def test_explicit_stage_stitch_bypasses_cascade_flag( + tmp_path: Path, patched_reader +) -> None: + """When the user explicitly requests stage-stitch, the concrete stitcher + is the same as what the cascade would pick — but cascade_selected must + stay False so the audit accurately records intent. + """ + reader = _cascade_reader(stage=True, grid=True) + patched_reader(reader) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", MosaicPlacementWarning) + result = convert( + "/tmp/x.lif", + tmp_path / "out", + pyramid_min_size=4, + lif_mosaic="stage-stitch", + ) + + mosaic = _get_mosaic(result) + assert mosaic["stitcher"] == "zarrmony-stage" + assert mosaic.get("cascade_selected") is not True diff --git a/tests/test_lif_grid_stitch.py b/tests/test_lif_grid_stitch.py index c1fda30..72fe6ad 100644 --- a/tests/test_lif_grid_stitch.py +++ b/tests/test_lif_grid_stitch.py @@ -358,25 +358,34 @@ def test_grid_stitch_composes_with_plate_layout(tmp_path: Path, patched_reader) assert np.all(block_m0 == 1) -# ---------- auto-stitch (default) is unaffected ---------- +# ---------- default cascade falls back to grid when stage is ineligible ---------- -def test_default_mode_does_not_use_grid_stitch_path( +def test_default_cascade_falls_back_to_grid_when_pixel_size_missing( tmp_path: Path, patched_reader ) -> None: - """The default lif_mosaic value does NOT invoke grid-stitch, even when the - scene would be eligible. Regression guard for the no-flag user.""" - reader = _make_3x3_mosaic_reader() + """v0.7.0 flipped the default to a cascade: stage-stitch first, then + grid-stitch when stage is ineligible. A scene with per-tile PosX/PosY + but no scene physical pixel size (bioio-lif surfaces None for some + non-calibrated confocals) can't run stage-stitch — the cascade must + land on grid-stitch, not on bioio-lif. + """ + from tests.conftest import FakePhysicalPixelSizes + + scene = TileScene(tiles=_shuffled_3x3_tiles(), tile_yx=(8, 8)) + reader = FakeReader( + scenes=["Position 1"], + dims="TCZYX", + shape=(1, 1, 1, 8, 8), + channel_names=["DAPI"], + pixel_sizes=FakePhysicalPixelSizes(Y=None, X=None), + per_tile_scenes={0: scene}, + ) patched_reader(reader) out = tmp_path / "out" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", MosaicStitchingWarning) - result = convert("/tmp/x.lif", out, pyramid_min_size=4) + result = convert("/tmp/x.lif", out, pyramid_min_size=4) - # Default writes via the auto-stitch path (bioio-lif mosaic_xarray_dask_data), - # whose mosaic_summary carries stitcher="bioio-lif" — not zarrmony-grid. - # (FakeReader's mosaic_summary is None by default, so this is asserted - # via the ABSENCE of grid-stitch fields.) - scene_record = result["stores"][0]["per_scene"][0] - assert scene_record.get("mosaic", {}).get("stitcher") != "zarrmony-grid" + mosaic = result["stores"][0]["per_scene"][0]["mosaic"] + assert mosaic["stitcher"] == "zarrmony-grid" + assert mosaic["cascade_selected"] is True diff --git a/tests/test_lif_per_tile.py b/tests/test_lif_per_tile.py index 22e3047..007ef7a 100644 --- a/tests/test_lif_per_tile.py +++ b/tests/test_lif_per_tile.py @@ -312,10 +312,16 @@ def test_auto_stitch_default_does_not_use_per_tile_path( out = tmp_path / "out" # Default lif_mosaic — FakeReader is per-tile-eligible but the dispatch - # only fires when lif_mosaic=="per-tile". The standard per-scene path - # writes one OME-Zarr at out/Position_1.ome.zarr/. + # only fires when lif_mosaic=="per-tile". Under the v0.7.0 cascade default + # this fixture has stage metadata, so it lands on stage-stitch — the + # single-store on-disk shape (asserted below) is unchanged either way. + # Suppress MosaicPlacementWarning because this fixture's stage positions + # aren't calibrated for a realistic overlap. + from zarrmony.errors import MosaicPlacementWarning + with warnings.catch_warnings(): warnings.simplefilter("ignore", MosaicStitchingWarning) + warnings.simplefilter("ignore", MosaicPlacementWarning) result = convert("/tmp/x.lif", out, pyramid_min_size=8) assert (out / "Position_1.ome.zarr" / "zarr.json").exists() diff --git a/tests/test_lif_stage_stitch.py b/tests/test_lif_stage_stitch.py index d66e985..cc34335 100644 --- a/tests/test_lif_stage_stitch.py +++ b/tests/test_lif_stage_stitch.py @@ -348,23 +348,29 @@ def test_stage_stitch_does_not_emit_stitching_warning( convert("/tmp/x.lif", out, pyramid_min_size=4, lif_mosaic="stage-stitch") -# ---------- default mode is unaffected ---------- +# ---------- default cascade lands on stage when metadata is complete ---------- -def test_default_mode_does_not_use_stage_stitch_path( +def test_default_cascade_lands_on_stage_when_metadata_complete( tmp_path: Path, patched_reader ) -> None: - """Regression guard: the no-flag user still gets bioio-lif auto-stitch.""" + """v0.7.0 flipped the default to a cascade that picks stage-stitch when + per-tile PosX/PosY + scene pixel size are all present. This 3x3 stage + fixture supplies both, so the no-flag convert must land on stage-stitch + and stamp cascade_selected=True on the audit for downstream visibility. + """ reader = _make_3x3_stage_reader() patched_reader(reader) out = tmp_path / "out" with warnings.catch_warnings(): warnings.simplefilter("ignore", MosaicStitchingWarning) + warnings.simplefilter("ignore", MosaicPlacementWarning) result = convert("/tmp/x.lif", out, pyramid_min_size=4) - scene_record = result["stores"][0]["per_scene"][0] - assert scene_record.get("mosaic", {}).get("stitcher") != "zarrmony-stage" + mosaic = result["stores"][0]["per_scene"][0]["mosaic"] + assert mosaic["stitcher"] == "zarrmony-stage" + assert mosaic["cascade_selected"] is True # ---------- API validation: unknown values still error clearly ---------- diff --git a/tests/test_readers.py b/tests/test_readers.py index 2d09650..a85dc3c 100644 --- a/tests/test_readers.py +++ b/tests/test_readers.py @@ -413,11 +413,14 @@ def test_proxy_warning_quotes_per_tile_escape_hatch() -> None: assert 'lif_mosaic="per-tile"' in msg -def test_proxy_warning_quotes_grid_stitch_escape_hatch() -> None: - """The MosaicStitchingWarning body must also name the lif_mosaic='grid-stitch' - escape hatch (#39). Grid-stitch fixes M-scan-order tile placement while - preserving one-store-per-scene, so users with tooling that expects a single - canvas see it as an alternative to per-tile.""" +def test_proxy_warning_does_not_suggest_cascade_alternatives() -> None: + """Under the v0.7.0 cascade default (lif_mosaic="auto-stitch"), when the + bioio-lif fallback path fires it's because the cascade already tried and + couldn't use stage-stitch or grid-stitch (or the user explicitly asked for + the bioio-lif escape). The warning must NOT re-suggest either as an + "escape hatch" — that would tell the user to try things the cascade + already tried on their behalf. + """ proxy = lif_mod._MosaicAwareLifReader(_FakeBioioLifReader({0: True}, tile_count=4)) proxy.set_scene(0) @@ -425,7 +428,10 @@ def test_proxy_warning_quotes_grid_stitch_escape_hatch() -> None: _ = proxy.xarray_dask_data msg = str(captured[0].message) - assert 'lif_mosaic="grid-stitch"' in msg + assert 'lif_mosaic="grid-stitch"' not in msg + assert 'lif_mosaic="stage-stitch"' not in msg + # per-tile IS still recommended as the remaining alternative. + assert 'lif_mosaic="per-tile"' in msg def test_proxy_warning_names_m_scan_arrangement_bug() -> None: