Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3646dd9
feat: Add 0.6 ome zarr affine transform support
afonsobspinto Nov 10, 2025
6b0a0f4
feat: Add 0.6 ome zarr rotation transform support
afonsobspinto Nov 10, 2025
6bf25de
chore: Update zarr tests
afonsobspinto Nov 21, 2025
0bfbcd5
feat: Add mapAxis support
afonsobspinto Nov 23, 2025
07d4178
feat: Add sequence support
afonsobspinto Nov 24, 2025
8c1882f
feat: Make neuroglancer constraints explicit
afonsobspinto Nov 24, 2025
08e4f1d
chore: Add mapzAxis example
afonsobspinto Nov 24, 2025
0ba4fbe
Merge branch 'feature/NA-533-b' of github.com:MetaCell/neuroglancer i…
afonsobspinto Nov 24, 2025
c2f95de
fix: Correct mapAxis implementation
afonsobspinto Nov 24, 2025
bdc4d09
chore: Add identity example
afonsobspinto Nov 24, 2025
87c404f
feat: Update rotation test
afonsobspinto Nov 24, 2025
a8727d8
feat: Update affine example and test
afonsobspinto Nov 24, 2025
5defd3b
feat: Add input / output validation
afonsobspinto Nov 24, 2025
6554808
feat: Add proper multiscale test
afonsobspinto Nov 24, 2025
15b232b
chore: Remove multiscale tests
afonsobspinto Nov 24, 2025
586811f
chore: run formatter
seankmartin Dec 5, 2025
35a793c
refactor: extract scale function to different file
seankmartin Dec 5, 2025
fa20a20
feat: run transform from ome to model transform
seankmartin Dec 5, 2025
f363e2c
feat: pull out user transform as model transform
seankmartin Dec 5, 2025
4bf86f8
fix: more general base transform back out
seankmartin Dec 8, 2025
74b79b6
feat: more complete scale calculation
seankmartin Dec 15, 2025
64732b0
feat: add new affine scale method and use in frontend
seankmartin Jan 5, 2026
003616a
refactor: small clean up of ome zarr
seankmartin Jan 5, 2026
f9c71e5
fix(test): update pyton tests for new transform handling
seankmartin Jan 6, 2026
9fe45f1
test: update test data to use symlink to reduce size
seankmartin Jan 7, 2026
cdcc947
fix: correct map axis matrix filling
seankmartin Jan 7, 2026
3f1bb06
refactor: clean up some code and tests
seankmartin Jan 7, 2026
556882a
test: add a number of parsing tests
seankmartin Jan 7, 2026
a03673d
test: add multiscale test
seankmartin Jan 7, 2026
f7514ff
chore: remove accidental log from tests
seankmartin Jan 7, 2026
8c7c951
docs: update code comments
seankmartin Jan 7, 2026
46e6b83
test: clarify it and order for affine
seankmartin Jan 7, 2026
a7e188e
test: add example
seankmartin Jan 7, 2026
e37ccbc
chore: formattign
seankmartin Jan 7, 2026
168f273
chore: add back newline for clean diff
seankmartin Jan 7, 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
281 changes: 281 additions & 0 deletions python/tests/zarr_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,284 @@ def test_data(driver: str, data_dir: pathlib.Path, static_file_server, webdriver
vol = webdriver.viewer.volume("a").result()
b = vol.read().result()
np.testing.assert_equal(a, b)


## OME-ZARR 0.6 tests
"""Simplified OME-ZARR 0.6 transform tests.

Each test loads the corresponding example dataset for a transformation
defined in RFC-5

Transformation types from RFC-5 supported by neuroglancer:
identity, mapAxis, translation, scale, affine, rotation,
sequence

Transformation types from RFC-5 not yet supported by neuroglancer:
displacements, coordinates, inverseOf, bijection, byDimension.
"""

OME_ZARR_0_6_ROOT = TEST_DATA_DIR / "ome_zarr" / "all_0.6"
TEST_VOXEL = (13, 122, 169) # (z, y, x)
EXPECTED_VALUE = 145 # Value at the specified voxel coordinates


def test_ome_zarr_0_6_identity(static_file_server, webdriver):
"""identity: Do-nothing transformation; usually implicit.
Example dataset: basic/identity.zarr
"""
test_dir = OME_ZARR_0_6_ROOT / "basic" / "identity.zarr"
server_url = static_file_server(test_dir)
with webdriver.viewer.txn() as s:
s.layers.append(
name="identity",
layer=neuroglancer.ImageLayer(source=f"zarr3://{server_url}"),
)
webdriver.sync()
model_space = _assert_renders(webdriver, "identity")

_verify_data_at_point(model_space["volume"], TEST_VOXEL, EXPECTED_VALUE)


def test_ome_zarr_0_6_scale(static_file_server, webdriver):
"""scale: Per-axis scaling factors (JSON vector form).
Example dataset: basic/scale.zarr

Scale transform: [4, 3, 2] for (z, y, x) axes maps array coordinates to physical space.
This test verifies the scale was correctly applied by:
1. Checking the coordinate space has correct scale factors
2. Verifying we can read data using physical coordinates
"""
test_dir = OME_ZARR_0_6_ROOT / "basic" / "scale.zarr"
server_url = static_file_server(test_dir)
with webdriver.viewer.txn() as s:
s.layers.append(
name="scale", layer=neuroglancer.ImageLayer(source=f"zarr3://{server_url}")
)
webdriver.sync()
model_space = _assert_renders(webdriver, "scale")

# Verify the scale transform was applied by checking the coordinate space
# Expected scales are [4, 3, 2] for (z, y, x) in micrometers from the scale.zarr metadata
# Neuroglancer converts to meters internally, so we expect [4e-6, 3e-6, 2e-6]
expected_scales = np.array([4e-6, 3e-6, 2e-6]) # meters
actual_scales = np.array(model_space["scales"])
actual_units = model_space["units"]

assert len(actual_scales) == 3, f"Expected 3 scales, got {len(actual_scales)}"
assert actual_units == [
"m",
"m",
"m",
], f"Expected units ['m', 'm', 'm'], got {actual_units}"
assert np.allclose(
expected_scales, actual_scales
), f"Scale values do not match, got {actual_scales}, expected {expected_scales}"

_verify_data_at_point(model_space["volume"], TEST_VOXEL, EXPECTED_VALUE)


def test_ome_zarr_0_6_translation(static_file_server, webdriver):
"""translation: Per-axis translation (JSON vector form).
Example dataset: basic/translation.zarr
"""
test_dir = OME_ZARR_0_6_ROOT / "basic" / "translation.zarr"
server_url = static_file_server(test_dir)
with webdriver.viewer.txn() as s:
s.layers.append(
name="translation",
layer=neuroglancer.ImageLayer(source=f"zarr3://{server_url}"),
)
webdriver.sync()
model_space = _assert_renders(webdriver, "translation")

# Translation: [30, 20, 10] (z, y, x) in micrometers
# So the origin should be at [8, 7, 5] in (z, y, x) in world coordinates.
# However, in neuroglancer at the chunk/model level this is reversed in order (see getSources in zarr/frontend.ts for the permutation)
domain = model_space["volume"].domain
expected_origin = (10, 20, 30)
actual_origin = domain.origin
assert (
actual_origin == expected_origin
), f"Domain origin mismatch: expected {expected_origin}, got {actual_origin}"

translated_voxel = (TEST_VOXEL[0] + 10, TEST_VOXEL[1] + 20, TEST_VOXEL[2] + 30)
_verify_data_at_point(model_space["volume"], translated_voxel, EXPECTED_VALUE)


def test_ome_zarr_0_6_map_axis(static_file_server, webdriver):
"""mapAxis: Axis permutation via axis name mapping.
Example dataset: axis_dependent/mapAxis.zarr

This dataset uses the same underlying array as identity.zarr (shape [27, 226, 186])
but applies a mapAxis [1, 2, 0] transform that permutes the axes.

"""
mapaxis_dir = OME_ZARR_0_6_ROOT / "axis_dependent" / "mapAxis.zarr"
mapaxis_url = static_file_server(mapaxis_dir)

with webdriver.viewer.txn() as s:
s.layers.append(
name="mapAxis",
layer=neuroglancer.ImageLayer(source=f"zarr3://{mapaxis_url}"),
)
webdriver.sync()

model_space = _assert_renders(webdriver, "mapAxis")
vol = model_space["volume"]

# The real map axis transform permutes axes (0, 1, 2) -> (1, 2, 0). The inverse of this is (2, 0, 1).
permuted_voxel = (TEST_VOXEL[2], TEST_VOXEL[0], TEST_VOXEL[1])
_verify_data_at_point(vol, permuted_voxel, EXPECTED_VALUE)


def _check_sequence_result(model_space):
"""Reused across the sequence and affine tests"""
# Verify scales - the scale component of the sequence transform
# Expected scales are [4, 3, 2] micrometers -> [4e-6, 3e-6, 2e-6] meters
expected_scales = [4e-6, 3e-6, 2e-6]
actual_scales = model_space["scales"]
for i, (expected, actual) in enumerate(zip(expected_scales, actual_scales)):
assert (
abs(actual - expected) < 1e-9
), f"Scale mismatch on axis {i}: expected {expected}, got {actual}"

# In voxel space with scale factored out:
# - The translation/scale = [32/4, 21/3, 10/2] = [8, 7, 5] voxels
# So the origin should be at [8, 7, 5] in (z, y, x) in world coordinates.
# However, in neuroglancer at the chunk/model level this is reversed in order (see getSources in zarr/frontend.ts for the permutation)
# so check for [5, 7, 8] as the domain
domain = model_space["volume"].domain
expected_origin = (5, 7, 8)
actual_origin = domain.origin
assert (
actual_origin == expected_origin
), f"Domain origin mismatch: expected {expected_origin}, got {actual_origin}"

new_test_voxel = np.array(TEST_VOXEL) + np.array(expected_origin)
_verify_data_at_point(model_space["volume"], new_test_voxel, EXPECTED_VALUE)


def test_ome_zarr_0_6_sequence(static_file_server, webdriver):
"""sequence: Ordered composition of transforms (scale + translation).
Example dataset: basic/sequenceScaleTranslation.zarr

Transform:
1. Scale: [4, 3, 2] (z, y, x)
2. Translation: [32, 21, 10] (z, y, x)

The translation values are chosen so that translation/scale yields
integer origins [8, 7, 5] in voxel space.
Neuroglancer requires an integer origin for the python integration (see volume.ts)
"""
test_dir = OME_ZARR_0_6_ROOT / "basic" / "sequenceScaleTranslation.zarr"
server_url = static_file_server(test_dir)
with webdriver.viewer.txn() as s:
s.layers.append(
name="sequence",
layer=neuroglancer.ImageLayer(source=f"zarr3://{server_url}"),
)
webdriver.sync()
model_space = _assert_renders(webdriver, "sequence")
_check_sequence_result(model_space)


def test_ome_zarr_0_6_affine(static_file_server, webdriver):
"""affine: Affine matrix (JSON form) applied to single scale.
Example dataset: simple/affine.zarr

Affine transform: Diagonal scale matrix with translation (equivalent to sequence of scale + translation).
Matrix:
[4, 0, 0, 32] - z axis: scale 4, translation 32
[0, 3, 0, 21] - y axis: scale 3, translation 21
[0, 0, 2, 10] - x axis: scale 2, translation 10
"""
test_dir = OME_ZARR_0_6_ROOT / "simple" / "affine.zarr"
server_url = static_file_server(test_dir)
with webdriver.viewer.txn() as s:
s.layers.append(
name="affine", layer=neuroglancer.ImageLayer(source=f"zarr3://{server_url}")
)
webdriver.sync()

model_space = _assert_renders(webdriver, "affine")
_check_sequence_result(model_space)


def test_ome_zarr_0_6_rotation(static_file_server, webdriver):
"""rotation: Rotation matrix (or axis permutation) example.
Example dataset: simple/rotation.zarr
"""
test_dir = OME_ZARR_0_6_ROOT / "simple" / "rotation.zarr"
server_url = static_file_server(test_dir)
with webdriver.viewer.txn() as s:
s.layers.append(
name="rotation",
layer=neuroglancer.ImageLayer(source=f"zarr3://{server_url}"),
)
webdriver.sync()

model_space = _assert_renders(webdriver, "rotation")
vol = model_space["volume"]
# The rotation transform permutes axes (0, 1, 2) -> (2, 0, 1). The inverse of this is (1, 2, 0).
rotated_voxel = (TEST_VOXEL[1], TEST_VOXEL[2], TEST_VOXEL[0])
_verify_data_at_point(vol, rotated_voxel, EXPECTED_VALUE)


# Helper functions
def get_layer_model_space(webdriver, layer_name):
"""Helper to retrieve the modelSpace from the backend volume object."""
try:
vol = webdriver.viewer.volume(layer_name).result()

# Extract names from domain labels
names = list(vol.domain.labels)

# Extract units and scales from dimension_units
# vol.dimension_units returns a tuple of Unit objects
# Unit object has .base_unit (string) and .multiplier (float)
units = [u.base_unit for u in vol.dimension_units]
scales = [u.multiplier for u in vol.dimension_units]

return {
"volume": vol,
"names": names,
"units": units,
"scales": scales,
}
except Exception as e:
return f"Failed to get volume: {e}"


def _assert_renders(webdriver, layer_name: str):
model_space = get_layer_model_space(webdriver, layer_name)
assert (
model_space is not None
), f"Layer '{layer_name}' did not render (modelSpace missing)."
assert isinstance(
model_space, dict
), f"Layer '{layer_name}' failed to render: {model_space}"
return model_space


def _verify_data_at_point(vol, voxel_point, expected_value):
"""Verifies that the data value at the given voxel coordinates matches expected_value.

vol: The volume object (to avoid reading it multiple times).
voxel_point: tuple of voxel coordinates.
expected_value: The expected value at the given voxel.
"""
# Read the entire volume (for small test datasets this is fine)
data = vol.read().result()
domain = vol.domain

# Calculate index into the data array accounting for domain origin
origin = domain.origin
idx = tuple(int(v - o) for v, o in zip(voxel_point, origin))

if any(i < 0 or i >= s for i, s in zip(idx, data.shape)):
assert False, f"Voxel {voxel_point} is out of bounds"

value = data[idx]
assert (
value == expected_value
), f"Expected value {expected_value} at voxel {voxel_point}, got {value}"
22 changes: 19 additions & 3 deletions src/datasource/zarr/codec/zstd/decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,18 @@

import { decodeZstd } from "#src/async_computation/decode_zstd_request.js";
import { requestAsyncComputation } from "#src/async_computation/request.js";
import type { BytesToBytesCodec } from "#src/datasource/zarr/codec/decode.js";
import { registerCodec } from "#src/datasource/zarr/codec/decode.js";
import { CodecKind } from "#src/datasource/zarr/codec/index.js";
import type { Configuration } from "#src/datasource/zarr/codec/zstd/resolve.js";

registerCodec({
name: "zstd",
const zstdDecode: Omit<BytesToBytesCodec<Configuration>, "name"> = {
kind: CodecKind.bytesToBytes,
decode(configuration: Configuration, encoded, signal: AbortSignal) {
decode(
configuration: Configuration,
encoded: Uint8Array<ArrayBuffer>,
signal: AbortSignal,
) {
configuration;
return requestAsyncComputation(
decodeZstd,
Expand All @@ -32,4 +36,16 @@ registerCodec({
encoded,
);
},
};

// Register "zstd" (v2 and older v3)
registerCodec({
name: "zstd",
...zstdDecode,
});

// Register "zstandard" (v3 0.6+ spec)
registerCodec({
name: "zstandard",
...zstdDecode,
});
16 changes: 14 additions & 2 deletions src/datasource/zarr/codec/zstd/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,28 @@
*/

import { CodecKind } from "#src/datasource/zarr/codec/index.js";
import type { BytesToBytesCodecResolver } from "#src/datasource/zarr/codec/resolve.js";
import { registerCodec } from "#src/datasource/zarr/codec/resolve.js";
import { verifyObject } from "#src/util/json.js";

export type Configuration = object;

registerCodec({
name: "zstd",
const zstdResolver: Omit<BytesToBytesCodecResolver<Configuration>, "name"> = {
kind: CodecKind.bytesToBytes,
resolve(configuration: unknown): { configuration: Configuration } {
verifyObject(configuration);
return { configuration: {} };
},
};

// Register "zstd" (v2 and older v3)
registerCodec({
name: "zstd",
...zstdResolver,
});

// Register "zstandard" (v3 0.6+ spec)
registerCodec({
name: "zstandard",
...zstdResolver,
});
Loading