Skip to content

Commit

Permalink
Merge pull request #589 from girder/heatmap-annotations
Browse files Browse the repository at this point in the history
Heatmap annotations
  • Loading branch information
manthey authored May 14, 2021
2 parents 939e51a + 3014ad8 commit 2cb4132
Show file tree
Hide file tree
Showing 13 changed files with 692 additions and 34 deletions.
2 changes: 1 addition & 1 deletion girder/girder_large_image/web_client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
height 600px
-webkit-touch-callout none
user-select none
overflow hidden

&:focus
outline none
Expand Down
81 changes: 80 additions & 1 deletion girder_annotation/docs/annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<id, label> # 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
<id, label> # 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

Expand Down
162 changes: 157 additions & 5 deletions girder_annotation/girder_large_image_annotation/models/annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import datetime
import enum
import jsonschema
import numpy
import re
import threading
import time
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand Down Expand Up @@ -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
Expand All @@ -320,6 +455,8 @@ class AnnotationSchema:
arrowShapeSchema,
circleShapeSchema,
ellipseShapeSchema,
griddataSchema,
heatmapSchema,
pointShapeSchema,
polylineShapeSchema,
rectangleShapeSchema,
Expand Down Expand Up @@ -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)):
Expand Down Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 2cb4132

Please sign in to comment.