Source code for ctapipe.visualization.bokeh

import sys
from abc import ABCMeta
from tempfile import NamedTemporaryFile

import astropy.units as u
import numpy as np

from ..exceptions import OptionalDependencyMissing
from ..instrument import CameraGeometry, PixelShape
from .utils import build_hillas_overlay

try:
    from bokeh.io import output_file, output_notebook, push_notebook, show
    from bokeh.models import (
        BoxZoomTool,
        CategoricalColorMapper,
        ColorBar,
        ColumnDataSource,
        ContinuousColorMapper,
        Ellipse,
        HoverTool,
        Label,
        LinearColorMapper,
        LogColorMapper,
        TapTool,
    )
    from bokeh.palettes import Greys256, Inferno256, Magma256, Viridis256, d3
    from bokeh.plotting import figure
    from bokeh.transform import transform
except ModuleNotFoundError:
    raise OptionalDependencyMissing("bokeh") from None


__all__ = [
    "BokehPlot",
    "CameraDisplay",
    "ArrayDisplay",
]


PLOTARGS = dict(tools="", toolbar_location=None, outline_line_color="#595959")

# mapper to mpl names
CMAPS = {
    "viridis": Viridis256,
    "magma": Magma256,
    "inferno": Inferno256,
    "grey": Greys256,
    "gray": Greys256,
}


def palette_from_mpl_name(name):
    """Create a bokeh palette from a matplotlib colormap name"""
    if name in CMAPS:
        return CMAPS[name]

    try:
        import matplotlib.pyplot as plt
        from matplotlib.colors import to_hex
    except ModuleNotFoundError:
        raise OptionalDependencyMissing("matplotlib") from None

    rgba = plt.get_cmap(name)(np.linspace(0, 1, 256))
    palette = [to_hex(color) for color in rgba]
    return palette


def is_notebook():
    """
    Returns True if currently running in a notebook session,
    see https://stackoverflow.com/a/37661854/3838691
    """
    return "ipykernel" in sys.modules


def generate_hex_vertices(geom):
    """Generate vertices of pixels for a hexagonal grid camera geometry"""
    phi = np.arange(0, 2 * np.pi, np.pi / 3)

    # apply pixel rotation and conversion from flat top to pointy top
    phi += geom.pix_rotation.rad + np.deg2rad(30)

    # we need the circumcircle radius, pixel_width is incircle diameter
    unit = geom.pix_x.unit
    r = 2 / np.sqrt(3) * geom.pixel_width.to_value(unit) / 2

    x = geom.pix_x.to_value(unit)
    y = geom.pix_y.to_value(unit)

    return (
        x[:, np.newaxis] + r[:, np.newaxis] * np.cos(phi)[np.newaxis],
        y[:, np.newaxis] + r[:, np.newaxis] * np.sin(phi)[np.newaxis],
    )


def generate_square_vertices(geom):
    """Generate vertices of pixels for a square grid camera geometry"""
    unit = geom.pix_x.unit
    width = geom.pixel_width.to_value(unit) / 2
    x = geom.pix_x.to_value(unit)
    y = geom.pix_y.to_value(unit)

    x_offset = width[:, np.newaxis] * np.array([-1, -1, 1, 1])
    y_offset = width[:, np.newaxis] * np.array([1, -1, -1, 1])

    x = x[:, np.newaxis] + x_offset
    y = y[:, np.newaxis] + y_offset
    return x, y


