From 5f6efe99fa06796e7596602129ba8e15fdafb7c8 Mon Sep 17 00:00:00 2001 From: Jonas Teuwen Date: Wed, 21 Aug 2024 10:52:13 +0200 Subject: [PATCH] Improve test coverage to 90% --- dlup/annotations_experimental.py | 37 +++++++++++++--- dlup/geometry.py | 10 +++++ tests/infer_ann.py | 3 -- tests/test_slide_annotations.py | 66 ++++++++++++++++++++++++++-- tests/utils/test_annotation_utils.py | 7 ++- 5 files changed, 108 insertions(+), 15 deletions(-) delete mode 100644 tests/infer_ann.py diff --git a/dlup/annotations_experimental.py b/dlup/annotations_experimental.py index e79dded5..cd7cd926 100644 --- a/dlup/annotations_experimental.py +++ b/dlup/annotations_experimental.py @@ -73,11 +73,11 @@ class DarwinV7Metadata(NamedTuple): @functools.lru_cache(maxsize=None) def get_v7_metadata(filename: pathlib.Path) -> Optional[dict[tuple[str, str], DarwinV7Metadata]]: if not DARWIN_SDK_AVAILABLE: - raise RuntimeError("`darwin` is not available. Install using `python -m pip install darwin-py`.") + raise ImportError("`darwin` is not available. Install using `python -m pip install darwin-py`.") import darwin.path_utils if not filename.is_dir(): - raise RuntimeError("Provide the path to the root folder of the Darwin V7 annotations") + raise ValueError("Provide the path to the root folder of the Darwin V7 annotations") v7_metadata_fn = filename / ".v7" / "metadata.json" if not v7_metadata_fn.exists(): @@ -129,7 +129,6 @@ def to_sorting_params(self) -> tuple[Callable[[Polygon], Optional[int | float | if self == AnnotationSorting.Z_INDEX: return lambda x: x.get_field("z_index"), False - raise ValueError(f"Unsupported sorting {self}") def _geometry_to_geojson( @@ -383,6 +382,10 @@ def from_asap_xml( ------- SlideAnnotations """ + path = pathlib.Path(asap_xml) + if not path.exists(): + raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(path)) + tree = ET.parse(asap_xml) opened_annotation = tree.getroot() collection: GeometryCollection = GeometryCollection() @@ -447,6 +450,9 @@ def from_darwin_json( import darwin darwin_json_fn = pathlib.Path(darwin_json) + if not darwin_json_fn.exists(): + raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(darwin_json_fn)) + darwin_an = darwin.utils.parse_darwin_json(darwin_json_fn, None) v7_metadata = get_v7_metadata(darwin_json_fn.parent) @@ -513,10 +519,15 @@ def from_darwin_json( else: raise ValueError(f"Annotation type {annotation_type} is not supported.") - for polygon, _ in sorted(polygons, key=lambda x: x[1]): - collection.add_polygon(polygon) + if sorting == "Z_INDEX": + for polygon, _ in sorted(polygons, key=lambda x: x[1]): + collection.add_polygon(polygon) + else: + _ = [collection.add_polygon(polygon) for polygon, _ in polygons] - SlideAnnotations._in_place_sort_and_scale(collection, scaling, sorting) + SlideAnnotations._in_place_sort_and_scale( + collection, scaling, sorting="NONE" if sorting == "Z_INDEX" else sorting + ) return cls(layers=collection, tags=tuple(tags), sorting=sorting) @classmethod @@ -533,6 +544,10 @@ def from_dlup_xml(cls: Type[_TSlideAnnotations], dlup_xml: PathLike) -> _TSlideA ------- SlideAnnotations """ + path = pathlib.Path(dlup_xml) + if not path.exists(): + raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(path)) + parser = XmlParser() with open(dlup_xml, "rb") as f: dlup_annotations = parser.from_bytes(f.read(), XMLDlupAnnotations) @@ -628,6 +643,10 @@ def from_halo_xml( ------- SlideAnnotations """ + path = pathlib.Path(halo_xml) + if not path.exists(): + raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(path)) + if not PYHALOXML_AVAILABLE: raise RuntimeError("`pyhaloxml` is not available. Install using `python -m pip install pyhaloxml`.") import pyhaloxml.shapely @@ -657,14 +676,19 @@ def from_halo_xml( raise NotImplementedError(f"Regiontype {region.type} is not implemented in dlup") SlideAnnotations._in_place_sort_and_scale(collection, scaling, sorting) + def offset_function(slide): return slide.slide_bounds[0] - slide.slide_bounds[0] % 256 + return cls(collection, tags=None, sorting=sorting, offset_function=offset_function) @staticmethod def _in_place_sort_and_scale( collection: GeometryCollection, scaling: Optional[float], sorting: Optional[AnnotationSorting | str] ) -> None: + if sorting == "REVERSE": + raise NotImplementedError("This doesn't work for now.") + if scaling != 1.0 and scaling is not None: collection.scale(scaling) if sorting == AnnotationSorting.NONE or sorting is None: @@ -815,7 +839,6 @@ def __contains__(self, item: str | Point | Polygon) -> bool: return item in self.available_classes if isinstance(item, Point): return item in self._layers.points - if isinstance(item, Polygon): return item in self._layers.polygons diff --git a/dlup/geometry.py b/dlup/geometry.py index 4fe045d2..b92f3cf5 100644 --- a/dlup/geometry.py +++ b/dlup/geometry.py @@ -371,6 +371,7 @@ def _point_factory(point: _dg.Point) -> Point: _dg.set_point_factory(_point_factory) +# TODO: Allow to construct geometry collection from a list of polygons, bypassing the python loop class GeometryCollection(_dg.GeometryCollection): def __init__(self) -> None: super().__init__() @@ -418,6 +419,15 @@ def __setstate__(self, state: dict[str, list[dict[str, Any]]]) -> None: for point in points: self.add_point(point) + def __copy__(self): + collection = GeometryCollection() + for polygon in self.polygons: + collection.add_polygon(polygon.__copy__()) + for point in self.points: + collection.add_point(point.__copy__()) + collection.rebuild_rtree() + return collection + def __eq__(self, other: Any) -> bool: if not isinstance(other, type(self)): return False diff --git a/tests/infer_ann.py b/tests/infer_ann.py deleted file mode 100644 index e7be73c0..00000000 --- a/tests/infer_ann.py +++ /dev/null @@ -1,3 +0,0 @@ -from dlup.annotations import WsiAnnotations - -ann = WsiAnnotations.from_geojson("/Users/jteuwen/annotations.json") diff --git a/tests/test_slide_annotations.py b/tests/test_slide_annotations.py index 730c54b5..81ea4dae 100644 --- a/tests/test_slide_annotations.py +++ b/tests/test_slide_annotations.py @@ -11,7 +11,7 @@ import numpy as np import pytest -from dlup.annotations_experimental import SlideAnnotations, geojson_to_dlup +from dlup.annotations_experimental import GeometryCollection, SlideAnnotations, geojson_to_dlup, get_v7_metadata from dlup.geometry import Point as Point from dlup.geometry import Polygon as Polygon from dlup.utils.imports import DARWIN_SDK_AVAILABLE @@ -93,6 +93,15 @@ + + + + + + + + + @@ -106,6 +115,14 @@ """ +polygons = [ + Polygon([(0, 0), (0, 3), (3, 3), (3, 0)], []), + Polygon([(2, 2), (2, 5), (5, 5), (5, 2)], []), + Polygon([(4, 2), (4, 7), (7, 7), (7, 4)], []), + Polygon([(6, 6), (6, 9), (9, 9), (9, 6)], []), +] + + class TestAnnotations: with tempfile.NamedTemporaryFile(suffix=".xml") as asap_file: asap_file.write(ASAP_XML_EXAMPLE) @@ -155,8 +172,8 @@ def test_conversion_geojson_v7(self): assert self.v7_annotations.num_points == annotations.num_points assert self.v7_annotations.num_polygons == annotations.num_polygons - assert self.v7_annotations._layers.polygons == annotations._layers.polygons - assert self.v7_annotations._layers.points == annotations._layers.points + assert self.v7_annotations.layers.polygons == annotations.layers.polygons + assert self.v7_annotations.layers.points == annotations.layers.points self.v7_annotations.rebuild_rtree() annotations.rebuild_rtree() @@ -190,6 +207,12 @@ def test_reading_qupath05_geojson_export(self): annotations = SlideAnnotations.from_geojson(pathlib.Path("tests/files/qupath05.geojson")) assert len(annotations.available_classes) == 2 + @pytest.mark.parametrize("class_method", ["from_geojson", "from_halo_xml", "from_dlup_xml", "from_asap_xml"]) + def test_missing_file_constructor(self, class_method): + constructor = getattr(SlideAnnotations, class_method) + with pytest.raises(FileNotFoundError): + constructor("doesnotexist.xml.json") + def test_asap_to_geojson(self): # TODO: Make sure that the annotations hit the border of the region. asap_geojson = self.asap_annotations.as_geojson() @@ -435,3 +458,40 @@ def test_add_with_invalid_type(self): annotations += "invalid type" with pytest.raises(TypeError): _ = "invalid type" + annotations + + def test_v7_metadata(self, monkeypatch): + with pytest.raises(ValueError): + get_v7_metadata(pathlib.Path("../tests")) + + monkeypatch.setattr("dlup.annotations_experimental.DARWIN_SDK_AVAILABLE", False) + with pytest.raises(ImportError): + get_v7_metadata(pathlib.Path(".")) + + @pytest.mark.parametrize("sorting_type", ["NONE", "REVERSE", "AREA", "Z_INDEX", "NON_EXISTENT"]) + def test_sorting(self, sorting_type): + collection = GeometryCollection() + for polygon in polygons: + collection.add_polygon(polygon) + + if sorting_type == "NONE": + curr_collection = collection.__copy__() + SlideAnnotations._in_place_sort_and_scale(curr_collection, scaling=1.0, sorting=sorting_type) + assert curr_collection == collection + + if sorting_type == "REVERSE": + with pytest.raises(NotImplementedError): + curr_collection = collection.__copy__() + SlideAnnotations._in_place_sort_and_scale(curr_collection, scaling=1.0, sorting=sorting_type) + # Needs fixing + # assert curr_collection.polygons == collection.polygons[::-1] + + if sorting_type == "Z_INDEX": + curr_collection = collection.__copy__() + for idx, polygon in enumerate(curr_collection.polygons): + polygon.set_field("z_index", len(curr_collection.polygons) - idx) + SlideAnnotations._in_place_sort_and_scale(curr_collection, scaling=1.0, sorting=sorting_type) + assert curr_collection.polygons == collection.polygons[::-1] + + if sorting_type == "NON_EXISTENT": + with pytest.raises(KeyError): + SlideAnnotations._in_place_sort_and_scale(collection, scaling=1.0, sorting=sorting_type) diff --git a/tests/utils/test_annotation_utils.py b/tests/utils/test_annotation_utils.py index 0d0093d3..b2d4966d 100644 --- a/tests/utils/test_annotation_utils.py +++ b/tests/utils/test_annotation_utils.py @@ -1,7 +1,8 @@ # Copyright (c) dlup contributors import pytest -from dlup.utils.annotations_utils import rgb_to_hex, hex_to_rgb +from dlup.utils.annotations_utils import hex_to_rgb, rgb_to_hex + @pytest.mark.parametrize("rgb", [(0, 0, 0), (255, 10, 255), (255, 127, 0), (0, 28, 0), (0, 0, 255)]) def test_rgb_to_hex_to_rgb(rgb): @@ -9,9 +10,11 @@ def test_rgb_to_hex_to_rgb(rgb): rgb2 = hex_to_rgb(hex_repr) assert rgb == rgb2 + def test_fixed_colors(): assert hex_to_rgb("black") == (0, 0, 0) + def test_exceptions(): with pytest.raises(ValueError): rgb_to_hex(256, 0, 0) @@ -42,4 +45,4 @@ def test_exceptions(): with pytest.raises(ValueError): hex_to_rgb("#") with pytest.raises(ValueError): - hex_to_rgb("") \ No newline at end of file + hex_to_rgb("")