Skip to content

Commit

Permalink
Merge pull request #1385 from girder/annotation-to-geojson
Browse files Browse the repository at this point in the history
Add a utility function to yield annotations as geojson.
  • Loading branch information
manthey authored Nov 24, 2023
2 parents dc9d49b + 83a147a commit 8003551
Show file tree
Hide file tree
Showing 6 changed files with 251 additions and 4 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion girder_annotation/docs/annotations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
<id, label, group, user, lineColor, lineWidth> # Optional general shape properties
"center": [10.3, -40.0, 0], # Coordinate. Required
"width": 5.3, # Number >= 0. Required
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'])
24 changes: 24 additions & 0 deletions girder_annotation/girder_large_image_annotation/rest/annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
159 changes: 159 additions & 0 deletions girder_annotation/girder_large_image_annotation/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -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
58 changes: 55 additions & 3 deletions girder_annotation/test_annotation/test_annotations_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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')
Expand Down

0 comments on commit 8003551

Please sign in to comment.