From 6624b31812b8fa8407be1f042b45bc1fc995619a Mon Sep 17 00:00:00 2001 From: David Manthey Date: Wed, 8 May 2024 19:24:25 -0400 Subject: [PATCH] Improve ingesting geojson annotations --- CHANGELOG.md | 2 + .../girder_large_image_annotation/handlers.py | 3 +- .../models/annotation.py | 6 +- .../utils/__init__.py | 177 ++++++++++++++++++ .../test_annotation/test_annotations_rest.py | 23 +++ 5 files changed, 209 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dee7bb98e..dbe9cf314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Speed up multi source compositing in tiled cases ([#1513](../../pull/1513)) - Speed up some tifffile and multi source access cases ([#1515](../../pull/1515)) - Allow specifying a minimum number of annotations elements when maxDetails is used ([#1521](../../pull/1521)) +- Improved import of GeoJSON annotations ([#1522](../../pull/1522)) ### Changes - Limit internal metadata on multi-source files with huge numbers of sources ([#1514](../../pull/1514)) @@ -15,6 +16,7 @@ ### Bug Fixes - Fix touch actions in the image viewer in some instances ([#1516](../../pull/1516)) +- Fix multisource dtype issues that resulted in float32 results ([#1520](../../pull/1520)) ## 1.28.1 diff --git a/girder_annotation/girder_large_image_annotation/handlers.py b/girder_annotation/girder_large_image_annotation/handlers.py index 0c89924d6..eeeaf6d7d 100644 --- a/girder_annotation/girder_large_image_annotation/handlers.py +++ b/girder_annotation/girder_large_image_annotation/handlers.py @@ -13,6 +13,7 @@ from girder.models.user import User from .models.annotation import Annotation +from .utils import isGeoJSON _recentIdentifiers = cachetools.TTLCache(maxsize=100, ttl=86400) @@ -142,7 +143,7 @@ def process_annotations(event): # noqa: C901 if time.time() - startTime > 10: logger.info('Decoded json in %5.3fs', time.time() - startTime) - if not isinstance(data, list): + if not isinstance(data, list) or isGeoJSON(data): data = [data] data = [entry['annotation'] if 'annotation' in entry else entry for entry in data] # Check some of the early elements to see if there are any girderIds diff --git a/girder_annotation/girder_large_image_annotation/models/annotation.py b/girder_annotation/girder_large_image_annotation/models/annotation.py index 9181d2423..172c5c2a6 100644 --- a/girder_annotation/girder_large_image_annotation/models/annotation.py +++ b/girder_annotation/girder_large_image_annotation/models/annotation.py @@ -38,7 +38,7 @@ from girder.models.setting import Setting from girder.models.user import User -from ..utils import AnnotationGeoJSON +from ..utils import AnnotationGeoJSON, GeoJSONAnnotation, isGeoJSON from .annotationelement import Annotationelement # Some arrays longer than this are validated using numpy rather than jsonschema @@ -788,6 +788,10 @@ def _migrateACL(self, annotation): return annotation def createAnnotation(self, item, creator, annotation, public=None): + if isGeoJSON(annotation): + geojson = GeoJSONAnnotation(annotation) + if geojson.elementCount: + annotation = geojson.annotation now = datetime.datetime.now(datetime.timezone.utc) doc = { 'itemId': item['_id'], diff --git a/girder_annotation/girder_large_image_annotation/utils/__init__.py b/girder_annotation/girder_large_image_annotation/utils/__init__.py index 6efed3b65..4f9f99e90 100644 --- a/girder_annotation/girder_large_image_annotation/utils/__init__.py +++ b/girder_annotation/girder_large_image_annotation/utils/__init__.py @@ -157,3 +157,180 @@ def elementToGeoJSON(self, element): if result['geometry']['type'].lower() != element['type']: result['properties']['type'] = element['type'] return result + + @property + def geojson(self): + return ''.join(self) + + +class GeoJSONAnnotation: + def __init__(self, geojson): + if not isinstance(geojson, (dict, list, tuple)): + geojson = json.loads(geojson) + self._elements = [] + self._annotation = {'elements': self._elements} + self._parseFeature(geojson) + + def _parseFeature(self, geoelem): + if isinstance(geoelem, (list, tuple)): + for entry in geoelem: + self._parseFeature(entry) + if not isinstance(geoelem, dict) or 'type' not in geoelem: + return + if geoelem['type'] == 'FeatureCollection': + return self._parseFeature(geoelem.get('features', [])) + if geoelem['type'] == 'GeometryCollection' and isinstance(geoelem.get('geometries'), list): + for entry in geoelem['geometry']: + self._parseFeature({'type': 'Feature', 'geometry': entry}) + return + if geoelem['type'] in {'Point', 'LineString', 'Polygon', 'MultiPoint', + 'MultiLineString', 'MultiPolygon'}: + geoelem = {'type': 'Feature', 'geometry': geoelem} + element = {k: v for k, v in geoelem.get('properties', {}).items() if k in { + 'id', 'label', 'group', 'user', 'lineColor', 'lineWidth', + 'fillColor', 'radius', 'width', 'height', 'rotation', + 'normal', + }} + if 'annotation' in geoelem.get('properties', {}): + self._annotation.update(geoelem['properties']['annotation']) + self._annotation['elements'] = self._elements + elemtype = geoelem.get('properties', {}).get('type', '') or geoelem['geometry']['type'] + func = getattr(self, elemtype.lower() + 'Type', None) + if func is not None: + result = func(geoelem['geometry'], element) + if isinstance(result, list): + self._elements.extend(result) + else: + self._elements.append(result) + + def circleType(self, elem, result): + cx = sum(e[0] for e in elem['coordinates'][0][:4]) / 4 + cy = sum(e[1] for e in elem['coordinates'][0][:4]) / 4 + try: + cz = elem['coordinates'][0][0][2] + except Exception: + cz = 0 + radius = (max(e[0] for e in elem['coordinates'][0][:4]) - + min(e[0] for e in elem['coordinates'][0][:4])) / 2 + result['type'] = 'circle' + result['radius'] = radius + result['center'] = [cx, cy, cz] + return result + + def ellipseType(self, elem, result): + result = self.rectangleType(elem, result) + result['type'] = 'ellipse' + return result + + def rectangleType(self, elem, result): + coor = elem['coordinates'][0] + cx = sum(e[0] for e in coor[:4]) / 4 + cy = sum(e[1] for e in coor[:4]) / 4 + try: + cz = elem['coordinates'][0][0][2] + except Exception: + cz = 0 + width = ((coor[0][0] - coor[1][0]) ** 2 + (coor[0][1] - coor[1][1]) ** 2) ** 0.5 + height = ((coor[1][0] - coor[2][0]) ** 2 + (coor[1][1] - coor[2][1]) ** 2) ** 0.5 + rotation = math.atan2(coor[1][1] - coor[0][1], coor[1][0] - coor[0][0]) + result['center'] = [cx, cy, cz] + result['width'] = width + result['height'] = height + result['rotation'] = rotation + result['type'] = 'rectangle' + return result + + def pointType(self, elem, result): + result['center'] = (elem['coordinates'] + [0, 0, 0])[:3] + result['type'] = 'point' + return result + + def multipointType(self, elem, result): + results = [] + result['type'] = 'point' + for entry in elem['coordinates']: + subresult = result.copy() + subresult['center'] = (entry + [0, 0, 0])[:3] + results.append(subresult) + return results + + def polylineType(self, elem, result): + if elem.get('type') == 'LineString': + return self.linestringType(elem, result) + return self.polygonType(elem, result) + + def polygonType(self, elem, result): + result['points'] = [(pt + [0])[:3] for pt in elem['coordinates'][0][:-1]] + if len(elem['coordinates']) > 1: + result['holes'] = [ + [(pt + [0])[:3] for pt in loop[:-1]] + for loop in elem['coordinates'][1:] + ] + result['closed'] = True + result['type'] = 'polyline' + return result + + def multipolygonType(self, elem, result): + results = [] + result['closed'] = True + result['type'] = 'polyline' + for entry in elem['coordinates']: + subresult = result.copy() + subresult['points'] = [(pt + [0])[:3] for pt in entry[0][:-1]] + if len(entry) > 1: + subresult['holes'] = [ + [(pt + [0])[:3] for pt in loop[:-1]] + for loop in entry[1:] + ] + results.append(subresult) + return results + + def linestringType(self, elem, result): + result['points'] = [(pt + [0])[:3] for pt in elem['coordinates']] + result['closed'] = False + result['type'] = 'polyline' + return result + + def multilinestringType(self, elem, result): + results = [] + result['closed'] = False + result['type'] = 'polyline' + for entry in elem['coordinates']: + subresult = result.copy() + subresult['points'] = [(pt + [0])[:3] for pt in entry] + results.append(subresult) + return results + + def annotationToJSON(self): + return json.dumps(self._annotation) + + @property + def annotation(self): + return self._annotation + + @property + def elements(self): + return self._elements + + @property + def elementCount(self): + return len(self._elements) + + +def isGeoJSON(annotation): + """ + Check if a list or dictionary appears to contain a GeoJSON record. + + :param annotation: a list or dictionary. + :returns: True if this appears to be GeoJSON + """ + if isinstance(annotation, list): + if len(annotation) < 1: + return False + annotation = annotation[0] + if not isinstance(annotation, dict) or 'type' not in annotation: + return False + return annotation['type'] in { + 'Feature', 'FeatureCollection', 'GeometryCollection', 'Point', + 'LineString', 'Polygon', 'MultiPoint', 'MultiLineString', + 'MultiPolygon'} diff --git a/girder_annotation/test_annotation/test_annotations_rest.py b/girder_annotation/test_annotation/test_annotations_rest.py index 844b685cf..7abc343d0 100644 --- a/girder_annotation/test_annotation/test_annotations_rest.py +++ b/girder_annotation/test_annotation/test_annotations_rest.py @@ -780,6 +780,15 @@ def testLoadAnnotationGeoJSON(self, server, admin): assert resp.json['type'] == 'FeatureCollection' assert len(resp.json['features']) == 3 + def testGeoJSONRoundTrip(self, admin): + import girder_large_image_annotation + + self.makeAnnot(admin) + geojson = girder_large_image_annotation.utils.AnnotationGeoJSON( + self.hasGroups['_id']).geojson + annot = girder_large_image_annotation.utils.GeoJSONAnnotation(geojson) + assert annot.elementCount == 3 + def testLoadAnnotationGeoJSONVariety(self, server, admin): self.makeAnnot(admin) annot = Annotation().createAnnotation( @@ -828,6 +837,20 @@ def testLoadAnnotationGeoJSONVariety(self, server, admin): assert resp.json['type'] == 'FeatureCollection' assert len(resp.json['features']) == 6 + import girder_large_image_annotation + annot = girder_large_image_annotation.utils.GeoJSONAnnotation(resp.json) + assert annot.elementCount == 6 + + resp = server.request( + path='/annotation/item/{}'.format(self.item['_id']), + method='POST', + user=admin, + type='application/json', + body=json.dumps(resp.json), + ) + assert utilities.respStatus(resp) == 200 + assert resp.json == 1 + @pytest.mark.usefixtures('unbindLargeImage', 'unbindAnnotation') @pytest.mark.plugin('large_image_annotation')