Skip to content

Commit

Permalink
✨ Add Support for QuPath Annotation Imports (TissueImageAnalytics#721)
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
measty and shaneahmed authored Nov 22, 2023
1 parent 890e5a3 commit 1d3f039
Show file tree
Hide file tree
Showing 2 changed files with 110 additions and 5 deletions.
62 changes: 62 additions & 0 deletions tests/test_annotation_stores.py
Original file line number Diff line number Diff line change
Expand Up @@ -2823,3 +2823,65 @@ def test_query_min_area(
_, store = fill_store(store_cls, ":memory:")
result = store.query((0, 0, 1000, 1000), min_area=1)
assert len(result) == 100 # should only get cells, pts are too small

@staticmethod
def test_import_transform(
tmp_path: Path,
store_cls: type[AnnotationStore],
) -> None:
"""Test importing with an application-specific transform."""
# make a simple example of a .geojson exported from QuPath
anns = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[1076, 2322.55],
[1073.61, 2323.23],
[1072.58, 2323.88],
[1070.93, 2325.61],
[1076, 2322.55],
],
],
},
"properties": {
"object_type": "detection",
"isLocked": "false",
"measurements": [
{
"name": "Detection probability",
"value": 0.847621500492096,
},
{"name": "Area µm^2", "value": 27.739423751831055},
],
},
},
],
}
with (tmp_path / "test_annotations.geojson").open("w") as f:
json.dump(anns, f)

def unpack_qupath(ann: Annotation) -> Annotation:
"""Helper function to unpack QuPath measurements."""
props = ann.properties
measurements = props.pop("measurements")
for m in measurements:
props[m["name"]] = m["value"]
return ann

store = store_cls.from_geojson(
tmp_path / "test_annotations.geojson",
transform=unpack_qupath,
)
assert len(store) == 1
ann = next(iter(store.values()))
assert ann.properties == {
"object_type": "detection",
"isLocked": "false",
"Detection probability": 0.847621500492096,
"Area µm^2": 27.739423751831055,
}
53 changes: 48 additions & 5 deletions tiatoolbox/annotation/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -1724,6 +1724,7 @@ def from_geojson(
fp: IO | str,
scale_factor: tuple[float, float] = (1, 1),
origin: tuple[float, float] = (0, 0),
transform: Callable[[Annotation], Annotation] | None = None,
) -> AnnotationStore:
"""Create a new database with annotations loaded from a geoJSON file.
Expand All @@ -1736,21 +1737,56 @@ def from_geojson(
annotations saved at non-baseline resolution.
origin (Tuple[float, float]):
The x and y coordinates to use as the origin for the annotations.
transform (Callable):
A function to apply to each annotation after loading. Should take an
annotation as input and return an annotation. Defaults to None.
Intended to facilitate modifying the way annotations are loaded to
accomodate the specifics of different annotation formats.
Returns:
AnnotationStore:
A new annotation store with the annotations loaded from the file.
Example:
To load annotations from a GeoJSON exported by QuPath, with measurements
stored in a 'measurements' property as a list of name-value pairs, and
unpack those measurements into a flat dictionary of properties of
each annotation:
>>> from tiatoolbox.annotation.storage import SQLiteStore
>>> def unpack_qupath(ann: Annotation) -> Annotation:
>>> #Helper function to unpack QuPath measurements.
>>> props = ann.properties
>>> measurements = props.pop("measurements")
>>> for m in measurements:
>>> props[m["name"]] = m["value"]
>>> return ann
>>> store = SQLiteStore.from_geojson(
... "exported_file.geojson",
... transform=unpack_qupath,
... )
"""
store = cls()
store.add_from_geojson(fp, scale_factor, origin=origin)
if transform is None:

def transform(annotation: Annotation) -> Annotation:
"""Default import transform. Does Nothing."""
return annotation

store.add_from_geojson(
fp,
scale_factor,
origin=origin,
transform=transform,
)
return store

def add_from_geojson(
self: AnnotationStore,
fp: IO | str,
scale_factor: tuple[float, float] = (1, 1),
origin: tuple[float, float] = (0, 0),
transform: Callable[[Annotation], Annotation] | None = None,
) -> None:
"""Add annotations from a .geojson file to an existing store.
Expand All @@ -1765,6 +1801,11 @@ def add_from_geojson(
at non-baseline resolution.
origin (Tuple[float, float]):
The x and y coordinates to use as the origin for the annotations.
transform (Callable):
A function to apply to each annotation after loading. Should take an
annotation as input and return an annotation. Defaults to None.
Intended to facilitate modifying the way annotations are loaded to
accommodate the specifics of different annotation formats.
"""

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

annotations = [
Annotation(
transform_geometry(
feature2geometry(feature["geometry"]),
transform(
Annotation(
transform_geometry(
feature2geometry(feature["geometry"]),
),
feature["properties"],
),
feature["properties"],
)
for feature in geojson["features"]
]
Expand Down

0 comments on commit 1d3f039

Please sign in to comment.