Source code for pywwt.annotation

# Copyright 2021 the .NET Foundation
# Licensed under the three-clause BSD License

"""
This module defines types for controlling WWT on-screen "annotations".

To actually create annotations in a WWT viewer, use methods on your ``wwt``
variable, such as:

- `~pywwt.core.BaseWWTWidget.add_circle`
- `~pywwt.core.BaseWWTWidget.add_collection`
- `~pywwt.core.BaseWWTWidget.add_fov`
- `~pywwt.core.BaseWWTWidget.add_line`
- `~pywwt.core.BaseWWTWidget.add_polygon`

"""

import uuid
from traitlets import HasTraits, TraitError, validate
from astropy import units as u
from astropy.coordinates import concatenate, SkyCoord
import numpy as np

from .traits import (Color, ColorWithOpacity, Bool,
                     Float, Unicode, AstropyQuantity)
from .utils import validate_traits

__all__ = ['Annotation', 'Circle', 'Polygon', 'Line', 'FieldOfView']


[docs] class Annotation(HasTraits): """ Base class for annotations which provides settings common to all shapes. """ shape = None """ To be specified in the individual shape classes. """ label = Unicode('', help='Contains descriptive text ' 'for the annotation (`str`)').tag(wwt='label') opacity = Float(1, help='Specifies the opacity to be applied to the ' 'complete annotation (`int`)').tag(wwt='opacity') hover_label = Bool(False, help='Specifies whether to render the label ' 'if the mouse is hovering over the annotation ' '(`bool`)').tag(wwt='showHoverLabel') tag = Unicode(help='Contains a string for use by the web client ' '(`str`)').tag(wwt='tag') def __init__(self, parent=None, **kwargs): self.parent = parent self.observe(self._on_trait_change, type='change') self.id = str(uuid.uuid4()) # Check that all kwargs are valid -- throws error if not validate_traits(self, kwargs) self.parent._send_msg(event='annotation_create', id=self.id, shape=self.shape) # Normally we would pass the kwargs off to super().__init__(), but it # seems that doing so bypasses the validation functions, which (at a # minimum) causes problems for circles because it doesn't normalize # their radius measurements to the proper units. Just apply the settings # manually, which invokes the validation machinery properly. super(Annotation, self).__init__() for k, v in kwargs.items(): setattr(self, k, v) self.parent._annotation_set.add(self) def _on_trait_change(self, changed): # This method gets called anytime a trait gets changed. Since this class # gets inherited by the Jupyter widgets class which adds some traits of # its own, we only want to react to changes in traits that have the wwt # metadata attribute (which indicates the name of the corresponding WWT # setting). wwt_name = self.trait_metadata(changed['name'], 'wwt') if wwt_name is not None: self.parent._send_msg(event='annotation_set', id=self.id, setting=wwt_name, value=changed['new']) def _serialize_state(self): state = {'shape': self.shape, 'id': self.id, 'settings': {}} for trait in self.traits().values(): wwt_name = trait.metadata.get('wwt') if wwt_name: trait_val = trait.get(self) if isinstance(trait_val, u.Quantity): trait_val = trait_val.value state['settings'][wwt_name] = trait_val return state
[docs] def remove(self): """ Removes the specified annotation from the current view. """ self.parent._send_msg(event='remove_annotation', id=self.id) self.parent._annotation_set.discard(self)
[docs] class Circle(Annotation): """ A circular annotation. """ shape = 'circle' """ The name of the shape (:class:`str`). """ fill = Bool(False, help='Whether or not the circle should be filled ' '(`bool`)').tag(wwt='fill') fill_color = ColorWithOpacity('white', help='Assigns fill color for the circle ' '(`str` or `tuple`)').tag(wwt='fillColor') line_color = Color('white', help='Assigns line color for the circle ' '(`str` or `tuple`)').tag(wwt='lineColor') line_width = AstropyQuantity(1 * u.pixel, help='Assigns line width in pixels ' '(:class:`~astropy.units.Quantity`)').tag(wwt='lineWidth') radius = AstropyQuantity(1 * u.degree, help='Sets the radius for the circle ' '(:class:`~astropy.units.Quantity`)').tag(wwt='radius') def __init__(self, parent=None, center=None, **kwargs): super(Circle, self).__init__(parent, **kwargs) if center: self.set_center(center) else: self._center = parent.get_center().icrs @validate('line_width') def _validate_linewidth(self, proposal): if proposal['value'].unit.is_equivalent(u.pixel): return proposal['value'].to(u.pixel) else: raise TraitError('line width must be in pixel equivalent unit') @validate('radius') def _validate_radius(self, proposal): if proposal['value'].unit.is_equivalent(u.pixel): return proposal['value'].to(u.pixel) elif proposal['value'].unit.is_equivalent(u.degree): return proposal['value'].to(u.degree) else: raise TraitError('radius must be in pixel or degree equivalent unit')
[docs] def set_center(self, coord): """ Set the center coordinates of the circle object. Parameters ---------- coord : `~astropy.units.Quantity` The coordinates of the desired center of the circle. """ coord_icrs = coord.icrs self.parent._send_msg(event='circle_set_center', id=self.id, ra=coord_icrs.ra.degree, dec=coord_icrs.dec.degree) self._center = coord_icrs
def _on_trait_change(self, changed): if changed['name'] == 'radius': if changed['new'].unit.is_equivalent(u.pixel): self.parent._send_msg(event='annotation_set', id=self.id, setting='skyRelative', value=False) elif changed['new'].unit.is_equivalent(u.degree): self.parent._send_msg(event='annotation_set', id=self.id, setting='skyRelative', value=True) if isinstance(changed['new'], u.Quantity): changed['new'] = changed['new'].value super(Circle, self)._on_trait_change(changed) def _serialize_state(self): state = super(Circle, self)._serialize_state() state['settings']['skyRelative'] = self.radius.unit.is_equivalent(u.degree) state['center'] = {'ra': self._center.ra.deg, 'dec': self._center.dec.deg} return state
[docs] class Polygon(Annotation): """ A polygon annotation. """ shape = 'polygon' """ The name of the shape (:class:`str`). """ fill = Bool(False, help='Whether or not the polygon should be filled ' '(`bool`)').tag(wwt='fill') fill_color = ColorWithOpacity('white', help='Assigns fill color for the polygon ' '(`str` or `tuple`)').tag(wwt='fillColor') line_color = Color('white', help='Assigns line color for the polygon ' '(`str` or `tuple`)').tag(wwt='lineColor') line_width = AstropyQuantity(1 * u.pixel, help='Assigns line width in pixels ' '(:class:`~astropy.units.Quantity`)').tag(wwt='lineWidth') def __init__(self, parent=None, **kwargs): self._points = [] super(Polygon, self).__init__(parent, **kwargs) @validate('line_width') def _validate_linewidth(self, proposal): if proposal['value'].unit.is_equivalent(u.pixel): return proposal['value'].to(u.pixel) else: raise TraitError('line width must be in pixel equivalent unit')
[docs] def add_point(self, coord): """ Add one or more points to a polygon object. If you want to fill the polygon, you should ensure that the vertices form a counter-clockwise polygon. Parameters ---------- coord : `~astropy.units.Quantity` The coordinates of the desired point(s). """ coord_icrs = coord.icrs if coord_icrs.isscalar: # if coord only has one point self.parent._send_msg(event='polygon_add_point', id=self.id, ra=coord_icrs.ra.degree, dec=coord_icrs.dec.degree) self._points.append(coord_icrs) else: for point in coord_icrs: self.parent._send_msg(event='polygon_add_point', id=self.id, ra=point.ra.degree, dec=point.dec.degree) self._points.append(point)
def _on_trait_change(self, changed): if isinstance(changed['new'], u.Quantity): changed['new'] = changed['new'].value super(Polygon, self)._on_trait_change(changed) def _serialize_state(self): state = super(Polygon, self)._serialize_state() state['points'] = [] for point in self._points: state['points'].append({'ra': point.ra.degree, 'dec': point.dec.degree}) return state
[docs] class Line(Annotation): """ A line annotation. """ shape = 'line' """ The name of the shape (:class:`str`). """ color = ColorWithOpacity('white', help='Assigns color for the line ' '(`str` or `tuple`)').tag(wwt='lineColor') width = AstropyQuantity(1 * u.pixel, help='Assigns width for the line in pixels ' '(:class:`~astropy.units.Quantity`)').tag(wwt='lineWidth') def __init__(self, parent=None, **kwargs): self._points = [] super(Line, self).__init__(parent, **kwargs) @validate('width') def _validate_width(self, proposal): if proposal['value'].unit.is_equivalent(u.pixel): return proposal['value'].to(u.pixel) else: raise TraitError('width must be in pixel equivalent unit')
[docs] def add_point(self, coord): """ Add one or more points to a line object. Parameters ---------- coord : `~astropy.units.Quantity` The coordinates of the desired point(s). """ coord_icrs = coord.icrs if coord_icrs.isscalar: # if coord only has one point self.parent._send_msg(event='line_add_point', id=self.id, ra=coord_icrs.ra.degree, dec=coord_icrs.dec.degree) self._points.append(coord_icrs) else: for point in coord_icrs: self.parent._send_msg(event='line_add_point', id=self.id, ra=point.ra.degree, dec=point.dec.degree) self._points.append(point)
def _on_trait_change(self, changed): if isinstance(changed['new'], u.Quantity): changed['new'] = changed['new'].value super(Line, self)._on_trait_change(changed) def _serialize_state(self): state = super(Line, self)._serialize_state() state['points'] = [] for point in self._points: state['points'].append({'ra': point.ra.degree, 'dec': point.dec.degree}) return state
[docs] class FieldOfView(): """ A collection of polygon annotations. Takes the name of a pre-loaded telescope and displays its field of view. """ # a more efficient method than CircleCollection of changing trait values? def __init__(self, parent, telescope, center, rot, **kwargs): # make sure rot is astropy quantity in proper units for self._rotate() try: if not rot.unit.is_equivalent(u.deg): raise AttributeError except AttributeError: raise ValueError('rotate argument must be Astropy quantity with ' 'degree or radian-equivalent units') # get the JSON list of FOVs from parent BaseWWTWidget class self.parent = parent self._available = self.parent.instruments.available self._entry = self.parent.instruments.entry # list of IDs of annotations created in this FieldofView instance self.active = [] self._gen_fov(telescope, center, rot, **kwargs) def _gen_fov(self, telescope, center, rot, **kwargs): # test if telescope is available if telescope not in self._available: raise ValueError('the given telescope\'s field of view is ' 'unavailable at this time') position = self._entry[telescope][0] dimensions = self._entry[telescope][-1] # dimensions is a list of lists, i.e. [ [[ras], [decs]], '', ...] # test that pos matches what the user entered if center and position == 'absolute': raise ValueError('the given telescope does not take center ' 'coordinates') elif not center and position == 'relative': raise ValueError('the given telescope requires center coordinates') for panel in dimensions: ras = (panel[0] * u.deg).value decs = (panel[1] * u.deg).value # rotate points if necessary if position == 'relative' and rot != 0: ras, decs = self._rotate(ras, decs, rot) # translate points to user-specified location if relative if position == 'relative': center_ra = center.ra.to(u.deg).value center_dec = center.dec.to(u.deg).value decs = center_dec + decs # scale RA by dec (due to polar contraction of spherical coords) ras = center_ra + ras / np.cos(decs * u.deg).value # check that abs(dec) < 90. if not, adjust it, and then # set the corresponding ra to be swapped by 180 for i, dec in enumerate(decs): if abs(dec) > 90: if dec < -90: decs[i] = -90. - (dec + 90.) else: # dec > 90 decs[i] = 90. - (dec - 90.) ras[i] += 180. corners = SkyCoord(ras, decs, unit=u.deg) if self.parent.galactic_mode: corners = corners.galactic # draw the panel annot = self.parent.add_polygon(corners, **kwargs) self.active.append(annot) def _rotate(self, ras, decs, rot): cos, sin = np.cos(rot).value, np.sin(rot).value ra_rot = ras * cos - decs * sin dec_rot = ras * sin + decs * cos return ra_rot, dec_rot
[docs] def remove(self): """ Removes the specified field of view from the viewer. """ for annot in self.active: annot.remove()
class CircleCollection(): """ A collection of circle annotations. Takes a set of several points (e.g. a column of SkyCoords from an astropy Table) to make generating several circles at once easier. """ def __init__(self, parent, points, **kwargs): if len(points) <= 1e4: self.points = points else: raise IndexError('For performance reasons, only 10,000 ' 'annotations can be added at once for the time being.') self.parent = parent self.collection = [] self._gen_circles(self.points, **kwargs) def _set_all_attributes(self, name, value): for elem in self.collection: setattr(elem, name, value) def _get_all_attributes(self, name): values = [] for elem in self.collection: attr = getattr(elem, name) if attr not in values: values.append(attr) if len(values) == 1: return values[0] else: return values def _gen_circles(self, points, **kwargs): for elem in points: circle = Circle(self.parent, elem, **kwargs) self.collection.append(circle) def add_points(self, points, **kwargs): """ Adds multiple points to the CircleCollection. """ self._gen_circles(points, **kwargs) self.points = concatenate((self.points, points)) def remove(self): """ Removes all circles in the CircleCollection from view. """ for elem in self.collection: elem.remove() # Circle.__dict__ attributes @property def fill(self): """ Whether or not the circles in the CircleCollection have a fill. """ return self._get_all_attributes('fill') @fill.setter def fill(self, value): return self._set_all_attributes('fill', value) @property def fill_color(self): """ The fill color of the circles in the CircleCollection. """ return self._get_all_attributes('fill_color') @fill_color.setter def fill_color(self, value): return self._set_all_attributes('fill_color', value) @property def line_color(self): """ The line color of the circles in the CircleCollection. """ return self._get_all_attributes('line_color') @line_color.setter def line_color(self, value): return self._set_all_attributes('line_color', value) @property def line_width(self): """ The line width of the circles in the CircleCollection. """ return self._get_all_attributes('line_width') @line_width.setter def line_width(self, value): return self._set_all_attributes('line_width', value) @property def radius(self): """ The radii of the circles in the CircleCollection. """ return self._get_all_attributes('radius') @radius.setter def radius(self, value): return self._set_all_attributes('radius', value) @property def shape(self): """ The shapes comprising the CircleCollection (always 'circle'). """ return self._get_all_attributes('shape') # Annotation.__dict__ attributes @property def label(self): """ Descriptive text for the CircleCollection. """ return self._get_all_attributes('label') @label.setter def label(self, value): return self._set_all_attributes('label', value) @property def hover_label(self): """ Whether to show a label when the mouse hovers over the CircleCollection. """ return self._get_all_attributes('hover_label') @hover_label.setter def hover_label(self, value): return self._set_all_attributes('hover_label', value) @property def opacity(self): """ The opacity of the circles in the CircleCollection. """ return self._get_all_attributes('opacity') @opacity.setter def opacity(self, value): return self._set_all_attributes('opacity', value) @property def tag(self): """ A string that can be used by the web client. """ return self._get_all_attributes('tag') @tag.setter def tag(self, value): return self._set_all_attributes('tag', value)