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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 8 additions & 2 deletions src/zarrmony/metadata/lif_tiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
45 changes: 45 additions & 0 deletions tests/test_lif_tiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------------------------------------------


Expand Down Expand Up @@ -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
Loading