Skip to content

Commit a7b4116

Browse files
authored
Annotation: support empty volumes & make volume location optional (#814)
* support empty volumes in temporary_volume_layer_copy(), make volume locations optional * rm superflouos import * update message * add changelog entries * use cls for BoundingBox constructors
1 parent d867f53 commit a7b4116

File tree

10 files changed

+199
-100
lines changed

10 files changed

+199
-100
lines changed

webknossos/Changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@ For upgrade instructions, please check the respective *Breaking Changes* section
1717
### Added
1818

1919
### Changed
20+
- Make volume locations optional, allowing to parse segment information in future NML-only annotations. [#814](https://github.com/scalableminds/webknossos-libs/pull/814)
2021

2122
### Fixed
23+
- `annotation.temporary_volume_layer_copy()` works also with empty volume annotations. [#814](https://github.com/scalableminds/webknossos-libs/pull/814)
24+
2225

2326

2427
## [0.10.19](https://github.com/scalableminds/webknossos-libs/releases/tag/v0.10.19) - 2022-10-18
888 Bytes
Binary file not shown.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<things>
2+
<meta name="writer" content="NmlWriter.scala" />
3+
<meta name="writerGitCommit" content="d35e587fb59752e1899be99e2d5e046b186a5d6f" />
4+
<meta name="timestamp" content="1666187511849" />
5+
<meta name="annotationId" content="635000ac0100007800c91264" />
6+
<meta name="username" content="Sample User" />
7+
<parameters>
8+
<experiment name="l4_sample" organization="sample_organization" description="" />
9+
<scale x="11.239999771118164" y="11.239999771118164" z="28.0" />
10+
<offset x="0" y="0" z="0" />
11+
<time ms="1666187436145" />
12+
<editPosition x="3581" y="3585" z="1024" />
13+
<editRotation xRot="0.0" yRot="0.0" zRot="0.0" />
14+
<zoomLevel zoom="1.0" />
15+
</parameters>
16+
<branchpoints />
17+
<comments />
18+
<groups />
19+
<volume id="0" name="segmentation" fallbackLayer="segmentation" largestSegmentId="2504698"><!--Note that volume data was omitted when downloading this annotation.-->
20+
<segments>
21+
<segment id="2504698" name="test_segment" created="1666187488227" anchorPositionX="3581" anchorPositionY="3585" anchorPositionZ="1024" />
22+
</segments>
23+
</volume>
24+
</things>
720 Bytes
Binary file not shown.

webknossos/tests/test_annotation.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,39 @@ def check_properties(annotation: wk.Annotation) -> None:
204204

205205
annotation_deserialized = wk.Annotation.load(output_path)
206206
check_properties(annotation_deserialized)
207+
208+
209+
def test_empty_volume_annotation() -> None:
210+
a = wk.Annotation.load(TESTDATA_DIR / "annotations" / "empty_volume_annotation.zip")
211+
with a.temporary_volume_layer_copy() as layer:
212+
assert layer.bounding_box == wk.BoundingBox.empty()
213+
assert layer.largest_segment_id == 0
214+
assert set(layer.mags) == set(
215+
[
216+
wk.Mag(1),
217+
wk.Mag((2, 2, 1)),
218+
wk.Mag((4, 4, 1)),
219+
wk.Mag((8, 8, 1)),
220+
wk.Mag((16, 16, 1)),
221+
]
222+
)
223+
224+
225+
@pytest.mark.parametrize(
226+
"nml_path",
227+
[
228+
TESTDATA_DIR / "annotations" / "nml_with_volumes.nml",
229+
TESTDATA_DIR / "annotations" / "nml_with_volumes.zip",
230+
],
231+
)
232+
def test_nml_with_volumes(nml_path: Path) -> None:
233+
if nml_path.suffix == ".zip":
234+
with pytest.warns(UserWarning, match="location is not referenced in the NML"):
235+
a = wk.Annotation.load(nml_path)
236+
else:
237+
a = wk.Annotation.load(nml_path)
238+
segment_info = a.get_volume_layer_segments("segmentation")
239+
assert set(segment_info) == set([2504698])
240+
assert segment_info[2504698] == wk.SegmentInformation(
241+
name="test_segment", anchor_position=Vec3Int(3581, 3585, 1024), color=None
242+
)

webknossos/webknossos/_nml/volume.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
class Volume(NamedTuple):
1111
id: int
12-
location: str # path to a ZIP file containing a wK volume annotation
12+
location: Optional[
13+
str
14+
] # path to a ZIP file containing a wK volume annotation, may be omitted when using skip_volume_data
1315
# name of an already existing wK volume annotation segmentation layer:
1416
fallback_layer: Optional[str]
1517
# older wk versions did not serialize the name which is why the name is optional:
@@ -39,7 +41,7 @@ def _dump(self, xf: XmlWriter) -> None:
3941
def _parse(cls, nml_volume: Element) -> "Volume":
4042
return cls(
4143
id=int(enforce_not_null(nml_volume.get("id"))),
42-
location=enforce_not_null(nml_volume.get("location")),
44+
location=nml_volume.get("location"),
4345
fallback_layer=nml_volume.get("fallbackLayer", default=None),
4446
name=nml_volume.get("name", default=None),
4547
segments=[],

webknossos/webknossos/annotation/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Annotation,
33
AnnotationState,
44
AnnotationType,
5+
SegmentInformation,
56
open_annotation,
67
)
78
from webknossos.annotation.annotation_info import AnnotationInfo

webknossos/webknossos/annotation/annotation.py

Lines changed: 88 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -219,14 +219,7 @@ def load(cls, annotation_path: Union[str, PathLike]) -> "Annotation":
219219
return cls._load_from_zip(annotation_path)
220220
elif annotation_path.suffix == ".nml":
221221
with annotation_path.open(mode="rb") as f:
222-
annotation, nml = cls._load_from_nml(annotation_path.stem, f)
223-
if len(nml.volumes) > 0:
224-
warnings.warn(
225-
"The loaded nml contains references to volume layer annotations. "
226-
+ "Those can only be loaded from a zip-file containing the nml and the volume annotation layer zips. "
227-
+ "Omitting the volume layer annotations."
228-
)
229-
return annotation
222+
return cls._load_from_nml(annotation_path.stem, f)
230223
else:
231224
raise RuntimeError(
232225
"The loaded annotation must have the suffix .zip or .nml, but is {annotation_path.suffix}"
@@ -327,12 +320,9 @@ def download(
327320
_header_value, header_params = cgi.parse_header(content_disposition_header)
328321
filename = header_params.get("filename", "")
329322
if filename.endswith(".nml"):
330-
annotation, nml = Annotation._load_from_nml(
323+
annotation = Annotation._load_from_nml(
331324
filename[:-4], BytesIO(response.content)
332325
)
333-
assert (
334-
len(nml.volumes) == 0
335-
), "The downloaded NML contains volume tags, it should have downloaded a zip instead."
336326
else:
337327
assert filename.endswith(
338328
".zip"
@@ -367,54 +357,66 @@ def open_as_remote_dataset(
367357

368358
@classmethod
369359
def _load_from_nml(
370-
cls, name: str, nml_content: BinaryIO
371-
) -> Tuple["Annotation", wknml.Nml]:
360+
cls,
361+
name: str,
362+
nml_content: BinaryIO,
363+
possible_volume_paths: Optional[List[ZipPath]] = None,
364+
) -> "Annotation":
372365
nml = wknml.Nml.parse(nml_content)
373366

374-
return (
375-
cls(
376-
name=name,
377-
skeleton=nml_to_skeleton(nml),
378-
owner_name=nml.get_meta("username"),
379-
annotation_id=nml.get_meta("annotationId"),
380-
time=nml.parameters.time,
381-
edit_position=nml.parameters.editPosition,
382-
edit_rotation=nml.parameters.editRotation,
383-
zoom_level=nml.parameters.zoomLevel,
384-
task_bounding_box=nml.parameters.taskBoundingBox,
385-
user_bounding_boxes=nml.parameters.userBoundingBoxes or [],
386-
metadata={
387-
i.name: i.content
388-
for i in nml.meta
389-
if i.name not in ["username", "annotationId"]
390-
},
391-
),
392-
nml,
367+
annotation = cls(
368+
name=name,
369+
skeleton=nml_to_skeleton(nml),
370+
owner_name=nml.get_meta("username"),
371+
annotation_id=nml.get_meta("annotationId"),
372+
time=nml.parameters.time,
373+
edit_position=nml.parameters.editPosition,
374+
edit_rotation=nml.parameters.editRotation,
375+
zoom_level=nml.parameters.zoomLevel,
376+
task_bounding_box=nml.parameters.taskBoundingBox,
377+
user_bounding_boxes=nml.parameters.userBoundingBoxes or [],
378+
metadata={
379+
i.name: i.content
380+
for i in nml.meta
381+
if i.name not in ["username", "annotationId"]
382+
},
393383
)
384+
annotation._volume_layers = cls._parse_volumes(nml, possible_volume_paths)
385+
return annotation
394386

395-
@classmethod
396-
def _load_from_zip(cls, content: Union[str, PathLike, BinaryIO]) -> "Annotation":
397-
zipfile = ZipFile(content)
398-
paths = [ZipPath(zipfile, i.filename) for i in zipfile.filelist]
399-
nml_paths = [i for i in paths if i.suffix == ".nml"]
400-
assert len(nml_paths) > 0, "Couldn't find an nml file in the supplied zip-file."
401-
assert (
402-
len(nml_paths) == 1
403-
), f"There must be exactly one nml file in the zip-file, buf found {len(nml_paths)}."
404-
with nml_paths[0].open(mode="rb") as f:
405-
annotation, nml = cls._load_from_nml(nml_paths[0].stem, f)
387+
@staticmethod
388+
def _parse_volumes(
389+
nml: wknml.Nml, possible_paths: Optional[List[ZipPath]]
390+
) -> List[_VolumeLayer]:
406391
volume_layers = []
392+
layers_with_not_found_location = []
393+
layers_without_location = []
407394
for volume in nml.volumes:
408-
fitting_volume_paths = [i for i in paths if str(i.at) == volume.location]
409-
assert (
410-
len(fitting_volume_paths) == 1
411-
), f"Couldn't find the file {volume.location} for the volume annotation {volume.name or volume.id}"
412-
with fitting_volume_paths[0].open(mode="rb") as f:
413-
with ZipFile(f) as volume_layer_zipfile:
414-
if len(volume_layer_zipfile.filelist) == 0:
415-
volume_path = None
416-
else:
417-
volume_path = fitting_volume_paths[0]
395+
if possible_paths is None: # when parsing NML files
396+
volume_path = None
397+
if volume.location is not None:
398+
# This should only happen if a zipped nml
399+
# is unpacked and loaded directly.
400+
layers_with_not_found_location.append(volume)
401+
volume_path = None
402+
elif volume.location is None:
403+
volume_path = None
404+
layers_without_location.append(volume)
405+
else:
406+
fitting_volume_paths = [
407+
i for i in possible_paths if str(i.at) == volume.location
408+
]
409+
if len(fitting_volume_paths) == 1:
410+
with fitting_volume_paths[0].open(mode="rb") as f:
411+
with ZipFile(f) as volume_layer_zipfile:
412+
if len(volume_layer_zipfile.filelist) == 0:
413+
volume_path = None
414+
else:
415+
volume_path = fitting_volume_paths[0]
416+
else:
417+
layers_with_not_found_location.append(volume)
418+
volume_path = None
419+
418420
segments = {}
419421
if volume.segments is not None:
420422
for segment in volume.segments:
@@ -435,8 +437,32 @@ def _load_from_zip(cls, content: Union[str, PathLike, BinaryIO]) -> "Annotation"
435437
assert len(set(i.id for i in volume_layers)) == len(
436438
volume_layers
437439
), "Some volume layers have the same id, this is not allowed."
438-
annotation._volume_layers = volume_layers
439-
return annotation
440+
if len(layers_without_location) > 0:
441+
warnings.warn(
442+
"Omitting the volume layer annotation data for layers "
443+
+ f"{[v.name or v.id for v in layers_without_location]}, "
444+
+ "as their location is not referenced in the NML."
445+
)
446+
if len(layers_with_not_found_location) > 0:
447+
warnings.warn(
448+
"Omitting the volume layer annotation data for layers "
449+
+ f"{[v.name or v.id for v in layers_without_location]}, "
450+
+ f"as their referenced files {[v.location for v in layers_without_location]} "
451+
+ "cannot be found."
452+
)
453+
return volume_layers
454+
455+
@classmethod
456+
def _load_from_zip(cls, content: Union[str, PathLike, BinaryIO]) -> "Annotation":
457+
zipfile = ZipFile(content)
458+
paths = [ZipPath(zipfile, i.filename) for i in zipfile.filelist]
459+
nml_paths = [i for i in paths if i.suffix == ".nml"]
460+
assert len(nml_paths) > 0, "Couldn't find an nml file in the supplied zip-file."
461+
assert (
462+
len(nml_paths) == 1
463+
), f"There must be exactly one nml file in the zip-file, buf found {len(nml_paths)}."
464+
with nml_paths[0].open(mode="rb") as f:
465+
return cls._load_from_nml(nml_paths[0].stem, f, possible_volume_paths=paths)
440466

441467
def save(self, path: Union[str, PathLike]) -> None:
442468
"""
@@ -716,7 +742,7 @@ def export_volume_layer_to_dataset(
716742

717743
assert (
718744
volume_zip_path is not None
719-
), "The selected volume layer is empty and cannot be exported."
745+
), "The selected volume layer data is not available and cannot be exported."
720746

721747
with volume_zip_path.open(mode="rb") as f:
722748
data_zip = ZipFile(f)
@@ -739,8 +765,11 @@ def export_volume_layer_to_dataset(
739765

740766
if largest_segment_id is None:
741767
max_value = max(
742-
view.read().max()
743-
for view in best_mag_view.get_views_on_disk(read_only=True)
768+
(
769+
view.read().max()
770+
for view in best_mag_view.get_views_on_disk(read_only=True)
771+
),
772+
default=0,
744773
)
745774
layer.largest_segment_id = int(max_value)
746775
else:

webknossos/webknossos/dataset/_utils/infer_bounding_box_existing_files.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,7 @@ def infer_bounding_box_existing_files(mag_view: MagView) -> BoundingBox:
1010
The returned bounding box is measured in Mag(1) voxels."""
1111

1212
return reduce(
13-
lambda acc, bbox: acc.extended_by(bbox), mag_view.get_bounding_boxes_on_disk()
13+
lambda acc, bbox: acc.extended_by(bbox),
14+
mag_view.get_bounding_boxes_on_disk(),
15+
BoundingBox.empty(),
1416
)

0 commit comments

Comments
 (0)