"""
This module provides utilities for creating custom data viewers. The goal of
this module is to make it easy for users to make new data viewers by focusing on
matplotlib visualization logic, and not UI or event processing logic.
The end user typically interacts with this code via :func:`glue.custom_viewer`
"""
# Implementation notes:
#
# Here's a high-level summary of how this code works right now:
#
# The user creates a custom viewer using either of the following
# syntaxes:
#
#
# from glue import custom_viewer
# my_viewer = custom_viewer('my viewer', checked=True, x='att', ...)
# @my_viewer.plot_data
# def plot_data(x, checked, axes):
# if checked:
# axes.plot(x)
# ...
#
# or
#
# from glue_qt.viewers.custom import CustomViewer
# class MyViewer(CustomViewer):
#
# checked = True
# x = 'att'
#
# def plot_data(self, x, checked, axes):
# if checked:
# axes.plot(x)
#
# This code has two "magic" features:
#
# 1. Attributes like 'checked' and 'x', passed as kwargs to custom_viewer
# or set as class-level attributes in the subclass, are turned
# into widgets based on their value
#
# 2. Functions like plot_data can take these settings as input (as well
# as some general purpose arguments like axes). Glue takes care of
# passing the proper arguments to these functions by introspecting
# their call signature. Furthermore, it extracts the current
# value of each setting (ie checked is set to True or False depending
# on what if the box is checked).
#
# The intention of all of this magic is to let a user write "simple" functions
# to draw custom plots, without having to use Glue or Qt logic directly.
#
# Internally, Glue accomlishes this magic as follows:
#
# `FormElement`s are created for each attribute in (1). They build the widget
# and have a method of extracting the current value of the widget
#
# Functions like `plot_data` that are designed to be overriden by users
# are defined as custom descriptors -- when called at the class level,
# they become decorators that wrap and register the user-defined function.
# When called at the instance level, they become dispatch functions which
# deal with the logic in (2). The metaclass deals with registering
# UDFs when they are overridden in a subclass.
from inspect import getmodule
from functools import partial
from inspect import getfullargspec
from types import FunctionType, MethodType
import numpy as np
from qtpy.QtWidgets import QWidget, QGridLayout, QLabel
from echo.qt import autoconnect_callbacks_to_qt
from echo import ignore_callback
from glue_qt.config import qt_client
from glue.core import BaseData
from glue.core.subset import SubsetState
from glue.core.data_combo_helper import ComponentIDComboHelper
from glue.core.component_id import ComponentID
from glue.utils import as_list, all_artists, new_artists, categorical_ndarray, defer_draw
from glue_qt.viewers.matplotlib.data_viewer import MatplotlibDataViewer
from glue.viewers.matplotlib.state import MatplotlibDataViewerState, MatplotlibLayerState
from glue.viewers.matplotlib.layer_artist import MatplotlibLayerArtist
from glue_qt.viewers.custom.elements import (FormElement,
DynamicComponentIDProperty,
FixedComponentIDProperty)
__all__ = ["AttributeWithInfo", "ViewerUserState", "UserDefinedFunction",
"CustomViewer", "CustomViewerMeta", "CustomSubsetState",
"CustomViewer", "CustomLayerArtist", "CustomMatplotlibDataViewer"]
[docs]class AttributeWithInfo(np.ndarray):
"""
An array subclass wrapping a Component of a dataset It is an array with the
following additional attributes: ``id`` contains the ComponentID or string
name of the Component, and ``categories`` is an array or `None`. For
categorical Components, it contains the distinct categories which are
integer-encoded in the AttributeWithInfo
"""
[docs] @classmethod
def make(cls, id, values, categories=None):
values = np.asarray(values)
result = values.view(AttributeWithInfo)
result.id = id
result.values = values
result.categories = categories
return result
[docs] @classmethod
def from_layer(cls, layer, cid, view=None):
"""
Build an AttributeWithInfo out of a subset or dataset.
Parameters
----------
layer : :class:`~glue.core.data.Data` or :class:`~glue.core.subset.Subset`
The data to use
cid : ComponentID
The ComponentID to use
view : numpy-style view (optional)
What slice into the data to use
"""
values = layer[cid, view]
if isinstance(values, categorical_ndarray):
categories = values.categories
values = values.codes
else:
categories = None
return cls.make(cid, values, categories)
def __gluestate__(self, context):
return dict(cid=context.id(self.id))
@classmethod
def __setgluestate__(cls, rec, context):
return cls.make(context.object(rec['cid']), [], None)
[docs]class ViewerUserState(object):
"""
Empty object for users to store data inside.
"""
def __gluestate__(self, context):
return dict(data=[(k, context.id(v)) for k, v in self.__dict__.items()])
@classmethod
def __setgluestate__(cls, rec, context):
result = cls()
rec = rec['data']
for k in rec:
setattr(result, k, context.object(rec[k]))
return result
[docs]class UserDefinedFunction(object):
"""
Descriptor to specify a UserDefinedFunction.
Defined in CustomViewer like this::
class CustomViewer(object):
...
plot_data = UserDefinedFunction('plot_data')
The descriptor gives CustomViewer.plot_data a dual functionality.
When accessed at the class level, it behaves as a decorator to
register new UDFs::
cv = custom_viewer(...)
@cv.plot_data # becomes a decorator
def plot_data_implementation(...):
...
When accessed at the instance level, it becomes a dispatch function
that calls `plot_data_implementation` with the proper arguments
Alternatively, plot_data_implementation can be specified by explicitly
overriding plot_data in a subclass. A metaclass takes care of registering
the UDF in that case, so you can define plot_data as a normal
(non-decorator, non-descriptor) method.
"""
def __init__(self, name):
self.name = name
def __get__(self, instance, cls=None):
if instance is None:
# accessed from class level, return a decorator
# to wrap a custom UDF
return partial(cls._register_override_method, self.name)
# method called at instance level,
# return a dispatcher to the UDF
return partial(instance._call_udf, self.name)
def introspect_and_call(func, state, override):
"""
Introspect a function for its arguments, extract values for those
arguments from a state class, and call the function
Parameters
----------
func : function
A function to call. It should not define any keywords
state : State
A state class containing the values to pass
override : dict
A dictionary containing values that should override the state
Returns
-------
The result of calling func with the proper arguments
*Example*
def a(x, y):
return x, y
introspect_and_call(a, state) will return
a(state.x, state.y)
Attributes will be used from ``override`` before ``state``.
"""
a, k = getfullargspec(func)[:2]
args = []
for item in a:
if item in override:
args.append(override[item])
elif hasattr(state, item):
args.append(getattr(state, item))
else:
setting_list = "\n -".join(state.callback_properties() + list(override))
raise MissingSettingError("This custom viewer is trying to use an "
"unrecognized variable named %s\n. Valid "
"variable names are\n -%s" %
(item, setting_list))
k = k or {}
return func(*args, **k)
class MissingSettingError(KeyError):
pass
[docs]class CustomSubsetState(SubsetState):
"""
A SubsetState subclass that uses a CustomViewer's "select" function
"""
def __init__(self, coordinator, roi):
super(CustomSubsetState, self).__init__()
self._coordinator = coordinator
self._roi = roi
[docs] def to_mask(self, data, view=None):
return self._coordinator.select(layer=data, roi=self._roi, view=view)
[docs] def copy(self):
return CustomSubsetState(self._coordinator, self._roi)
def __gluestate__(self, context):
result = {}
result['viewer'] = context.id(self._coordinator.viewer)
result['roi'] = context.id(self._roi)
return result
@classmethod
def __setgluestate__(cls, rec, context):
roi = context.object(rec['roi'])
subset_state = cls(None, roi)
subset_state._viewer_rec = rec['viewer']
return subset_state
def __setgluestate_callback__(self, context):
# When __setgluestate__ is created, the viewers might not yet be
# deserialized, and these depend on the Data and Subsets existing so
# we need to deserialize the viewer in a callback so it can be called
# later on.
viewer = context.object(self._viewer_rec)
self._coordinator = viewer._coordinator
self._viewer_rec = None
class BaseCustomOptionsWidget(QWidget):
"""
Base class for the Qt widget which will be used to show the options.
"""
_widgets = None
def __init__(self, viewer_state=None, session=None):
super(BaseCustomOptionsWidget, self).__init__()
layout = QGridLayout()
for row, (name, (prefix, viewer_cls)) in enumerate(self._widgets.items()):
widget = viewer_cls()
setattr(self, prefix + name, widget)
layout.addWidget(QLabel(name.capitalize()), row, 0)
layout.addWidget(widget, row, 1)
if len(self._widgets) > 0:
layout.setRowStretch(row + 1, 10)
self.setLayout(layout)
self.viewer_state = viewer_state
self.session = session
self._connections = autoconnect_callbacks_to_qt(self.viewer_state, self)
[docs]class CustomViewer(object, metaclass=CustomViewerMeta):
"""
Base class for custom data viewers.
Users can either subclass this class and override
one or more custom methods listed below, or use the
:func:`glue.custom_viewer` function and decorate custom
plot functions.
*Custom Plot Methods*
The following methods can be overridden:
- :meth:`CustomViewer.setup`
- :meth:`CustomViewer.plot_data`
- :meth:`CustomViewer.plot_subset`
- :meth:`CustomViewer.settings_changed`
- :meth:`CustomViewer.make_selector`
- :meth:`CustomViewer.select`
*Method Signatures*
Custom methods should use argument names from the following list:
- The name of a UI element (e.g. keywords passed to :func:`glue.custom_viewer`,
or class-level variables in subclasses). The value assigned to this
argument will be the current UI setting (e.g. booleans for checkboxes).
- ``axes`` will contain a matplotlib Axes object
- ``roi`` will contain the ROI a user has drawn (only available for ``make_selector``)
- ``state`` will contain a general-purpose object to store other data
- ``style`` contains the :class:`~glue.core.visual.VisualAttributes` describing
a subset or dataset. Only available for ``plot_data`` and `plot_subset``
- ``subset`` will contain the relevant :class:`~glue.core.subset.Subset` object.
Only available for ``plot_subset``
*Defining the UI*
Simple widget-based UIs can be specified by providing keywords to :func:`~glue.custom_viewer`
or class-level variables to subsets. The kind of widget to associate with each
UI element is determined from it's type.
*Example decorator*
::
v = custom_viewer('Example', checkbox=False)
@v.plot_data
def plot(checkbox, axes):
axes.plot([1, 2, 3])
*Example subclass*
::
class CustomViewerSubset(CustomViewer):
checkbox = False
def plot_data(self, checkbox, axes):
axes.plot([1, 2, 3])
The order of arguments can be listed in any order.
"""
# Label to give this widget in the GUI
name = ''
# Container to hold user descriptions of desired FormElements to create
ui = {}
# map, e.g., 'plot_data' -> user defined function - we also make sure we
# override this in sub-classes in CustomViewerMeta
_custom_functions = {}
def __init__(self, viewer):
self.viewer = viewer
self.state = ViewerUserState()
self.setup()
[docs] @property
def selections_enabled(self):
return 'make_selector' in self._custom_functions or 'select' in self._custom_functions
[docs] @classmethod
def create_new_subclass(cls, name, **kwargs):
"""
Convenience method to build a new CustomViewer subclass.
This is used by the custom_viewer function.
Parameters
----------
name : str
Name of the new viewer
kwargs
UI elements in the subclass
"""
kwargs = kwargs.copy()
kwargs['name'] = name
# each subclass needs its own dict
kwargs['_custom_functions'] = {}
name = name.replace(' ', '')
return CustomViewerMeta(name, (CustomViewer,), kwargs)
@classmethod
def _build_data_viewer(cls):
"""
Build the DataViewer subclass for this viewer.
"""
# At this point, the metaclass has put all the user options in a dict
# called .ui, so we go over this dictionary and find the widgets and
# callback properties for each of them.
widgets = {}
properties = {}
for name in sorted(cls.ui):
value = cls.ui[name]
prefix, widget, property = FormElement.auto(value).ui_and_state()
if widget is not None:
widgets[name] = prefix, widget
properties[name] = property
options_cls = type(cls.__name__ + 'OptionsWidget',
(BaseCustomOptionsWidget,), {'_widgets': widgets})
state_cls = type(cls.__name__ + 'ViewerState', (CustomMatplotlibViewerState,), properties)
widget_dict = {'LABEL': cls.name,
'ui': cls.ui,
'_options_cls': options_cls,
'_state_cls': state_cls,
'_coordinator_cls': cls}
viewer_cls = type(cls.__name__ + 'DataViewer',
(CustomMatplotlibDataViewer,),
widget_dict)
cls._viewer_cls = viewer_cls
qt_client.add(viewer_cls)
# add new classes to module namespace
# needed for proper state saving/restoring
for c in [viewer_cls, cls]:
mod = getmodule(ViewerUserState)
w = getattr(mod, c.__name__, None)
if w is not None:
raise RuntimeError("Duplicate custom viewer detected %s" % c)
setattr(mod, c.__name__, c)
c.__module__ = mod.__name__
@classmethod
def _register_override_method(cls, name, func):
"""
Register a new custom method like "plot_data"
Users need not call this directly - it is called when a method is
overridden or decorated
"""
cls._custom_functions[name] = func
def _build_subset_state(self, roi):
if 'make_selector' in self._custom_functions:
return self.make_selector(roi=roi)
if 'select' in self._custom_functions:
return CustomSubsetState(self, roi)
raise RuntimeError("Selection not supported for this viewer.")
# List of user-defined functions.
# Users can either use these as decorators to
# wrap custom functions, or override them in subclasses.
setup = UserDefinedFunction('setup')
"""
Custom method called when plot is created
"""
plot_subset = UserDefinedFunction('plot_subset')
"""
Custom method called to show a subset
"""
plot_data = UserDefinedFunction('plot_data')
"""
Custom method called to show a dataset
"""
make_selector = UserDefinedFunction('make_selector')
"""
Custom method called to build a :class:`~glue.core.subset.SubsetState` from an ROI.
See :meth:`~CustomViewer.select` for an alternative way to define selections,
by returning Boolean arrays instead of SubsetStates.
Functions have access to the roi by accepting an ``roi``
argument to this function
"""
settings_changed = UserDefinedFunction('settings_changed')
"""
Custom method called when UI settings change.
"""
select = UserDefinedFunction('select')
"""
Custom method called to filter data using an ROI.
This is an alternative function to :meth:`~CustomViewer.make_selector`,
which returns a numpy boolean array instead of a SubsetState.
Functions have access to the roi by accepting an ``roi``
argument to this function
"""
def _call_udf(self, method_name, **kwargs):
"""
Call a user-defined function stored in the _custom_functions dict
Parameters
----------
method_name : str
The name of the user-defined method to setup a dispatch for
use_cid : bool, optional
Whether to pass component IDs to the user function instead of the
data itself.
**kwargs : dict
Custom settings to pass to the UDF if they are requested by name
as input arguments
Returns
-------
The result of the UDF
Notes
-----
This function builds the necessary arguments to the
user-defined function. It also attempts to monitor
the state of the matplotlib plot, removing stale
artists and re-rendering the canvas as needed.
"""
# get the custom function
try:
func = self._custom_functions[method_name]
except KeyError:
return []
override = kwargs.copy()
if 'layer' not in override and len(self.viewer.state.layers) > 0:
override['layer'] = self.viewer.state.layers[0].layer
if 'layer' in override:
override.setdefault('style', override['layer'].style)
# Dereference attributes
for name, property in self.viewer.state.iter_callback_properties():
value = getattr(self.viewer.state, name)
if isinstance(value, ComponentID) or isinstance(property, FixedComponentIDProperty):
override[name] = AttributeWithInfo.from_layer(override['layer'], value, view=override.get('view', None))
# add some extra information that the user might want
override.setdefault('self', self)
override.setdefault('axes', self.viewer.axes)
override.setdefault('figure', self.viewer.axes.figure)
override.setdefault('state', self.state)
# call method, keep track of newly-added artists
result = introspect_and_call(func, self.viewer.state, override)
self.viewer.redraw()
return result
[docs]class CustomLayerArtist(MatplotlibLayerArtist):
"""
LayerArtist for simple custom viewers that use Matplotlib
"""
_layer_state_cls = MatplotlibLayerState
def __init__(self, coordinator, *args, **kwargs):
super(CustomLayerArtist, self).__init__(*args, **kwargs)
self._coordinator = coordinator
self.state.add_global_callback(self.update)
self._viewer_state.add_global_callback(self.update)
[docs] def update(self, *args, **kwargs):
if not self._visible:
return
self.clear()
old = all_artists(self.axes.figure)
if isinstance(self.state.layer, BaseData):
a = self._coordinator.plot_data(layer=self.state.layer)
else:
a = self._coordinator.plot_subset(layer=self.state.layer, subset=self.state.layer)
# if user explicitly returns the newly-created artists,
# then use them. Otherwise, introspect to find the new artists
if a is None:
self.mpl_artists = list(new_artists(self.axes.figure, old))
else:
self.mpl_artists = as_list(a)
for a in self.mpl_artists:
a.set_zorder(self.state.zorder)
[docs]class CustomMatplotlibDataViewer(MatplotlibDataViewer):
"""
Base Qt widget class for simple custom viewers that use Matplotlib
"""
LABEL = ''
tools = ['select:rectangle', 'select:polygon']
_state_cls = None
_options_cls = None
_layer_style_viewer_cls = None
_data_artist_cls = CustomLayerArtist
_subset_artist_cls = CustomLayerArtist
_coordinator_cls = None
def __init__(self, session, parent=None, **kwargs):
super(CustomMatplotlibDataViewer, self).__init__(session, parent, **kwargs)
self._coordinator = self._coordinator_cls(self)
self.state.add_global_callback(self._on_state_change)
self._on_state_change()
def _on_state_change(self, *args, **kwargs):
self._coordinator.settings_changed()
[docs] def get_layer_artist(self, cls, layer=None, layer_state=None):
return cls(self._coordinator, self.axes, self.state, layer=layer, layer_state=layer_state)
[docs] @defer_draw
def apply_roi(self, roi):
# Force redraw to get rid of ROI. We do this because applying the
# subset state below might end up not having an effect on the viewer,
# for example there may not be any layers, or the active subset may not
# be one of the layers. So we just explicitly redraw here to make sure
# a redraw will happen after this method is called.
self.redraw()
if len(self.layers) == 0:
return
subset_state = self._coordinator._build_subset_state(roi=roi)
self.apply_subset_state(subset_state)
[docs] def add_data(self, data):
"""
For convenience, we set x/y limits from the Matplotlib
dataLim box when we add data if and only if we haven't
set the x_min and y_min limits to something different
from the default 0-1 range. This way we do not
override custom limits set in a setup function but
most custom viewers will show data as expected.
"""
super(CustomMatplotlibDataViewer, self).add_data(data)
if (self.state.x_min == 0 and self.state.x_max == 1 and self.state.y_min == 0 and self.state.y_max == 1):
with ignore_callback(self.state, "x_min", "x_max", "y_min", "y_max"):
if not np.isinf(self.axes.dataLim.xmin):
self.state.x_min = self.axes.dataLim.xmin
if not np.isinf(self.axes.dataLim.xmax):
self.state.x_max = self.axes.dataLim.xmax
if not np.isinf(self.axes.dataLim.ymin):
self.state.y_min = self.axes.dataLim.ymin
if not np.isinf(self.axes.dataLim.ymax):
self.state.y_max = self.axes.dataLim.ymax
self.limits_to_mpl()
return True
class CustomMatplotlibViewerState(MatplotlibDataViewerState):
def __init__(self, *args, **kwargs):
super(CustomMatplotlibViewerState, self).__init__(*args)
self._cid_helpers = []
for name, property in self.iter_callback_properties():
if isinstance(property, DynamicComponentIDProperty):
self._cid_helpers.append(ComponentIDComboHelper(self, name))
self.add_callback('layers', self._on_layer_change)
self.update_from_dict(kwargs)
def _on_layer_change(self, *args):
for helper in self._cid_helpers:
helper.set_multiple_data(self.layers_data)