Skip to content

Commit 67bcb0c

Browse files
authored
Fix OME metadata (#1272)
* OME 0.5 metadata + only for non-symlinks * add tests * changelog * always resolve * cache resolved_path * cache resolved_path
1 parent 6a78840 commit 67bcb0c

File tree

5 files changed

+153
-9
lines changed

5 files changed

+153
-9
lines changed

webknossos/Changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ For upgrade instructions, please check the respective _Breaking Changes_ section
1717
### Added
1818

1919
### Changed
20+
- For Zarr3 datasets, the OME-Zarr 0.5 metadata is now written. [#1272](https://github.com/scalableminds/webknossos-libs/pull/1272)
2021

2122
### Fixed
23+
- Fixed an issue where it was attempted to overwrite the OME-Zarr metadata for symlinked layers. [#1272](https://github.com/scalableminds/webknossos-libs/pull/1272)
2224

2325

2426
## [2.0.3](https://github.com/scalableminds/webknossos-libs/releases/tag/v2.0.3) - 2025-03-18

webknossos/tests/dataset/test_dataset.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ def test_create_dataset_with_layer_and_mag(
230230

231231

232232
@pytest.mark.parametrize("output_path", [TESTOUTPUT_DIR, REMOTE_TESTOUTPUT_DIR])
233-
def test_ome_ngff_metadata(output_path: Path) -> None:
233+
def test_ome_ngff_0_4_metadata(output_path: Path) -> None:
234234
ds_path = prepare_dataset_path(DataFormat.Zarr, output_path)
235235
ds = Dataset(ds_path, voxel_size=(11, 11, 28))
236236
layer = ds.add_layer("color", COLOR_CATEGORY, data_format=DataFormat.Zarr)
@@ -272,6 +272,84 @@ def test_ome_ngff_metadata(output_path: Path) -> None:
272272
)
273273

274274

275+
@pytest.mark.parametrize("output_path", [TESTOUTPUT_DIR, REMOTE_TESTOUTPUT_DIR])
276+
def test_ome_ngff_0_5_metadata(output_path: Path) -> None:
277+
ds_path = prepare_dataset_path(DataFormat.Zarr3, output_path)
278+
ds = Dataset(ds_path, voxel_size=(11, 11, 28))
279+
layer = ds.add_layer("color", COLOR_CATEGORY, data_format=DataFormat.Zarr3)
280+
layer.add_mag("1")
281+
layer.add_mag("2-2-1")
282+
283+
assert (ds_path / "zarr.json").exists()
284+
assert (ds_path / "color" / "zarr.json").exists()
285+
assert (ds_path / "color" / "1" / "zarr.json").exists()
286+
assert (ds_path / "color" / "2-2-1" / "zarr.json").exists()
287+
288+
zattrs = json.loads((ds_path / "color" / "zarr.json").read_bytes())["attributes"]
289+
assert zattrs["ome"]["version"] == "0.5"
290+
assert len(zattrs["ome"]["multiscales"][0]["datasets"]) == 2
291+
assert zattrs["ome"]["multiscales"][0]["datasets"][0]["coordinateTransformations"][
292+
0
293+
]["scale"] == [
294+
1,
295+
11,
296+
11,
297+
28,
298+
]
299+
assert zattrs["ome"]["multiscales"][0]["datasets"][1]["coordinateTransformations"][
300+
0
301+
]["scale"] == [
302+
1,
303+
22,
304+
22,
305+
28,
306+
]
307+
308+
validate(
309+
instance=zattrs,
310+
schema=json.loads(
311+
UPath(
312+
"https://ngff.openmicroscopy.org/0.5/schemas/image.schema"
313+
).read_bytes()
314+
),
315+
)
316+
317+
318+
def test_ome_ngff_0_5_metadata_symlink() -> None:
319+
def recursive_chmod(ds_path: Path, mode: int) -> None:
320+
# See https://docs.python.org/3/library/os.html#os.chmod for how to use mode
321+
os.chmod(ds_path, mode)
322+
for root, dirs, files in os.walk(ds_path):
323+
root_path = Path(root)
324+
for _dir in dirs:
325+
path = root_path / _dir
326+
os.chmod(path, mode)
327+
for file in files:
328+
path = root_path / file
329+
os.chmod(path, mode)
330+
331+
ds_path = copy_simple_dataset(DEFAULT_DATA_FORMAT, TESTOUTPUT_DIR, "original")
332+
# Add an additional segmentation layer to the original dataset
333+
Dataset.open(ds_path).add_layer(
334+
"segmentation", SEGMENTATION_CATEGORY, largest_segment_id=999
335+
).add_mag(1)
336+
337+
# remove write permissions
338+
recursive_chmod(ds_path, 0o555)
339+
try:
340+
symlink_path = prepare_dataset_path(
341+
DEFAULT_DATA_FORMAT, TESTOUTPUT_DIR, "with_symlink"
342+
)
343+
ds = Dataset(symlink_path, voxel_size=(1, 1, 1))
344+
345+
# add symlink color layer
346+
ds.add_symlink_layer(ds_path / "color")
347+
348+
finally:
349+
# restore write permissions
350+
recursive_chmod(ds_path, 0o777)
351+
352+
275353
def test_create_default_layer() -> None:
276354
ds_path = prepare_dataset_path(DEFAULT_DATA_FORMAT, TESTOUTPUT_DIR)
277355
ds = Dataset(ds_path, voxel_size=(1, 1, 1))

webknossos/webknossos/dataset/dataset.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
ZATTRS_FILE_NAME,
6767
ZGROUP_FILE_NAME,
6868
)
69-
from .ome_metadata import write_ome_0_4_metadata
69+
from .ome_metadata import write_ome_metadata
7070
from .remote_dataset_registry import RemoteDatasetRegistry
7171
from .remote_folder import RemoteFolder
7272
from .sampling_modes import SamplingModes
@@ -329,6 +329,7 @@ def __init__(
329329
330330
"""
331331
self._read_only = read_only
332+
self._resolved_path: Optional[Path] = None
332333
self.path: Path = strip_trailing_slash(UPath(dataset_path))
333334

334335
if count_defined_values((voxel_size, voxel_size_with_unit)) > 1:
@@ -472,8 +473,15 @@ def open(
472473
dataset = cls.__new__(cls)
473474
dataset.path = dataset_path
474475
dataset._read_only = read_only
476+
dataset._resolved_path = None
475477
return dataset._init_from_properties(dataset_properties)
476478

479+
@property
480+
def resolved_path(self) -> Path:
481+
if self._resolved_path is None:
482+
self._resolved_path = self.path.resolve()
483+
return self._resolved_path
484+
477485
@classmethod
478486
def announce_manual_upload(
479487
cls,
@@ -2399,12 +2407,12 @@ def add_remote_layer(
23992407
mag.path = str(foreign_layer.mags[mag.mag].path)
24002408
layer_properties.name = new_layer_name
24012409
self._properties.data_layers += [layer_properties]
2402-
self._layers[new_layer_name] = self._initialize_layer_from_properties(
2403-
layer_properties
2404-
)
2410+
new_layer = self._initialize_layer_from_properties(layer_properties)
2411+
new_layer._resolved_path = foreign_layer_path
2412+
self._layers[new_layer_name] = new_layer
24052413

24062414
self._export_as_json()
2407-
return self.layers[new_layer_name]
2415+
return new_layer
24082416

24092417
def add_fs_copy_layer(
24102418
self,
@@ -2794,7 +2802,9 @@ def _export_as_json(self) -> None:
27942802
json.dump({"zarr_format": 3, "node_type": "group"}, outfile, indent=4)
27952803

27962804
for layer in self.layers.values():
2797-
write_ome_0_4_metadata(self, layer)
2805+
# Only write out OME metadata if the layer is a child of the dataset
2806+
if layer.resolved_path.parent == self.resolved_path:
2807+
write_ome_metadata(self, layer)
27982808

27992809
def _initialize_layer_from_properties(self, properties: LayerProperties) -> Layer:
28002810
if properties.category == COLOR_CATEGORY:

webknossos/webknossos/dataset/layer.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ def __init__(self, dataset: "Dataset", properties: LayerProperties) -> None:
216216
properties.element_class, properties.num_channels
217217
)
218218
self._mags: Dict[Mag, MagView] = {}
219+
self._resolved_path: Optional[Path] = None
219220

220221
for mag in properties.mags:
221222
self._setup_mag(Mag(mag.mag), mag.path)
@@ -260,6 +261,12 @@ def path(self) -> Path:
260261
else self.dataset.path / self.name
261262
)
262263

264+
@property
265+
def resolved_path(self) -> Path:
266+
if self._resolved_path is None:
267+
self._resolved_path = self.path.resolve()
268+
return self._resolved_path
269+
263270
@property
264271
def is_remote_to_dataset(self) -> bool:
265272
"""Whether this layer's data is stored remotely relative to its dataset.

webknossos/webknossos/dataset/ome_metadata.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,53 @@
1212
from .layer import Layer
1313

1414

15+
def get_ome_0_5_multiscale_metadata(
16+
dataset: "Dataset", layer: "Layer"
17+
) -> Dict[str, Any]:
18+
return {
19+
"ome": {
20+
"version": "0.5",
21+
"multiscales": [
22+
{
23+
"axes": [
24+
{"name": "c", "type": "channel"},
25+
{
26+
"name": "x",
27+
"type": "space",
28+
"unit": "nanometer",
29+
},
30+
{
31+
"name": "y",
32+
"type": "space",
33+
"unit": "nanometer",
34+
},
35+
{
36+
"name": "z",
37+
"type": "space",
38+
"unit": "nanometer",
39+
},
40+
],
41+
"datasets": [
42+
{
43+
"path": mag.path.name,
44+
"coordinateTransformations": [
45+
{
46+
"type": "scale",
47+
"scale": [1.0]
48+
+ (
49+
np.array(dataset.voxel_size) * mag.mag.to_np()
50+
).tolist(),
51+
}
52+
],
53+
}
54+
for mag in layer.mags.values()
55+
],
56+
}
57+
],
58+
}
59+
}
60+
61+
1562
def get_ome_0_4_multiscale_metadata(
1663
dataset: "Dataset", layer: "Layer"
1764
) -> Dict[str, Any]:
@@ -57,7 +104,7 @@ def get_ome_0_4_multiscale_metadata(
57104
}
58105

59106

60-
def write_ome_0_4_metadata(dataset: "Dataset", layer: "Layer") -> None:
107+
def write_ome_metadata(dataset: "Dataset", layer: "Layer") -> None:
61108
if not is_writable_path(layer.path):
62109
return
63110
if layer.data_format == DataFormat.Zarr3:
@@ -66,7 +113,7 @@ def write_ome_0_4_metadata(dataset: "Dataset", layer: "Layer") -> None:
66113
{
67114
"zarr_format": 3,
68115
"node_type": "group",
69-
"attributes": get_ome_0_4_multiscale_metadata(dataset, layer),
116+
"attributes": get_ome_0_5_multiscale_metadata(dataset, layer),
70117
},
71118
outfile,
72119
indent=4,

0 commit comments

Comments
 (0)