Skip to content
Closed
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
41 changes: 28 additions & 13 deletions biahub/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def create_empty_hcs_zarr(
scale: Tuple[float] = (1, 1, 1, 1, 1),
dtype: DTypeLike = np.float32,
max_chunk_size_bytes=500e6,
version: str = "0.4",
) -> None:
"""
If the plate does not exist, create an empty zarr plate.
Expand All @@ -65,13 +66,19 @@ def create_empty_hcs_zarr(
channel_names : list[str]
Channel names, will append if not present in metadata.
dtype : DTypeLike
version : str
OME-NGFF version ("0.4" or "0.5"), by default "0.4"

Modifying from recOrder
https://github.com/mehta-lab/recOrder/blob/d31ad910abf84c65ba927e34561f916651cbb3e8/recOrder/cli/utils.py#L12
"""
MAX_CHUNK_SIZE = max_chunk_size_bytes # in bytes
bytes_per_pixel = np.dtype(dtype).itemsize

# Convert shape and scale to native Python types for zarr v3 compatibility
shape = tuple(int(x) for x in shape)
scale = tuple(float(x) for x in scale)

# Limiting the chunking to 500MB
if chunks is None:
chunk_zyx_shape = list(shape[-3:])
Expand All @@ -82,21 +89,31 @@ def create_empty_hcs_zarr(
and np.prod(chunk_zyx_shape) * bytes_per_pixel > MAX_CHUNK_SIZE
):
chunk_zyx_shape[-3] = np.ceil(chunk_zyx_shape[-3] / 2).astype(int)
chunk_zyx_shape = tuple(chunk_zyx_shape)
# Convert to native Python integers for zarr v3 compatibility
chunk_zyx_shape = tuple(int(x) for x in chunk_zyx_shape)

chunks = 2 * (1,) + chunk_zyx_shape
else:
# Convert provided chunks to native Python integers for zarr v3 compatibility
chunks = tuple(int(x) for x in chunks)

# Create plate
output_plate = open_ome_zarr(
str(store_path), layout="hcs", mode="a", channel_names=channel_names
str(store_path),
layout="hcs",
mode="a",
channel_names=channel_names,
version=version,
)
transform = [TransformationMeta(type="scale", scale=scale)]

