From 33d548505d7f76a34a16654a1d09d6a2de38c807 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 2 Apr 2021 16:24:58 -0400 Subject: [PATCH 1/2] Add heatmap and griddata to the annotation schema. Render heatmap and griddata heatmap annotations. griddata contour and choropleth annotations are not yet rendered. Store large annotation elements outside of mongo. This also fixes the progress bar when uploading annotations. --- .../stylesheets/imageViewerSelectWidget.styl | 1 + girder_annotation/docs/annotations.md | 81 ++++++++- .../models/annotation.py | 162 +++++++++++++++++- .../models/annotationelement.py | 85 ++++++++- .../rest/annotation.py | 13 +- .../web_client/annotations/convertFeatures.js | 156 +++++++++++++++++ .../web_client/annotations/index.js | 2 + .../web_client/models/AnnotationModel.js | 13 ++ .../web_client/views/annotationListWidget.js | 21 ++- .../views/imageViewerWidget/geojs.js | 33 +++- .../test_annotation/test_annotations.py | 39 +++++ .../web_client_specs/annotationSpec.js | 73 ++++++++ 12 files changed, 646 insertions(+), 33 deletions(-) create mode 100644 girder_annotation/girder_large_image_annotation/web_client/annotations/convertFeatures.js diff --git a/girder/girder_large_image/web_client/stylesheets/imageViewerSelectWidget.styl b/girder/girder_large_image/web_client/stylesheets/imageViewerSelectWidget.styl index 982f3dbc3..c512dbd00 100644 --- a/girder/girder_large_image/web_client/stylesheets/imageViewerSelectWidget.styl +++ b/girder/girder_large_image/web_client/stylesheets/imageViewerSelectWidget.styl @@ -12,6 +12,7 @@ height 600px -webkit-touch-callout none user-select none + overflow hidden &:focus outline none diff --git a/girder_annotation/docs/annotations.md b/girder_annotation/docs/annotations.md index 09c755fcb..1a66bbf14 100755 --- a/girder_annotation/docs/annotations.md +++ b/girder_annotation/docs/annotations.md @@ -146,11 +146,90 @@ A Rectangle Grid is a rectangle which contains regular subdivisions, such as tha } ``` +### Heatmap + +A list of points with values that is interpreted as a heatmap so that near by values aggregate together when viewed. + +``` +{ + "type": "heatmap", # Exact string. Required + # Optional general shape properties + "points": [ # A list of coordinate-value entries. Each is x, y, z, value. + [32320, 48416, 0, 0.192], + [40864, 109568, 0, 0.87], + [53472, 63392, 0, 0.262], + [23232, 96096, 0, 0.364], + [10976, 93376, 0, 0.2], + [42368, 65248, 0, 0.054] + ], + "radius": 25, # Positive number. Optional. The size of the gaussian plot spread, + "colorRange": ["rgba(0, 0, 0, 0)", "rgba(255, 255, 0, 1)"], # A list of colors corresponding to the + # rangeValues. Optional + "rangeValues: [0, 1], # A list of range values corresponding to the colorRange list + # and possibly normalized to a scale of [0, 1]. Optional + "normalizeRange": true # If true, the rangeValues are normalized to [0, 1]. If false, the + # rangeValues are in the value domain. Defaults to true. Optional +} +``` + +### Grid Data + +For evenly spaced data that is interpreted as a heatmap, contour, or choropleth, a grid with a list of values can be specified. + +``` +{ + "type": "griddata", # Exact string. Required + # Optional general shape properties + "interpretation": "contour", # One of heatmap, contour, or choropleth + "gridWidth": 6, # Number of values across the grid. Required + "origin": [0, 0, 0], # Origin including fized x value. Optional + "dx": 32, # Grid spacing in x. Optional + "dy": 32, # Grid spacing in y. Optional + "colorRange": ["rgba(0, 0, 0, 0)", "rgba(255, 255, 0, 1)"], # A list of colors corresponding to the + # rangeValues. Optional + "rangeValues: [0, 1], # A list of range values corresponding to the colorRange list. This + # should have the same number of entries as colorRange unless a contour + # where stepped is true. Possibly normalized to a scale of [0, 1]. + # Optional + "normalizeRange": false, # If true, the rangeValues are normalized to [0, 1]. If false, the + # rangeValues are in the value domain. Defaults to true. Optional + "minColor": "rgba(0, 0, 255, 1)", # The color of data below the minimum range. Optional + "maxColor": "rgba(255, 255, 0, 1)", # The color of data above the maximum range. Optional + "stepped": true, # For contours, whether discrete colors or continuous colors should be used. Default false. Optional + "values": [ + 0.508, + 0.806, + 0.311, + 0.402, + 0.535, + 0.661, + 0.866, + 0.31, + 0.241, + 0.63, + 0.555, + 0.067, + 0.668, + 0.164, + 0.512, + 0.647, + 0.501, + 0.637, + 0.498, + 0.658, + 0.332, + 0.431, + 0.053, + 0.531 + ] +} +``` + ## Component Values ### Colors -Colors are specified using a css-like string. Specifically, values of the form `#RRGGBB` and `#RGB` are allowed where `R`, `G`, and `B` are case-insensitive hexadecimal digits. Additonally, values of the form `rgb(123, 123, 123)` and `rgba(123, 123, 123, 0.123)` are allowed, where the colors are specified on a [0-255] integer scale, and the opacity is specified as a [0-1] floating-point number. +Colors are specified using a css-like string. Specifically, values of the form `#RRGGBB` and `#RGB` are allowed where `R`, `G`, and `B` are case-insensitive hexadecimal digits. Additionally, values of the form `rgb(123, 123, 123)` and `rgba(123, 123, 123, 0.123)` are allowed, where the colors are specified on a [0-255] integer scale, and the opacity is specified as a [0-1] floating-point number. ### Coordinates diff --git a/girder_annotation/girder_large_image_annotation/models/annotation.py b/girder_annotation/girder_large_image_annotation/models/annotation.py index 79b7d1d86..a491ad76c 100644 --- a/girder_annotation/girder_large_image_annotation/models/annotation.py +++ b/girder_annotation/girder_large_image_annotation/models/annotation.py @@ -19,6 +19,7 @@ import datetime import enum import jsonschema +import numpy import re import threading import time @@ -38,6 +39,9 @@ from .annotationelement import Annotationelement +# Some arrays longer than this are validated using numpy rather than jsonschema +VALIDATE_ARRAY_LENGTH = 1000 + class AnnotationSchema: coordSchema = { @@ -50,8 +54,19 @@ class AnnotationSchema: 'maxItems': 3, 'name': 'Coordinate', # TODO: define origin for 3D images - 'description': 'An X, Y, Z coordinate tuple, in base layer pixel' - ' coordinates, where the origin is the upper-left.' + 'description': 'An X, Y, Z coordinate tuple, in base layer pixel ' + 'coordinates, where the origin is the upper-left.' + } + coordValueSchema = { + 'type': 'array', + 'items': { + 'type': 'number' + }, + 'minItems': 4, + 'maxItems': 4, + 'name': 'CoordinateWithValue', + 'description': 'An X, Y, Z, value coordinate tuple, in base layer ' + 'pixel coordinates, where the origin is the upper-left.' } colorSchema = { @@ -65,7 +80,19 @@ class AnnotationSchema: # TODO: make rgb and rgba spec validate that rgb is [0-255] and a is # [0-1], rather than just checking if they are digits and such. 'pattern': r'^(#[0-9a-fA-F]{3,6}|rgb\(\d+,\s*\d+,\s*\d+\)|' - r'rgba\(\d+,\s*\d+,\s*\d+,\s*(\d?\.|)\d+\))$' + r'rgba\(\d+,\s*\d+,\s*\d+,\s*(\d?\.|)\d+\))$', + } + + colorRangeSchema = { + 'type': 'array', + 'items': colorSchema, + 'description': 'A list of colors', + } + + rangeValueSchema = { + 'type': 'array', + 'items': {'type': 'number'}, + 'description': 'A weakly monotonic list of range values', } baseShapeSchema = { @@ -309,9 +336,117 @@ class AnnotationSchema: ] } + heatmapSchema = { + 'allOf': [ + baseShapeSchema, + { + 'type': 'object', + 'properties': { + 'type': { + 'type': 'string', + 'enum': ['heatmap'] + }, + 'points': { + 'type': 'array', + 'items': coordValueSchema, + }, + 'radius': { + 'type': 'number', + 'minimum': 0, + 'exclusiveMinimum': True, + }, + 'colorRange': colorRangeSchema, + 'rangeValues': rangeValueSchema, + 'normalizeRange': { + 'type': 'boolean', + 'description': + 'If true, rangeValues are on a scale of 0 to 1 ' + 'and map to the minimum and maximum values on the ' + 'data. If false (the default), the rangeValues ' + 'are the actual data values.', + }, + }, + 'required': ['type', 'points'], + 'patternProperties': baseShapePatternProperties, + 'additionalProperties': False, + 'description': + 'ColorRange and rangeValues should have a one-to-one ' + 'correspondence.', + } + ] + } + + griddataSchema = { + 'allOf': [ + baseShapeSchema, + { + 'type': 'object', + 'properties': { + 'type': { + 'type': 'string', + 'enum': ['griddata'] + }, + 'origin': coordSchema, + 'dx': { + 'type': 'number', + 'description': 'grid spacing in the x direction', + }, + 'dy': { + 'type': 'number', + 'description': 'grid spacing in the y direction', + }, + 'gridWidth': { + 'type': 'integer', + 'minimum': 1, + 'description': 'The number of values across the width of the grid', + }, + 'values': { + 'type': 'array', + 'items': {'type': 'number'}, + 'description': + 'The values of the grid. This must have a ' + 'multiple of gridWidth entries', + }, + 'interpretation': { + 'type': 'string', + 'enum': ['heatmap', 'contour', 'choropleth'] + }, + 'radius': { + 'type': 'number', + 'minimum': 0, + 'exclusiveMinimum': True, + 'description': 'radius used for heatmap interpretation', + }, + 'colorRange': colorRangeSchema, + 'rangeValues': rangeValueSchema, + 'normalizeRange': { + 'type': 'boolean', + 'description': + 'If true, rangeValues are on a scale of 0 to 1 ' + 'and map to the minimum and maximum values on the ' + 'data. If false (the default), the rangeValues ' + 'are the actual data values.', + }, + 'stepped': {'type': 'boolean'}, + 'minColor': colorSchema, + 'maxColor': colorSchema, + }, + 'required': ['type', 'values', 'gridWidth'], + 'patternProperties': baseShapePatternProperties, + 'additionalProperties': False, + 'description': + 'ColorRange and rangeValues should have a one-to-one ' + 'correspondence except for stepped contours where ' + 'rangeValues needs one more entry than colorRange. ' + 'minColor and maxColor are the colors applies to values ' + 'beyond the ranges in rangeValues.', + } + ] + } + annotationElementSchema = { '$schema': 'http://json-schema.org/schema#', - # Shape subtypes are mutually exclusive, so for efficiency, don't use + # Shape subtypes are mutually exclusive, so for efficiency, don't use # 'oneOf' 'anyOf': [ # If we include the baseShapeSchema, then shapes that are as-yet @@ -320,6 +455,8 @@ class AnnotationSchema: arrowShapeSchema, circleShapeSchema, ellipseShapeSchema, + griddataSchema, + heatmapSchema, pointShapeSchema, polylineShapeSchema, rectangleShapeSchema, @@ -776,7 +913,7 @@ def _similarElementStructure(self, a, b, parentKey=None): # noqa return False elif isinstance(a, list): if len(a) != len(b): - if parentKey != 'points' or len(a) < 3 or len(b) < 3: + if parentKey not in {'points', 'values'} or len(a) < 3 or len(b) < 3: return False # If this is an array of points, let it pass for idx in range(len(b)): @@ -811,9 +948,24 @@ def validate(self, doc): for element in elements: if isinstance(element.get('id'), ObjectId): element['id'] = str(element['id']) + # Handle elements with large arrays by checking that a + # conversion to a numpy array works + key = None + if len(element.get('points', element.get('values', []))) > VALIDATE_ARRAY_LENGTH: + key = 'points' if 'points' in element else 'values' + try: + # Check if the entire array converts in an obvious + # manner + numpy.array(element[key], dtype=float) + keydata = element[key] + element[key] = element[key][:VALIDATE_ARRAY_LENGTH] + except Exception: + key = None if not self._similarElementStructure(element, lastValidatedElement): self.validatorAnnotationElement.validate(element) lastValidatedElement = element + if key: + element[key] = keydata annot['elements'] = elements except jsonschema.ValidationError as exp: raise ValidationException(exp) diff --git a/girder_annotation/girder_large_image_annotation/models/annotationelement.py b/girder_annotation/girder_large_image_annotation/models/annotationelement.py index f0f813da6..cb7633538 100644 --- a/girder_annotation/girder_large_image_annotation/models/annotationelement.py +++ b/girder_annotation/girder_large_image_annotation/models/annotationelement.py @@ -15,14 +15,24 @@ ############################################################################## import datetime +import io import math +import pickle import pymongo import time from girder.constants import AccessType, SortDir +from girder.models.file import File +from girder.models.item import Item from girder.models.model_base import Model +from girder.models.upload import Upload from girder import logger +# Some annotation elements can be very large. If they pass a size threshold, +# store part of them in an associated file. This is slower, so don't do it for +# small ones. +MAX_ELEMENT_DOCUMENT = 10000 + class Annotationelement(Model): bboxKeys = { @@ -135,7 +145,7 @@ def getElements(self, annotation, region=None): element for element in self.yieldElements( annotation, region, annotation['_elementQuery'])] - def yieldElements(self, annotation, region=None, info=None): + def yieldElements(self, annotation, region=None, info=None): # noqa """ Given an annotation, fetch the elements from the database. @@ -200,7 +210,7 @@ def yieldElements(self, annotation, region=None, info=None): queryLimit = maxDetails if maxDetails and (not limit or maxDetails < limit) else limit offset = int(region['offset']) if region.get('offset') else 0 logger.debug('element query %r for %r', query, region) - fields = {'_id': True, 'element': True, 'bbox.details': True} + fields = {'_id': True, 'element': True, 'bbox.details': True, 'datafile': True} centroids = str(region.get('centroids')).lower() == 'true' if centroids: # fields = {'_id': True, 'element': True, 'bbox': True} @@ -251,6 +261,18 @@ def yieldElements(self, annotation, region=None, info=None): ] details += 1 else: + if entry.get('datafile'): + datafile = entry['datafile'] + data = io.BytesIO() + chunksize = 1024 ** 2 + with File().open(File().load(datafile['fileId'], force=True)) as fptr: + while True: + chunk = fptr.read(chunksize) + if not len(chunk): + break + data.write(chunk) + data.seek(0) + element[datafile['key']] = pickle.load(data) yield element details += entry.get('bbox', {}).get('details', 1) count += 1 @@ -272,6 +294,12 @@ def removeWithQuery(self, query): """ assert query + attachedQuery = query.copy() + attachedQuery['datafile'] = {'$exists': True} + for element in self.collection.find(attachedQuery): + file = File().load(element['datafile']['fileId'], force=True) + if file: + File().remove(file) self.collection.bulk_write([pymongo.DeleteMany(query)], ordered=False) def removeElements(self, annotation): @@ -316,13 +344,26 @@ def _boundingBox(self, element): """ bbox = {} if 'points' in element: - bbox['lowx'] = min(p[0] for p in element['points']) - bbox['lowy'] = min(p[1] for p in element['points']) - bbox['lowz'] = min(p[2] for p in element['points']) - bbox['highx'] = max(p[0] for p in element['points']) - bbox['highy'] = max(p[1] for p in element['points']) - bbox['highz'] = max(p[2] for p in element['points']) - bbox['details'] = len(element['points']) + key = 'points' + bbox['lowx'] = min(p[0] for p in element[key]) + bbox['lowy'] = min(p[1] for p in element[key]) + bbox['lowz'] = min(p[2] for p in element[key]) + bbox['highx'] = max(p[0] for p in element[key]) + bbox['highy'] = max(p[1] for p in element[key]) + bbox['highz'] = max(p[2] for p in element[key]) + bbox['details'] = len(element[key]) + elif element.get('type') == 'griddata': + x0, y0, z = element['origin'] + isElements = element.get('interpretation') == 'choropleth' + x1 = x0 + element['dx'] * (element['gridWidth'] - (1 if not isElements else 0)) + y1 = y0 + element['dy'] * (math.ceil(len(element['values']) / element['gridWidth']) - + (1 if not isElements else 0)) + bbox['lowx'] = min(x0, x1) + bbox['lowy'] = min(y0, y1) + bbox['lowz'] = bbox['highz'] = z + bbox['highx'] = max(x0, x1) + bbox['highy'] = max(y0, y1) + bbox['details'] = len(element['values']) else: center = element['center'] bbox['lowz'] = bbox['highz'] = center[2] @@ -360,6 +401,29 @@ def _boundingBox(self, element): # simplify to points return bbox + def saveElementAsFile(self, annotation, entries): + """ + If an element has a large points or values array, save that array to an + attached file. + + :param annotation: the parent annotation. + :param entries: the database entries document. Modified. + """ + item = Item().load(annotation['itemId'], force=True) + element = entries[0]['element'].copy() + entries[0]['element'] = element + key = 'points' if 'points' in element else 'values' + # Use the highest protocol support by all python versions we support + data = pickle.dumps(element.pop(key), protocol=4) + elementFile = Upload().uploadFromFile( + io.BytesIO(data), size=len(data), name='_annotationElementData', + parentType='item', parent=item, user=None, + mimeType='application/json', attachParent=True) + entries[0]['datafile'] = { + 'key': key, + 'fileId': elementFile['_id'], + } + def updateElements(self, annotation): """ Given an annotation, extract the elements from it and update the @@ -383,6 +447,9 @@ def updateElements(self, annotation): 'element': element } for element in elements[chunk:chunk + chunkSize]] prepTime = time.time() - chunkStartTime + if (len(entries) == 1 and len(entries[0]['element'].get( + 'points', entries[0]['element'].get('values', []))) > MAX_ELEMENT_DOCUMENT): + self.saveElementAsFile(annotation, entries) res = self.collection.insert_many(entries) for pos, entry in enumerate(entries): if 'id' not in entry['element']: diff --git a/girder_annotation/girder_large_image_annotation/rest/annotation.py b/girder_annotation/girder_large_image_annotation/rest/annotation.py index ee98ac12d..9233e8a33 100644 --- a/girder_annotation/girder_large_image_annotation/rest/annotation.py +++ b/girder_annotation/girder_large_image_annotation/rest/annotation.py @@ -490,10 +490,12 @@ def generateResult(): @autoDescribeRoute( Description('Create multiple annotations on an item.') .modelParam('id', model=Item, level=AccessType.WRITE) - .jsonParam('annotations', 'A JSON list of annotation model records or ' - 'annotations. If these are complete models, the value of ' - 'the "annotation" key is used and the other information is ' - 'ignored (such as original creator ID).', paramType='body') + # Use param instead of jsonParam; it lets us use ujson which is much + # faster + .param('annotations', 'A JSON list of annotation model records or ' + 'annotations. If these are complete models, the value of ' + 'the "annotation" key is used and the other information is ' + 'ignored (such as original creator ID).', paramType='body') .errorResponse('ID was invalid.') .errorResponse('Write access was denied for the item.', 403) .errorResponse('Invalid JSON passed in request body.') @@ -502,6 +504,9 @@ def generateResult(): @access.user def createItemAnnotations(self, item, annotations): user = self.getCurrentUser() + if hasattr(annotations, 'read'): + annotations = annotations.read().decode('utf8') + annotations = ujson.loads(annotations) if not isinstance(annotations, list): annotations = [annotations] for entry in annotations: diff --git a/girder_annotation/girder_large_image_annotation/web_client/annotations/convertFeatures.js b/girder_annotation/girder_large_image_annotation/web_client/annotations/convertFeatures.js new file mode 100644 index 000000000..9cd2af4eb --- /dev/null +++ b/girder_annotation/girder_large_image_annotation/web_client/annotations/convertFeatures.js @@ -0,0 +1,156 @@ +/** + * Create a color table that can be used for a heatmap. + * + * @param record: the heatmap or griddata heatmap annotation element. + * @param values: a list of data values. + * @returns: an object with: + * color: a color object that can be passed to the heatmap. + * min: the minIntensity for the heatmap. + * max: the maxIntensity for the heatmap. + */ +function heatmapColorTable(record, values) { + let range0 = 0; + let range1 = 1; + let min = 0; + let max = null; + let color = { + 0: {r: 0, g: 0, b: 0, a: 0}, + 1: {r: 1, g: 1, b: 0, a: 1} + }; + if (record.colorRange && record.rangeValues) { + if (record.normalizeRange || !values.length) { + for (let i = 0; i < record.colorRange.length && i < record.rangeValues.length; i += 1) { + let val = Math.max(0, Math.min(1, record.rangeValues[i])); + color[val] = record.colorRange[i]; + if (val >= 1) { + break; + } + } + } else if (record.colorRange.length >= 2 && record.rangeValues.length >= 2) { + range0 = range1 = record.rangeValues[0] || 0; + for (let i = 1; i < record.rangeValues.length; i += 1) { + let val = record.rangeValues[i] || 0; + if (val < range0) { + range0 = val; + } + if (val > range1) { + range1 = val; + } + } + if (range0 === range1) { + range0 -= 1; + } + min = undefined; + for (let i = 0; i < record.colorRange.length && i < record.rangeValues.length; i += 1) { + let val = (record.rangeValues[i] - range0) / ((range1 - range0) || 1); + if (val <= 0 || min === undefined) { + min = record.rangeValues[i]; + } + max = record.rangeValues[i]; + val = Math.max(0, Math.min(1, val)); + color[val] = record.colorRange[i]; + if (val >= 1) { + break; + } + } + } + } + return { + color: color, + min: min, + max: max + }; +} + +/** + * Convert a heatmap annotation to a geojs feature. + * + * @param record: the heatmap annotation element. + * @param properties: a property map of additional data, such as the original + * annotation id. + * @param layer: the layer where this may be added. + */ +function convertHeatmap(record, properties, layer) { + /* Heatmaps need to be in their own layer */ + const map = layer.map(); + const heatmapLayer = map.createLayer('feature', {features: ['heatmap']}); + const colorTable = heatmapColorTable(record, record.points.map((d) => d[3])); + const heatmap = heatmapLayer.createFeature('heatmap', { + style: { + radius: record.radius || 25, + blurRadius: 0, + gaussian: true, + color: colorTable.color + }, + position: (d) => ({x: d[0], y: d[1], z: d[2]}), + intensity: (d) => d[3] || 0, + minIntensity: colorTable.min, + maxIntensity: colorTable.max, + updateDelay: 100 + }).data(record.points); + heatmap._ownLayer = true; + return [heatmap]; +} + +/** + * Convert a griddata heatmap annotation to a geojs feature. + * + * @param record: the griddata heatmap annotation element. + * @param properties: a property map of additional data, such as the original + * annotation id. + * @param layer: the layer where this may be added. + */ +function convertGridToHeatmap(record, properties, layer) { + /* Heatmaps need to be in their own layer */ + const map = layer.map(); + const heatmapLayer = map.createLayer('feature', {features: ['heatmap']}); + const x0 = (record.origin || [0, 0, 0])[0] || 0; + const y0 = (record.origin || [0, 0, 0])[1] || 0; + const z = (record.origin || [0, 0, 0])[2] || 0; + const dx = (record.dx || 1); + const dy = (record.dy || 1); + const colorTable = heatmapColorTable(record, record.values); + const heatmap = heatmapLayer.createFeature('heatmap', { + style: { + radius: record.radius || 25, + blurRadius: 0, + gaussian: true, + color: colorTable.color + }, + position: (d, i) => ({ + x: x0 + dx * (i % record.gridWidth), + y: y0 + dy * Math.floor(i / record.gridWidth), + z: z}), + intensity: (d) => d || 0, + minIntensity: colorTable.min, + maxIntensity: colorTable.max, + updateDelay: 100 + }).data(record.values); + heatmap._ownLayer = true; + return [heatmap]; +} + +const converters = { + griddata_heatmap: convertGridToHeatmap, + heatmap: convertHeatmap +}; + +function convertFeatures(json, properties = {}, layer) { + try { + var features = []; + json.forEach((element) => { + const func = converters[element.type + '_' + element.interpretation] || converters[element.type]; + if (func) { + features = features.concat(func(element, properties, layer)); + } + }); + return features; + } catch (err) { + console.error(err); + } +} + +export { + convertFeatures, + heatmapColorTable +}; diff --git a/girder_annotation/girder_large_image_annotation/web_client/annotations/index.js b/girder_annotation/girder_large_image_annotation/web_client/annotations/index.js index b7d2f3846..8a9f5a1cb 100644 --- a/girder_annotation/girder_large_image_annotation/web_client/annotations/index.js +++ b/girder_annotation/girder_large_image_annotation/web_client/annotations/index.js @@ -1,4 +1,5 @@ import convert from './convert'; +import * as convertFeatures from './convertFeatures'; import rotate from './rotate'; import style from './style'; import * as geometry from './geometry'; @@ -7,6 +8,7 @@ import * as geojs from './geojs'; export { convert, + convertFeatures, rotate, style, geometry, diff --git a/girder_annotation/girder_large_image_annotation/web_client/models/AnnotationModel.js b/girder_annotation/girder_large_image_annotation/web_client/models/AnnotationModel.js index 2060205d5..91f9a3697 100644 --- a/girder_annotation/girder_large_image_annotation/web_client/models/AnnotationModel.js +++ b/girder_annotation/girder_large_image_annotation/web_client/models/AnnotationModel.js @@ -4,6 +4,7 @@ import { restRequest } from '@girder/core/rest'; import ElementCollection from '../collections/ElementCollection'; import convert from '../annotations/convert'; +import { convertFeatures } from '../annotations/convertFeatures'; import style from '../annotations/style.js'; @@ -348,6 +349,18 @@ export default AccessControlledModel.extend({ return convert(elements, {annotation: this.id}); }, + /** + * Return annotations that cannot be represented as geojson as geojs + * features specifications. + * + * @param webglLayer: the parent feature layer. + */ + non_geojson(layer) { + const json = this.get('annotation') || {}; + const elements = json.elements || []; + return convertFeatures(elements, {annotation: this.id}, layer); + }, + /** * Set the view. If we are paging elements, possibly refetch the elements. * Callers should listen for the g:fetched event to know when new elements diff --git a/girder_annotation/girder_large_image_annotation/web_client/views/annotationListWidget.js b/girder_annotation/girder_large_image_annotation/web_client/views/annotationListWidget.js index ea8434169..137f0873e 100644 --- a/girder_annotation/girder_large_image_annotation/web_client/views/annotationListWidget.js +++ b/girder_annotation/girder_large_image_annotation/web_client/views/annotationListWidget.js @@ -152,6 +152,20 @@ const AnnotationListWidget = View.extend({ var parent = new FileModel(); parent.updateContents = (data) => { restRequest({ + xhr: () => { + var xhr = new window.XMLHttpRequest(); + xhr.upload.addEventListener('progress', (evt) => { + if (evt.lengthComputable) { + this._uploadWidget.currentFile.trigger('g:upload.progress', { + startByte: 0, + loaded: evt.loaded, + total: evt.total, + file: this._uploadWidget.files[this._uploadWidget.currentIndex] + }); + } + }, false); + return xhr; + }, url: `annotation/item/${this.model.id}`, method: 'POST', data: data, @@ -159,12 +173,7 @@ const AnnotationListWidget = View.extend({ processData: false }).done((resp) => { parent.name = this.model.name(); - parent.trigger('g:upload.progress', { - startByte: 0, - loaded: data.size, - total: data.size, - file: parent - }); + this._uploadWidget.overallProgress += data.size; this._uploadWidget.parent = this._makeUploadParent(); parent.trigger('g:upload.complete'); }).fail((resp) => { diff --git a/girder_annotation/girder_large_image_annotation/web_client/views/imageViewerWidget/geojs.js b/girder_annotation/girder_large_image_annotation/web_client/views/imageViewerWidget/geojs.js index 6ff6665f4..1397fde32 100644 --- a/girder_annotation/girder_large_image_annotation/web_client/views/imageViewerWidget/geojs.js +++ b/girder_annotation/girder_large_image_annotation/web_client/views/imageViewerWidget/geojs.js @@ -57,8 +57,8 @@ var GeojsImageViewerWidgetExtension = function (viewer) { /** * Render an annotation model on the image. Currently, this is limited - * to annotation types that can be directly converted into geojson - * primatives. + * to annotation types that can be (1) directly converted into geojson + * primatives, OR (2) be represented as heatmaps. * * Internally, this generates a new feature layer for the annotation * that is referenced by the annotation id. All "elements" contained @@ -84,7 +84,11 @@ var GeojsImageViewerWidgetExtension = function (viewer) { if (present) { _.each(this._annotations[annotation.id].features, (feature, idx) => { if (idx || !annotation._centroids || feature.data().length !== annotation._centroids.data.length) { - this.featureLayer.deleteFeature(feature); + if (feature._ownLayer) { + feature.layer().map().deleteLayer(feature.layer()); + } else { + this.featureLayer.deleteFeature(feature); + } } else { centroidFeature = feature; } @@ -189,6 +193,12 @@ var GeojsImageViewerWidgetExtension = function (viewer) { this._featureOpacity[annotation.id] = {}; geo.createFileReader('jsonReader', {layer: this.featureLayer}) .read(geojson, (features) => { + if (features.length === 0) { + features = annotation.non_geojson(this.featureLayer); + if (features.length) { + this.featureLayer.map().draw(); + } + } _.each(features || [], (feature) => { var events = geo.event.feature; featureList.push(feature); @@ -371,10 +381,8 @@ var GeojsImageViewerWidgetExtension = function (viewer) { }, /** - * Remove an annotation from the image. This simply - * finds a layer with the given id and removes it because - * each annotation is contained in its own layer. If - * the annotation is not drawn, this is a noop. + * Remove an annotation from the image. If the annotation is not + * drawn, this does nothing. * * @param {AnnotationModel} annotation */ @@ -388,7 +396,11 @@ var GeojsImageViewerWidgetExtension = function (viewer) { ); if (_.has(this._annotations, annotation.id)) { _.each(this._annotations[annotation.id].features, (feature) => { - this.featureLayer.deleteFeature(feature); + if (feature._ownLayer) { + feature.layer().map().deleteLayer(feature.layer()); + } else { + this.featureLayer.deleteFeature(feature); + } }); delete this._annotations[annotation.id]; delete this._featureOpacity[annotation.id]; @@ -484,6 +496,11 @@ var GeojsImageViewerWidgetExtension = function (viewer) { if (this.featureLayer) { this.featureLayer.opacity(opacity); } + Object.values(this._annotations).forEach((annot) => annot.features.forEach((feature) => { + if (feature._ownLayer) { + feature.layer().opacity(opacity); + } + })); return this; }, diff --git a/girder_annotation/test_annotation/test_annotations.py b/girder_annotation/test_annotation/test_annotations.py index 0f4c3b17e..883175c7a 100644 --- a/girder_annotation/test_annotation/test_annotations.py +++ b/girder_annotation/test_annotation/test_annotations.py @@ -356,6 +356,45 @@ def testPermissions(self, admin): check = Annotation().load(annot['_id'], force=True) assert len(check['annotation']['elements']) > 0 + def testHeatmapAnnotation(self, db, admin, fsAssetstore): + item = Item().createItem('sample', admin, utilities.namedFolder(admin, 'Public')) + annotation = { + 'name': 'testAnnotation', + 'elements': [{ + 'type': 'heatmap', + 'points': [[random.random() for _ in range(4)] for _ in range(10240)] + }] + } + annot = Annotation().createAnnotation(item, admin, annotation) + assert Annotation().load(annot['_id'], user=admin) is not None + Setting().set(constants.PluginSettings.LARGE_IMAGE_ANNOTATION_HISTORY, False) + result = Annotation().remove(annot) + Setting().set(constants.PluginSettings.LARGE_IMAGE_ANNOTATION_HISTORY, True) + assert result.deleted_count == 1 + assert Annotation().load(annot['_id'], user=admin) is None + + def testGridHeatmapAnnotation(self, db, admin, fsAssetstore): + item = Item().createItem('sample', admin, utilities.namedFolder(admin, 'Public')) + annotation = { + 'name': 'testAnnotation', + 'elements': [{ + 'type': 'griddata', + 'interpretation': 'heatmap', + 'origin': [30, 40, 50], + 'dx': 3, + 'dy': 4, + 'gridWidth': 128, + 'values': [random.random() for _ in range(10240)] + }] + } + annot = Annotation().createAnnotation(item, admin, annotation) + assert Annotation().load(annot['_id'], user=admin) is not None + Setting().set(constants.PluginSettings.LARGE_IMAGE_ANNOTATION_HISTORY, False) + result = Annotation().remove(annot) + Setting().set(constants.PluginSettings.LARGE_IMAGE_ANNOTATION_HISTORY, True) + assert result.deleted_count == 1 + assert Annotation().load(annot['_id'], user=admin) is None + @pytest.mark.usefixtures('unbindLargeImage', 'unbindAnnotation') @pytest.mark.plugin('large_image_annotation') diff --git a/girder_annotation/test_annotation/web_client_specs/annotationSpec.js b/girder_annotation/test_annotation/web_client_specs/annotationSpec.js index 754f182b4..c233aad1b 100644 --- a/girder_annotation/test_annotation/web_client_specs/annotationSpec.js +++ b/girder_annotation/test_annotation/web_client_specs/annotationSpec.js @@ -131,6 +131,79 @@ describe('Annotations', function () { expect(obj.annotationType).toBe('point'); expect(obj.coordinates).toEqual([1, 2]); }); + + describe('heatmapColorTable', function () { + var values = [0.508, 0.806, 0.311, 0.402, 0.535, 0.661, 0.866, 0.31, 0.241, 0.63, 0.555, 0.067, 0.668, 0.164, 0.512, 0.647, 0.501, 0.637, 0.498, 0.658, 0.332, 0.431, 0.053, 0.531]; + var tests = [{ + name: 'no parameters', + record: {}, + result: { + min: 0, + max: null, + color: {'0': {r: 0, g: 0, b: 0, a: 0}, '1': {r: 1, g: 1, b: 0, a: 1}} + } + }, { + name: 'normalize range, one value', + record: {normalizeRange: true, colorRange: ['red'], rangeValues: [0.5]}, + result: { + min: 0, + max: null, + color: {'0': {r: 0, g: 0, b: 0, a: 0}, '1': {r: 1, g: 1, b: 0, a: 1}, '0.5': 'red'} + } + }, { + name: 'normalize range, several values', + record: { + normalizeRange: true, + colorRange: ['blue', 'red', 'green', 'white', 'black'], + rangeValues: [-1, -0.2, 0.5, 1.1, 2]}, + result: { + min: 0, + max: null, + color: {'0': 'red', '0.5': 'green', '1': 'white'} + } + }, { + name: 'no normalize range', + record: {}, + result: { + min: 0, + max: null, + color: {'0': {r: 0, g: 0, b: 0, a: 0}, '1': {r: 1, g: 1, b: 0, a: 1}} + } + }, { + name: 'set range', + record: {colorRange: ['red', 'blue'], rangeValues: [0, 1]}, + result: { + min: 0, + max: 1, + color: {'0': 'red', '1': 'blue'} + } + }, { + name: 'set range, more values', + record: {colorRange: ['red', 'blue', 'green'], rangeValues: [0, 0.2, 0.8]}, + result: { + min: 0, + max: 0.8, + color: {'0': 'red', '0.25': 'blue', '1': 'green'} + } + }, { + name: 'set range, more range', + record: {colorRange: ['red', 'blue'], rangeValues: [0.8, 0.8]}, + result: { + min: 0.8, + max: 0.8, + color: {'0': {r: 0, g: 0, b: 0, a: 0}, '1': 'red'} + } + }]; + tests.forEach(function (test) { + it(test.name, function () { + var heatmapColorTable = largeImageAnnotation.annotations.convertFeatures.heatmapColorTable; + var obj = heatmapColorTable(test.record, values); + expect(obj.min).toBe(test.result.min); + expect(obj.max).toBe(test.result.max); + expect(obj.color).toEqual(test.result.color); + }); + }); + }); }); describe('style', function () { From 3014ad8fc7b7056a3c72c973dc65c0e166663e17 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Tue, 6 Apr 2021 07:59:02 -0400 Subject: [PATCH 2/2] Add support for grid contour annotations. --- .../web_client/package.json | 2 +- .../web_client/annotations/convertFeatures.js | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/girder/girder_large_image/web_client/package.json b/girder/girder_large_image/web_client/package.json index 4fd859cab..09648cee1 100644 --- a/girder/girder_large_image/web_client/package.json +++ b/girder/girder_large_image/web_client/package.json @@ -17,7 +17,7 @@ "dependencies": { "copy-webpack-plugin": "^4.5.2", "d3": "^3.5.16", - "geojs": "^1.0.0", + "geojs": "^1.0.1", "hammerjs": "^2.0.8", "js-yaml": "^3.14.0", "sinon": "^2.1.0", diff --git a/girder_annotation/girder_large_image_annotation/web_client/annotations/convertFeatures.js b/girder_annotation/girder_large_image_annotation/web_client/annotations/convertFeatures.js index 9cd2af4eb..2c9f69d1e 100644 --- a/girder_annotation/girder_large_image_annotation/web_client/annotations/convertFeatures.js +++ b/girder_annotation/girder_large_image_annotation/web_client/annotations/convertFeatures.js @@ -130,7 +130,52 @@ function convertGridToHeatmap(record, properties, layer) { return [heatmap]; } +/** + * Convert a griddata heatmap contour to a geojs feature. + * + * @param record: the griddata contour annotation element. + * @param properties: a property map of additional data, such as the original + * annotation id. + * @param layer: the layer where this may be added. + */ +function convertGridToContour(record, properties, layer) { + let min = record.values[0] || 0; + let max = min; + for (let i = 1; i < record.values.length; i += 1) { + if (record.values[i] > max) { + max = record.values[i]; + } + if (record.values[i] < max) { + min = record.values[i]; + } + } + if (min >= 0) { + min = -1; /* any negative number will do */ + } + const contour = layer.createFeature('contour', { + style: { + value: (d) => d || 0 + }, + contour: { + gridWidth: record.gridWidth, + x0: (record.origin || [])[0] || 0, + y0: (record.origin || [])[1] || 0, + dx: record.dx || 1, + dy: record.dy || 1, + stepped: false, + colorRange: [ + record.minColor || {r: 0, g: 0, b: 1, a: 1}, + record.zeroColor || {r: 0, g: 0, b: 0, a: 0}, + record.maxColor || {r: 1, g: 1, b: 0, a: 1} + ], + rangeValues: [min, 0, Math.max(0, max)] + } + }).data(record.values); + return [contour]; +} + const converters = { + griddata_contour: convertGridToContour, griddata_heatmap: convertGridToHeatmap, heatmap: convertHeatmap };