From 83a147a72ce00cf18cc0dd32378e098aa4bd8435 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Thu, 27 Apr 2023 16:57:42 -0400 Subject: [PATCH] Add a utility function to yield annotations as geojson. --- CHANGELOG.md | 1 + girder_annotation/docs/annotations.rst | 2 +- .../models/annotation.py | 11 ++ .../rest/annotation.py | 24 +++ .../utils/__init__.py | 159 ++++++++++++++++++ .../test_annotation/test_annotations_rest.py | 58 ++++++- 6 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 girder_annotation/girder_large_image_annotation/utils/__init__.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e9b38521..eff6bb9aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Fix the DICOM limit to say "Series" instead of "Studies" ([#1379](../../pull/1379)) - Add token authentication option for DICOMweb ([#1349](../../pull/1349)) - Use vips resize rather than reduce to increase speed ([#1384](../../pull/1384)) +- Added annotation to geojson endpoint and utility ([#1385](../../pull/1385)) ### Bug Fixes - Fix an issue applying ICC profile adjustments to multiple image modes ([#1382](../../pull/1382)) diff --git a/girder_annotation/docs/annotations.rst b/girder_annotation/docs/annotations.rst index 55e200d9f..a3518219c 100644 --- a/girder_annotation/docs/annotations.rst +++ b/girder_annotation/docs/annotations.rst @@ -95,7 +95,7 @@ The width and height of an ellipse are the major and minor axes. :: { - "type": "rectangle", # Exact string. Required + "type": "ellipse", # Exact string. Required # Optional general shape properties "center": [10.3, -40.0, 0], # Coordinate. Required "width": 5.3, # Number >= 0. Required diff --git a/girder_annotation/girder_large_image_annotation/models/annotation.py b/girder_annotation/girder_large_image_annotation/models/annotation.py index 527d3f58e..4025dea4c 100644 --- a/girder_annotation/girder_large_image_annotation/models/annotation.py +++ b/girder_annotation/girder_large_image_annotation/models/annotation.py @@ -38,6 +38,7 @@ from girder.models.setting import Setting from girder.models.user import User +from ..utils import AnnotationGeoJSON from .annotationelement import Annotationelement # Some arrays longer than this are validated using numpy rather than jsonschema @@ -1454,3 +1455,13 @@ def deleteMetadata(self, annotation, fields): annotation['updated'] = datetime.datetime.now(datetime.timezone.utc) return super().save(annotation) + + def geojson(self, annotation): + """ + Yield an annotation as geojson generator. + + :param annotation: The annotation to delete metadata from. + :yields: geojson. General annotation properties are added to the first + feature under the annotation tag. + """ + yield from AnnotationGeoJSON(annotation['_id']) diff --git a/girder_annotation/girder_large_image_annotation/rest/annotation.py b/girder_annotation/girder_large_image_annotation/rest/annotation.py index c8bdcdc70..60887b211 100644 --- a/girder_annotation/girder_large_image_annotation/rest/annotation.py +++ b/girder_annotation/girder_large_image_annotation/rest/annotation.py @@ -51,6 +51,7 @@ def __init__(self): self.route('GET', ('schema',), self.getAnnotationSchema) self.route('GET', ('images',), self.findAnnotatedImages) self.route('GET', (':id',), self.getAnnotation) + self.route('GET', (':id', ':format'), self.getAnnotationWithFormat) self.route('PUT', (':id',), self.updateAnnotation) self.route('DELETE', (':id',), self.deleteAnnotation) self.route('GET', (':id', 'access'), self.getAnnotationAccess) @@ -178,6 +179,29 @@ def getAnnotation(self, id, params): raise RestException(msg, 404) return self._getAnnotation(annotation, params) + @autoDescribeRoute( + Description('Get an annotation by id in a specific format.') + .param('id', 'The ID of the annotation.', paramType='path') + .param('format', 'The format of the annotation.', paramType='path', + enum=['geojson']) + .errorResponse('ID was invalid.') + .errorResponse('Read access was denied for the annotation.', 403) + .notes('Use "size" or "details" as possible sort keys.'), + ) + @access.public(cookie=True, scope=TokenScope.DATA_READ) + @loadmodel(model='annotation', plugin='large_image', getElements=False, level=AccessType.READ) + def getAnnotationWithFormat(self, annotation, format): + _handleETag('getAnnotationWithFormat', annotation, format, max_age=86400 * 30) + if annotation is None: + msg = 'Annotation not found' + raise RestException(msg, 404) + + def generateResult(): + yield from Annotation().geojson(annotation) + + setResponseHeader('Content-Type', 'application/json') + return generateResult + def _getAnnotation(self, annotation, params): """ Get a generator function that will yield the json of an annotation. diff --git a/girder_annotation/girder_large_image_annotation/utils/__init__.py b/girder_annotation/girder_large_image_annotation/utils/__init__.py new file mode 100644 index 000000000..6efed3b65 --- /dev/null +++ b/girder_annotation/girder_large_image_annotation/utils/__init__.py @@ -0,0 +1,159 @@ +import json +import math + + +class AnnotationGeoJSON: + """ + Generate GeoJSON for an annotation via an iterator. + """ + + def __init__(self, annotationId, asFeatures=False, mustConvert=False): + """ + Return an itertor for converting an annotation into geojson. + + :param annotatioId: the id of the annotation. No permissions checks + are performed. + :param asFeatures: if False, return a geojson string. If True, return + the features of the geojson. This can be wrapped in + `{'type': 'FeatureCollection', 'features: [...output...]}` + to make it a full geojson object. + :param mustConvert: if True, raise an exception if any annotation + elements cannot be converted. Otherwise, skip those elements. + """ + from ..models.annotation import Annotation + from ..models.annotationelement import Annotationelement + + self._id = annotationId + self.annotation = Annotation().load(id=self._id, force=True, getElements=False) + self.elemIterator = Annotationelement().yieldElements(self.annotation) + self.stage = 'header' + self.first = self.annotation['annotation'] + self.asFeatures = asFeatures + self.mustConvert = mustConvert + + def __iter__(self): + from ..models.annotationelement import Annotationelement + + self.elemIterator = Annotationelement().yieldElements(self.annotation) + self.stage = 'header' + return self + + def __next__(self): + if self.stage == 'header': + self.stage = 'firstelement' + if not self.asFeatures: + return '{"type":"FeatureCollection","features":[' + if self.stage == 'done': + raise StopIteration + try: + while True: + element = next(self.elemIterator) + result = self.elementToGeoJSON(element) + if result is not None: + break + if self.mustConvert: + msg = f'Element of type {element["type"]} cannot be represented as geojson' + raise Exception(msg) + prefix = '' + if self.stage == 'firstelement': + result['properties']['annotation'] = self.first + self.stage = 'elements' + else: + prefix = ',' + if not self.asFeatures: + return prefix + json.dumps(result, separators=(',', ':')) + return result + except StopIteration: + self.stage = 'done' + if not self.asFeatures: + return ']}' + raise + + def rotate(self, r, cx, cy, x, y, z): + if not r: + return [x + cx, y + cy, z] + cosr = math.cos(r) + sinr = math.sin(r) + x -= cx + y -= cy + return [x * cosr - y * sinr + cx, x * sinr + y * sinr + cy, z] + + def circleType(self, element, geom, prop): + x, y, z = element['center'] + r = element['radius'] + geom['type'] = 'Polygon' + geom['coordinates'] = [[ + [x - r, y - r, z], + [x + r, y - r, z], + [x + r, y + r, z], + [x - r, y + r, z], + [x - r, y - r, z], + ]] + + def ellipseType(self, element, geom, prop): + return self.rectangleType(element, geom, prop) + + def pointType(self, element, geom, prop): + geom['type'] = 'Point' + geom['coordinates'] = element['center'] + + def polylineType(self, element, geom, prop): + if element['closed']: + geom['type'] = 'Polygon' + geom['coordinates'] = [element['points'][:]] + geom['coordinates'][0].append(geom['coordinates'][0][0]) + if element.get('holes'): + for hole in element['holes']: + hole = hole[:] + hole.append(hole[0]) + geom['coordinates'].append(hole) + else: + geom['type'] = 'LineString' + geom['coordinates'] = element['points'] + + def rectangleType(self, element, geom, prop): + x, y, z = element['center'] + width = element['width'] + height = element['height'] + rotation = element.get('rotation', 0) + left = x - width / 2 + right = x + width / 2 + top = y - height / 2 + bottom = y + height / 2 + + geom['type'] = 'Polygon' + geom['coordinates'] = [[ + self.rotate(rotation, x, y, left, top, z), + self.rotate(rotation, x, y, right, top, z), + self.rotate(rotation, x, y, right, bottom, z), + self.rotate(rotation, x, y, left, bottom, z), + self.rotate(rotation, x, y, left, top, z), + ]] + + # not represented + # heatmap, griddata, image, pixelmap, arrow, rectanglegrid + # heatmap could be MultiPoint, griddata could be rectangle with lots of + # properties, image and pixelmap could be rectangle with the image id as a + # property, arrow and rectangelgrid aren't really supported + + def elementToGeoJSON(self, element): + elemType = element.get('type', '') + funcName = elemType + 'Type' + if not hasattr(self, funcName): + return None + result = { + 'type': 'Feature', + 'geometry': {}, + 'properties': { + k: v if k != 'id' else str(v) + for k, v in element.items() if k in { + 'id', 'label', 'group', 'user', 'lineColor', 'lineWidth', + 'fillColor', 'radius', 'width', 'height', 'rotation', + 'normal', + } + }, + } + getattr(self, funcName)(element, result['geometry'], result['properties']) + if result['geometry']['type'].lower() != element['type']: + result['properties']['type'] = element['type'] + return result diff --git a/girder_annotation/test_annotation/test_annotations_rest.py b/girder_annotation/test_annotation/test_annotations_rest.py index d69c2e63d..844b685cf 100644 --- a/girder_annotation/test_annotation/test_annotations_rest.py +++ b/girder_annotation/test_annotation/test_annotations_rest.py @@ -623,9 +623,6 @@ def testAnnotationsAfterCopyItem(self, server, admin): assert utilities.respStatus(resp) == 200 assert len(resp.json) == 1 - # Add tests for: - # find - @pytest.mark.plugin('large_image_annotation') class TestLargeImageAnnotationElementGroups: @@ -776,6 +773,61 @@ def testUpdateAnnotation(self, server, admin): assert utilities.respStatus(resp) == 200 assert resp.json['groups'] == ['d'] + def testLoadAnnotationGeoJSON(self, server, admin): + self.makeAnnot(admin) + resp = server.request('/annotation/%s/geojson' % str(self.hasGroups['_id']), user=admin) + assert utilities.respStatus(resp) == 200 + assert resp.json['type'] == 'FeatureCollection' + assert len(resp.json['features']) == 3 + + def testLoadAnnotationGeoJSONVariety(self, server, admin): + self.makeAnnot(admin) + annot = Annotation().createAnnotation( + self.item, admin, + { + 'name': 'sample', + 'elements': [{ + 'type': 'rectangle', + 'center': [20.0, 25.0, 0], + 'rotation': 0.1, + 'width': 14.0, + 'height': 15.0, + }, { + 'type': 'circle', + 'center': [10.3, -40.0, 0], + 'radius': 5.3, + 'fillColor': '#0000ff', + }, { + 'type': 'ellipse', + 'center': [10.3, -40.0, 0], + 'width': 5.3, + 'height': 17.3, + 'rotation': 0, + 'normal': [0, 0, 1.0], + 'fillColor': 'rgba(0, 255, 0, 1)', + }, { + 'type': 'point', + 'center': [123.3, 144.6, -123], + }, { + 'type': 'polyline', + 'points': [[5, 6, 0], [-17, 6, 0], [56, -45, 6]], + 'closed': True, + 'holes': [[[10, 10, 0], [20, 30, 0], [10, 30, 0]]], + 'fillColor': 'rgba(0, 255, 0, 1)', + }, { + 'type': 'polyline', + 'points': [[7, 8, 0], [17, 6, 0], [27, 9, 0], [37, 14, 0], [46, 8, 0]], + 'closed': False, + 'fillColor': 'rgba(0, 255, 0, 1)', + }], + }, + ) + + resp = server.request('/annotation/%s/geojson' % str(annot['_id']), user=admin) + assert utilities.respStatus(resp) == 200 + assert resp.json['type'] == 'FeatureCollection' + assert len(resp.json['features']) == 6 + @pytest.mark.usefixtures('unbindLargeImage', 'unbindAnnotation') @pytest.mark.plugin('large_image_annotation')