From 8f40a4c47b20e9a01853d3dc29719795d7501c53 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Thu, 24 Sep 2026 14:47:12 +0200 Subject: [PATCH] OME 0.6: validate spec MUST rules (error on write, warn on read) Add v0_6.OmeValidator covering coordinate systems, multiscales axes and datasets, scale-level arrays, transformations, plate, well and scene metadata. The v0.6 create methods throw a ZarrException listing all violations; the open methods log them as warnings and still open. Co-Authored-By: Claude Opus 5.5 --- .../ome/v0_6/MultiscaleImage.java | 25 +- .../experimental/ome/v0_6/OmeValidator.java | 830 ++++++++++++++++++ .../zarrjava/experimental/ome/v0_6/Plate.java | 7 +- .../zarrjava/experimental/ome/v0_6/Scene.java | 2 + .../zarrjava/experimental/ome/v0_6/Well.java | 2 + .../experimental/ome/OmeValidatorV06Test.java | 549 ++++++++++++ 6 files changed, 1413 insertions(+), 2 deletions(-) create mode 100644 src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/OmeValidator.java create mode 100644 src/test/java/dev/zarr/zarrjava/experimental/ome/OmeValidatorV06Test.java diff --git a/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/MultiscaleImage.java b/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/MultiscaleImage.java index 327f7187..c2bca840 100644 --- a/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/MultiscaleImage.java +++ b/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/MultiscaleImage.java @@ -61,6 +61,9 @@ public static MultiscaleImage openMultiscaleImage(@Nonnull StoreHandle storeHand } throw new ZarrException("No 'multiscales' found in ome metadata at " + storeHandle); } + for (MultiscalesEntry entry : omeMetadata.multiscales) { + OmeValidator.warnIfInvalid(storeHandle.toString(), OmeValidator.validateMultiscalesEntry(entry)); + } return new MultiscaleImage(storeHandle, group.metadata, omeMetadata); } @@ -71,6 +74,7 @@ public static MultiscaleImage create( @Nonnull StoreHandle storeHandle, @Nonnull MultiscalesEntry multiscalesEntry ) throws IOException, ZarrException { + OmeValidator.throwIfInvalid(storeHandle.toString(), OmeValidator.validateMultiscalesEntry(multiscalesEntry, true)); OmeMetadata omeMetadata = new OmeMetadata("0.6", Collections.singletonList(multiscalesEntry)); Group group = Group.create(storeHandle, omeAttributes(omeMetadata)); return new MultiscaleImage(storeHandle, group.metadata, omeMetadata); @@ -122,7 +126,6 @@ public void createScaleLevel( if (!(arrayMetadata instanceof dev.zarr.zarrjava.v3.ArrayMetadata)) { throw new ZarrException("Expected v3.ArrayMetadata for OME-Zarr v0.6, got " + arrayMetadata.getClass()); } - Array.create(storeHandle.resolve(path), (dev.zarr.zarrjava.v3.ArrayMetadata) arrayMetadata); // Convert ome.metadata.CoordinateTransformation to v0.6 CoordinateTransformation List v06Transforms = new ArrayList<>(); @@ -216,6 +219,8 @@ public void createScaleLevel( MultiscalesEntry current = omeMetadata.multiscales.get(0); MultiscalesEntry updated = current.withDataset(new dev.zarr.zarrjava.experimental.ome.v0_6.metadata.Dataset(path, v06Transforms)); + validateScaleLevel(updated, arrayMetadata); + Array.create(storeHandle.resolve(path), (dev.zarr.zarrjava.v3.ArrayMetadata) arrayMetadata); List updatedList = new ArrayList<>(omeMetadata.multiscales); updatedList.set(0, updated); omeMetadata = new OmeMetadata( @@ -229,6 +234,24 @@ public void createScaleLevel( setAttributes(omeAttributes(omeMetadata)); } + private void validateScaleLevel(MultiscalesEntry updated, dev.zarr.zarrjava.core.ArrayMetadata newArray) + throws ZarrException { + List violations = new ArrayList<>(OmeValidator.validateMultiscalesEntry(updated)); + List arrays = new ArrayList<>(); + for (int i = 0; i < updated.datasets.size() - 1; i++) { + dev.zarr.zarrjava.core.ArrayMetadata existing = null; + try { + existing = Array.open(storeHandle.resolve(updated.datasets.get(i).path)).metadata(); + } catch (Exception ignored) { + // existing level cannot be opened; skip it in the array consistency check + } + arrays.add(existing); + } + arrays.add(newArray); + violations.addAll(OmeValidator.validateScaleLevelArrays(updated, arrays)); + OmeValidator.throwIfInvalid(storeHandle.toString(), violations); + } + @Override public dev.zarr.zarrjava.experimental.ome.metadata.MultiscalesEntry getMultiscaleNode(int i) throws ZarrException { MultiscalesEntry entry = getMultiscalesEntry(i); diff --git a/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/OmeValidator.java b/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/OmeValidator.java new file mode 100644 index 00000000..7fd516b0 --- /dev/null +++ b/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/OmeValidator.java @@ -0,0 +1,830 @@ +package dev.zarr.zarrjava.experimental.ome.v0_6; + +import dev.zarr.zarrjava.ZarrException; +import dev.zarr.zarrjava.core.ArrayMetadata; +import dev.zarr.zarrjava.experimental.ome.metadata.Acquisition; +import dev.zarr.zarrjava.experimental.ome.metadata.Axis; +import dev.zarr.zarrjava.experimental.ome.metadata.NamedEntry; +import dev.zarr.zarrjava.experimental.ome.metadata.PlateMetadata; +import dev.zarr.zarrjava.experimental.ome.metadata.WellImage; +import dev.zarr.zarrjava.experimental.ome.metadata.WellMetadata; +import dev.zarr.zarrjava.experimental.ome.metadata.WellRef; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.CoordinateSystem; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.Dataset; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.MultiscalesEntry; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.SceneMetadata; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.AffineCoordinateTransformation; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.BijectionCoordinateTransformation; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.ByDimensionCoordinateTransformation; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.CoordinateTransformation; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.IdentityCoordinateTransformation; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.MapAxisCoordinateTransformation; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.RotationCoordinateTransformation; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.ScaleCoordinateTransformation; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.SequenceCoordinateTransformation; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.TranslationCoordinateTransformation; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +/** + * Validates OME-Zarr v0.6 metadata against the MUST-level rules of the specification. + * + *

Every {@code validate*} method returns a (possibly empty) list of human-readable violations. + * The v0.6 node classes throw a {@link ZarrException} listing the violations when writing + * metadata, and log them as warnings (but still open the node) when reading. + */ +public final class OmeValidator { + + private static final Logger LOGGER = Logger.getLogger(OmeValidator.class.getName()); + + private static final Pattern ALPHANUMERIC = Pattern.compile("[A-Za-z0-9]+"); + private static final Pattern WELL_IMAGE_PATH = Pattern.compile("[A-Za-z0-9._-]+"); + private static final Pattern ONLY_PERIODS = Pattern.compile("\\.+"); + private static final double MATRIX_TOLERANCE = 1e-6; + + private OmeValidator() { + } + + // ── multiscales ────────────────────────────────────────────────────────── + + /** Validates a complete multiscales entry (datasets must be non-empty). */ + public static List validateMultiscalesEntry(MultiscalesEntry entry) { + return validateMultiscalesEntry(entry, false); + } + + /** + * Validates a multiscales entry. With {@code allowEmptyDatasets}, an empty {@code datasets} list + * is accepted, which is the state right after {@code MultiscaleImage.create} and before scale + * levels have been added via {@code createScaleLevel}. + */ + public static List validateMultiscalesEntry(MultiscalesEntry entry, boolean allowEmptyDatasets) { + List violations = new ArrayList<>(); + if (entry == null) { + violations.add("multiscales entry must not be null"); + return violations; + } + + if (entry.coordinateSystems == null || entry.coordinateSystems.isEmpty()) { + violations.add("multiscales: 'coordinateSystems' must be present and non-empty"); + if (entry.axes != null) { + validateAxes("multiscales.axes", entry.axes, violations); + validateMultiscalesAxes("multiscales.axes", entry.axes, violations); + } + } else { + validateCoordinateSystems("multiscales.coordinateSystems", entry.coordinateSystems, violations); + for (int i = 0; i < entry.coordinateSystems.size(); i++) { + CoordinateSystem cs = entry.coordinateSystems.get(i); + if (cs != null && cs.axes != null) { + validateMultiscalesAxes( + "multiscales.coordinateSystems[" + i + "] ('" + cs.name + "')", cs.axes, violations); + } + } + } + + if (entry.datasets == null || (entry.datasets.isEmpty() && !allowEmptyDatasets)) { + violations.add("multiscales: 'datasets' must be present and non-empty"); + } + if (entry.datasets != null) { + String firstOutputName = null; + boolean haveFirst = false; + for (int i = 0; i < entry.datasets.size(); i++) { + String ctx = "multiscales.datasets[" + i + "]"; + Dataset dataset = entry.datasets.get(i); + if (dataset == null) { + violations.add(ctx + " must not be null"); + continue; + } + if (dataset.path == null || dataset.path.isEmpty()) { + violations.add(ctx + ": 'path' must be present and non-empty"); + } + validateDatasetTransformations(ctx, entry, dataset, violations); + if (dataset.coordinateTransformations != null && dataset.coordinateTransformations.size() == 1 + && dataset.coordinateTransformations.get(0) != null) { + String outputName = outputName(dataset.coordinateTransformations.get(0)); + if (!haveFirst) { + firstOutputName = outputName; + haveFirst = true; + } else if (!Objects.equals(firstOutputName, outputName)) { + violations.add(ctx + ": coordinateTransformations output '" + outputName + + "' differs from the output '" + firstOutputName + + "' of datasets[0]; all datasets must share the same intrinsic coordinate system"); + } + } + } + } + + if (entry.coordinateTransformations != null) { + for (int i = 0; i < entry.coordinateTransformations.size(); i++) { + validateTransformation("multiscales.coordinateTransformations[" + i + "]", + entry.coordinateTransformations.get(i), -1, violations); + } + } + return violations; + } + + /** + * Validates the arrays backing the scale levels of a multiscales entry: each array must have as + * many dimensions as the intrinsic coordinate system has axes, and all arrays must share the + * same data type and number of dimensions. {@code arrays} is aligned with {@code entry.datasets}; + * {@code null} elements (arrays that could not be opened) are skipped. + */ + public static List validateScaleLevelArrays(MultiscalesEntry entry, List arrays) { + List violations = new ArrayList<>(); + ArrayMetadata reference = null; + int referenceIndex = -1; + for (int i = 0; i < arrays.size(); i++) { + ArrayMetadata array = arrays.get(i); + if (array == null) { + continue; + } + String path = entry.datasets != null && i < entry.datasets.size() && entry.datasets.get(i) != null + ? entry.datasets.get(i).path : String.valueOf(i); + String ctx = "multiscales.datasets[" + i + "] (array '" + path + "')"; + Dataset dataset = entry.datasets != null && i < entry.datasets.size() ? entry.datasets.get(i) : null; + int axisCount = dataset != null ? intrinsicAxisCount(entry, dataset) : -1; + if (axisCount >= 0 && array.ndim() != axisCount) { + violations.add(ctx + ": array has " + array.ndim() + + " dimensions but the intrinsic coordinate system has " + axisCount + " axes"); + } + if (reference == null) { + reference = array; + referenceIndex = i; + continue; + } + if (array.ndim() != reference.ndim()) { + violations.add(ctx + ": array has " + array.ndim() + " dimensions but datasets[" + + referenceIndex + "] has " + reference.ndim() + "; all scale levels must have the same number of dimensions"); + } + if (!Objects.equals(array.dataType(), reference.dataType())) { + violations.add(ctx + ": array data type " + array.dataType() + " differs from datasets[" + + referenceIndex + "] data type " + reference.dataType() + "; all scale levels must have the same data type"); + } + } + return violations; + } + + private static void validateMultiscalesAxes(String ctx, List axes, List violations) { + if (axes.size() < 2 || axes.size() > 5) { + violations.add(ctx + ": must have between 2 and 5 axes, got " + axes.size()); + } + int space = 0; + int time = 0; + int other = 0; + int previousRank = -1; + boolean orderViolated = false; + for (Axis axis : axes) { + if (axis == null) { + continue; + } + int rank; + if ("space".equals(axis.type)) { + space++; + rank = 2; + } else if ("time".equals(axis.type)) { + time++; + rank = 0; + } else { + // channel, custom or null type + other++; + rank = 1; + } + if (rank < previousRank) { + orderViolated = true; + } + previousRank = rank; + } + if (space < 2 || space > 3) { + violations.add(ctx + ": must have 2 or 3 axes of type 'space', got " + space); + } + if (time > 1) { + violations.add(ctx + ": must have at most one axis of type 'time', got " + time); + } + if (other > 1) { + violations.add(ctx + ": must have at most one axis of type 'channel' or custom/null type, got " + other); + } + if (orderViolated) { + violations.add(ctx + ": axes must be ordered time, then channel/custom, then space; got " + axisSummary(axes)); + } + } + + private static void validateDatasetTransformations( + String ctx, MultiscalesEntry entry, Dataset dataset, List violations) { + List transforms = dataset.coordinateTransformations; + if (transforms == null || transforms.isEmpty()) { + violations.add(ctx + ": 'coordinateTransformations' must be present and non-empty"); + return; + } + if (transforms.size() != 1) { + violations.add(ctx + ": 'coordinateTransformations' must contain exactly one transformation " + + "(a single scale, a single identity, or a sequence of one scale and one translation), got " + + transforms.size()); + } + CoordinateTransformation first = transforms.get(0); + if (first == null) { + violations.add(ctx + ".coordinateTransformations[0] must not be null"); + return; + } + String outputName = outputName(first); + if (outputName != null && entry.coordinateSystems != null && findCoordinateSystem(entry, outputName) == null) { + violations.add(ctx + ": coordinateTransformations output '" + outputName + + "' does not name a coordinate system of this multiscales"); + } + int axisCount = intrinsicAxisCount(entry, dataset); + if (transforms.size() == 1) { + if (first instanceof SequenceCoordinateTransformation) { + List inner = ((SequenceCoordinateTransformation) first).transformations; + if (inner == null || inner.size() != 2 + || !(inner.get(0) instanceof ScaleCoordinateTransformation) + || !(inner.get(1) instanceof TranslationCoordinateTransformation)) { + violations.add(ctx + ": a sequence in dataset coordinateTransformations must contain exactly " + + "one scale followed by one translation, got " + typeSummary(inner)); + } + } else if (!(first instanceof ScaleCoordinateTransformation) + && !(first instanceof IdentityCoordinateTransformation)) { + violations.add(ctx + ": dataset coordinateTransformations must be a single scale, a single identity, " + + "or a sequence of one scale and one translation, got '" + first.getType() + "'"); + } + } + for (int i = 0; i < transforms.size(); i++) { + validateTransformation(ctx + ".coordinateTransformations[" + i + "]", transforms.get(i), axisCount, violations); + } + } + + /** + * Returns the number of axes of the intrinsic (output) coordinate system of the given dataset, + * or -1 if it cannot be determined. + */ + private static int intrinsicAxisCount(MultiscalesEntry entry, Dataset dataset) { + String name = null; + if (dataset.coordinateTransformations != null && !dataset.coordinateTransformations.isEmpty() + && dataset.coordinateTransformations.get(0) != null) { + name = outputName(dataset.coordinateTransformations.get(0)); + } + if (name != null) { + CoordinateSystem cs = findCoordinateSystem(entry, name); + if (cs != null) { + return cs.axes != null ? cs.axes.size() : -1; + } + } else if (entry.coordinateSystems != null && entry.coordinateSystems.size() == 1 + && entry.coordinateSystems.get(0) != null && entry.coordinateSystems.get(0).axes != null) { + return entry.coordinateSystems.get(0).axes.size(); + } + if ((entry.coordinateSystems == null || entry.coordinateSystems.isEmpty()) && entry.axes != null) { + return entry.axes.size(); + } + return -1; + } + + @Nullable + private static CoordinateSystem findCoordinateSystem(MultiscalesEntry entry, String name) { + if (entry.coordinateSystems == null) { + return null; + } + for (CoordinateSystem cs : entry.coordinateSystems) { + if (cs != null && name.equals(cs.name)) { + return cs; + } + } + return null; + } + + /** + * Returns the coordinate system name referenced by a transformation's output. + * + *

This is the single place where output references are interpreted. Output references are + * currently strings, either a plain name or {@code "#"} for object-form references. + */ + @Nullable + static String outputName(CoordinateTransformation transformation) { + String ref = transformation.getOutput(); + if (ref == null) { + return null; + } + int hash = ref.lastIndexOf('#'); + return hash >= 0 ? ref.substring(hash + 1) : ref; + } + + // ── coordinate systems ─────────────────────────────────────────────────── + + /** Validates names and axes of a coordinateSystems array. */ + public static List validateCoordinateSystems(List coordinateSystems) { + List violations = new ArrayList<>(); + validateCoordinateSystems("coordinateSystems", coordinateSystems, violations); + return violations; + } + + private static void validateCoordinateSystems( + String ctx, List coordinateSystems, List violations) { + Set names = new HashSet<>(); + for (int i = 0; i < coordinateSystems.size(); i++) { + String csCtx = ctx + "[" + i + "]"; + CoordinateSystem cs = coordinateSystems.get(i); + if (cs == null) { + violations.add(csCtx + " must not be null"); + continue; + } + if (cs.name == null || cs.name.isEmpty()) { + violations.add(csCtx + ": 'name' must be a non-empty string"); + } else if (!names.add(cs.name)) { + violations.add(csCtx + ": duplicate coordinate system name '" + cs.name + "'"); + } + if (cs.axes == null) { + violations.add(csCtx + ": 'axes' must be present"); + } else { + validateAxes(csCtx + ".axes", cs.axes, violations); + } + } + } + + private static void validateAxes(String ctx, List axes, List violations) { + Set names = new HashSet<>(); + for (int i = 0; i < axes.size(); i++) { + Axis axis = axes.get(i); + if (axis == null) { + violations.add(ctx + "[" + i + "] must not be null"); + continue; + } + if (axis.name == null || axis.name.isEmpty()) { + violations.add(ctx + "[" + i + "]: 'name' must be a non-empty string"); + } else if (!names.add(axis.name)) { + violations.add(ctx + "[" + i + "]: duplicate axis name '" + axis.name + "'"); + } + } + } + + // ── transformations ────────────────────────────────────────────────────── + + /** Validates a single coordinate transformation, recursing into nested transformations. */ + public static List validateTransformation(CoordinateTransformation transformation) { + List violations = new ArrayList<>(); + validateTransformation("coordinateTransformation", transformation, -1, violations); + return violations; + } + + /** + * @param dims expected dimensionality of the transformation's parameters, or -1 if unknown. + */ + private static void validateTransformation( + String ctx, CoordinateTransformation t, int dims, List violations) { + if (t == null) { + violations.add(ctx + " must not be null"); + return; + } + if (t instanceof ScaleCoordinateTransformation) { + List scale = ((ScaleCoordinateTransformation) t).scale; + if (dims >= 0 && scale != null && scale.size() != dims) { + violations.add(ctx + ": scale has " + scale.size() + " elements but the coordinate system has " + + dims + " axes"); + } + } else if (t instanceof TranslationCoordinateTransformation) { + List translation = ((TranslationCoordinateTransformation) t).translation; + if (dims >= 0 && translation != null && translation.size() != dims) { + violations.add(ctx + ": translation has " + translation.size() + + " elements but the coordinate system has " + dims + " axes"); + } + } else if (t instanceof SequenceCoordinateTransformation) { + List inner = ((SequenceCoordinateTransformation) t).transformations; + if (inner == null || inner.isEmpty()) { + violations.add(ctx + ": sequence 'transformations' must be present and non-empty"); + } else { + for (int i = 0; i < inner.size(); i++) { + validateTransformation(ctx + ".transformations[" + i + "]", inner.get(i), dims, violations); + } + } + } else if (t instanceof RotationCoordinateTransformation) { + validateRotation(ctx, ((RotationCoordinateTransformation) t).rotation, dims, violations); + } else if (t instanceof AffineCoordinateTransformation) { + validateAffine(ctx, ((AffineCoordinateTransformation) t).affine, violations); + } else if (t instanceof MapAxisCoordinateTransformation) { + MapAxisCoordinateTransformation m = (MapAxisCoordinateTransformation) t; + if (m.mapAxis == null) { + violations.add(ctx + ": mapAxis transformation must contain the field 'mapAxis'"); + } else { + int n = m.mapAxis.size(); + boolean[] seen = new boolean[n]; + boolean valid = true; + for (Integer index : m.mapAxis) { + if (index == null || index < 0 || index >= n || seen[index]) { + valid = false; + break; + } + seen[index] = true; + } + if (!valid) { + violations.add(ctx + ": mapAxis " + m.mapAxis + " must be a permutation of 0.." + (n - 1)); + } + if (dims >= 0 && n != dims) { + violations.add(ctx + ": mapAxis has " + n + " elements but the coordinate system has " + + dims + " axes"); + } + } + if (m.transformation != null) { + validateTransformation(ctx + ".transformation", m.transformation, -1, violations); + } + } else if (t instanceof ByDimensionCoordinateTransformation) { + validateByDimension(ctx, (ByDimensionCoordinateTransformation) t, dims, violations); + } else if (t instanceof BijectionCoordinateTransformation) { + BijectionCoordinateTransformation b = (BijectionCoordinateTransformation) t; + if (b.forward != null) { + validateTransformation(ctx + ".forward", b.forward, dims, violations); + } + if (b.inverse != null) { + validateTransformation(ctx + ".inverse", b.inverse, dims, violations); + } + } + } + + private static void validateRotation(String ctx, @Nullable List> rotation, int dims, + List violations) { + if (rotation == null) { + return; // stored in a Zarr array referenced by 'path' + } + int n = rotation.size(); + for (List row : rotation) { + if (row == null || row.size() != n) { + violations.add(ctx + ": rotation matrix must be N x N, got " + n + " rows with a row of length " + + (row == null ? 0 : row.size())); + return; + } + for (Double value : row) { + if (value == null) { + violations.add(ctx + ": rotation matrix must not contain null values"); + return; + } + } + } + if (dims >= 0 && n != dims) { + violations.add(ctx + ": rotation matrix is " + n + " x " + n + " but the coordinate system has " + + dims + " axes"); + } + double[][] m = new double[n][n]; + for (int i = 0; i < n; i++) { + for (int j = 0; j < n; j++) { + m[i][j] = rotation.get(i).get(j); + } + } + for (int i = 0; i < n; i++) { + for (int j = 0; j < n; j++) { + double dot = 0; + for (int k = 0; k < n; k++) { + dot += m[i][k] * m[j][k]; + } + double expected = i == j ? 1.0 : 0.0; + if (Math.abs(dot - expected) > MATRIX_TOLERANCE) { + violations.add(ctx + ": rotation matrix must be orthonormal"); + return; + } + } + } + double det = determinant(m); + if (Math.abs(det - 1.0) > MATRIX_TOLERANCE) { + violations.add(ctx + ": rotation matrix must have determinant 1, got " + det); + } + } + + private static double determinant(double[][] matrix) { + int n = matrix.length; + double[][] a = new double[n][]; + for (int i = 0; i < n; i++) { + a[i] = matrix[i].clone(); + } + double det = 1.0; + for (int col = 0; col < n; col++) { + int pivot = col; + for (int row = col + 1; row < n; row++) { + if (Math.abs(a[row][col]) > Math.abs(a[pivot][col])) { + pivot = row; + } + } + if (a[pivot][col] == 0.0) { + return 0.0; + } + if (pivot != col) { + double[] tmp = a[pivot]; + a[pivot] = a[col]; + a[col] = tmp; + det = -det; + } + det *= a[col][col]; + for (int row = col + 1; row < n; row++) { + double factor = a[row][col] / a[col][col]; + for (int k = col; k < n; k++) { + a[row][k] -= factor * a[col][k]; + } + } + } + return det; + } + + private static void validateAffine(String ctx, @Nullable List> affine, List violations) { + if (affine == null) { + return; // stored in a Zarr array referenced by 'path' + } + if (affine.isEmpty()) { + violations.add(ctx + ": affine matrix must not be empty"); + return; + } + int width = affine.get(0) == null ? 0 : affine.get(0).size(); + for (int i = 0; i < affine.size(); i++) { + List row = affine.get(i); + if (row == null || row.size() != width) { + violations.add(ctx + ": all rows of the affine matrix must have the same length"); + return; + } + } + if (width < 2) { + violations.add(ctx + ": affine matrix rows must have length N+1 with N >= 1, got " + width); + } + } + + private static void validateByDimension(String ctx, ByDimensionCoordinateTransformation t, int dims, + List violations) { + if (t.transformations == null || t.transformations.isEmpty()) { + violations.add(ctx + ": byDimension 'transformations' must be present and non-empty"); + return; + } + Set outputAxes = new HashSet<>(); + int max = -1; + for (int i = 0; i < t.transformations.size(); i++) { + String itemCtx = ctx + ".transformations[" + i + "]"; + ByDimensionCoordinateTransformation.ByDimensionTransformation item = t.transformations.get(i); + if (item == null) { + violations.add(itemCtx + " must not be null"); + continue; + } + if (item.outputAxes != null) { + for (Integer axis : item.outputAxes) { + if (axis == null || axis < 0) { + violations.add(itemCtx + ": output axis index " + axis + " is invalid"); + continue; + } + if (!outputAxes.add(axis)) { + violations.add(itemCtx + ": output axis " + axis + + " appears in more than one child transformation"); + } + max = Math.max(max, axis); + } + } + if (item.transformation != null) { + int childDims = item.outputAxes != null ? item.outputAxes.size() : -1; + validateTransformation(itemCtx + ".transformation", item.transformation, childDims, violations); + } + } + int expected = dims >= 0 ? dims : max + 1; + for (int axis = 0; axis < expected; axis++) { + if (!outputAxes.contains(axis)) { + violations.add(ctx + ": output axis " + axis + " does not appear in any child transformation's outputAxes"); + } + } + } + + // ── scene ──────────────────────────────────────────────────────────────── + + /** Validates scene metadata. */ + public static List validateScene(SceneMetadata scene) { + List violations = new ArrayList<>(); + if (scene == null) { + violations.add("scene metadata must not be null"); + return violations; + } + if (scene.coordinateTransformations == null) { + violations.add("scene: 'coordinateTransformations' must be present"); + } else { + for (int i = 0; i < scene.coordinateTransformations.size(); i++) { + validateTransformation("scene.coordinateTransformations[" + i + "]", + scene.coordinateTransformations.get(i), -1, violations); + } + } + if (scene.coordinateSystems != null) { + validateCoordinateSystems("scene.coordinateSystems", scene.coordinateSystems, violations); + } + return violations; + } + + // ── HCS ────────────────────────────────────────────────────────────────── + + /** Validates plate metadata. */ + public static List validatePlate(PlateMetadata plate) { + List violations = new ArrayList<>(); + if (plate == null) { + violations.add("plate metadata must not be null"); + return violations; + } + validateNamedEntries("plate.rows", plate.rows, violations); + validateNamedEntries("plate.columns", plate.columns, violations); + if (plate.wells == null) { + violations.add("plate: 'wells' must be present"); + } else { + for (int i = 0; i < plate.wells.size(); i++) { + String ctx = "plate.wells[" + i + "]"; + WellRef well = plate.wells.get(i); + if (well == null) { + violations.add(ctx + " must not be null"); + continue; + } + boolean rowOk = plate.rows != null && well.rowIndex >= 0 && well.rowIndex < plate.rows.size(); + boolean colOk = plate.columns != null && well.columnIndex >= 0 && well.columnIndex < plate.columns.size(); + if (!rowOk) { + violations.add(ctx + ": rowIndex " + well.rowIndex + " is out of range"); + } + if (!colOk) { + violations.add(ctx + ": columnIndex " + well.columnIndex + " is out of range"); + } + if (rowOk && colOk && plate.rows.get(well.rowIndex) != null && plate.columns.get(well.columnIndex) != null) { + String expected = plate.rows.get(well.rowIndex).name + "/" + plate.columns.get(well.columnIndex).name; + if (!expected.equals(well.path)) { + violations.add(ctx + ": path '" + well.path + "' must equal '" + expected + + "' (rows[rowIndex].name + \"/\" + columns[columnIndex].name)"); + } + } + } + } + if (plate.acquisitions != null) { + Set ids = new HashSet<>(); + for (int i = 0; i < plate.acquisitions.size(); i++) { + String ctx = "plate.acquisitions[" + i + "]"; + Acquisition acquisition = plate.acquisitions.get(i); + if (acquisition == null) { + violations.add(ctx + " must not be null"); + continue; + } + if (acquisition.id < 0) { + violations.add(ctx + ": id must be >= 0, got " + acquisition.id); + } + if (!ids.add(acquisition.id)) { + violations.add(ctx + ": duplicate acquisition id " + acquisition.id); + } + if (acquisition.maximumfieldcount != null && acquisition.maximumfieldcount <= 0) { + violations.add(ctx + ": maximumfieldcount must be a positive integer, got " + + acquisition.maximumfieldcount); + } + } + } + if (plate.field_count != null && plate.field_count <= 0) { + violations.add("plate: field_count must be a positive integer, got " + plate.field_count); + } + return violations; + } + + private static void validateNamedEntries(String ctx, @Nullable List entries, List violations) { + if (entries == null) { + violations.add(ctx + " must be present"); + return; + } + Set names = new HashSet<>(); + for (int i = 0; i < entries.size(); i++) { + NamedEntry entry = entries.get(i); + if (entry == null || entry.name == null) { + violations.add(ctx + "[" + i + "]: 'name' must be present"); + continue; + } + if (!ALPHANUMERIC.matcher(entry.name).matches()) { + violations.add(ctx + "[" + i + "]: name '" + entry.name + "' must contain only alphanumeric characters"); + } + if (!names.add(entry.name)) { + violations.add(ctx + "[" + i + "]: duplicate name '" + entry.name + "'"); + } + } + } + + /** Validates well metadata on its own (without the context of the enclosing plate). */ + public static List validateWell(WellMetadata well) { + return validateWell(well, null); + } + + /** + * Validates well metadata. If {@code plate} is given, acquisition references are additionally + * checked against the plate's acquisitions. + */ + public static List validateWell(WellMetadata well, @Nullable PlateMetadata plate) { + List violations = new ArrayList<>(); + if (well == null) { + violations.add("well metadata must not be null"); + return violations; + } + if (well.images == null) { + violations.add("well: 'images' must be present"); + return violations; + } + Set paths = new HashSet<>(); + for (int i = 0; i < well.images.size(); i++) { + String ctx = "well.images[" + i + "]"; + WellImage image = well.images.get(i); + if (image == null) { + violations.add(ctx + " must not be null"); + continue; + } + validateWellImagePath(ctx, image.path, violations); + if (image.path != null && !paths.add(image.path)) { + violations.add(ctx + ": duplicate image path '" + image.path + "'"); + } + } + violations.addAll(validateWellAcquisitions(well, plate)); + return violations; + } + + /** + * Checks the well's image acquisition references against the plate's acquisitions. Returns no + * violations when either side is unknown. + */ + static List validateWellAcquisitions(WellMetadata well, @Nullable PlateMetadata plate) { + if (well == null || well.images == null || plate == null || plate.acquisitions == null) { + return Collections.emptyList(); + } + List violations = new ArrayList<>(); + Set ids = new HashSet<>(); + for (Acquisition acquisition : plate.acquisitions) { + if (acquisition != null) { + ids.add(acquisition.id); + } + } + for (int i = 0; i < well.images.size(); i++) { + WellImage image = well.images.get(i); + if (image == null) { + continue; + } + String ctx = "well.images[" + i + "]"; + if (image.acquisition == null) { + if (plate.acquisitions.size() > 1) { + violations.add(ctx + ": 'acquisition' must be present because the plate has multiple acquisitions"); + } + } else if (!ids.contains(image.acquisition)) { + violations.add(ctx + ": acquisition " + image.acquisition + + " does not match any acquisition id of the plate"); + } + } + return violations; + } + + private static void validateWellImagePath(String ctx, @Nullable String path, List violations) { + if (path == null || path.isEmpty()) { + violations.add(ctx + ": 'path' must be a non-empty string"); + return; + } + if (path.contains("/")) { + violations.add(ctx + ": path '" + path + "' must not contain '/'"); + } + if (ONLY_PERIODS.matcher(path).matches()) { + violations.add(ctx + ": path '" + path + "' must not consist only of periods"); + } + if (path.startsWith("__")) { + violations.add(ctx + ": path '" + path + "' must not start with the reserved prefix '__'"); + } + if (!WELL_IMAGE_PATH.matcher(path).matches()) { + violations.add(ctx + ": path '" + path + "' must only contain the characters a-z, A-Z, 0-9, '-', '_', '.'"); + } + } + + // ── reporting ──────────────────────────────────────────────────────────── + + /** Throws a {@link ZarrException} listing all violations, if there are any. Used when writing. */ + static void throwIfInvalid(String context, List violations) throws ZarrException { + if (!violations.isEmpty()) { + throw new ZarrException(format("Invalid OME-Zarr 0.6 metadata for " + context, violations)); + } + } + + /** Logs all violations as a single warning, if there are any. Used when reading. */ + static void warnIfInvalid(String context, List violations) { + if (!violations.isEmpty()) { + LOGGER.warning(format("OME-Zarr 0.6 metadata at " + context + " violates the specification", violations)); + } + } + + private static String format(String header, List violations) { + StringBuilder sb = new StringBuilder(header).append(':'); + for (String violation : violations) { + sb.append("\n - ").append(violation); + } + return sb.toString(); + } + + private static String axisSummary(List axes) { + List parts = new ArrayList<>(); + for (Axis axis : axes) { + if (axis != null) { + parts.add(axis.name + ":" + axis.type); + } + } + return parts.toString(); + } + + private static String typeSummary(@Nullable List transforms) { + if (transforms == null) { + return "no transformations"; + } + List types = new ArrayList<>(); + for (CoordinateTransformation t : transforms) { + types.add(t == null ? "null" : t.getType()); + } + return types.toString(); + } +} diff --git a/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/Plate.java b/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/Plate.java index d270f9a1..4922fd89 100644 --- a/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/Plate.java +++ b/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/Plate.java @@ -41,6 +41,7 @@ public static Plate openPlate(@Nonnull StoreHandle storeHandle) throws IOExcepti if (omeMetadata.plate == null) { throw new ZarrException("No 'plate' found in ome metadata at " + storeHandle); } + OmeValidator.warnIfInvalid(storeHandle.toString(), OmeValidator.validatePlate(omeMetadata.plate)); return new Plate(storeHandle, group.metadata, omeMetadata); } @@ -51,6 +52,7 @@ public static Plate createPlate( @Nonnull StoreHandle storeHandle, @Nonnull PlateMetadata plateMetadata ) throws IOException, ZarrException { + OmeValidator.throwIfInvalid(storeHandle.toString(), OmeValidator.validatePlate(plateMetadata)); OmeMetadata omeMetadata = new OmeMetadata("0.6", null, null, null, null, plateMetadata, null); Group group = Group.create(storeHandle, omeAttributes(omeMetadata)); return new Plate(storeHandle, group.metadata, omeMetadata); @@ -63,7 +65,10 @@ public PlateMetadata getPlateMetadata() throws ZarrException { @Override public dev.zarr.zarrjava.experimental.ome.Well openWell(String rowColPath) throws IOException, ZarrException { - return Well.openWell(storeHandle.resolve(rowColPath)); + Well well = Well.openWell(storeHandle.resolve(rowColPath)); + OmeValidator.warnIfInvalid(well.getStoreHandle().toString(), + OmeValidator.validateWellAcquisitions(well.getWellMetadata(), omeMetadata.plate)); + return well; } @Override diff --git a/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/Scene.java b/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/Scene.java index a4416c7e..599fd8c1 100644 --- a/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/Scene.java +++ b/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/Scene.java @@ -57,6 +57,7 @@ public static Scene openScene(@Nonnull StoreHandle storeHandle) throws IOExcepti if (omeMetadata.scene == null) { throw new ZarrException("No 'scene' found in ome metadata at " + storeHandle); } + OmeValidator.warnIfInvalid(storeHandle.toString(), OmeValidator.validateScene(omeMetadata.scene)); Map discovered = new LinkedHashMap<>(); for (String child : asList(storeHandle.listChildren())) { @@ -76,6 +77,7 @@ public static Scene createScene( @Nonnull StoreHandle storeHandle, @Nonnull SceneMetadata sceneMetadata ) throws IOException, ZarrException { + OmeValidator.throwIfInvalid(storeHandle.toString(), OmeValidator.validateScene(sceneMetadata)); dev.zarr.zarrjava.experimental.ome.v0_6.metadata.OmeMetadata omeMetadata = new dev.zarr.zarrjava.experimental.ome.v0_6.metadata.OmeMetadata("0.6", null, null, sceneMetadata); Group group = Group.create(storeHandle, omeAttributes(omeMetadata)); diff --git a/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/Well.java b/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/Well.java index e2b0da1d..c86a18bb 100644 --- a/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/Well.java +++ b/src/main/java/dev/zarr/zarrjava/experimental/ome/v0_6/Well.java @@ -42,6 +42,7 @@ public static Well openWell(@Nonnull StoreHandle storeHandle) throws IOException if (omeMetadata.well == null) { throw new ZarrException("No 'well' found in ome metadata at " + storeHandle); } + OmeValidator.warnIfInvalid(storeHandle.toString(), OmeValidator.validateWell(omeMetadata.well)); return new Well(storeHandle, group.metadata, omeMetadata); } @@ -52,6 +53,7 @@ public static Well createWell( @Nonnull StoreHandle storeHandle, @Nonnull WellMetadata wellMetadata ) throws IOException, ZarrException { + OmeValidator.throwIfInvalid(storeHandle.toString(), OmeValidator.validateWell(wellMetadata)); OmeMetadata omeMetadata = new OmeMetadata("0.6", null, null, null, null, null, wellMetadata); Group group = Group.create(storeHandle, omeAttributes(omeMetadata)); return new Well(storeHandle, group.metadata, omeMetadata); diff --git a/src/test/java/dev/zarr/zarrjava/experimental/ome/OmeValidatorV06Test.java b/src/test/java/dev/zarr/zarrjava/experimental/ome/OmeValidatorV06Test.java new file mode 100644 index 00000000..61e2fad8 --- /dev/null +++ b/src/test/java/dev/zarr/zarrjava/experimental/ome/OmeValidatorV06Test.java @@ -0,0 +1,549 @@ +package dev.zarr.zarrjava.experimental.ome; + +import dev.zarr.zarrjava.ZarrException; +import dev.zarr.zarrjava.ZarrTest; +import dev.zarr.zarrjava.core.ArrayMetadata; +import dev.zarr.zarrjava.experimental.ome.metadata.Acquisition; +import dev.zarr.zarrjava.experimental.ome.metadata.Axis; +import dev.zarr.zarrjava.experimental.ome.metadata.NamedEntry; +import dev.zarr.zarrjava.experimental.ome.metadata.PlateMetadata; +import dev.zarr.zarrjava.experimental.ome.metadata.WellImage; +import dev.zarr.zarrjava.experimental.ome.metadata.WellMetadata; +import dev.zarr.zarrjava.experimental.ome.metadata.WellRef; +import dev.zarr.zarrjava.experimental.ome.v0_6.OmeValidator; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.CoordinateSystem; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.Dataset; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.MultiscalesEntry; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.SceneMetadata; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.AffineCoordinateTransformation; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.ByDimensionCoordinateTransformation; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.CoordinateTransformation; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.MapAxisCoordinateTransformation; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.RotationCoordinateTransformation; +import dev.zarr.zarrjava.experimental.ome.v0_6.metadata.transform.SequenceCoordinateTransformation; +import dev.zarr.zarrjava.store.FilesystemStore; +import dev.zarr.zarrjava.store.StoreHandle; +import dev.zarr.zarrjava.v3.DataType; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.*; + +public class OmeValidatorV06Test extends ZarrTest { + + // ── helpers ────────────────────────────────────────────────────────────── + + private static Axis space(String name) { + return new Axis(name, "space", "micrometer"); + } + + private static Axis axis(String name, String type) { + return new Axis(name, type, null); + } + + private static CoordinateSystem cs(String name, Axis... axes) { + return new CoordinateSystem(name, Arrays.asList(axes)); + } + + private static Dataset scaleDataset(String path, String output, Double... scale) { + return new Dataset(path, Collections.singletonList( + CoordinateTransformation.scale(Arrays.asList(scale), path, output))); + } + + private static MultiscalesEntry entry(List systems, Dataset... datasets) { + return new MultiscalesEntry(null, Arrays.asList(datasets), null, systems, "multiscales", null, null); + } + + private static MultiscalesEntry validEntry() { + return entry(Collections.singletonList(cs("physical", space("y"), space("x"))), + scaleDataset("s0", "physical", 1.0, 1.0), + scaleDataset("s1", "physical", 2.0, 2.0)); + } + + private static List> matrix(double[]... rows) { + List> out = new ArrayList<>(); + for (double[] row : rows) { + List r = new ArrayList<>(); + for (double v : row) { + r.add(v); + } + out.add(r); + } + return out; + } + + private static void assertViolation(List violations, String fragment) { + for (String v : violations) { + if (v.contains(fragment)) { + return; + } + } + fail("Expected a violation containing '" + fragment + "', got " + violations); + } + + private static StoreHandle store(String name) throws Exception { + return new FilesystemStore(TESTOUTPUT.resolve("ome_v06_validation").resolve(name)).resolve(); + } + + // ── coordinate systems ─────────────────────────────────────────────────── + + @Test + void validMultiscalesHasNoViolations() { + assertEquals(Collections.emptyList(), OmeValidator.validateMultiscalesEntry(validEntry())); + } + + @Test + void coordinateSystemNameMustBeNonEmptyAndUnique() { + assertViolation(OmeValidator.validateCoordinateSystems( + Collections.singletonList(cs("", space("y"), space("x")))), "non-empty"); + assertViolation(OmeValidator.validateCoordinateSystems(Arrays.asList( + cs("a", space("y"), space("x")), cs("a", space("y"), space("x")))), "duplicate coordinate system name"); + assertTrue(OmeValidator.validateCoordinateSystems(Arrays.asList( + cs("a", space("y"), space("x")), cs("b", space("y"), space("x")))).isEmpty()); + } + + @Test + void axisNamesMustBeNonEmptyAndUnique() { + assertViolation(OmeValidator.validateCoordinateSystems( + Collections.singletonList(cs("a", space("y"), space("")))), "'name' must be a non-empty string"); + assertViolation(OmeValidator.validateCoordinateSystems( + Collections.singletonList(cs("a", space("x"), space("x")))), "duplicate axis name 'x'"); + } + + // ── multiscales axes ───────────────────────────────────────────────────── + + private static List validateAxes(Axis... axes) { + List scale = new ArrayList<>(); + for (int i = 0; i < axes.length; i++) { + scale.add(1.0); + } + return OmeValidator.validateMultiscalesEntry(entry( + Collections.singletonList(cs("physical", axes)), + scaleDataset("s0", "physical", scale.toArray(new Double[0])))); + } + + @Test + void multiscalesAxisCountAndTypes() { + assertTrue(validateAxes(axis("t", "time"), axis("c", "channel"), space("z"), space("y"), space("x")).isEmpty()); + assertTrue(validateAxes(axis("c", null), space("y"), space("x")).isEmpty()); + assertViolation(validateAxes(space("x")), "between 2 and 5 axes"); + assertViolation(validateAxes(axis("t", "time"), space("x")), "2 or 3 axes of type 'space'"); + assertViolation(validateAxes(space("w"), space("z"), space("y"), space("x")), "2 or 3 axes of type 'space'"); + assertViolation(validateAxes(axis("t", "time"), axis("t2", "time"), space("y"), space("x")), + "at most one axis of type 'time'"); + assertViolation(validateAxes(axis("c", "channel"), axis("f", "foo"), space("y"), space("x")), + "at most one axis of type 'channel'"); + } + + @Test + void multiscalesAxisOrder() { + assertViolation(validateAxes(axis("c", "channel"), axis("t", "time"), space("y"), space("x")), "must be ordered"); + assertViolation(validateAxes(space("y"), space("x"), axis("c", "channel")), "must be ordered"); + } + + @Test + void legacyAxesAreValidatedWhenCoordinateSystemsMissing() { + MultiscalesEntry legacy = new MultiscalesEntry( + Arrays.asList(space("x"), space("x")), + Collections.singletonList(scaleDataset("s0", null, 1.0, 1.0)), + null, null, "multiscales", null, null); + List violations = OmeValidator.validateMultiscalesEntry(legacy); + assertViolation(violations, "'coordinateSystems' must be present"); + assertViolation(violations, "duplicate axis name 'x'"); + } + + // ── datasets ───────────────────────────────────────────────────────────── + + @Test + void datasetsMustBePresentAndNonEmpty() { + MultiscalesEntry empty = entry(Collections.singletonList(cs("physical", space("y"), space("x")))); + assertViolation(OmeValidator.validateMultiscalesEntry(empty), "'datasets' must be present and non-empty"); + assertTrue(OmeValidator.validateMultiscalesEntry(empty, true).isEmpty()); + } + + @Test + void datasetMustHavePathAndTransformations() { + MultiscalesEntry e = entry(Collections.singletonList(cs("physical", space("y"), space("x"))), + new Dataset("", Collections.emptyList())); + List violations = OmeValidator.validateMultiscalesEntry(e); + assertViolation(violations, "'path' must be present"); + assertViolation(violations, "'coordinateTransformations' must be present and non-empty"); + } + + @Test + void datasetTransformationShape() { + List systems = Collections.singletonList(cs("physical", space("y"), space("x"))); + CoordinateTransformation scale = CoordinateTransformation.scale(Arrays.asList(1.0, 1.0), null, null); + CoordinateTransformation translation = CoordinateTransformation.translation(Arrays.asList(0.5, 0.5), null, null); + + // valid: identity + assertTrue(OmeValidator.validateMultiscalesEntry(entry(systems, new Dataset("s0", + Collections.singletonList(CoordinateTransformation.identity("s0", "physical"))))).isEmpty()); + // valid: sequence[scale, translation] + assertTrue(OmeValidator.validateMultiscalesEntry(entry(systems, new Dataset("s0", Collections.singletonList( + new SequenceCoordinateTransformation("s0", "physical", null, Arrays.asList(scale, translation)))))).isEmpty()); + + // invalid: two top-level transformations + assertViolation(OmeValidator.validateMultiscalesEntry(entry(systems, new Dataset("s0", Arrays.asList( + CoordinateTransformation.scale(Arrays.asList(1.0, 1.0), "s0", "physical"), + CoordinateTransformation.translation(Arrays.asList(1.0, 1.0), "s0", "physical"))))), + "exactly one transformation"); + // invalid: single translation + assertViolation(OmeValidator.validateMultiscalesEntry(entry(systems, new Dataset("s0", Collections.singletonList( + CoordinateTransformation.translation(Arrays.asList(1.0, 1.0), "s0", "physical"))))), + "got 'translation'"); + // invalid: sequence in wrong order + assertViolation(OmeValidator.validateMultiscalesEntry(entry(systems, new Dataset("s0", Collections.singletonList( + new SequenceCoordinateTransformation("s0", "physical", null, Arrays.asList(translation, scale)))))), + "one scale followed by one translation"); + } + + @Test + void scaleLengthMustMatchIntrinsicAxes() { + assertViolation(OmeValidator.validateMultiscalesEntry(entry( + Collections.singletonList(cs("physical", space("y"), space("x"))), + scaleDataset("s0", "physical", 1.0, 1.0, 1.0))), "scale has 3 elements"); + CoordinateTransformation scale = CoordinateTransformation.scale(Arrays.asList(1.0, 1.0), null, null); + CoordinateTransformation translation = CoordinateTransformation.translation(Arrays.asList(0.5), null, null); + assertViolation(OmeValidator.validateMultiscalesEntry(entry( + Collections.singletonList(cs("physical", space("y"), space("x"))), + new Dataset("s0", Collections.singletonList( + new SequenceCoordinateTransformation("s0", "physical", null, Arrays.asList(scale, translation)))))), + "translation has 1 elements"); + } + + @Test + void datasetOutputsMustReferenceSameCoordinateSystem() { + List systems = Arrays.asList( + cs("physical", space("y"), space("x")), cs("other", space("y"), space("x"))); + assertViolation(OmeValidator.validateMultiscalesEntry(entry(systems, + scaleDataset("s0", "physical", 1.0, 1.0), + scaleDataset("s1", "other", 2.0, 2.0))), "same intrinsic coordinate system"); + assertViolation(OmeValidator.validateMultiscalesEntry(entry(systems, + scaleDataset("s0", "missing", 1.0, 1.0))), "does not name a coordinate system"); + // object-form reference {"name": "physical"} is deserialized as ".#physical" + assertTrue(OmeValidator.validateMultiscalesEntry(entry(systems, + scaleDataset("s0", "physical", 1.0, 1.0), + scaleDataset("s1", ".#physical", 2.0, 2.0))).isEmpty()); + } + + @Test + void scaleLevelArraysMustMatchAxesAndEachOther() throws Exception { + MultiscalesEntry e = validEntry(); + ArrayMetadata uint16x2 = dev.zarr.zarrjava.v3.Array.metadataBuilder() + .withShape(16, 16).withChunkShape(8, 8).withDataType(DataType.UINT16).build(); + ArrayMetadata float32x2 = dev.zarr.zarrjava.v3.Array.metadataBuilder() + .withShape(8, 8).withChunkShape(8, 8).withDataType(DataType.FLOAT32).build(); + ArrayMetadata uint16x3 = dev.zarr.zarrjava.v3.Array.metadataBuilder() + .withShape(8, 8, 8).withChunkShape(8, 8, 8).withDataType(DataType.UINT16).build(); + + assertTrue(OmeValidator.validateScaleLevelArrays(e, Arrays.asList(uint16x2, uint16x2)).isEmpty()); + assertViolation(OmeValidator.validateScaleLevelArrays(e, Arrays.asList(uint16x2, float32x2)), "same data type"); + List violations = OmeValidator.validateScaleLevelArrays(e, Arrays.asList(uint16x2, uint16x3)); + assertViolation(violations, "intrinsic coordinate system has 2 axes"); + assertViolation(violations, "same number of dimensions"); + } + + // ── transformations ────────────────────────────────────────────────────── + + @Test + void rotationMustBeSquareOrthonormalWithUnitDeterminant() { + double c = Math.cos(0.3); + double s = Math.sin(0.3); + assertTrue(OmeValidator.validateTransformation(new RotationCoordinateTransformation( + "a", "b", null, matrix(new double[]{c, -s}, new double[]{s, c}), null)).isEmpty()); + assertViolation(OmeValidator.validateTransformation(new RotationCoordinateTransformation( + "a", "b", null, matrix(new double[]{1, 0, 0}, new double[]{0, 1, 0}), null)), "N x N"); + assertViolation(OmeValidator.validateTransformation(new RotationCoordinateTransformation( + "a", "b", null, matrix(new double[]{2, 0}, new double[]{0, 1}), null)), "orthonormal"); + assertViolation(OmeValidator.validateTransformation(new RotationCoordinateTransformation( + "a", "b", null, matrix(new double[]{0, 1}, new double[]{1, 0}), null)), "determinant 1"); + } + + @Test + void affineRowsMustHaveEqualLength() { + assertTrue(OmeValidator.validateTransformation(new AffineCoordinateTransformation( + "a", "b", null, matrix(new double[]{1, 0, 5}, new double[]{0, 1, 6}), null)).isEmpty()); + assertViolation(OmeValidator.validateTransformation(new AffineCoordinateTransformation( + "a", "b", null, matrix(new double[]{1, 0, 5}, new double[]{0, 1}), null)), "same length"); + } + + @Test + void mapAxisMustBePermutation() { + assertTrue(OmeValidator.validateTransformation(new MapAxisCoordinateTransformation( + "a", "b", null, Arrays.asList(2, 0, 1), null)).isEmpty()); + assertViolation(OmeValidator.validateTransformation(new MapAxisCoordinateTransformation( + "a", "b", null, Arrays.asList(0, 0, 1), null)), "permutation"); + assertViolation(OmeValidator.validateTransformation(new MapAxisCoordinateTransformation( + "a", "b", null, Arrays.asList(0, 3), null)), "permutation"); + } + + @Test + void byDimensionOutputAxesMustAppearExactlyOnce() { + CoordinateTransformation scale1 = CoordinateTransformation.scale(Collections.singletonList(2.0), null, null); + CoordinateTransformation scale2 = CoordinateTransformation.scale(Arrays.asList(2.0, 3.0), null, null); + assertTrue(OmeValidator.validateTransformation(new ByDimensionCoordinateTransformation("a", "b", null, Arrays.asList( + new ByDimensionCoordinateTransformation.ByDimensionTransformation(Arrays.asList(0, 1), Arrays.asList(0, 1), scale2), + new ByDimensionCoordinateTransformation.ByDimensionTransformation( + Collections.singletonList(2), Collections.singletonList(2), scale1)))).isEmpty()); + assertViolation(OmeValidator.validateTransformation(new ByDimensionCoordinateTransformation("a", "b", null, Arrays.asList( + new ByDimensionCoordinateTransformation.ByDimensionTransformation(Arrays.asList(0, 1), Arrays.asList(0, 1), scale2), + new ByDimensionCoordinateTransformation.ByDimensionTransformation( + Collections.singletonList(1), Collections.singletonList(1), scale1)))), "more than one"); + assertViolation(OmeValidator.validateTransformation(new ByDimensionCoordinateTransformation("a", "b", null, Arrays.asList( + new ByDimensionCoordinateTransformation.ByDimensionTransformation( + Collections.singletonList(0), Collections.singletonList(0), scale1), + new ByDimensionCoordinateTransformation.ByDimensionTransformation( + Collections.singletonList(2), Collections.singletonList(2), scale1)))), "output axis 1 does not appear"); + } + + @Test + void sequenceMustBeNonEmpty() { + assertViolation(OmeValidator.validateTransformation(new SequenceCoordinateTransformation( + "a", "b", null, Collections.emptyList())), "non-empty"); + assertViolation(OmeValidator.validateTransformation(new SequenceCoordinateTransformation( + "a", "b", null, null)), "non-empty"); + } + + @Test + void nestedTransformationsAreValidated() { + CoordinateTransformation badRotation = new RotationCoordinateTransformation( + null, null, null, matrix(new double[]{2, 0}, new double[]{0, 1}), null); + assertViolation(OmeValidator.validateTransformation(new SequenceCoordinateTransformation( + "a", "b", null, Collections.singletonList(badRotation))), "transformations[0]: rotation matrix must be orthonormal"); + } + + // ── plate ──────────────────────────────────────────────────────────────── + + private static PlateMetadata plate(List rows, List columns, List wells, + List acquisitions, Integer fieldCount) { + return new PlateMetadata(columns, rows, wells, acquisitions, fieldCount, null, null); + } + + private static List names(String... names) { + List out = new ArrayList<>(); + for (String n : names) { + out.add(new NamedEntry(n)); + } + return out; + } + + private static Acquisition acquisition(int id) { + return new Acquisition(id, null, null, null, null, null); + } + + @Test + void validPlateHasNoViolations() { + assertEquals(Collections.emptyList(), OmeValidator.validatePlate(plate( + names("A", "B"), names("1", "2"), + Arrays.asList(new WellRef("A/1", 0, 0), new WellRef("B/2", 1, 1)), + Arrays.asList(acquisition(0), acquisition(1)), 4))); + } + + @Test + void plateRowAndColumnNamesMustBeAlphanumericAndUnique() { + List noWells = Collections.emptyList(); + assertViolation(OmeValidator.validatePlate(plate(names("A-1"), names("1"), noWells, null, null)), + "alphanumeric"); + assertViolation(OmeValidator.validatePlate(plate(names("A"), names("1", "1"), noWells, null, null)), + "plate.columns[1]: duplicate name '1'"); + } + + @Test + void plateWellPathMustMatchIndices() { + assertViolation(OmeValidator.validatePlate(plate(names("A", "B"), names("1"), + Collections.singletonList(new WellRef("A/1", 1, 0)), null, null)), "must equal 'B/1'"); + assertViolation(OmeValidator.validatePlate(plate(names("A"), names("1"), + Collections.singletonList(new WellRef("A/1", 0, 3)), null, null)), "columnIndex 3 is out of range"); + assertViolation(OmeValidator.validatePlate(plate(names("A"), names("1"), + Collections.singletonList(new WellRef("A/1", -1, 0)), null, null)), "rowIndex -1 is out of range"); + } + + @Test + void plateAcquisitionsAndFieldCount() { + List noWells = Collections.emptyList(); + assertViolation(OmeValidator.validatePlate(plate(names("A"), names("1"), noWells, + Arrays.asList(acquisition(0), acquisition(0)), null)), "duplicate acquisition id 0"); + assertViolation(OmeValidator.validatePlate(plate(names("A"), names("1"), noWells, + Collections.singletonList(acquisition(-1)), null)), "id must be >= 0"); + assertViolation(OmeValidator.validatePlate(plate(names("A"), names("1"), noWells, null, 0)), + "field_count must be a positive integer"); + } + + // ── well ───────────────────────────────────────────────────────────────── + + private static WellMetadata well(String... paths) { + List images = new ArrayList<>(); + for (String p : paths) { + images.add(new WellImage(p, null)); + } + return new WellMetadata(images); + } + + @Test + void wellImagePaths() { + assertTrue(OmeValidator.validateWell(well("0", "fov_1", "a.b-c")).isEmpty()); + assertViolation(OmeValidator.validateWell(well("0", "0")), "duplicate image path"); + assertViolation(OmeValidator.validateWell(well("")), "non-empty"); + assertViolation(OmeValidator.validateWell(well("a/b")), "must not contain '/'"); + assertViolation(OmeValidator.validateWell(well("..")), "only of periods"); + assertViolation(OmeValidator.validateWell(well("__hidden")), "reserved prefix"); + assertViolation(OmeValidator.validateWell(well("a b")), "must only contain the characters"); + } + + @Test + void wellAcquisitionRequiredWhenPlateHasMultipleAcquisitions() { + PlateMetadata multi = plate(names("A"), names("1"), Collections.singletonList(new WellRef("A/1", 0, 0)), + Arrays.asList(acquisition(0), acquisition(1)), null); + PlateMetadata single = plate(names("A"), names("1"), Collections.singletonList(new WellRef("A/1", 0, 0)), + Collections.singletonList(acquisition(0)), null); + assertViolation(OmeValidator.validateWell(well("0"), multi), "'acquisition' must be present"); + assertTrue(OmeValidator.validateWell(well("0"), single).isEmpty()); + assertTrue(OmeValidator.validateWell(well("0")).isEmpty()); + assertTrue(OmeValidator.validateWell(new WellMetadata( + Collections.singletonList(new WellImage("0", 1))), multi).isEmpty()); + assertViolation(OmeValidator.validateWell(new WellMetadata( + Collections.singletonList(new WellImage("0", 5))), multi), "does not match any acquisition id"); + } + + // ── scene ──────────────────────────────────────────────────────────────── + + @Test + void sceneRequiresCoordinateTransformations() { + List systems = Collections.singletonList(cs("world", space("y"), space("x"))); + assertViolation(OmeValidator.validateScene(new SceneMetadata(null, systems)), + "'coordinateTransformations' must be present"); + assertTrue(OmeValidator.validateScene(new SceneMetadata( + Collections.singletonList(CoordinateTransformation.translation(Arrays.asList(1.0, 2.0), "imageA#physical", ".#world")), + systems)).isEmpty()); + assertViolation(OmeValidator.validateScene(new SceneMetadata( + Collections.emptyList(), + Arrays.asList(cs("world", space("y"), space("x")), cs("world", space("y"), space("x"))))), + "duplicate coordinate system name"); + } + + // ── write rejects invalid metadata ─────────────────────────────────────── + + @Test + void createMultiscaleImageRejectsInvalidMetadata() throws Exception { + MultiscalesEntry invalid = entry(Collections.singletonList(cs("physical", space("x"), space("x"))), + scaleDataset("s0", "physical", 1.0, 1.0, 1.0)); + ZarrException ex = assertThrows(ZarrException.class, () -> + dev.zarr.zarrjava.experimental.ome.v0_6.MultiscaleImage.create(store("invalid_image"), invalid)); + assertTrue(ex.getMessage().contains("Invalid OME-Zarr 0.6 metadata"), ex.getMessage()); + assertTrue(ex.getMessage().contains("duplicate axis name 'x'"), ex.getMessage()); + assertTrue(ex.getMessage().contains("scale has 3 elements"), ex.getMessage()); + } + + @Test + void createScaleLevelRejectsMismatchedArrays() throws Exception { + MultiscalesEntry e = entry(Collections.singletonList(cs("physical", space("y"), space("x")))); + dev.zarr.zarrjava.experimental.ome.v0_6.MultiscaleImage image = + dev.zarr.zarrjava.experimental.ome.v0_6.MultiscaleImage.create(store("scale_levels"), e); + image.createScaleLevel("s0", + dev.zarr.zarrjava.v3.Array.metadataBuilder() + .withShape(16, 16).withChunkShape(8, 8).withDataType(DataType.UINT16).build(), + Collections.singletonList(dev.zarr.zarrjava.experimental.ome.metadata.transform.CoordinateTransformation.scale( + Arrays.asList(1.0, 1.0)))); + + ZarrException dtype = assertThrows(ZarrException.class, () -> image.createScaleLevel("s1", + dev.zarr.zarrjava.v3.Array.metadataBuilder() + .withShape(8, 8).withChunkShape(8, 8).withDataType(DataType.FLOAT32).build(), + Collections.singletonList(dev.zarr.zarrjava.experimental.ome.metadata.transform.CoordinateTransformation.scale( + Arrays.asList(2.0, 2.0))))); + assertTrue(dtype.getMessage().contains("same data type"), dtype.getMessage()); + + ZarrException ndim = assertThrows(ZarrException.class, () -> image.createScaleLevel("s1", + dev.zarr.zarrjava.v3.Array.metadataBuilder() + .withShape(8, 8, 8).withChunkShape(8, 8, 8).withDataType(DataType.UINT16).build(), + Collections.singletonList(dev.zarr.zarrjava.experimental.ome.metadata.transform.CoordinateTransformation.scale( + Arrays.asList(2.0, 2.0, 2.0))))); + assertTrue(ndim.getMessage().contains("intrinsic coordinate system has 2 axes"), ndim.getMessage()); + assertEquals(1, image.getScaleLevelCount()); + } + + @Test + void createPlateWellSceneRejectInvalidMetadata() throws Exception { + ZarrException plateEx = assertThrows(ZarrException.class, () -> + dev.zarr.zarrjava.experimental.ome.v0_6.Plate.createPlate(store("invalid_plate"), plate( + names("A"), names("1"), Collections.singletonList(new WellRef("A/2", 0, 0)), null, null))); + assertTrue(plateEx.getMessage().contains("must equal 'A/1'"), plateEx.getMessage()); + + ZarrException wellEx = assertThrows(ZarrException.class, () -> + dev.zarr.zarrjava.experimental.ome.v0_6.Well.createWell(store("invalid_well"), well("__x"))); + assertTrue(wellEx.getMessage().contains("reserved prefix"), wellEx.getMessage()); + + ZarrException sceneEx = assertThrows(ZarrException.class, () -> + dev.zarr.zarrjava.experimental.ome.v0_6.Scene.createScene(store("invalid_scene"), new SceneMetadata(null, null))); + assertTrue(sceneEx.getMessage().contains("'coordinateTransformations' must be present"), sceneEx.getMessage()); + } + + // ── read warns but still opens ─────────────────────────────────────────── + + private static final class CapturingHandler extends Handler { + final List messages = new ArrayList<>(); + + @Override + public void publish(LogRecord record) { + if (record.getLevel().intValue() >= Level.WARNING.intValue()) { + messages.add(record.getMessage()); + } + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + } + + @Test + void existingFixturesStillOpen() throws Exception { + Logger logger = Logger.getLogger(OmeValidator.class.getName()); + CapturingHandler handler = new CapturingHandler(); + logger.addHandler(handler); + try { + String[] images = { + "ome/v0.6/examples/2d/basic/scale_multiscale.zarr", + "ome/v0.6/examples/3d/basic/scale_multiscale.zarr", + "ome/v0.6/examples/user_stories/human_organ_atlas.zarr/overview.ome.zarr", + // known to violate MUST rules (array-typed axes / dangling output name): open with warnings + "ome/v0.6/examples/2d/axis_dependent/mapAxis.zarr", + "ome/v0.6/examples/user_stories/image_registration_3d.zarr/FCWB", + }; + for (String image : images) { + Path path = TESTDATA.resolve(image); + assertNotNull(MultiscaleImage.open(new FilesystemStore(path).resolve()), image); + } + String[] scenes = { + "ome/v0.6_scene/example1_instrument_registration.zarr", + "ome/v0.6_scene/example2_multi_instrument_chain.zarr", + "ome/v0.6/examples/user_stories/image_registration_3d.zarr", + }; + for (String scene : scenes) { + Path path = TESTDATA.resolve(scene); + assertNotNull(dev.zarr.zarrjava.experimental.ome.v0_6.Scene.openScene(new FilesystemStore(path).resolve()), scene); + } + } finally { + logger.removeHandler(handler); + } + boolean warnedAboutFcwb = false; + for (String message : handler.messages) { + if (message.contains("FCWB") && message.contains("does not name a coordinate system")) { + warnedAboutFcwb = true; + } + } + assertTrue(warnedAboutFcwb, "expected a warning for the FCWB fixture, got " + handler.messages); + } +}