From 4c80e4815573c669a610f744dd2cbc94e4b3bc88 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:47:41 +0100 Subject: [PATCH] fix: keep non-uniform image coordinates in NPZ transfers and HDF5 files Images with non-uniform coordinates lost them in DataLab-Kernel: the NPZ serializer used by the Web API backend wrote and read only x0, y0, dx and dy, and so did the HDF5 save/load of the standalone and Web API backends. For such images Sigima keeps the default x0 = y0 = 0 and dx = dy = 1, so they came back with the pixel indices as coordinates, silently. NPZ archives now follow the format of DataLab (>= 1.4.0): the is_uniform_coords flag and, for non-uniform images, the pixel center coordinates in xcoords.npy and ycoords.npy, whose lengths are checked against the image shape. HDF5 image groups store the same flag and the xcoords/ycoords datasets (h5coords module shared by both backends). Archives and files without the flag (earlier versions) are still read with uniform coordinates. The NPZ serializer also accesses the attributes guaranteed by Sigima objects directly instead of through getattr fallbacks, and the round-trip tests cover compression, labels, units and metadata. The live round-trip test is skipped with DataLab < 1.4.0, which does not transfer non-uniform coordinates. Closes #3 Assisted-by: Claude Opus 5.5 --- datalab_kernel/backends/h5coords.py | 78 +++++++++ datalab_kernel/backends/standalone.py | 11 +- datalab_kernel/backends/webapi.py | 11 +- datalab_kernel/serialization_npz.py | 75 ++++++--- datalab_kernel/tests/test_webapi_backend.py | 151 +++++++++++++++++- datalab_kernel/tests/unit/test_persistence.py | 78 ++++++++- doc/release_notes/release_0.02.md | 1 + 7 files changed, 362 insertions(+), 43 deletions(-) create mode 100644 datalab_kernel/backends/h5coords.py diff --git a/datalab_kernel/backends/h5coords.py b/datalab_kernel/backends/h5coords.py new file mode 100644 index 0000000..31b7b70 --- /dev/null +++ b/datalab_kernel/backends/h5coords.py @@ -0,0 +1,78 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause License +# See LICENSE file for details + +""" +HDF5 Image Coordinates +====================== + +Storage of the pixel coordinates of images in the HDF5 workspace files written by +the backends. + +An image group holds: + +- for uniform coordinates, the ``x0``, ``y0``, ``dx`` and ``dy`` attributes; +- for non-uniform coordinates, the ``xcoords`` and ``ycoords`` datasets (pixel + center coordinates along X and Y). + +The ``is_uniform_coords`` attribute tells which one applies. Files written by +earlier versions (without this attribute) have uniform coordinates. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +from sigima import ImageObj + +UNIFORM_ATTRS = ("x0", "y0", "dx", "dy") + + +def save_image_coords(grp: Any, obj: ImageObj) -> None: + """Save the pixel coordinates of an image to an HDF5 group. + + Args: + grp: HDF5 group of the image. + obj: Image. + """ + grp.attrs["is_uniform_coords"] = bool(obj.is_uniform_coords) + if obj.is_uniform_coords: + for attr in UNIFORM_ATTRS: + value = getattr(obj, attr) + if value is not None: + grp.attrs[attr] = value + else: + grp.create_dataset("xcoords", data=np.asarray(obj.xcoords, dtype=float)) + grp.create_dataset("ycoords", data=np.asarray(obj.ycoords, dtype=float)) + + +def load_image_coords(grp: Any, obj: ImageObj) -> None: + """Restore the pixel coordinates of an image from an HDF5 group. + + Args: + grp: HDF5 group of the image. + obj: Image, whose data is already set. + + Raises: + ValueError: If non-uniform coordinates are missing or do not match the + shape of the image. + """ + if bool(grp.attrs.get("is_uniform_coords", True)): + for attr in UNIFORM_ATTRS: + if attr in grp.attrs: + setattr(obj, attr, float(grp.attrs[attr])) + return + if "xcoords" not in grp or "ycoords" not in grp: + raise ValueError( + "Invalid image group: missing xcoords or ycoords " + "for non-uniform coordinates" + ) + xcoords = np.array(grp["xcoords"], dtype=float) + ycoords = np.array(grp["ycoords"], dtype=float) + nrows, ncols = obj.data.shape + if xcoords.shape != (ncols,) or ycoords.shape != (nrows,): + raise ValueError( + f"Invalid image group: coordinates of shape {xcoords.shape} (X) and " + f"{ycoords.shape} (Y) do not match image shape {obj.data.shape}" + ) + obj.set_coords(xcoords, ycoords) diff --git a/datalab_kernel/backends/standalone.py b/datalab_kernel/backends/standalone.py index ff89770..4afe823 100644 --- a/datalab_kernel/backends/standalone.py +++ b/datalab_kernel/backends/standalone.py @@ -16,6 +16,7 @@ import numpy as np from sigima import ImageObj, SignalObj +from datalab_kernel.backends.h5coords import load_image_coords, save_image_coords from datalab_kernel.workspace import WorkspaceBackend if TYPE_CHECKING: @@ -166,11 +167,7 @@ def _save_object_to_group(self, grp, obj: DataObject) -> None: # Save image data grp.create_dataset("data", data=obj.data) # Save coordinate info - for attr in ("x0", "y0", "dx", "dy"): - if hasattr(obj, attr): - val = getattr(obj, attr) - if val is not None: - grp.attrs[attr] = val + save_image_coords(grp, obj) # Save metadata if hasattr(obj, "title") and obj.title: grp.attrs["title"] = obj.title @@ -253,9 +250,7 @@ def _load_image(self, grp, name: str) -> DataObject: obj.data = data obj.title = grp.attrs.get("title", name) - for attr in ("x0", "y0", "dx", "dy"): - if attr in grp.attrs: - setattr(obj, attr, float(grp.attrs[attr])) + load_image_coords(grp, obj) if "xlabel" in grp.attrs: obj.xlabel = grp.attrs["xlabel"] diff --git a/datalab_kernel/backends/webapi.py b/datalab_kernel/backends/webapi.py index 3a41851..a58d0df 100644 --- a/datalab_kernel/backends/webapi.py +++ b/datalab_kernel/backends/webapi.py @@ -41,6 +41,7 @@ import numpy as np from sigima import ImageObj, SignalObj +from datalab_kernel.backends.h5coords import load_image_coords, save_image_coords from datalab_kernel.backends.pyfetch import ( HttpError, HttpResponse, @@ -463,11 +464,7 @@ def _save_object_to_group(self, grp, obj: DataObject) -> None: grp.create_dataset("data", data=obj.data) if hasattr(obj, "title") and obj.title: grp.attrs["title"] = obj.title - for attr in ("x0", "y0", "dx", "dy"): - if hasattr(obj, attr): - val = getattr(obj, attr) - if val is not None: - grp.attrs[attr] = val + save_image_coords(grp, obj) def load(self, filepath: str) -> None: """Load workspace from HDF5 file. @@ -507,9 +504,7 @@ def _load_object_from_group(self, grp, name: str) -> DataObject | None: obj = ImageObj() obj.data = data obj.title = grp.attrs.get("title", name) - for attr in ("x0", "y0", "dx", "dy"): - if attr in grp.attrs: - setattr(obj, attr, float(grp.attrs[attr])) + load_image_coords(grp, obj) return obj return None diff --git a/datalab_kernel/serialization_npz.py b/datalab_kernel/serialization_npz.py index 38e942a..582c101 100644 --- a/datalab_kernel/serialization_npz.py +++ b/datalab_kernel/serialization_npz.py @@ -24,7 +24,17 @@ For ImageObj: - ``data.npy``: Image data (2D array, preserves dtype) + - ``xcoords.npy``, ``ycoords.npy``: Pixel center coordinates along X and Y + (1D float arrays, only for non-uniform coordinates) - ``metadata.json``: JSON-encoded metadata + +Metadata JSON includes: + - ``type``: "signal" or "image" + - ``title``, ``xlabel``, ``ylabel``, etc. + - ``x0``, ``y0``, ``dx``, ``dy`` (for images) + - ``is_uniform_coords`` (for images): False when the coordinates are given by + ``xcoords.npy`` and ``ycoords.npy``. Archives without this key (written by + earlier versions) have uniform coordinates. """ from __future__ import annotations @@ -122,14 +132,14 @@ def _serialize_signal(obj, buffer: io.BytesIO, *, compress: bool = True) -> None """Serialize a SignalObj to NPZ format.""" metadata = { "type": "signal", - "title": getattr(obj, "title", None), - "xlabel": getattr(obj, "xlabel", None), - "ylabel": getattr(obj, "ylabel", None), - "xunit": getattr(obj, "xunit", None), - "yunit": getattr(obj, "yunit", None), + "title": obj.title, + "xlabel": obj.xlabel, + "ylabel": obj.ylabel, + "xunit": obj.xunit, + "yunit": obj.yunit, } # Include object metadata (contains Geometry_*, Table_* results) - obj_metadata = getattr(obj, "metadata", None) + obj_metadata = obj.metadata if obj_metadata: metadata["obj_metadata"] = _serialize_obj_metadata(obj_metadata) @@ -150,26 +160,30 @@ def _serialize_image(obj, buffer: io.BytesIO, *, compress: bool = True) -> None: """Serialize an ImageObj to NPZ format.""" metadata = { "type": "image", - "title": getattr(obj, "title", None), - "xlabel": getattr(obj, "xlabel", None), - "ylabel": getattr(obj, "ylabel", None), - "zlabel": getattr(obj, "zlabel", None), - "xunit": getattr(obj, "xunit", None), - "yunit": getattr(obj, "yunit", None), - "zunit": getattr(obj, "zunit", None), - "x0": getattr(obj, "x0", 0.0), - "y0": getattr(obj, "y0", 0.0), - "dx": getattr(obj, "dx", 1.0), - "dy": getattr(obj, "dy", 1.0), + "title": obj.title, + "xlabel": obj.xlabel, + "ylabel": obj.ylabel, + "zlabel": obj.zlabel, + "xunit": obj.xunit, + "yunit": obj.yunit, + "zunit": obj.zunit, + "x0": obj.x0, + "y0": obj.y0, + "dx": obj.dx, + "dy": obj.dy, + "is_uniform_coords": bool(obj.is_uniform_coords), } # Include object metadata (contains Geometry_*, Table_* results) - obj_metadata = getattr(obj, "metadata", None) + obj_metadata = obj.metadata if obj_metadata: metadata["obj_metadata"] = _serialize_obj_metadata(obj_metadata) compression = zipfile.ZIP_DEFLATED if compress else zipfile.ZIP_STORED with zipfile.ZipFile(buffer, "w", compression) as zf: _write_array_to_zip(zf, "data.npy", obj.data) + if not obj.is_uniform_coords: + _write_array_to_zip(zf, "xcoords.npy", obj.xcoords) + _write_array_to_zip(zf, "ycoords.npy", obj.ycoords) zf.writestr("metadata.json", json.dumps(metadata, ensure_ascii=False)) @@ -272,10 +286,27 @@ def _deserialize_image(zf: zipfile.ZipFile, metadata: dict) -> DataObject: if metadata.get("zunit"): obj.zunit = metadata["zunit"] - obj.x0 = metadata.get("x0", 0.0) - obj.y0 = metadata.get("y0", 0.0) - obj.dx = metadata.get("dx", 1.0) - obj.dy = metadata.get("dy", 1.0) + # Set coordinate info + if metadata.get("is_uniform_coords", True): + obj.x0 = metadata.get("x0", 0.0) + obj.y0 = metadata.get("y0", 0.0) + obj.dx = metadata.get("dx", 1.0) + obj.dy = metadata.get("dy", 1.0) + else: + xcoords = _read_array_from_zip(zf, "xcoords.npy") + ycoords = _read_array_from_zip(zf, "ycoords.npy") + if xcoords is None or ycoords is None: + raise ValueError( + "Invalid image NPZ: missing xcoords.npy or ycoords.npy " + "for non-uniform coordinates" + ) + nrows, ncols = data.shape + if xcoords.shape != (ncols,) or ycoords.shape != (nrows,): + raise ValueError( + f"Invalid image NPZ: coordinates of shape {xcoords.shape} (X) and " + f"{ycoords.shape} (Y) do not match image shape {data.shape}" + ) + obj.set_coords(xcoords, ycoords) # Restore object metadata (Geometry_*, Table_* results) if metadata.get("obj_metadata"): diff --git a/datalab_kernel/tests/test_webapi_backend.py b/datalab_kernel/tests/test_webapi_backend.py index 9789383..5f7e642 100644 --- a/datalab_kernel/tests/test_webapi_backend.py +++ b/datalab_kernel/tests/test_webapi_backend.py @@ -11,9 +11,11 @@ from __future__ import annotations import io +import json import re import zipfile +import h5py import numpy as np import pytest from sigima import ImageObj, SignalObj @@ -23,12 +25,14 @@ deserialize_object_from_npz, serialize_object_to_npz, ) +from datalab_kernel.tests.data import make_test_image, make_test_image_nonuniform class TestNPZSerialization: """Tests for NPZ serialization module.""" - def test_signal_round_trip(self): + @pytest.mark.parametrize("compress", [False, True]) + def test_signal_round_trip(self, compress): """Test serializing and deserializing a SignalObj.""" # Create a signal x = np.linspace(0, 10, 100) @@ -40,9 +44,10 @@ def test_signal_round_trip(self): obj.ylabel = "Amplitude" obj.xunit = "s" obj.yunit = "V" + obj.metadata["acquisition"] = {"sample": "signal", "gain": 2.5} # Serialize - data = serialize_object_to_npz(obj) + data = serialize_object_to_npz(obj, compress=compress) assert isinstance(data, bytes) assert len(data) > 0 @@ -65,6 +70,9 @@ def test_signal_round_trip(self): assert result.ylabel == "Amplitude" assert result.xunit == "s" assert result.yunit == "V" + assert result.metadata["acquisition"] == obj.metadata["acquisition"] + assert result.dx is None + assert result.dy is None def test_signal_with_uncertainties(self): """Test signal with dx/dy uncertainties.""" @@ -83,7 +91,8 @@ def test_signal_with_uncertainties(self): np.testing.assert_array_equal(result.dx, dx) np.testing.assert_array_equal(result.dy, dy) - def test_image_round_trip(self): + @pytest.mark.parametrize("compress", [False, True]) + def test_image_round_trip(self, compress): """Test serializing and deserializing an ImageObj.""" # Create an image data = np.random.rand(128, 128).astype(np.float32) @@ -93,13 +102,17 @@ def test_image_round_trip(self): obj.xlabel = "X" obj.ylabel = "Y" obj.zlabel = "Intensity" + obj.xunit = "mm" + obj.yunit = "mm" + obj.zunit = "counts" + obj.metadata["acquisition"] = {"sample": "image", "gain": 2.5} obj.x0 = 10.0 obj.y0 = 20.0 obj.dx = 0.5 obj.dy = 0.5 # Serialize - npz_data = serialize_object_to_npz(obj) + npz_data = serialize_object_to_npz(obj, compress=compress) assert isinstance(npz_data, bytes) # Verify structure @@ -115,6 +128,13 @@ def test_image_round_trip(self): assert type(result).__name__ == "ImageObj" np.testing.assert_array_equal(result.data, data) assert result.title == "Test Image" + assert result.xlabel == "X" + assert result.ylabel == "Y" + assert result.zlabel == "Intensity" + assert result.xunit == "mm" + assert result.yunit == "mm" + assert result.zunit == "counts" + assert result.metadata["acquisition"] == obj.metadata["acquisition"] assert result.x0 == 10.0 assert result.y0 == 20.0 assert result.dx == 0.5 @@ -134,6 +154,80 @@ def test_image_preserves_dtype(self): f"Expected {dtype}, got {result.data.dtype}" ) + @pytest.mark.parametrize("compress", [False, True]) + def test_image_nonuniform_round_trip(self, compress): + """Non-uniform coordinates are kept, in the same layout as DataLab.""" + obj = make_test_image_nonuniform() + + npz_data = serialize_object_to_npz(obj, compress=compress) + with zipfile.ZipFile(io.BytesIO(npz_data), "r") as zf: + assert {"xcoords.npy", "ycoords.npy"} <= set(zf.namelist()) + metadata = json.loads(zf.read("metadata.json")) + assert metadata["is_uniform_coords"] is False + + result = deserialize_object_from_npz(npz_data) + assert not result.is_uniform_coords + np.testing.assert_array_equal(result.xcoords, obj.xcoords) + np.testing.assert_array_equal(result.ycoords, obj.ycoords) + np.testing.assert_array_equal(result.data, obj.data) + + def test_image_uniform_has_no_coordinate_arrays(self): + """Uniform coordinates are given by x0, y0, dx and dy only.""" + obj = make_test_image("uniform") + obj.set_uniform_coords(dx=0.5, dy=0.25, x0=10.0, y0=-2.0) + npz_data = serialize_object_to_npz(obj) + with zipfile.ZipFile(io.BytesIO(npz_data), "r") as zf: + assert "xcoords.npy" not in zf.namelist() + assert "ycoords.npy" not in zf.namelist() + metadata = json.loads(zf.read("metadata.json")) + assert metadata["is_uniform_coords"] is True + result = deserialize_object_from_npz(npz_data) + assert result.is_uniform_coords + assert (result.x0, result.y0, result.dx, result.dy) == ( + obj.x0, + obj.y0, + obj.dx, + obj.dy, + ) + + def test_image_archive_without_coordinate_flag(self): + """Archives written by earlier versions have uniform coordinates.""" + metadata = {"type": "image", "x0": 1.0, "y0": 2.0, "dx": 0.5, "dy": 0.25} + npz_data = _make_image_archive(np.zeros((3, 4)), metadata) + result = deserialize_object_from_npz(npz_data) + assert result.is_uniform_coords + assert (result.x0, result.y0, result.dx, result.dy) == (1.0, 2.0, 0.5, 0.25) + + @pytest.mark.parametrize( + "arrays", + [ + {}, + {"xcoords.npy": np.arange(4.0)}, + {"xcoords.npy": np.arange(3.0), "ycoords.npy": np.arange(3.0)}, + ], + ids=["missing", "missing_y", "wrong_shape"], + ) + def test_image_invalid_nonuniform_archive(self, arrays): + """Missing or mismatched non-uniform coordinates are rejected.""" + metadata = {"type": "image", "is_uniform_coords": False} + npz_data = _make_image_archive(np.zeros((3, 4)), metadata, arrays) + with pytest.raises(ValueError, match="Invalid image NPZ"): + deserialize_object_from_npz(npz_data) + + +def _make_image_archive( + data: np.ndarray, metadata: dict, arrays: dict[str, np.ndarray] | None = None +) -> bytes: + """Build an image NPZ archive by hand, as written by DataLab.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + for name, array in {"data.npy": data, **(arrays or {})}.items(): + array_buffer = io.BytesIO() + np.save(array_buffer, array, allow_pickle=False) + zf.writestr(name, array_buffer.getvalue()) + zf.writestr("metadata.json", json.dumps(metadata)) + return buffer.getvalue() + class TestWebApiBackendHelpers: """Unit tests for WebApiBackend helper methods (no live server required).""" @@ -184,6 +278,32 @@ def test_short_id_pattern_matching(self): match = re.match(pattern, case.lower()) assert match is None, f"Should not match: {case}" + @pytest.mark.parametrize("nonuniform", [False, True]) + def test_hdf5_group_keeps_image_coordinates(self, tmp_path, nonuniform): + """Saving a workspace locally keeps uniform and non-uniform coordinates.""" + backend = WebApiBackend.__new__(WebApiBackend) + if nonuniform: + obj = make_test_image_nonuniform() + else: + obj = make_test_image("uniform") + obj.set_uniform_coords(dx=0.5, dy=0.25, x0=10.0, y0=-2.0) + filepath = tmp_path / "workspace.h5" + # pylint: disable=protected-access + with h5py.File(filepath, "w") as f: + backend._save_object_to_group(f.create_group("img"), obj) + with h5py.File(filepath, "r") as f: + result = backend._load_object_from_group(f["img"], "img") + assert result.is_uniform_coords is not nonuniform + np.testing.assert_array_equal(result.xcoords, obj.xcoords) + np.testing.assert_array_equal(result.ycoords, obj.ycoords) + if not nonuniform: + assert (result.x0, result.y0, result.dx, result.dy) == ( + 10.0, + -2.0, + 0.5, + 0.25, + ) + class TestWorkspaceBackendSelection: """Tests for workspace backend auto-detection.""" @@ -303,6 +423,29 @@ def test_add_and_get_image(self, webapi_backend): # Cleanup webapi_backend.remove("webapi_test_image") + def test_add_and_get_nonuniform_image(self, webapi_backend): + """Non-uniform coordinates survive a round trip through DataLab.""" + # pylint: disable=protected-access + version = webapi_backend._client.get("/api/v1/status").json()["version"] + if _version_tuple(version) < (1, 4, 0): + pytest.skip(f"DataLab {version} does not transfer non-uniform coordinates") + obj = make_test_image_nonuniform("webapi_test_nonuniform") + + webapi_backend.add("webapi_test_nonuniform", obj, overwrite=True) + retrieved = webapi_backend.get("webapi_test_nonuniform") + + assert not retrieved.is_uniform_coords + np.testing.assert_array_equal(retrieved.xcoords, obj.xcoords) + np.testing.assert_array_equal(retrieved.ycoords, obj.ycoords) + np.testing.assert_array_equal(retrieved.data, obj.data) + + webapi_backend.remove("webapi_test_nonuniform") + + +def _version_tuple(version: str) -> tuple[int, ...]: + """Return the numeric release part of a version string (e.g. (1, 4, 0)).""" + return tuple(int(part) for part in re.findall(r"\d+", version)[:3]) + @pytest.mark.webapi @pytest.mark.integration diff --git a/datalab_kernel/tests/unit/test_persistence.py b/datalab_kernel/tests/unit/test_persistence.py index 513a7d0..28dcffb 100644 --- a/datalab_kernel/tests/unit/test_persistence.py +++ b/datalab_kernel/tests/unit/test_persistence.py @@ -18,7 +18,11 @@ import pytest from datalab_kernel.backends import StandaloneBackend -from datalab_kernel.tests.data import make_test_image, make_test_signal +from datalab_kernel.tests.data import ( + make_test_image, + make_test_image_nonuniform, + make_test_signal, +) from datalab_kernel.workspace import Workspace @@ -128,6 +132,78 @@ def test_workspace_save_load_roundtrip_image(self): assert loaded.data.shape == image.data.shape np.testing.assert_array_almost_equal(loaded.data, image.data) + def test_workspace_save_load_roundtrip_image_coordinates(self): + """Save then load preserves uniform and non-uniform image coordinates.""" + workspace = Workspace(backend=StandaloneBackend()) + uniform = make_test_image("uniform", shape=(16, 32)) + uniform.set_uniform_coords(dx=0.5, dy=0.25, x0=10.0, y0=-2.0) + nonuniform = make_test_image_nonuniform("nonuniform") + workspace.add("uniform", uniform) + workspace.add("nonuniform", nonuniform) + + with tempfile.TemporaryDirectory() as tmpdir: + filepath = os.path.join(tmpdir, "test.h5") + workspace.save(filepath) + + workspace2 = Workspace(backend=StandaloneBackend()) + workspace2.load(filepath) + + loaded = workspace2.get("uniform") + assert loaded.is_uniform_coords + assert (loaded.x0, loaded.y0, loaded.dx, loaded.dy) == ( + 10.0, + -2.0, + 0.5, + 0.25, + ) + loaded = workspace2.get("nonuniform") + assert not loaded.is_uniform_coords + np.testing.assert_array_equal(loaded.xcoords, nonuniform.xcoords) + np.testing.assert_array_equal(loaded.ycoords, nonuniform.ycoords) + + def test_workspace_load_image_without_coordinate_flag(self): + """Images of files written by earlier versions have uniform coordinates.""" + import h5py + + with tempfile.TemporaryDirectory() as tmpdir: + filepath = os.path.join(tmpdir, "legacy.h5") + with h5py.File(filepath, "w") as f: + grp = f.create_group("img") + grp.attrs["type"] = "ImageObj" + grp.create_dataset("data", data=np.zeros((3, 4))) + for attr, value in { + "x0": 1.0, + "y0": 2.0, + "dx": 0.5, + "dy": 0.25, + }.items(): + grp.attrs[attr] = value + + workspace = Workspace(backend=StandaloneBackend()) + workspace.load(filepath) + + loaded = workspace.get("img") + assert loaded.is_uniform_coords + assert (loaded.x0, loaded.y0, loaded.dx, loaded.dy) == (1.0, 2.0, 0.5, 0.25) + + def test_workspace_load_image_with_invalid_coordinates(self): + """Non-uniform coordinates that do not match the image are rejected.""" + import h5py + + with tempfile.TemporaryDirectory() as tmpdir: + filepath = os.path.join(tmpdir, "invalid.h5") + with h5py.File(filepath, "w") as f: + grp = f.create_group("img") + grp.attrs["type"] = "ImageObj" + grp.attrs["is_uniform_coords"] = False + grp.create_dataset("data", data=np.zeros((3, 4))) + grp.create_dataset("xcoords", data=np.arange(3.0)) + grp.create_dataset("ycoords", data=np.arange(3.0)) + + workspace = Workspace(backend=StandaloneBackend()) + with pytest.raises(ValueError, match="do not match image shape"): + workspace.load(filepath) + def test_workspace_save_load_roundtrip_multiple(self): """Save then load preserves all objects.""" workspace = Workspace(backend=StandaloneBackend()) diff --git a/doc/release_notes/release_0.02.md b/doc/release_notes/release_0.02.md index f7c2631..7ccc8fd 100644 --- a/doc/release_notes/release_0.02.md +++ b/doc/release_notes/release_0.02.md @@ -4,6 +4,7 @@ * Python 3.10 or later is now required. Python 3.9 users must keep an earlier compatible release. * Kernel language information now reports the Python version actually running the kernel, rather than always announcing Python 3.9. +* Fixed images with non-uniform coordinates losing their coordinates when transferred to or from DataLab, or when the workspace was saved to and loaded from an HDF5 file: they came back with uniform coordinates equal to the pixel indices, so that their physical positions were wrong. The pixel center coordinates are now kept, in the same NPZ format as DataLab (1.4.0 or later); data and files written by earlier versions are still read with uniform coordinates. ## DataLab-Kernel Version 0.2.11 (2026-02-15) ##