[docs] class BokehPlot(metaclass=ABCMeta): """Base class for bokeh plots""" def __init__( self, use_notebook=None, autoscale=True, cmap="inferno", norm="lin", **figure_kwargs, ): # only use autoshow / use_notebook by default if we are in a notebook self._use_notebook = use_notebook if use_notebook is not None else is_notebook() self._patches = None self._handle = None self._color_bar = None self._color_mapper = None self._palette = None self._annotations = [] self._labels = [] self.cmap = cmap self.norm = norm self.autoscale = autoscale self.datasource = ColumnDataSource(data={}) self.figure = figure(**figure_kwargs) self.figure.add_tools(HoverTool(tooltips=[("id", "@id"), ("value", "@values")])) if figure_kwargs.get("match_aspect"): # Make sure the box zoom tool does not distort the camera display for tool in self.figure.toolbar.tools: if isinstance(tool, BoxZoomTool): tool.match_aspect = True
[docs] def show(self): """Display the figure""" if self._use_notebook: output_notebook() else: # this only sets the default name, created only when show is called tmp = NamedTemporaryFile( delete=False, prefix="ctapipe_bokeh_", suffix=".html" ) output_file(tmp.name) self._handle = show(self.figure, notebook_handle=self._use_notebook)
[docs] def update(self): """Update the figure""" if self._use_notebook and self._handle: push_notebook(handle=self._handle)
[docs] def add_colorbar(self): self._color_bar = ColorBar( color_mapper=self._color_mapper, label_standoff=12, border_line_color=None, location=(0, 0), ) self.figure.add_layout(self._color_bar, "right") self.update()
[docs] def set_limits_minmax(self, zmin, zmax): """Set the limits of the color range to ``zmin`` / ``zmax``""" self._color_mapper.update(low=zmin, high=zmax) self.update()
[docs] def set_limits_percent(self, percent=95): """Set the limits to min / fraction of max value""" low = np.nanmin(self.datasource.data["values"]) high = np.nanmax(self.datasource.data["values"]) frac = percent / 100.0 self.set_limits_minmax(low, high - (1.0 - frac) * (high - low))
@property def cmap(self): """Get the current colormap""" return self._palette @cmap.setter def cmap(self, cmap): """Set colormap""" if isinstance(cmap, str): cmap = palette_from_mpl_name(cmap) self._palette = cmap # might be called in __init__ before color mapper is setup if self._color_mapper is not None: self._color_mapper.palette = cmap self._trigger_cm_update() self.update() def _trigger_cm_update(self): # it seems changing palette does not trigger a color change, # so we reassign a property if isinstance(self._color_mapper, CategoricalColorMapper): self._color_mapper.update(factors=self._color_mapper.factors) else: self._color_mapper.update(low=self._color_mapper.low)
[docs] def clear_overlays(self): """Remove any added overlays from the figure""" while self._annotations: self.figure.renderers.remove(self._annotations.pop()) while self._labels: self.figure.center.remove(self._labels.pop())
[docs] def rescale(self): """Scale pixel colors to min/max range""" low = self.datasource.data["values"].min() high = self.datasource.data["values"].max() # force color to be at lower end of the colormap if # data is all equal if low == high: high += 1 self.set_limits_minmax(low, high)
@property def norm(self): """ The norm instance of the Display Possible values: - "lin": linear scale - "log": log scale (cannot have negative values) - "symlog": symmetric log scale (negative values are ok) """ return self._color_mapper @norm.setter def norm(self, norm): """Set the norm""" if not isinstance(norm, ContinuousColorMapper): if norm == "lin": norm = LinearColorMapper elif norm == "log": norm = LogColorMapper else: raise ValueError(f"Unsupported norm {norm}") self._color_mapper = norm(palette=self.cmap) if self._patches is not None: color = transform("values", self._color_mapper) self._patches.glyph.update(fill_color=color) if self._color_bar is not None: self._color_bar.update(color_mapper=self._color_mapper) self.update()
[docs] class CameraDisplay(BokehPlot): """ CameraDisplay implementation in Bokeh Parameters ---------- geometry: CameraGeometry CameraGeometry for the display image: array Values to display for each pixel cmap: str or bokeh.palette.Palette matplotlib colormap name or bokeh palette for color mapping values norm: str or bokeh. lin, log, symlog or a bokeh.models.ColorMapper instance autoscale: bool Whether to automatically adjust color range after updating image use_notebook: bool or None Whether to use bokehs notebook output. If None, tries to autodetect running in a notebook. **figure_kwargs are passed to bokeh.plots.figure """ def __init__( # same options as MPL display self, geometry: CameraGeometry = None, image=None, cmap="inferno", norm="lin", autoscale=True, title=None, # bokeh specific options use_notebook=None, **figure_kwargs, ): super().__init__( use_notebook=use_notebook, cmap=cmap, norm=norm, autoscale=autoscale, title=title, match_aspect=True, aspect_scale=1, **figure_kwargs, ) self._geometry = geometry self._tap_tool = None if geometry is not None: self._init_datasource(image) if title is None: frame = ( geometry.frame.__class__.__name__ if geometry.frame else "CameraFrame" ) title = f"{geometry} ({frame})" self.figure.title = title # order is important because steps depend on each other self.cmap = cmap self.norm = norm if geometry is not None: self.rescale() self._setup_camera() def _init_datasource(self, image=None): if image is None: image = np.zeros(self._geometry.n_pixels) data = dict( id=self._geometry.pix_id, values=image, line_width=np.zeros(self._geometry.n_pixels), line_color=["green"] * self._geometry.n_pixels, line_alpha=np.zeros(self._geometry.n_pixels), ) self._unit = self._geometry.pix_x.unit if self._geometry.pix_type == PixelShape.HEXAGON: x, y = generate_hex_vertices(self._geometry) elif self._geometry.pix_type == PixelShape.SQUARE: x, y = generate_square_vertices(self._geometry) elif self._geometry.pix_type == PixelShape.CIRCLE: x = self._geometry.pix_x.to_value(self._unit) y = self._geometry.pix_y.to_value(self._unit) data["radius"] = self._geometry.pixel_width.to_value(self._unit) / 2 else: raise NotImplementedError( f"Unsupported pixel shape {self._geometry.pix_type}" ) data["xs"], data["ys"] = x.tolist(), y.tolist() self.datasource.update(data=data) def _setup_camera(self): kwargs = dict( fill_color=dict(field="values", transform=self.norm), line_width="line_width", line_color="line_color", line_alpha="line_alpha", source=self.datasource, ) if self._geometry.pix_type in (PixelShape.SQUARE, PixelShape.HEXAGON): self._patches = self.figure.patches(xs="xs", ys="ys", **kwargs) elif self._geometry.pix_type == PixelShape.CIRCLE: self._patches = self.figure.circle( x="xs", y="ys", radius="radius", **kwargs )
[docs] def enable_pixel_picker(self, callback): """Call ``callback`` when a pixel is clicked""" if self._tap_tool is None: self.figure.add_tools(TapTool()) self.datasource.selected.on_change("indices", callback)
[docs] def highlight_pixels(self, pixels, color="green", linewidth=1, alpha=0.75): """ Highlight the given pixels with a colored line around them Parameters ---------- pixels : index-like The pixels to highlight. Can either be a list or array of integers or a boolean mask of length number of pixels color: a matplotlib conform color the color for the pixel highlighting linewidth: float linewidth of the highlighting in points alpha: 0 <= alpha <= 1 The transparency """ n_pixels = self._geometry.n_pixels pixels = np.asanyarray(pixels) if pixels.dtype != bool: selected = np.zeros(n_pixels, dtype=bool) selected[pixels] = True pixels = selected new_data = {"line_alpha": [(slice(None), pixels.astype(float) * alpha)]} if linewidth != self.datasource.data["line_width"][0]: new_data["line_width"] = [(slice(None), np.full(n_pixels, linewidth))] if color != self.datasource.data["line_color"][0]: new_data["line_color"] = [(slice(None), [color] * n_pixels)] self.datasource.patch(new_data) self.update()
@property def geometry(self): """Get the current geometry""" return self._geometry @geometry.setter def geometry(self, new_geometry): """Set the geometry""" self._geometry = new_geometry if self._patches in self.figure.renderers: self.figure.renderers.remove(self._patches) self._init_datasource() self._setup_camera() self.rescale() self.update() @property def image(self): """Get the current image""" return self.datasource.data["values"] @image.setter def image(self, new_image): """Set the image""" self.datasource.patch({"values": [(slice(None), new_image)]}) if self.autoscale: self.rescale()
[docs] def add_ellipse(self, centroid, length, width, angle, asymmetry=0.0, **kwargs): """ plot an ellipse on top of the camera Parameters ---------- centroid: (float, float) position of centroid length: float major axis width: float minor axis angle: float rotation angle wrt x-axis about the centroid, anticlockwise, in radians asymmetry: float 3rd-order moment for directionality if known kwargs: any MatPlotLib style arguments to pass to the Ellipse patch """ ellipse = Ellipse( x=centroid[0], y=centroid[1], width=length, height=width, angle=angle, fill_color=None, **kwargs, ) glyph = self.figure.add_glyph(ellipse) self._annotations.append(glyph) self.update() return ellipse
[docs] def overlay_moments( self, hillas_parameters, with_label=True, keep_old=False, n_sigma=1, **kwargs ): """helper to overlay ellipse from a `~ctapipe.containers.HillasParametersContainer` structure Parameters ---------- hillas_parameters: `HillasParametersContainer` structuring containing Hillas-style parameterization with_label: bool If True, show coordinates of centroid and width and length keep_old: bool If True, to not remove old overlays kwargs: key=value any style keywords to pass to matplotlib (e.g. color='red' or linewidth=6) """ if not keep_old: self.clear_overlays() params = build_hillas_overlay( hillas_parameters, self._unit, n_sigma=n_sigma, with_label=with_label, ) el = self.add_ellipse( centroid=(params["cog_x"], params["cog_y"]), length=2 * n_sigma * params["length"], width=2 * n_sigma * params["width"], angle=params["psi_rad"], **kwargs, ) if with_label: label = Label( x=params["label_x"], y=params["label_y"], text=params["text"], angle=params["rotation"], angle_units="deg", text_align="center", text_color=el.line_color, ) self.figure.add_layout(label, "center") self._labels.append(label)
[docs] class ArrayDisplay(BokehPlot): """ Display a top-town view of a telescope array. This can be used in two ways: by default, you get a display of all telescopes in the subarray, colored by telescope type, however you can also color the telescopes by a value (like trigger pattern, or some other scalar per-telescope parameter). To set the color value, simply set the ``value`` attribute, and the fill color will be updated with the value. You might want to set the border color to zero to avoid confusion between the telescope type color and the value color ( ``array_disp.telescope.set_linewidth(0)``) To display a vector field over the telescope positions, e.g. for reconstruction, call ``set_uv()`` to set cartesian vectors, or ``set_r_phi()`` to set polar coordinate vectors. These both take an array of length N_tels, or a single value. Parameters ---------- subarray: ctapipe.instrument.SubarrayDescription the array layout to display values: array Value to display for each telescope. If None, the telescope type will be used. scale: float scaling between telescope mirror radius in m to displayed size title: str title of array plot alpha: float Alpha value for telescopes cmap: str or bokeh.palette.Palette matplotlib colormap name or bokeh palette for color mapping values norm: str or bokeh. lin, log, symlog or a bokeh.models.ColorMapper instance radius: Union[float, list, None] set telescope radius to value, list/array of values. If None, radius is taken from the telescope's mirror size. use_notebook: bool or None Whether to use bokehs notebook output. If None, tries to autodetect running in a notebook. frame: GroundFrame or TiltedGroundFrame If given, transform telescope positions into this frame **figure_kwargs are passed to bokeh.plots.figure """ def __init__( self, subarray, values=None, scale=5.0, alpha=1.0, title=None, cmap=None, norm="lin", radius=None, use_notebook=None, frame=None, **figure_kwargs, ): if title is None: frame_name = (frame or subarray.tel_coords.frame).__class__.__name__ title = f"{subarray.name} ({frame_name})" # color by type if no value given if values is None: types = list({str(t) for t in subarray.telescope_types}) cmap = cmap or d3["Category10"][10][: len(types)] field = "type" else: cmap = "inferno" field = "values" super().__init__( use_notebook=use_notebook, title=title, match_aspect=True, aspect_scale=1, cmap=cmap, norm=norm, **figure_kwargs, ) if values is None: self._color_mapper = CategoricalColorMapper(palette=cmap, factors=types) self.frame = frame self.subarray = subarray self._init_datasource( subarray, values=values, radius=radius, frame=frame, scale=scale, alpha=alpha, ) color = transform(field_name=field, transform=self._color_mapper) self._patches = self.figure.circle( x="x", y="y", radius="radius", alpha="alpha", line_alpha="alpha", fill_color=color, line_color=color, source=self.datasource, legend_field="type", ) self.figure.add_tools( HoverTool(tooltips=[("id", "@id"), ("type", "@type"), ("z", "@z")]) ) self.figure.legend.orientation = "horizontal" self.figure.legend.location = "top_left" def _init_datasource(self, subarray, values, *, radius, frame, scale, alpha): telescope_ids = subarray.tel_ids tel_coords = subarray.tel_coords # get the telescope positions. If a new frame is set, this will # transform to the new frame. if frame is not None: tel_coords = tel_coords.transform_to(frame) tel_types = [] mirror_radii = np.zeros(len(telescope_ids)) for i, telescope_id in enumerate(telescope_ids): telescope = subarray.tel[telescope_id] tel_types.append(str(telescope)) mirror_area = telescope.optics.mirror_area.to_value(u.m**2) mirror_radii[i] = np.sqrt(mirror_area) / np.pi if values is None: values = np.zeros(len(subarray)) if np.isscalar(alpha): alpha = np.full(len(telescope_ids), alpha) else: alpha = np.array(alpha) data = { "id": telescope_ids, "x": tel_coords.x.to_value(u.m).tolist(), "y": tel_coords.y.to_value(u.m).tolist(), "z": tel_coords.z.to_value(u.m).tolist(), "alpha": alpha.tolist(), "values": values, "type": tel_types, "mirror_radius": mirror_radii.tolist(), "radius": (radius if radius is not None else mirror_radii * scale).tolist(), } self.datasource.update(data=data) @property def values(self): """Get the current image""" return self.datasource.data["values"] @values.setter def values(self, new_values): """Set the image""" # currently displaying telescope types if self._patches.glyph.fill_color["field"] == "type": self.norm = "lin" self.cmap = "inferno" color = transform(field_name="values", transform=self._color_mapper) self._patches.glyph.update(fill_color=color, line_color=color) # recreate color bar, updating does not work here if self._color_bar is not None: self.figure.right.remove(self._color_bar) self.add_colorbar() self.update() self.datasource.patch({"values": [(slice(None), new_values)]}) if self.autoscale: self.rescale()