diff --git a/docs/design/rfc5-coordinate-transformations.md b/docs/design/rfc5-coordinate-transformations.md new file mode 100644 index 00000000..2be3b761 --- /dev/null +++ b/docs/design/rfc5-coordinate-transformations.md @@ -0,0 +1,171 @@ +# Design: RFC-5 Coordinate Systems & Transformations (OME-Zarr 0.6) + +Status: **draft for review** · Depends on: the `ome_version` selector (PR #240) · +Target: `ZarrOMEVersion_0_6` + +## 1. Goal + +Support the defining feature of OME-Zarr 0.6 — [RFC-5](https://ngff.openmicroscopy.org/rfc/5/): +**named coordinate systems** plus a **richer transformation model** (affine, +rotation, translation, sequence, …) — behind the existing `0.6` opt-in, with +`0.5` output unchanged. + +RFC-5 is accepted but still `S4` (implementations updating). We pin output to a +specific dev tag (0.6.dev3) and keep it opt-in until 0.6 is released. + +## 2. What RFC-5 changes (vs our current 0.5 output) + +Today (0.5) we emit, per `multiscales[0]`: + +```jsonc +"axes": [ {name,type,unit}, ... ], +"datasets": [ { "path":"0", "coordinateTransformations":[ {"type":"scale","scale":[...]} ] }, ... ] +``` + +RFC-5 restructures this into: + +- **`coordinateSystems`**: named sets of axes. The axis metadata (name/type/unit, + plus optional `discrete`, `longName`) moves *into* the coordinate system; the + top-level `axes` key is superseded. +- **`coordinateTransformations`** gain **`input`/`output`** referencing coordinate + systems (or array paths), and a full transform zoo. +- Each dataset's transform maps the **array coordinate system** (`input` == the + dataset `path`, e.g. `"0"`) to a shared physical system (`output`, e.g. + `"intrinsic"`). +- An optional **multiscales-level `coordinateTransformations`** maps `intrinsic` + to further systems (e.g. a rotated/registered space). + +Minimal faithful translation of what we already emit: + +```jsonc +"multiscales": [{ + "coordinateSystems": [ + { "name": "intrinsic", + "axes": [ {"name":"z","type":"space","unit":"micrometer"}, + {"name":"y","type":"space","unit":"micrometer"}, + {"name":"x","type":"space","unit":"micrometer"} ] } + ], + "datasets": [ + { "path": "0", + "coordinateTransformations": [ + { "type":"scale", "scale":[1,1,1], + "input":{"path":"0"}, "output":{"name":"intrinsic"} } ] }, + { "path": "1", + "coordinateTransformations": [ + { "type":"scale", "scale":[2,2,2], + "input":{"path":"1"}, "output":{"name":"intrinsic"} } ] } + ] +}] +``` + +### Transform zoo (RFC-5) + +| Type | Params | Notes | +|------|--------|-------| +| identity | — | | +| mapAxis | `mapAxis:[int]` | axis permutation | +| translation | `translation:[num]` \| `path` | | +| scale | `scale:[num]` \| `path` | what we emit today | +| affine | `affine:[[..]]` (M×(N+1)) \| `path` | | +| rotation | `rotation:[[..]]` (N×N, det 1) \| `path` | | +| sequence | `transformations:[...]`, `input`, `output` | ordered composition | +| byDimension | `transformations:[{...,input_axes,output_axes}]` | per-axis-subset | +| coordinates / displacements | `path`, `interpolation` | non-linear; array-backed | +| inverseOf / bijection | wrap other transforms | | + +`input`/`output` reference a coordinate-system `name` or a zarr path; array-backed +transforms (`path`) live under a `coordinateTransformations/` group in the container. + +## 3. Key insight: the first RFC-5 PR needs **no new user API** + +Everything we emit today (per-level `scale` derived from `dim.scale`) maps directly +into the RFC-5 shape. So the first slice is a **pure emission change** gated on +`ome_version == 0_6`:```` + +1. Build one `coordinateSystems` entry named `"intrinsic"` from the existing visible + axes (reusing `dimension_type_to_string` + units). +2. Emit each dataset transform as today's `scale`, adding `"input": {"path": ""}` + and `"output": {"name": "intrinsic"}` (both are objects, per the 0.6rc0 + `inputOutput` schema, not bare strings). +3. Omit the top-level `axes` key in 0.6 mode (superseded); keep it in 0.5 mode. + +This lands RFC-5-structural conformance with zero API surface and a clean 0.5/0.6```` +branch in `MultiscaleArray::make_multiscales_metadata_()`. + +## 4. Proposed staging + +**PR #2a — structural RFC-5 emission (this design's core).** +- Branch `make_multiscales_metadata_()` on `config_->ome_version`. +- Add a helper that builds `coordinateSystems` from `ArrayDimensions`. +- Rewrite the datasets loop to attach `input`/`output`. +- Tests: a 0.6 multiscale store asserts `coordinateSystems`, per-dataset + `input`/`output`, and absence of top-level `axes`; 0.5 output byte-identical. +- No C API / Python / config-file changes. + +**PR #2b — user-facing transforms (additive).** +- C API: a per-array optional list of extra transforms and named coordinate + systems, e.g. `ZarrCoordinateSystem` / `ZarrCoordinateTransform` (tagged union + over the transform zoo). Start with `translation`, `scale`, `affine`, `rotation`, + `sequence`; defer `coordinates`/`displacements`/`byDimension`/`bijection`/array-backed + `path` transforms. +- Internal model on `ArrayConfig` (mirrors the omero pattern: transient C struct → + owning C++ representation). +- Emit optional multiscales-level `coordinateTransformations`. +- Config-file + Python bindings (reuse the shared_ptr/opaque-vector pattern from omero). + +**PR #2c (optional, later) — group-level & array-backed transforms.** +- Cross-image transforms in a parent group's attributes; `coordinateTransformations/` + zarr arrays for affine/displacement fields. Larger; only if demand appears. + +## 5. Internal representation sketch (PR #2b) + +Mirror the omero design (`src/streaming/array.base.hh`): + +```cpp +struct CoordinateAxis { std::string name; ZarrDimensionType type; + std::optional unit; bool discrete{false}; }; +struct CoordinateSystem { std::string name; std::vector axes; }; + +struct Transform { // tagged union over the supported subset + enum class Kind { Identity, Translation, Scale, Affine, Rotation, Sequence } kind; + std::optional input, output, name; + std::vector vector; // translation/scale + std::vector> matrix; // affine/rotation + std::vector children; // sequence +}; +``` + +`ArrayConfig` gains optional `coordinate_systems` and `extra_transformations` +(both default-empty ⇒ PR #2a behavior). + +## 6. Open questions (need a decision) + +1. **Drop `axes` in 0.6 mode?** RFC-5 supersedes top-level `axes` with + `coordinateSystems`. Some readers still expect `axes`. **Recommendation:** follow + the spec — emit only `coordinateSystems` in 0.6; keep `axes` in 0.5. Revisit if a + target viewer needs both. +2. **Coordinate-system name.** RFC examples use `"intrinsic"`. **Recommendation:** + default `"intrinsic"`; allow override later via API. +3. **HCS + 0.6.** RFC-5 doesn't redefine plate/well, so only the image-level + metadata varies by version. Note the plate/well dicts carry no `version` key of + their own in 0.5 or later: RFC-2 moved the version up to `ome.version`, and the + leftover 0.5 prose requiring an inner `version` was a spec bug, fixed in + ome/ngff-spec#84 (see ome/ngff#309). So there is nothing version-dependent to + thread into `Plate::to_json`/`Well::to_json`. +4. **Non-space axes in transforms.** Today downsampling scales only space axes. + RFC-5 transforms cover all axes; keep the current scale semantics (identity on + non-space) and just restructure. + +## 7. Testing plan + +- C++ integration: extend `stream-3d-multiscale-to-filesystem`-style coverage with a + 0.6 variant asserting the RFC-5 shape; keep the 0.5 assertions as a regression guard. +- Python: a `0.6` multiscale store, validated against `ome-zarr-models-py` / + `ome-zarr-py` if available in the test env (aspirational). +- Round-trip: once #2b adds API, extend `settings.io` + `test_settings.py` as we did + for omero. + +## 8. Not in scope (for now) + +`coordinates`/`displacements` (array-backed non-linear fields), `byDimension`, +`bijection`, `inverseOf`, and cross-image/group-level transforms — tracked for #2c. diff --git a/python/tests/test_stream.py b/python/tests/test_stream.py index 2d30a373..9dd9b413 100644 --- a/python/tests/test_stream.py +++ b/python/tests/test_stream.py @@ -2359,4 +2359,18 @@ def test_ome_version_selector(tmp_path, settings): stream.close() group = zarr.open(settings.store_path, mode="r") - assert group.attrs["ome"]["version"] == "0.6" + ome = group.attrs["ome"] + assert ome["version"] == "0.6" + + # RFC-5 shape: named coordinate systems replace the top-level axes key, + # and each dataset's scale transform names its input/output. + ms = ome["multiscales"][0] + assert "coordinateSystems" in ms + assert "axes" not in ms + assert ms["coordinateSystems"][0]["name"] == "intrinsic" + assert len(ms["coordinateSystems"][0]["axes"]) == 3 + + xform = ms["datasets"][0]["coordinateTransformations"][0] + assert xform["type"] == "scale" + assert xform["input"] == {"path": "0"} + assert xform["output"] == {"name": "intrinsic"} diff --git a/src/streaming/multiscale.array.cpp b/src/streaming/multiscale.array.cpp index 9dbb2e42..81946295 100644 --- a/src/streaming/multiscale.array.cpp +++ b/src/streaming/multiscale.array.cpp @@ -253,7 +253,12 @@ zarr::MultiscaleArray::make_multiscales_metadata_() const const size_t start_dim = config_->dimensions->is_2d() ? 1 : 0; const size_t visible_ndims = ndims - start_dim; - auto& axes = multiscales[0]["axes"]; + const bool is_v06 = config_->ome_version == ZarrOMEVersion_0_6; + // RFC-5 (0.6): the physical coordinate system every dataset maps its array + // coordinates onto. + constexpr const char* intrinsic = "intrinsic"; + + nlohmann::json axes = nlohmann::json::array(); for (auto i = start_dim; i < ndims; ++i) { const auto& dim = config_->dimensions->at(i); const auto type = dimension_type_to_string(dim.type); @@ -270,6 +275,39 @@ zarr::MultiscaleArray::make_multiscales_metadata_() const } } + if (is_v06) { + // RFC-5 supersedes the top-level `axes` key with named coordinate + // systems; the axis metadata moves into the intrinsic system. + multiscales[0]["coordinateSystems"] = { + { + { "name", intrinsic }, + { "axes", axes }, + }, + }; + } else { + multiscales[0]["axes"] = axes; + } + + // Build one dataset entry. In 0.6 the scale transform additionally names + // its input (the array, referenced by path) and output (the intrinsic + // coordinate system, referenced by name). Both are objects, not strings. + auto make_dataset = [&](const std::string& path, + const std::vector& level_scales) { + nlohmann::json transform = { + { "type", "scale" }, + { "scale", level_scales }, + }; + if (is_v06) { + transform["input"] = nlohmann::json::object({ { "path", path } }); + transform["output"] = + nlohmann::json::object({ { "name", intrinsic } }); + } + return nlohmann::json{ + { "path", path }, + { "coordinateTransformations", { transform } }, + }; + }; + // spatial multiscale metadata std::vector scales(visible_ndims); for (auto i = start_dim; i < ndims; ++i) { @@ -277,18 +315,10 @@ zarr::MultiscaleArray::make_multiscales_metadata_() const scales[i - start_dim] = dim.scale; } - multiscales[0]["datasets"] = { - { - { "path", "0" }, - { "coordinateTransformations", - { - { - { "type", "scale" }, - { "scale", scales }, - }, - } }, - }, - }; + // json::array() is required here: MSVC resolves `= { }` to + // copy-assignment, yielding the object itself rather than an array of one. + multiscales[0]["datasets"] = + nlohmann::json::array({ make_dataset("0", scales) }); const auto& base_config = make_base_array_config_(); const auto& base_dims = base_config->dimensions; @@ -311,16 +341,8 @@ zarr::MultiscaleArray::make_multiscales_metadata_() const scales[j - start_dim] = base_dim.scale * std::bit_ceil(ratio); } - multiscales[0]["datasets"].push_back({ - { "path", std::to_string(i) }, - { "coordinateTransformations", - { - { - { "type", "scale" }, - { "scale", scales }, - }, - } }, - }); + multiscales[0]["datasets"].push_back( + make_dataset(std::to_string(i), scales)); // downsampling metadata multiscales[0]["type"] = downsampler_->downsampling_method(); diff --git a/tests/integration/stream-omero-and-ome-version.cpp b/tests/integration/stream-omero-and-ome-version.cpp index f6547b26..58973f42 100644 --- a/tests/integration/stream-omero-and-ome-version.cpp +++ b/tests/integration/stream-omero-and-ome-version.cpp @@ -140,6 +140,34 @@ run(ZarrOMEVersion ome_version, // an OME image group always has multiscales EXPECT(ome.contains("multiscales"), "Expected multiscales in ome metadata"); + // multiscales shape differs between 0.5 and RFC-5 (0.6) + const auto& ms = ome["multiscales"][0]; + const auto& xform = ms["datasets"][0]["coordinateTransformations"][0]; + if (ome_version == ZarrOMEVersion_0_6) { + EXPECT(ms.contains("coordinateSystems"), + "Expected coordinateSystems in 0.6 multiscales"); + EXPECT(!ms.contains("axes"), + "Expected no top-level axes in 0.6 multiscales"); + + const auto& cs = ms["coordinateSystems"][0]; + EXPECT_STR_EQ(cs["name"].get().c_str(), "intrinsic"); + EXPECT(cs["axes"].is_array() && !cs["axes"].empty(), + "Expected non-empty axes in the intrinsic coordinate system"); + + // input/output are objects: {"path": ...} and {"name": ...} + EXPECT_STR_EQ(xform["type"].get().c_str(), "scale"); + EXPECT_STR_EQ(xform["input"]["path"].get().c_str(), "0"); + EXPECT_STR_EQ(xform["output"]["name"].get().c_str(), + "intrinsic"); + } else { + EXPECT(ms.contains("axes"), + "Expected top-level axes in 0.5 multiscales"); + EXPECT(!ms.contains("coordinateSystems"), + "Expected no coordinateSystems in 0.5 multiscales"); + EXPECT(!xform.contains("input"), + "Expected no input key on the 0.5 scale transform"); + } + if (omero != nullptr) { EXPECT(ome.contains("omero"), "Expected omero in ome metadata"); const auto& j = ome["omero"];