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
7 changes: 0 additions & 7 deletions docs/itk.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,6 @@ the transformed corners -- so the whole grid boundary is walked instead. Cost is
proportional to the boundary, not the pixel count, and per block that boundary
is small.

```{note}
`itk.BSplineTransform` currently aborts inside the `itkwasm-downsample`
pipeline. This is an upstream defect in how that pipeline reconstructs a
transform, not a limitation of the approach; every other parameterization
tested -- rigid, similarity, affine, versor, and displacement fields -- works.
```

## TypeScript

The TypeScript package provides `itkTransformResampleBoundingBox`. It is async,
Expand Down
156 changes: 78 additions & 78 deletions py/pixi.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ dependencies = [
"dask[array]>=2026.1.2",
"importlib_resources",
"itkwasm >= 1.0b183",
"itkwasm-downsample >= 2.0.0",
"itkwasm-downsample >= 2.0.1",
"numpy",
"platformdirs",
"psutil; sys_platform != \"emscripten\"",
Expand Down
27 changes: 26 additions & 1 deletion py/test/_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,32 @@ def store_equals(baseline_store, test_store):
json.loads(baseline_contents[k].decode("utf-8"))
)
test_metadata = _drop_codecs(json.loads(test_contents[k].decode("utf-8")))
diff = DeepDiff(baseline_metadata, test_metadata, ignore_order=True)
# The multiscales "metadata" block records which tool produced the
# pyramid, version included. That provenance legitimately changes on
# every itkwasm-downsample upgrade while the pixels stay identical,
# so it would force a baseline-archive rebuild per upgrade if
# compared.
diff = DeepDiff(
baseline_metadata,
test_metadata,
ignore_order=True,
exclude_regex_paths=[
# Anchored to the two supported locations -- directly in
# .zattrs and embedded in consolidated .zmetadata -- so a
# look-alike path nested deeper is still compared.
r"^root\['multiscales'\]\[\d+\]\['metadata'\]\['version'\]$",
(
r"^root\['metadata'\]\['[^']*\.zattrs'\]\['multiscales'\]"
r"\[\d+\]\['metadata'\]\['version'\]$"
),
# OME-Zarr 0.5 keeps the attributes under the "ome" key of
# the zarr v3 zarr.json.
(
r"^root\['attributes'\]\['ome'\]\['multiscales'\]"
r"\[\d+\]\['metadata'\]\['version'\]$"
),
],
Comment thread
vboussot marked this conversation as resolved.
)
if diff:
sys.stderr.write(f"Metadata in {k} does not match\n")
sys.stderr.write(f"Differences: {diff}\n")
Expand Down
93 changes: 93 additions & 0 deletions py/test/test_baseline_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,96 @@ def test_store_equals_requires_identical_key_sets():
assert not store_equals(populated, test_store), (
"an extra array in the test store went unnoticed"
)


def test_provenance_version_is_ignored_only_in_place():
"""Only the recorded downsampler version may drift, where it belongs.

The multiscales metadata block records the version of the tool that
generated the pyramid; upgrades change it while the pixels stay
identical, so store_equals ignores it in .zattrs and in consolidated
.zmetadata. A look-alike path nested deeper must still be compared.
"""
import asyncio
import json

import zarr
from ngff_zarr import to_ngff_zarr
from packaging import version
from zarr.storage import MemoryStore

is_zarr3 = version.parse(zarr.__version__) >= version.parse("3.0.0b1")

def read(store, key):
if is_zarr3:
from zarr.core.buffer import default_buffer_prototype

return asyncio.run(store.get(key, default_buffer_prototype())).to_bytes()
return store[key]

def write(store, key, data):
if is_zarr3:
from zarr.core.buffer import default_buffer_prototype

buffer = default_buffer_prototype().buffer.from_bytes(data)
asyncio.run(store.set(key, buffer))
else:
store[key] = data

def edit_attrs(store, mutate):
for key in (".zattrs", ".zmetadata"):
document = json.loads(read(store, key))
attrs = document["metadata"][".zattrs"] if key == ".zmetadata" else document
mutate(attrs)
write(store, key, json.dumps(document).encode())

multiscales = _tiny_multiscales()
baseline = MemoryStore()
test_store = MemoryStore()
to_ngff_zarr(baseline, multiscales, version="0.4")
to_ngff_zarr(test_store, multiscales, version="0.4")

def bump_version(attrs):
attrs["multiscales"][0]["metadata"]["version"] = "0.0.0-provenance"

edit_attrs(test_store, bump_version)
assert store_equals(baseline, test_store), (
"a changed downsampler version in the provenance metadata must be ignored"
)

def nest_lookalike(value):
def mutate(attrs):
attrs["nested"] = {"multiscales": [{"metadata": {"version": value}}]}

return mutate