# Create positions
for position_key in position_keys:
position_key_string = "/".join(position_key)
# Check if position is already in the store, if not create it
if position_key_string not in output_plate.zgroup:
try:
position = output_plate[position_key_string]
except KeyError:
position = output_plate.create_position(*position_key)
_ = position.create_zeros(
name="0",
Expand All @@ -105,17 +122,15 @@ def create_empty_hcs_zarr(
dtype=dtype,
transform=transform,
)
else:
position = output_plate[position_key_string]

# Check if channel_names are already in the store, if not append them
for channel_name in channel_names:
# Read channel names directly from metadata to avoid race conditions
metadata_channel_names = [
channel.label for channel in position.metadata.omero.channels
]
if channel_name not in metadata_channel_names:
position.append_channel(channel_name, resize_arrays=True)
# Check if channel_names are already in the store, if not append them
for channel_name in channel_names:
# Read channel names directly from metadata to avoid race conditions
metadata_channel_names = [
channel.label for channel in position.metadata.omero.channels
]
if channel_name not in metadata_channel_names:
position.append_channel(channel_name, resize_arrays=True)


def get_output_paths(
Expand Down
35 changes: 31 additions & 4 deletions biahub/concatenate.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ def get_channel_combiner_metadata(
# Expand the data paths
expanded_paths = []
for paths in data_paths_list:
expanded_paths.append([Path(path) for path in natsorted(glob.glob(paths))])
# Filter out zarr.json files - they are metadata, not image directories
all_paths = natsorted(glob.glob(paths))
filtered_paths = [Path(path) for path in all_paths if not path.endswith('zarr.json')]
expanded_paths.append(filtered_paths)

# Flatten the expanded paths
all_data_paths = [path for paths in expanded_paths for path in paths]
Expand Down Expand Up @@ -344,18 +347,42 @@ def concatenate(
if cropped_shape_zyx[0] > Z or cropped_shape_zyx[1] > Y or cropped_shape_zyx[2] > X:
raise ValueError("The cropped shape is larger than the original shape.")

# Detect input zarr version to preserve it in output
input_version = settings.output_ome_zarr_version
try:
source_plate_path = Path(all_data_paths[0]).parent.parent.parent
with open_ome_zarr(source_plate_path, mode="r") as input_plate:
input_version = input_plate.version
click.echo(f"Detected input zarr version: {input_version}")
except (RuntimeError, FileNotFoundError):
# Position is not part of a plate, use default from settings
click.echo(
f"Could not detect input zarr version, using setting: {settings.output_ome_zarr_version}"
)
pass

# TODO: make assertion for chunk size?
if settings.chunks_czyx is not None:
chunk_size = [1] + list(settings.chunks_czyx)
# Convert to native Python integers for zarr v3 compatibility
chunk_size = tuple(int(x) for x in chunk_size)
else:
chunk_size = settings.chunks_czyx

# Convert to native Python types for zarr v3 compatibility
output_shape = tuple(
int(x)
for x in ((len(input_time_indices), len(all_channel_names)) + tuple(cropped_shape_zyx))
)
output_scale = tuple(float(x) for x in ((1,) * 2 + tuple(output_voxel_size)))

# Logic for creation of zarr and metadata
output_metadata = {
"shape": (len(input_time_indices), len(all_channel_names)) + tuple(cropped_shape_zyx),
"shape": output_shape,
"chunks": chunk_size,
"shards_ratio": settings.shards_ratio,
"version": settings.output_ome_zarr_version,
"scale": (1,) * 2 + tuple(output_voxel_size),
"version": input_version,
"scale": output_scale,
"channel_names": all_channel_names,
"dtype": dtype,
}
Expand Down
11 changes: 11 additions & 0 deletions biahub/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,10 +497,21 @@ def register_cli(
"dtype": np.float32,
}

# Detect input zarr version to preserve it in output
input_version = "0.4"
try:
source_plate_path = Path(source_position_dirpaths[0]).parent.parent.parent
with open_ome_zarr(source_plate_path, mode="r") as input_plate:
input_version = input_plate.version
except (RuntimeError, FileNotFoundError):
# Position is not part of a plate, use default version
pass

# Create the output zarr mirroring source_position_dirpaths
create_empty_hcs_zarr(
store_path=output_dirpath,
position_keys=[p.parts[-3:] for p in source_position_dirpaths],
version=input_version,
**output_metadata,
)

Expand Down
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ def _create_plate(
z_size=4,
y_size=5,
x_size=6,
version="0.4",
):
"""
Create a plate with custom channel names
Expand All @@ -177,6 +178,7 @@ def _create_plate(
z_size: Size of Z dimension (default: 4)
y_size: Size of Y dimension (default: 5)
x_size: Size of X dimension (default: 6)
version: OME-NGFF version ("0.4" or "0.5", default: "0.4")

Returns:
Tuple of (plate_path, plate_dataset)
Expand All @@ -189,6 +191,7 @@ def _create_plate(
layout="hcs",
mode="w",
channel_names=channel_names,
version=version,
)

for row, col, fov in position_list:
Expand Down
43 changes: 43 additions & 0 deletions tests/test_concatenate.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,3 +476,46 @@ def test_concatenate_with_unique_positions(create_custom_plate, tmp_path, sbatch
for pos_name, pos in output_plate_unique.positions():
# Both positions should have all channels
assert set(pos.channel_names) == {"DAPI", "Cy5", "GFP", "RFP"}


@pytest.mark.parametrize("input_version", ["0.4", "0.5"])
def test_concatenate_preserves_zarr_version(
create_custom_plate, tmp_path, sbatch_file, input_version
):
"""
Test that concatenate preserves the input zarr version in the output
(zarr v2 input → zarr v2 output, zarr v3 input → zarr v3 output)
"""
# Create input plates with specific zarr version
plate_1_path, plate_1 = create_custom_plate(
tmp_path / 'zarr1',
channel_names=['DAPI', 'Cy5'],
version=input_version,
)
plate_2_path, plate_2 = create_custom_plate(
tmp_path / 'zarr2',
channel_names=['GFP', 'RFP'],
version=input_version,
)

# Note: Not specifying output_ome_zarr_version in settings
# to test that version is detected from input
settings = ConcatenateSettings(
concat_data_paths=[str(plate_1_path) + "/*/*/*", str(plate_2_path) + "/*/*/*"],
channel_names=['all', 'all'],
time_indices='all',
)

output_path = tmp_path / "output.zarr"
concatenate(
settings=settings,
output_dirpath=output_path,
sbatch_filepath=sbatch_file,
local=True,
)

# Check that output has the same version as input
output_plate = open_ome_zarr(output_path)
assert (
output_plate.version == input_version
), f"Expected version {input_version}, got {output_plate.version}"
Loading