Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5a8f6d7
build: require itkwasm-downsample 2.0.0 in both ports
vboussot Aug 5, 2026
a0c5a4c
test(py): compare baselines by value, not by stored bytes
vboussot Aug 5, 2026
1313c80
test(py): rebuild the baseline archive
vboussot Aug 5, 2026
6121aad
fix(py): stop inheriting backend defaults and leaking into the caller
vboussot Aug 5, 2026
9b57a67
fix(ts): cast integer images to float32 around the Gaussian downsample
vboussot Aug 5, 2026
db6104d
test(ts): compare baselines by value and keep MR-head's orientation
vboussot Aug 5, 2026
210a01f
fix(py): default to the exact scale strategy and stop mutating the ca…
vboussot Aug 5, 2026
2b9ed54
test(py): rebuild the pad-diverged baselines
vboussot Aug 5, 2026
2631868
test(py): skip the native-method Gaussian baselines off x86
vboussot Aug 5, 2026
979bb21
fix(py): match zarr-python's codec handling on the TensorStore path
vboussot Aug 5, 2026
c23d0a0
test(py): require identical key sets and a non-empty baseline
vboussot Aug 5, 2026
c7a7780
fix(ts): harden the integer-type check and the by-value comparison
vboussot Aug 5, 2026
fcd681c
test(py): keep the guard and writer tests importable under zarr 2
vboussot Aug 5, 2026
e21c914
fix(py): keep "pad" as the default scale strategy
vboussot Aug 6, 2026
d421002
test(py): request the exact strategy where the baselines hold it
vboussot Aug 7, 2026
2f5e014
Merge remote-tracking branch 'origin/main' into feat/itkwasm-downsamp…
vboussot Aug 7, 2026
4465f2a
test(py): rebuild the brain_two_components baseline in canonical axis…
vboussot Aug 7, 2026
3d3b9e0
refactor(ts): delegate the cast conversions to itk-wasm's castImage
vboussot Aug 10, 2026
6e7c49c
test(py): skip the two-component Gaussian baseline off x86
vboussot Aug 10, 2026
1f85381
fix(py): preserve the explicit codec chain on the TensorStore path
vboussot Aug 10, 2026
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
9 changes: 8 additions & 1 deletion py/ngff_zarr/to_multiscales.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC
# SPDX-License-Identifier: MIT
import atexit
import copy
import shutil
import signal
import threading
Expand Down Expand Up @@ -502,7 +503,13 @@ def to_multiscales(
input image orders them differently.
:rtype : NgffMultiscales
"""
ngff_image = data if isinstance(data, NgffImage) else to_ngff_image(data)
# Shallow copy, with its own computed_callbacks: the rechunk and dask
# conversion below must not reach the caller's image (gh-issue-627).
if isinstance(data, NgffImage):
ngff_image = copy.copy(data)
ngff_image.computed_callbacks = list(data.computed_callbacks)
else:
ngff_image = to_ngff_image(data)

# IPFS and visualization friendly default chunks
default_chunk_size = 128 if "z" in ngff_image.dims else 256
Expand Down
127 changes: 99 additions & 28 deletions py/ngff_zarr/to_ngff_zarr.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC
# SPDX-License-Identifier: MIT
import copy
import sys
import tempfile
import warnings
Expand Down Expand Up @@ -68,6 +69,10 @@ def _numcodecs_to_zarr_v3_codec(compressor):

codec_id = getattr(compressor, "codec_id", None)
if codec_id is None:
# Already a native Zarr v3 codec: pass it through unchanged. The
# sharding path otherwise swaps it for the zstd default.
if hasattr(compressor, "to_dict"):
return compressor
return None

try:
Expand Down Expand Up @@ -269,6 +274,35 @@ def create_compression_codec(compressor):
if compressor is None:
return None

# Native Zarr v3 codecs carry no codec_id but serialise into
# exactly the form TensorStore expects.
if hasattr(compressor, "to_dict") and not hasattr(
compressor, "codec_id"
):
codec = compressor.to_dict()
# zarr-python records constructor-defaulted blosc attrs in
# _tunable_attrs and evolves them from the dtype at write
# time; mirror that so both backends land on the same
# configuration. Explicitly chosen values are kept.
config = codec.get("configuration")
if codec.get("name") == "blosc" and isinstance(config, dict):
config = dict(config)
itemsize = array.dtype.itemsize
tunable = getattr(compressor, "_tunable_attrs", None)
if tunable is None:
# Fallback for zarr without _tunable_attrs.
if config.get("typesize") in (None, 1):
config["typesize"] = itemsize
else:
if "typesize" in tunable:
config["typesize"] = itemsize
if "shuffle" in tunable:
config["shuffle"] = (
"bitshuffle" if itemsize == 1 else "shuffle"
)
codec = {**codec, "configuration": config}
return codec

if hasattr(compressor, "codec_id"):
# numcodecs compressor object
codec_id = compressor.codec_id
Expand Down Expand Up @@ -305,33 +339,33 @@ def create_compression_codec(compressor):
return compressor
return None

# Mirror the zarr-python codec chain. Left unset, TensorStore
# applies its own defaults: sharded and uncompressed.
bytes_codec = {"name": "bytes", "configuration": {"endian": "little"}}
Comment thread
vboussot marked this conversation as resolved.
default_compression = {
"name": "zstd",
"configuration": {"level": 0, "checksum": False},
}
compression_codec = (
create_compression_codec(compressor) if compressor is not None else None
)
inner_codecs = [bytes_codec, compression_codec or default_compression]

# Add sharding codec with inner codecs if needed
if internal_chunk_shape:
sharding_config = {"chunk_shape": internal_chunk_shape}

# If compression is specified, add it as inner codec for sharding
if compressor is not None:
compression_codec = create_compression_codec(compressor)
if compression_codec:
# For sharding, compression goes in the inner codecs
sharding_config["codecs"] = [compression_codec]

codecs.append(
{
"name": "sharding_indexed",
"configuration": sharding_config,
"configuration": {
"chunk_shape": internal_chunk_shape,
"codecs": inner_codecs,
},
}
)
else:
# No sharding, add compression codec directly if specified
if compressor is not None:
compression_codec = create_compression_codec(compressor)
if compression_codec:
codecs.append(compression_codec)

# Set codecs if any were added
if codecs:
spec["metadata"]["codecs"] = codecs
codecs.extend(inner_codecs)

spec["metadata"]["codecs"] = codecs
else:
raise ValueError(f"Unsupported zarr format: {zarr_format}")

Expand Down Expand Up @@ -567,6 +601,18 @@ def _configure_sharding(
return sharding_kwargs, internal_chunk_shape, arr


def _is_bytes_codec(codec) -> bool:
"""Is this the zarr v3 array-to-bytes codec rather than a compressor?"""
if isinstance(codec, str):
return codec == "bytes"
name = getattr(codec, "name", None)
if name is None and hasattr(codec, "to_dict"):
name = codec.to_dict().get("name")
if name is None and isinstance(codec, dict):
name = codec.get("name")
return name == "bytes"
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _write_array_with_tensorstore(
store_path: str,
path: str,
Expand All @@ -584,6 +630,17 @@ def _write_array_with_tensorstore(
"""Write an array using the TensorStore backend."""
# Extract compressor and other conflicting parameters from kwargs to avoid conflicts
compressor = kwargs.pop("compressor", None)
# The public API takes ``compressors`` (plural); without this a supplied
# codec is dropped and TensorStore silently writes its default.
compressors = kwargs.pop("compressors", None)
if compressor is None and compressors is not None:
if isinstance(compressors, (list, tuple)):
compressor = next(
(c for c in compressors if not _is_bytes_codec(c)),
None,
)
else:
compressor = compressors
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
kwargs.pop("chunks", None) # Remove chunks from kwargs since it's a positional arg

scale_path = f"{store_path}/{path}"
Expand Down Expand Up @@ -1302,10 +1359,13 @@ def _prepare_next_scale(
"""Prepare the next scale for processing if needed.

:param scale_strategy: Strategy for handling non-power-of-2 scale factors.
"pad" (default) always uses incremental downsampling from the previous level,
which may produce slightly different sizes due to floor-division rounding.
"exact" uses pre-computed images from the initial to_multiscales() call when
incremental downsampling cannot achieve the exact target size.
"pad" (default) always downsamples incrementally from the previous
level, which is memory-efficient but can miss the target sizes; the
datasets metadata still describes the exact targets, so the store then
misdescribes its own geometry. "exact" uses pre-computed images from
the initial to_multiscales() call when incremental downsampling cannot
achieve the exact target size, so the written arrays match the
coordinate metadata.
"""
# No next scale if we're at the last one
if index >= nscales - 1:
Expand Down Expand Up @@ -1442,11 +1502,14 @@ def to_ome_zarr(


:param scale_strategy: Strategy for handling non-power-of-2 scale factors during
multiscale writing. "pad" (default) always uses incremental downsampling from
the previous level, which is memory-efficient but may produce slightly different
sizes due to floor-division rounding. "exact" uses pre-computed images from
the initial to_multiscales() call when incremental downsampling cannot achieve
the exact target size (original_size // scale_factor).
multiscale writing. "pad" (default) always downsamples incrementally from
the previous level, which is memory-efficient but can miss the target
sizes; the datasets metadata still describes the exact targets, so the
store then misdescribes its own geometry. "exact" uses pre-computed
images from the initial to_multiscales() call when incremental
downsampling cannot achieve the exact target size
(original_size // scale_factor), so the written arrays match the
coordinate metadata.
:type scale_strategy: "pad" or "exact", optional

:param **kwargs: Passed to the zarr.create_array() or zarr.creation.create() function, e.g., compression options.
Expand Down Expand Up @@ -1565,6 +1628,14 @@ def _to_ngff_zarr_impl(
"""
Internal implementation of to_ngff_zarr without .ozx handling.
"""
# Shallow copy, with per-image computed_callbacks: the write loop swaps
# image data for on-disk views and regenerates scales, and none of that
# may reach the caller's multiscales (gh-issue-627).
multiscales = copy.copy(multiscales)
multiscales.images = [copy.copy(image) for image in multiscales.images]
for image in multiscales.images:
image.computed_callbacks = list(image.computed_callbacks)

# Setup and validation
store_path = str(store) if isinstance(store, (str, Path)) else None

Expand Down
Loading
Loading