edit_attrs(baseline, nest_lookalike("a"))
edit_attrs(test_store, nest_lookalike("b"))
assert not store_equals(baseline, test_store), (
"a version change on a nested look-alike path went unnoticed"
)

if not is_zarr3:
return

# OME-Zarr 0.5: the attributes live under the "ome" key of zarr.json.
def edit_ome_attrs(store, mutate):
document = json.loads(read(store, "zarr.json"))
mutate(document["attributes"]["ome"])
write(store, "zarr.json", json.dumps(document).encode())

baseline_v05 = MemoryStore()
test_store_v05 = MemoryStore()
to_ngff_zarr(baseline_v05, multiscales, version="0.5")
to_ngff_zarr(test_store_v05, multiscales, version="0.5")

edit_ome_attrs(test_store_v05, bump_version)
assert store_equals(baseline_v05, test_store_v05), (
"a changed downsampler version in the 0.5 provenance metadata must be ignored"
)

edit_ome_attrs(baseline_v05, nest_lookalike("a"))
edit_ome_attrs(test_store_v05, nest_lookalike("b"))
assert not store_equals(baseline_v05, test_store_v05), (
"a version change on a nested look-alike path in zarr.json went unnoticed"
)
56 changes: 56 additions & 0 deletions py/test/test_itk_transform_resample_bounding_box.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,62 @@ def test_float_displacement_field_matches_double():
assert bounding_box.size == reference.size == {"y": 34, "x": 34}


def _identity_bspline(itk, extent=32.0):
"""An identity B-spline transform whose domain spans ``extent`` per axis."""
bspline = itk.BSplineTransform[itk.D, 2, 3].New()
bspline.SetTransformDomainOrigin([0.0, 0.0])
bspline.SetTransformDomainPhysicalDimensions([extent, extent])
bspline.SetTransformDomainMeshSize([4, 4])
parameters = itk.OptimizerParameters[itk.D](bspline.GetNumberOfParameters())
parameters.Fill(0.0)
bspline.SetParameters(parameters)
return bspline


def test_bspline_transform_is_supported():
"""A B-spline transform passes through the pipeline directly.

itkwasm-downsample releases before 2.0.1 aborted while reconstructing
this parameterization, so it is pinned out and covered here.
"""
itk = pytest.importorskip("itk")

fixed = _image("yx", {"y": 32, "x": 32}, {"y": 1, "x": 1}, {"y": 0, "x": 0})
moving = _image("yx", {"y": 64, "x": 64}, {"y": 1, "x": 1}, {"y": 0, "x": 0})

bounding_box = itk_transform_resample_bounding_box(
_identity_bspline(itk), fixed, moving, padding=1
)

# An identity B-spline maps the grid onto itself; padding adds one pixel.
assert bounding_box.start_index == {"y": -1, "x": -1}
assert bounding_box.size == {"y": 34, "x": 34}


def test_composite_with_bspline_stage_is_supported():
"""The affine + B-spline composite a registration returns works directly."""
itk = pytest.importorskip("itk")

affine = itk.AffineTransform[itk.D, 2].New()
affine.Translate([5.0, 3.0])
composite = itk.CompositeTransform[itk.D, 2].New()
composite.AddTransform(affine)
composite.AddTransform(_identity_bspline(itk))

fixed = _image("yx", {"y": 32, "x": 32}, {"y": 1, "x": 1}, {"y": 0, "x": 0})
moving = _image("yx", {"y": 256, "x": 256}, {"y": 1, "x": 1}, {"y": 0, "x": 0})

bounding_box = itk_transform_resample_bounding_box(
composite, fixed, moving, padding=1
)

# The identity B-spline stage leaves the affine translation of ITK
# (x, y) = (5, 3), so NGFF (y, x) = (3, 5), matching the displacement
# field test above.
assert bounding_box.start_index == {"y": 2, "x": 4}
assert bounding_box.size == {"y": 34, "x": 34}


def test_unsupported_transform_type_is_rejected():
fixed = _image("yx", {"y": 4, "x": 4}, {"y": 1, "x": 1}, {"y": 0, "x": 0})
with pytest.raises(TypeError, match="unsupported transform type"):
Expand Down
2 changes: 1 addition & 1 deletion ts/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
},
"imports": {
"@itk-wasm/compare-images": "npm:@itk-wasm/compare-images@^5.4.1",
"@itk-wasm/downsample": "npm:@itk-wasm/downsample@^2.0.0",
"@itk-wasm/downsample": "npm:@itk-wasm/downsample@^2.0.1",
"@itk-wasm/image-io": "npm:@itk-wasm/image-io@^1.6.0",
"@std/assert": "jsr:@std/assert@^1.0.18",
"@std/cli": "jsr:@std/cli@^1.0.27",
Expand Down
53 changes: 44 additions & 9 deletions ts/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading