diff --git a/CHANGELOG.md b/CHANGELOG.md index a219ab5..f8dee8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-07-02 + ### Added - Fourth value on `lif_mosaic`: `"stage-stitch"` reassembles a single canvas @@ -28,6 +30,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `zarrmony.metadata.lif_tiles.compute_stage_placements`, `reassemble_stage`, and `stage_overlap_discrepancy`. (#40) +### Fixed + +- `lif_mosaic="grid-stitch"` and `"stage-stitch"` no longer crash with + `CoordinateValidationError: conflicting sizes for dimension 'Y'` when the + reader attaches per-tile Y/X pixel-space coords. Both reassemblers now + drop stale Y/X coords along with the M-indexed coords, so a canvas whose + Y/X size no longer matches a single tile stays consistent. Surfaced on a + real Leica LIF (3×3 mosaic, 2048×2048 tiles → 6144×6144 canvas); the + synthetic fixtures happened not to carry Y/X coords so the fault didn't + show up in CI. (#41) + ## [0.5.0] - 2026-07-01 ### Added diff --git a/src/zarrmony/metadata/lif_tiles.py b/src/zarrmony/metadata/lif_tiles.py index f404625..ff2e7a9 100644 --- a/src/zarrmony/metadata/lif_tiles.py +++ b/src/zarrmony/metadata/lif_tiles.py @@ -317,10 +317,14 @@ def reassemble_grid(tiles_xarr: xr.DataArray, tile_layout: dict | None) -> xr.Da row_arrays.append(da.concatenate(row_tiles, axis=x_axis)) canvas_data = da.concatenate(row_arrays, axis=y_axis) + # Y/X coords carry per-tile pixel positions — they no longer size-match + # the enlarged canvas, so drop them along with any other spatial coord + # whose dims touch Y or X. Non-spatial coords (T/C/Z, channel names, etc.) + # survive untouched. preserved_coords = { name: coord for name, coord in tiles_xarr.coords.items() - if "M" not in coord.dims + if "M" not in coord.dims and "Y" not in coord.dims and "X" not in coord.dims } return xr.DataArray(canvas_data, dims=non_m_dims, coords=preserved_coords) @@ -552,10 +556,12 @@ def reassemble_stage( ) canvas_data = da.from_delayed(delayed_canvas, shape=canvas_shape_t, dtype=dtype) + # See reassemble_grid: Y/X coords carry per-tile positions and can't + # be reused on a canvas of a different size. preserved_coords = { name: coord for name, coord in tiles_xarr.coords.items() - if "M" not in coord.dims + if "M" not in coord.dims and "Y" not in coord.dims and "X" not in coord.dims } return xr.DataArray(canvas_data, dims=non_m_dims, coords=preserved_coords) diff --git a/tests/test_lif_tiles.py b/tests/test_lif_tiles.py index 55d8d04..1b573b7 100644 --- a/tests/test_lif_tiles.py +++ b/tests/test_lif_tiles.py @@ -367,6 +367,32 @@ def test_reassemble_grid_preserves_non_m_coords() -> None: assert list(canvas.coords["C"].values) == ["DAPI"] +def test_reassemble_grid_drops_stale_tile_yx_coords() -> None: + # Real bioio-lif readers attach Y/X pixel-space coords sized for a single + # tile. The reassembled canvas is N× wider, so those coords no longer + # size-match — reassemble_grid must drop them (regression from a real + # Leica LIF where the 2048-long Y coord clashed with a 6144-tall canvas). + m_order = [(0, 0), (1, 0), (0, 1), (1, 1)] + tile_h, tile_w = 4, 4 + tiles_xarr = _tiles_xarr(m_order, tile_h=tile_h, tile_w=tile_w) + tiles_xarr = tiles_xarr.assign_coords( + Y=np.arange(tile_h, dtype=np.float64), + X=np.arange(tile_w, dtype=np.float64), + ) + tile_layout = { + "tiles": _tiles_from_grid(m_order), + "intended_overlap_x_pct": None, + "intended_overlap_y_pct": None, + } + + canvas = reassemble_grid(tiles_xarr, tile_layout) + + assert canvas.sizes["Y"] == 2 * tile_h + assert canvas.sizes["X"] == 2 * tile_w + assert "Y" not in canvas.coords + assert "X" not in canvas.coords + + # --- compute_stage_placements --------------------------------------------- @@ -537,3 +563,22 @@ def test_reassemble_stage_raises_on_offsets_m_count_mismatch() -> None: tiles_xarr = xr.DataArray(da.from_array(arr), dims=["M", "T", "C", "Y", "X"]) with pytest.raises(ValueError, match=r"offsets count \(1\).*M dim \(2\)"): reassemble_stage(tiles_xarr, [(0, 0)], canvas_shape_yx=(4, 8)) + + +def test_reassemble_stage_drops_stale_tile_yx_coords() -> None: + # Same shape-mismatch as reassemble_grid: the reader's tile-sized Y/X + # coords can't be reused on a canvas of a different size. + tile_h, tile_w = 4, 4 + arr = np.zeros((2, 1, 1, tile_h, tile_w), dtype=np.uint16) + tiles_xarr = xr.DataArray(da.from_array(arr), dims=["M", "T", "C", "Y", "X"]) + tiles_xarr = tiles_xarr.assign_coords( + Y=np.arange(tile_h, dtype=np.float64), + X=np.arange(tile_w, dtype=np.float64), + ) + + canvas = reassemble_stage(tiles_xarr, [(0, 0), (0, 2)], canvas_shape_yx=(4, 6)) + + assert canvas.sizes["Y"] == 4 + assert canvas.sizes["X"] == 6 + assert "Y" not in canvas.coords + assert "X" not in canvas.coords