Skip to content

Commit 1d3f039

Browse files
meastyshaneahmed
andauthored
✨ Add Support for QuPath Annotation Imports (#721)
Adds support to importing objects from QuPath (and other applications) easier. If you export some objects in .geojson from some versions of QuPath, and they have measurements, all the measurements are in a 'measurements' property in the geojson. This means, when these are imported into annotation store, it will import fine but the properties dict will look something like: `properties = {a couple other props, "measurements": [{'name': 'propA', 'value': valA}, {'name': 'propB', 'value': valB}, etc]}` Which is awkward for many downstream things with the nested data structure, and doesn't really mesh with how annotations are intended to be represented in the store (usually a flat dict of properties unless there's a very good reason for nesting). This commit adds an option to provide a transform to deal with any application-specific formatting, which for example can be used to unpack measurements when importing from QuPath to give instead in a properties dict with the measurement properties unpacked: `properties = {a couple other props, 'propA': valA, 'propB': valB, etc}` Importing from QuPath is probably one of the main use-cases for this, and an example is provided in the docstring for this case, but the additional flexibility means it can be used for other use-cases too. --------- Co-authored-by: Shan E Ahmed Raza <[email protected]>
1 parent 890e5a3 commit 1d3f039

File tree

2 files changed

+110
-5
lines changed

2 files changed

+110
-5
lines changed

tests/test_annotation_stores.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2823,3 +2823,65 @@ def test_query_min_area(
28232823
_, store = fill_store(store_cls, ":memory:")
28242824
result = store.query((0, 0, 1000, 1000), min_area=1)
28252825
assert len(result) == 100 # should only get cells, pts are too small
2826+
2827+
@staticmethod
2828+
def test_import_transform(
2829+
tmp_path: Path,
2830+
store_cls: type[AnnotationStore],
2831+
) -> None:
2832+
"""Test importing with an application-specific transform."""
2833+
# make a simple example of a .geojson exported from QuPath
2834+
anns = {
2835+
"type": "FeatureCollection",
2836+
"features": [
2837+
{
2838+
"type": "Feature",
2839+
"geometry": {
2840+
"type": "Polygon",
2841+
"coordinates": [
2842+
[
2843+
[1076, 2322.55],
2844+
[1073.61, 2323.23],
2845+
[1072.58, 2323.88],
2846+
[1070.93, 2325.61],
2847+
[1076, 2322.55],
2848+
],
2849+
],
2850+
},
2851+
"properties": {
2852+
"object_type": "detection",
2853+
"isLocked": "false",
2854+
"measurements": [
2855+
{
2856+
"name": "Detection probability",
2857+
"value": 0.847621500492096,
2858+
},
2859+
{"name": "Area µm^2", "value": 27.739423751831055},
2860+
],
2861+
},
2862+
},
2863+
],
2864+
}
2865+
with (tmp_path / "test_annotations.geojson").open("w") as f:
2866+
json.dump(anns, f)
2867+
2868+
def unpack_qupath(ann: Annotation) -> Annotation:
2869+
"""Helper function to unpack QuPath measurements."""
2870+
props = ann.properties
2871+
measurements = props.pop("measurements")
2872+
for m in measurements:
2873+
props[m["name"]] = m["value"]
2874+
return ann
2875+
2876+
store = store_cls.from_geojson(
2877+
tmp_path / "test_annotations.geojson",
2878+
transform=unpack_qupath,
2879+
)
2880+
assert len(store) == 1
2881+
ann = next(iter(store.values()))
2882+
assert ann.properties == {
2883+
"object_type": "detection",
2884+
"isLocked": "false",
2885+
"Detection probability": 0.847621500492096,
2886+
"Area µm^2": 27.739423751831055,
2887+
}

tiatoolbox/annotation/storage.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1724,6 +1724,7 @@ def from_geojson(
17241724
fp: IO | str,
17251725
scale_factor: tuple[float, float] = (1, 1),
17261726
origin: tuple[float, float] = (0, 0),
1727+
transform: Callable[[Annotation], Annotation] | None = None,
17271728
) -> AnnotationStore:
17281729
"""Create a new database with annotations loaded from a geoJSON file.
17291730
@@ -1736,21 +1737,56 @@ def from_geojson(
17361737
annotations saved at non-baseline resolution.
17371738
origin (Tuple[float, float]):
17381739
The x and y coordinates to use as the origin for the annotations.
1740+
transform (Callable):
1741+
A function to apply to each annotation after loading. Should take an
1742+
annotation as input and return an annotation. Defaults to None.
1743+
Intended to facilitate modifying the way annotations are loaded to
1744+
accomodate the specifics of different annotation formats.
17391745
17401746
Returns:
17411747
AnnotationStore:
17421748
A new annotation store with the annotations loaded from the file.
17431749
1750+
Example:
1751+
To load annotations from a GeoJSON exported by QuPath, with measurements
1752+
stored in a 'measurements' property as a list of name-value pairs, and
1753+
unpack those measurements into a flat dictionary of properties of
1754+
each annotation:
1755+
>>> from tiatoolbox.annotation.storage import SQLiteStore
1756+
>>> def unpack_qupath(ann: Annotation) -> Annotation:
1757+
>>> #Helper function to unpack QuPath measurements.
1758+
>>> props = ann.properties
1759+
>>> measurements = props.pop("measurements")
1760+
>>> for m in measurements:
1761+
>>> props[m["name"]] = m["value"]
1762+
>>> return ann
1763+
>>> store = SQLiteStore.from_geojson(
1764+
... "exported_file.geojson",
1765+
... transform=unpack_qupath,
1766+
... )
1767+
17441768
"""
17451769
store = cls()
1746-
store.add_from_geojson(fp, scale_factor, origin=origin)
1770+
if transform is None:
1771+
1772+
def transform(annotation: Annotation) -> Annotation:
1773+
"""Default import transform. Does Nothing."""
1774+
return annotation
1775+
1776+
store.add_from_geojson(
1777+
fp,
1778+
scale_factor,
1779+
origin=origin,
1780+
transform=transform,
1781+
)
17471782
return store
17481783

17491784
def add_from_geojson(
17501785
self: AnnotationStore,
17511786
fp: IO | str,
17521787
scale_factor: tuple[float, float] = (1, 1),
17531788
origin: tuple[float, float] = (0, 0),
1789+
transform: Callable[[Annotation], Annotation] | None = None,
17541790
) -> None:
17551791
"""Add annotations from a .geojson file to an existing store.
17561792
@@ -1765,6 +1801,11 @@ def add_from_geojson(
17651801
at non-baseline resolution.
17661802
origin (Tuple[float, float]):
17671803
The x and y coordinates to use as the origin for the annotations.
1804+
transform (Callable):
1805+
A function to apply to each annotation after loading. Should take an
1806+
annotation as input and return an annotation. Defaults to None.
1807+
Intended to facilitate modifying the way annotations are loaded to
1808+
accommodate the specifics of different annotation formats.
17681809
17691810
"""
17701811

@@ -1789,11 +1830,13 @@ def transform_geometry(geom: Geometry) -> Geometry:
17891830
)
17901831

17911832
annotations = [
1792-
Annotation(
1793-
transform_geometry(
1794-
feature2geometry(feature["geometry"]),
1833+
transform(
1834+
Annotation(
1835+
transform_geometry(
1836+
feature2geometry(feature["geometry"]),
1837+
),
1838+
feature["properties"],
17951839
),
1796-
feature["properties"],
17971840
)
17981841
for feature in geojson["features"]
17991842
]

0 commit comments

Comments
 (0)