Skip to content
Open
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
171 changes: 171 additions & 0 deletions docs/design/rfc5-coordinate-transformations.md
Original file line number Diff line number Diff line change
@@ -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": "<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<std::string> unit; bool discrete{false}; };
struct CoordinateSystem { std::string name; std::vector<CoordinateAxis> axes; };

struct Transform { // tagged union over the supported subset
enum class Kind { Identity, Translation, Scale, Affine, Rotation, Sequence } kind;
std::optional<std::string> input, output, name;
std::vector<double> vector; // translation/scale
std::vector<std::vector<double>> matrix; // affine/rotation
std::vector<Transform> 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.
16 changes: 15 additions & 1 deletion python/tests/test_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
68 changes: 45 additions & 23 deletions src/streaming/multiscale.array.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -270,25 +275,50 @@ 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<double>& 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<double> scales(visible_ndims);
for (auto i = start_dim; i < ndims; ++i) {
const auto& dim = config_->dimensions->at(i);
scales[i - start_dim] = dim.scale;
}

multiscales[0]["datasets"] = {
{
{ "path", "0" },
{ "coordinateTransformations",
{
{
{ "type", "scale" },
{ "scale", scales },
},
} },
},
};
// json::array() is required here: MSVC resolves `= { <json> }` 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;
Expand All @@ -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();
Expand Down
28 changes: 28 additions & 0 deletions tests/integration/stream-omero-and-ome-version.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string>().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<std::string>().c_str(), "scale");
EXPECT_STR_EQ(xform["input"]["path"].get<std::string>().c_str(), "0");
EXPECT_STR_EQ(xform["output"]["name"].get<std::string>().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"];
Expand Down