class ArrayDisplay: """ Display a top-town view of a telescope array """ def __init__(self, telx, tely, mirrorarea, axes=None, title="Array", autoupdate=True): patches = [Circle(xy=(x, y), radius=np.sqrt(a)) for x, y, a in zip(telx, tely, mirrorarea)] self.autoupdate = autoupdate self.telescopes = PatchCollection(patches) self.telescopes.set_clim(0, 100) self.telescopes.set_array(np.zeros(len(telx))) self.telescopes.set_cmap('spectral_r') self.telescopes.set_edgecolor('none') self.axes = axes if axes is not None else plt.gca() self.axes.add_collection(self.telescopes) self.axes.set_aspect(1.0) self.axes.set_title(title) self.axes.set_xlim(-1000, 1000) self.axes.set_ylim(-1000, 1000) self.bar = plt.colorbar(self.telescopes) self.bar.set_label("Value") @property def values(self): """An array containing a value per telescope""" return self.telescopes.get_array() @values.setter def values(self, values): """ set the telescope colors to display """ self.telescopes.set_array(values) self._update() def _update(self): """ signal a redraw if necessary """ if self.autoupdate: plt.draw()
class ArrayDisplay: """ 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 axes: matplotlib.axes.Axes matplotlib axes to plot on, or None to use current one title: str title of array plot tel_scale: float scaling between telescope mirror radius in m to displayed size autoupdate: bool redraw when the input changes 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. """ def __init__(self, subarray, axes=None, autoupdate=True, tel_scale=2.0, alpha=0.7, title=None, radius=None, frame=GroundFrame()): self.frame = frame self.subarray = subarray # get the telescope positions. If a new frame is set, this will # transform to the new frame. self.tel_coords = subarray.tel_coords.transform_to(frame) # set up colors per telescope type tel_types = [str(tel) for tel in subarray.tels.values()] if radius is None: # set radius to the mirror radius (so big tels appear big) radius = [np.sqrt(tel.optics.mirror_area.to("m2").value) * tel_scale for tel in subarray.tel.values()] if title is None: title = subarray.name # get default matplotlib color cycle (depends on the current style) color_cycle = cycle(plt.rcParams['axes.prop_cycle'].by_key()['color']) # map a color to each telescope type: tel_type_to_color = {} for tel_type in list(set(tel_types)): tel_type_to_color[tel_type] = next(color_cycle) tel_color = [tel_type_to_color[ttype] for ttype in tel_types] patches = [] for x, y, r, c in zip(list(self.tel_coords.x.value), list(self.tel_coords.y.value), list(radius), tel_color): patches.append( Circle( xy=(x, y), radius=r, fill=True, color=c, alpha=alpha, ) ) # build the legend: legend_elements = [] for ttype in list(set(tel_types)): color = tel_type_to_color[ttype] legend_elements.append( Line2D([0], [0], marker='o', color=color, label=ttype, markersize=10, alpha=alpha, linewidth=0) ) plt.legend(handles=legend_elements) self.tel_colors = tel_color self.autoupdate = autoupdate self.telescopes = PatchCollection(patches, match_original=True) self.telescopes.set_linewidth(2.0) self.axes = axes or plt.gca() self.axes.add_collection(self.telescopes) self.axes.set_aspect(1.0) self.axes.set_title(title) self._labels = [] self._quiver = None self.axes.autoscale_view() @property def values(self): """An array containing a value per telescope""" return self.telescopes.get_array() @values.setter def values(self, values): """ set the telescope colors to display """ self.telescopes.set_array(values) self._update() def set_vector_uv(self, u, v, c=None, **kwargs): """ sets the vector field U,V and color for all telescopes Parameters ---------- u: array[num_tels] x-component of direction vector v: array[num_tels] y-component of direction vector c: color or list of colors vector color for each telescope (or one for all) kwargs: extra args passed to plt.quiver(), ignored on subsequent updates """ if c is None: c = self.tel_colors if self._quiver is None: coords = self.tel_coords self._quiver = self.axes.quiver( coords.x, coords.y, u, v, color=c, scale_units='xy', angles='xy', scale=1, **kwargs ) else: self._quiver.set_UVC(u, v) def set_vector_rho_phi(self, rho, phi, c=None, **kwargs): """sets the vector field using R, Phi for each telescope Parameters ---------- rho: float or array[float] vector magnitude for each telescope phi: array[Angle] vector angle for each telescope c: color or list of colors vector color for each telescope (or one for all) """ phi = Angle(phi).rad u, v = polar_to_cart(rho, phi) self.set_vector_uv(u, v, c=c, **kwargs) def set_vector_hillas(self, hillas_dict, length, time_gradient, angle_offset): """ Function to set the vector angle and length from a set of Hillas parameters. In order to proper use the arrow on the ground, also a dictionary with the time gradients for the different telescopes is needed. If the gradient is 0 the arrow is not plotted on the ground, whereas if the value of the gradient is negative, the arrow is rotated by 180 degrees (Angle(angle_offset) not added). This plotting behaviour has been tested with the timing_parameters function in ctapipe/image. Parameters ---------- hillas_dict: Dict[int, HillasParametersContainer] mapping of tel_id to Hillas parameters length: Float length of the arrow (in meters) time_gradient: Dict[int, value of time gradient (no units)] dictionary for value of the time gradient for each telescope angle_offset: Float This should be the event.mcheader.run_array_direction[0] parameter """ # rot_angle_ellipse is psi parameter in HillasParametersContainer rho = np.zeros(self.subarray.num_tels) * u.m rot_angle_ellipse = np.zeros(self.subarray.num_tels) * u.deg for tel_id, params in hillas_dict.items(): idx = self.subarray.tel_indices[tel_id] rho[idx] = length * u.m if time_gradient[tel_id] > 0.01: params.psi = Angle(params.psi) angle_offset = Angle(angle_offset) rot_angle_ellipse[idx] = params.psi + angle_offset + 180 * u.deg elif time_gradient[tel_id] < -0.01: rot_angle_ellipse[idx] = params.psi + angle_offset else: rho[idx] = 0 * u.m self.set_vector_rho_phi(rho=rho, phi=rot_angle_ellipse) def set_line_hillas(self, hillas_dict, range, **kwargs): """ Function to plot a segment of length 2*range for each telescope from a set of Hillas parameters. The segment is centered on the telescope position. A point is added at each telescope position for better visualization. Parameters ---------- hillas_dict: Dict[int, HillasParametersContainer] mapping of tel_id to Hillas parameters range: float half of the length of the segments to be plotted (in meters) """ coords = self.tel_coords c = self.tel_colors for tel_id, params in hillas_dict.items(): idx = self.subarray.tel_indices[tel_id] x_0 = coords[idx].x.value y_0 = coords[idx].y.value m = np.tan(Angle(params.psi)) x = x_0 + np.linspace(-range, range, 50) y = y_0 + m * (x - x_0) distance = np.sqrt((x - x_0) ** 2 + (y - y_0) ** 2) mask = np.ma.masked_where(distance < range, distance).mask self.axes.plot(x[mask], y[mask], color=c[idx], **kwargs) self.axes.scatter(x_0, y_0, color=c[idx]) def add_labels(self): px = self.tel_coords.x.value py = self.tel_coords.y.value for tel, x, y in zip(self.subarray.tels, px, py): name = str(tel) lab = self.axes.text(x, y, name, fontsize=8, clip_on=True) self._labels.append(lab) def remove_labels(self): for lab in self._labels: lab.remove() self._labels = [] def _update(self): """ signal a redraw if necessary """ if self.autoupdate: plt.draw() def background_contour(self, x, y, background, **kwargs): """ Draw image contours in background of the display, useful when likelihood fitting Parameters ---------- x: ndarray array of image X coordinates y: ndarray array of image Y coordinates background: ndarray Array of image to use in background kwargs: key=value any style keywords to pass to matplotlib """ # use zorder to ensure the contours appear under the telescopes. self.axes.contour(x, y, background, zorder=0, **kwargs)
class ArrayDisplay: """ Display a top-town view of a telescope array """ def __init__(self, telx, tely, tel_type=None, radius=20, axes=None, title="Array", autoupdate=True): if tel_type is None: tel_type = np.ones(len(telx)) patches = [Rectangle(xy=(x-radius/2, y-radius/2), width=radius, height=radius, fill=False) for x, y in zip(telx, tely)] self.autoupdate = autoupdate self.telescopes = PatchCollection(patches, match_original=True) self.telescopes.set_clim(1, 9) rgb = matplotlib.cm.Set1((tel_type-1)/9) self.telescopes.set_edgecolor(rgb) self.telescopes.set_linewidth(2.0) self.axes = axes if axes is not None else plt.gca() self.axes.add_collection(self.telescopes) self.axes.set_aspect(1.0) self.axes.set_title(title) self.axes.set_xlim(-1000, 1000) self.axes.set_ylim(-1000, 1000) self.axes_hillas = axes if axes is not None else plt.gca() @property def values(self): """An array containing a value per telescope""" return self.telescopes.get_array() @values.setter def values(self, values): """ set the telescope colors to display """ self.telescopes.set_array(values) self._update() def _update(self): """ signal a redraw if necessary """ if self.autoupdate: plt.draw() def add_ellipse(self, centroid, length, width, angle, **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(xy=centroid, width=length, height=width, angle=np.degrees(angle), fill=True, **kwargs) self.axes.add_patch(ellipse) return ellipse def add_polygon(self, centroid, radius, nsides=3, **kwargs): """ plot a polygon on top of the camera Parameters ---------- centroid: (float, float) position of centroid radius: float radius nsides: int Number of points on polygon kwargs: any MatPlotLib style arguments to pass to the RegularPolygon patch """ polygon = RegularPolygon(xy=centroid, radius=radius, numVertices=nsides, **kwargs) self.axes.add_patch(polygon) return polygon def overlay_moments(self, momparams, tel_position, scale_fac, **kwargs): """helper to overlay ellipse from a `reco.MomentParameters` structure Parameters ---------- momparams: `reco.MomentParameters` structuring containing Hillas-style parameterization tel_position: list (x, y) positions of each telescope scale_fac: float scaling factor to apply to width and length when overlaying moments kwargs: key=value any style keywords to pass to matplotlib (e.g. color='red' or linewidth=6) """ # strip off any units ellipse_list = list() size_list = list() i = 0 for h in momparams: length = u.Quantity(momparams[h].length).value * scale_fac width = u.Quantity(momparams[h].width).value * scale_fac size_list.append(u.Quantity(momparams[h].size).value) tel_x = u.Quantity(tel_position[0][i]).value tel_y = u.Quantity(tel_position[1][i]).value i += 1 ellipse = Ellipse(xy=(tel_x,tel_y), width=length, height=width, angle=np.degrees(momparams[h].psi.rad)) ellipse_list.append(ellipse) patches = PatchCollection(ellipse_list, **kwargs) patches.set_clim(0, 1000) # Set ellipse colour based on image size patches.set_array(np.asarray(size_list)) self.axes_hillas.add_collection(patches) def overlay_axis(self, momparams, tel_position, **kwargs): """helper to overlay ellipse from a `reco.MomentParameters` structure Parameters ---------- momparams: `reco.MomentParameters` structuring containing Hillas-style parameterization tel_position: list (x, y) positions of each telescope kwargs: key=value any style keywords to pass to matplotlib (e.g. color='red' or linewidth=6) """ # strip off any units i = 0 for h in momparams: tel_x = u.Quantity(tel_position[0][i]).value tel_y = u.Quantity(tel_position[1][i]).value psi = u.Quantity(momparams[h].psi).value x_sc = [tel_x - np.cos(psi) * 10000, tel_x + np.cos(psi) * 10000] y_sc = [tel_y - np.sin(psi) * 10000, tel_y + np.sin(psi) * 10000] i += 1 self.axes_hillas.add_line(Line2D(x_sc, y_sc, linestyle='dashed', color='black'))
class CameraDisplay: """ Camera Display using matplotlib. Parameters ---------- geometry : `~ctapipe.instrument.CameraGeometry` Definition of the Camera/Image image: array_like array of values corresponding to the pixels in the CameraGeometry. ax : `matplotlib.axes.Axes` A matplotlib axes object to plot on, or None to create a new one title : str (default "Camera") Title to put on camera plot norm : str or `matplotlib.color.Normalize` instance (default 'lin') Normalization for the color scale. Supported str arguments are - 'lin': linear scale - 'log': logarithmic scale (base 10) cmap : str or `matplotlib.colors.Colormap` (default 'hot') Color map to use (see `matplotlib.cm`) allow_pick : bool (default False) if True, allow user to click and select a pixel autoupdate : bool (default True) redraw automatically (otherwise need to call plt.draw()) autoscale : bool (default True) rescale the vmin/vmax values when the image changes. This is set to False if `set_limits_*` is called to explicity set data limits. antialiased : bool (default True) whether to draw in antialiased mode or not. Notes ----- Speed: CameraDisplay is not intended to be very fast (matplotlib is not a very speed performant graphics library, it is intended for nice output plots). However, most of the slowness of CameraDisplay is in the constructor. Once one is displayed, changing the image that is displayed is relatively fast and efficient. Therefore it is best to initialize an instance, and change the data, rather than generating new CameraDisplays. Pixel Implementation: Pixels are rendered as a `matplotlib.collections.PatchCollection` of Polygons (either 6 or 4 sided). You can access the PatchCollection directly (to e.g. change low-level style parameters) via `CameraDisplay.pixels` Output: Since CameraDisplay uses matplotlib, any display can be saved to any output file supported via plt.savefig(filename). This includes `.pdf` and `.png`. """ def __init__( self, geometry, image=None, ax=None, title=None, norm="lin", cmap=None, allow_pick=False, autoupdate=True, autoscale=True, antialiased=True, ): self.axes = ax if ax is not None else plt.gca() self.geom = geometry self.pixels = None self.colorbar = None self.autoupdate = autoupdate self.autoscale = autoscale self._active_pixel = None self._active_pixel_label = None if title is None: title = geometry.cam_id # initialize the plot and generate the pixels as a # RegularPolyCollection patches = [] if not hasattr(self.geom, "mask"): self.geom.mask = np.ones_like(self.geom.pix_x.value, dtype=bool) for xx, yy, aa in zip( u.Quantity(self.geom.pix_x[self.geom.mask]).value, u.Quantity(self.geom.pix_y[self.geom.mask]).value, u.Quantity(np.array(self.geom.pix_area)[self.geom.mask]).value): if self.geom.pix_type.startswith("hex"): rr = sqrt(aa * 2 / 3 / sqrt(3)) + 2*PIXEL_EPSILON poly = RegularPolygon( (xx, yy), 6, radius=rr, orientation=self.geom.pix_rotation.rad, fill=True, ) else: rr = sqrt(aa) + PIXEL_EPSILON poly = Rectangle( (xx-rr/2., yy-rr/2.), width=rr, height=rr, angle=self.geom.pix_rotation.deg, fill=True, ) patches.append(poly) self.pixels = PatchCollection(patches, cmap=cmap, linewidth=0) self.axes.add_collection(self.pixels) self.pixel_highlighting = copy.copy(self.pixels) self.pixel_highlighting.set_facecolor('none') self.pixel_highlighting.set_linewidth(0) self.axes.add_collection(self.pixel_highlighting) # Set up some nice plot defaults self.axes.set_aspect('equal', 'datalim') self.axes.set_title(title) self.axes.set_xlabel("X position ({})".format(self.geom.pix_x.unit)) self.axes.set_ylabel("Y position ({})".format(self.geom.pix_y.unit)) self.axes.autoscale_view() # set up a patch to display when a pixel is clicked (and # pixel_picker is enabled): self._active_pixel = copy.copy(patches[0]) self._active_pixel.set_facecolor('r') self._active_pixel.set_alpha(0.5) self._active_pixel.set_linewidth(2.0) self._active_pixel.set_visible(False) self.axes.add_patch(self._active_pixel) self._active_pixel_label = self.axes.text(self._active_pixel.xy[0], self._active_pixel.xy[1], "0", horizontalalignment='center', verticalalignment='center') self._active_pixel_label.set_visible(False) # enable ability to click on pixel and do something (can be # enabled on-the-fly later as well: if allow_pick: self.enable_pixel_picker() if image is not None: self.image = image else: self.image = np.zeros_like(self.geom.pix_id, dtype=np.float) self.norm = norm def highlight_pixels(self, pixels, color='g', 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 ''' l = np.zeros_like(self.image) l[pixels] = linewidth self.pixel_highlighting.set_linewidth(l) self.pixel_highlighting.set_alpha(alpha) self.pixel_highlighting.set_edgecolor(color) self._update() def enable_pixel_picker(self): """ enable ability to click on pixels """ self.pixels.set_picker(True) # enable click self.pixels.set_pickradius(sqrt(u.Quantity(self.geom.pix_area[0]) .value) / np.pi) self.pixels.set_snap(True) # snap cursor to pixel center self.axes.figure.canvas.mpl_connect('pick_event', self._on_pick) def set_limits_minmax(self, zmin, zmax): """ set the color scale limits from min to max """ self.pixels.set_clim(zmin, zmax) self.autoscale = False self._update() def set_limits_percent(self, percent=95): """ auto-scale the color range to percent of maximum """ zmin = self.pixels.get_array().min() zmax = self.pixels.get_array().max() dz = zmax - zmin frac = percent / 100.0 self.autoscale = False self.set_limits_minmax(zmin, zmax - (1.0 - frac) * dz) @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) - any matplotlib.colors.Normalize instance, e. g. PowerNorm(gamma=-2) ''' return self.pixels.norm @norm.setter def norm(self, norm): if norm == 'lin': self.pixels.norm = Normalize() elif norm == 'log': self.pixels.norm = LogNorm() self.pixels.autoscale() # this is to handle matplotlib bug #5424 elif norm == 'symlog': self.pixels.norm = SymLogNorm(linthresh=1.0) self.pixels.autoscale() elif isinstance(norm, Normalize): self.pixels.norm = norm else: raise ValueError("Unsupported norm: '{}', options are 'lin'," "'log','symlog', or a matplotlib Normalize object" .format(norm)) self.update(force=True) self.pixels.autoscale() @property def cmap(self): """ Color map to use. Either a name or `matplotlib.colors.ColorMap` instance, e.g. from `matplotlib.pyplot.cm` """ return self.pixels.get_cmap() @cmap.setter def cmap(self, cmap): self.pixels.set_cmap(cmap) self._update() @property def image(self): """The image displayed on the camera (1D array of pixel values)""" return self.pixels.get_array() @image.setter def image(self, image): """ Change the image displayed on the Camera. Parameters ---------- image: array_like array of values corresponding to the pixels in the CameraGeometry. """ image = np.asanyarray(image) if image.shape != self.geom.pix_x.shape: raise ValueError( "Image has a different shape {} than the " "given CameraGeometry {}" .format(image.shape, self.geom.pix_x.shape) ) self.pixels.set_array(image[self.geom.mask]) self.pixels.changed() if self.autoscale: self.pixels.autoscale() self._update() def _update(self, force=False): """ signal a redraw if autoupdate is turned on """ if self.autoupdate: self.update(force) def update(self, force=False): """ redraw the display now """ self.axes.figure.canvas.draw() if self.colorbar is not None: if force is True: self.colorbar.update_bruteforce(self.pixels) else: self.colorbar.update_normal(self.pixels) self.colorbar.draw_all() def add_colorbar(self, **kwargs): """ add a colobar to the camera plot kwargs are passed to `figure.colorbar(self.pixels, **kwargs)` See matplotlib documentation for the supported kwargs: http://matplotlib.org/api/figure_api.html#matplotlib.figure.Figure.colorbar """ if self.colorbar is not None: raise ValueError( 'There is already a colorbar attached to this CameraDisplay' ) else: self.colorbar = self.axes.figure.colorbar(self.pixels, **kwargs) self.update() 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(xy=centroid, width=length, height=width, angle=np.degrees(angle), fill=False, **kwargs) self.axes.add_patch(ellipse) self.update() return ellipse def overlay_moments(self, momparams, with_label=True, **kwargs): """helper to overlay ellipse from a `reco.MomentParameters` structure Parameters ---------- momparams: `reco.MomentParameters` structuring containing Hillas-style parameterization kwargs: key=value any style keywords to pass to matplotlib (e.g. color='red' or linewidth=6) """ # strip off any units cen_x = u.Quantity(momparams.cen_x).value cen_y = u.Quantity(momparams.cen_y).value length = u.Quantity(momparams.length).value width = u.Quantity(momparams.width).value el = self.add_ellipse(centroid=(cen_x, cen_y), length=length*2, width=width*2, angle=momparams.psi.rad, **kwargs) if with_label: self.axes.text(cen_x, cen_y, ("({:.02f},{:.02f})\n" "[w={:.02f},l={:.02f}]") .format(momparams.cen_x, momparams.cen_y, momparams.width, momparams.length), color=el.get_edgecolor()) def _on_pick(self, event): """ handler for when a pixel is clicked """ pix_id = event.ind[-1] xx, yy, aa = u.Quantity(self.geom.pix_x[pix_id]).value, \ u.Quantity(self.geom.pix_y[pix_id]).value, \ u.Quantity(np.array(self.geom.pix_area)[pix_id]) if self.geom.pix_type.startswith("hex"): self._active_pixel.xy = (xx, yy) else: rr = sqrt(aa) self._active_pixel.xy = (xx - rr / 2., yy - rr / 2.) self._active_pixel.set_visible(True) self._active_pixel_label.set_x(xx) self._active_pixel_label.set_y(yy) self._active_pixel_label.set_text("{:003d}".format(pix_id)) self._active_pixel_label.set_visible(True) self._update() self.on_pixel_clicked(pix_id) # call user-function def on_pixel_clicked(self, pix_id): """virtual function to overide in sub-classes to do something special when a pixel is clicked """ print("Clicked pixel_id {}".format(pix_id)) def show(self): self.axes.figure.show()
class CameraPlot(object): '''A Class for a camera pixel''' def __init__( self, telescope, ax, data=None, cmap='gray', vmin=None, vmax=None, ): ''' :telescope: the telescope class for the pixel :data: array-like with one value for each pixel :cmap: a matpixellib colormap string or instance :vmin: minimum value of the colormap :vmax: maximum value of the colormap ''' self.telescope = telescope if data is None: data = np.zeros(telescope.n_pixel) patches = [] if telescope.pixel_shape == 'hexagon': for xy in zip(telescope.pixel_x, telescope.pixel_y): patches.append( RegularPolygon( xy=xy, numVertices=6, radius=telescope.pixel_size, orientation=telescope.pixel_orientation, ) ) self.pixel = PatchCollection(patches) self.pixel.set_linewidth(0) self.pixel.set_cmap(cmap) self.pixel.set_array(data) self.pixel.set_clim(vmin, vmax) self.vmin = vmin self.vmax = vmax self.ax = ax self.ax.add_collection(self.pixel) self.ax.set_xlim( self.telescope.pixel_x.min() - 2 * self.telescope.pixel_size, self.telescope.pixel_x.max() + 2 * self.telescope.pixel_size, ) self.ax.set_ylim( self.telescope.pixel_y.min() - 2 * self.telescope.pixel_size, self.telescope.pixel_y.max() + 2 * self.telescope.pixel_size, ) @property def data(self): return self.pixel.get_array() @data.setter def data(self, data): self.pixel.set_array(data) if not self.vmin or not self.vmax: self.pixel.autoscale() self.pixel.changed()
class CameraDisplay: """Camera Display using matplotlib. Parameters ---------- geometry : `~ctapipe.io.CameraGeometry` Definition of the Camera/Image image: array_like array of values corresponding to the pixels in the CameraGeometry. ax : `matplotlib.axes.Axes` A matplotlib axes object to plot on, or None to create a new one title : str Title to put on camera plot allow_pick : bool (default False) if True, allow user to click and select a pixel autoupdate : bool (default True) redraw automatically (otherwise need to call plt.draw()) antialiased : bool (default True) whether to draw in antialiased mode or not. Notes ----- Speed: CameraDisplay is not intended to be very fast (matplotlib is not a very speed performant graphics library, it is intended for nice output plots). However, most of the slowness of CameraDisplay is in the constructor. Once one is displayed, changing the image that is displayed is relatively fast and efficient. Therefore it is best to initialize an instance, and change the data, rather than generating new CameraDisplays. Pixel Implementation: Pixels are rendered as a `matplotlib.collections.PatchCollection` of Polygons (either 6 or 4 sided). You can access the PatchCollection directly (to e.g. change low-level style parameters) via `CameraDisplay.pixels` Output: Since CameraDisplay uses matplotlib, any display can be saved to any output file supported via plt.savefig(filename). This includes `.pdf` and `.png`. """ def __init__(self, geometry, image=None, ax=None, title="Camera", allow_pick=False, autoupdate=True, antialiased=True): self.axes = ax if ax is not None else plt.gca() self.geom = geometry self.pixels = None self.autoupdate = autoupdate self._active_pixel = None self._active_pixel_label = None # initialize the plot and generate the pixels as a # RegularPolyCollection patches = [] for xx, yy, aa in zip(u.Quantity(self.geom.pix_x).value, u.Quantity(self.geom.pix_y).value, u.Quantity(np.array(self.geom.pix_area))): if self.geom.pix_type.startswith("hex"): rr = sqrt(aa * 2 / 3 / sqrt(3)) poly = RegularPolygon((xx, yy), 6, radius=rr, orientation=np.radians(0), fill=True) else: rr = sqrt(aa) poly = Rectangle((xx, yy), width=rr, height=rr, angle=np.radians(0), fill=True) patches.append(poly) self.pixels = PatchCollection(patches, cmap='hot', linewidth=0) self.axes.add_collection(self.pixels) # Set up some nice plot defaults self.axes.set_aspect('equal', 'datalim') self.axes.set_title(title) self.axes.set_xlabel("X position ({})".format(self.geom.pix_x.unit)) self.axes.set_ylabel("Y position ({})".format(self.geom.pix_y.unit)) self.axes.autoscale_view() # set up a patch to display when a pixel is clicked (and # pixel_picker is enabled): self._active_pixel = copy.copy(patches[0]) self._active_pixel.set_facecolor('r') self._active_pixel.set_alpha(0.5) self._active_pixel.set_linewidth(2.0) self._active_pixel.set_visible(False) self.axes.add_patch(self._active_pixel) self._active_pixel_label = plt.text(self._active_pixel.xy[0], self._active_pixel.xy[1], "0", horizontalalignment='center', verticalalignment='center') self._active_pixel_label.set_visible(False) # enable ability to click on pixel and do something (can be # enabled on-the-fly later as well: if allow_pick: self.enable_pixel_picker() if image is not None: self.image = image else: self.image = np.zeros_like(self.geom.pix_id, dtype=np.float) def enable_pixel_picker(self): """ enable ability to click on pixels """ self.pixels.set_picker(True) # enable click self.pixels.set_pickradius(sqrt(u.Quantity(self.geom.pix_area[0]) .value) / np.pi) self.pixels.set_snap(True) # snap cursor to pixel center self.axes.figure.canvas.mpl_connect('pick_event', self._on_pick) def set_limits_minmax(self, zmin, zmax): """ set the color scale limits from min to max """ self.pixels.set_clim(zmin, zmax) self.update() def set_limits_percent(self, percent=95): """ auto-scale the color range to percent of maximum """ zmin = self.pixels.get_array().min() zmax = self.pixels.get_array().max() dz = zmax - zmin frac = percent / 100.0 self.set_limits_minmax(zmin, zmax - (1.0 - frac) * dz) @property def cmap(self): return self.pixels.get_cmap() @cmap.setter def cmap(self, cmap): """ Change the color map Parameters ---------- self: type description cmap: `matplotlib.colors.ColorMap` a color map, e.g. from `matplotlib.pyplot.cm.*` """ self.pixels.set_cmap(cmap) self.update() @property def image(self): return self.pixels.get_array() @image.setter def image(self, image): """ Change the image displayed on the Camera. Parameters ---------- image: array_like array of values corresponding to the pixels in the CameraGeometry. """ image = np.asanyarray(image) if image.shape != self.geom.pix_x.shape: raise ValueError( "Image has a different shape {} than the" "given CameraGeometry {}" .format(image.shape, self.geom.pix_x.shape) ) self.pixels.set_array(image) self.axes._sci(self.pixels) self.update() def set_image(self, image): logger.warn("set_image(x) is deprecated:" " use CameraDisplay.image = x instead") self.image = image def update(self): """ signal a redraw if necessary """ if self.autoupdate: plt.draw() def add_colorbar(self): """ add a colobar to the camera plot """ self.axes.figure.colorbar(self.pixels) 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 "up" about the centroid, clockwise, in radians asymmetry: float 3rd-order moment for directionality if known kwargs: any MatPlotLib style arguments to pass to the Ellipse patch """ ellipse = Ellipse(xy=centroid, width=width, height=length, angle=np.degrees(angle), fill=False, **kwargs) self.axes.add_patch(ellipse) self.update() return ellipse def overlay_moments(self, momparams, **kwargs): """helper to overlay ellipse from a `reco.MomentParameters` structure Parameters ---------- momparams: `reco.MomentParameters` structuring containing Hillas-style parameterization kwargs: key=value any style keywords to pass to matplotlib (e.g. color='red' or linewidth=6) """ el = self.add_ellipse(centroid=(momparams.cen_x, momparams.cen_y), length=momparams.length, width=momparams.width, angle=momparams.psi, **kwargs) self.axes.text(momparams.cen_x, momparams.cen_y, ("({:.02f},{:.02f})\n" "[w={:.02f},l={:.02f}]") .format(momparams.cen_x, momparams.cen_y, momparams.width, momparams.length), color=el.get_edgecolor()) def _on_pick(self, event): """ handler for when a pixel is clicked """ pix_id = event.ind.pop() xx, yy = u.Quantity(self.geom.pix_x[pix_id]).value,\ u.Quantity(self.geom.pix_y[pix_id]).value self._active_pixel.xy = (xx, yy) self._active_pixel.set_visible(True) self._active_pixel_label.set_x(xx) self._active_pixel_label.set_y(yy) self._active_pixel_label.set_text("{:003d}".format(pix_id)) self._active_pixel_label.set_visible(True) self.update() self.on_pixel_clicked(pix_id) # call user-function def on_pixel_clicked(self, pix_id): """virtual function to overide in sub-classes to do something special when a pixel is clicked """ print("Clicked pixel_id {}".format(pix_id))
class ArrayDisplay: """ 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 axes: matplotlib.axes.Axes matplotlib axes to plot on, or None to use current one title: str title of array plot tel_scale: float scaling between telescope mirror radius in m to displayed size autoupdate: bool redraw when the input changes 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. """ def __init__(self, subarray, axes=None, autoupdate=True, tel_scale=2.0, alpha=0.7, title=None, radius=None, frame=GroundFrame()): self.frame = frame self.subarray = subarray # get the telescope positions. If a new frame is set, this will # transform to the new frame. self.tel_coords = subarray.tel_coords.transform_to(frame) # set up colors per telescope type tel_types = [str(tel) for tel in subarray.tels.values()] if radius is None: # set radius to the mirror radius (so big tels appear big) radius = [ np.sqrt(tel.optics.mirror_area.to("m2").value) * tel_scale for tel in subarray.tel.values() ] if title is None: title = subarray.name # get default matplotlib color cycle (depends on the current style) color_cycle = cycle(plt.rcParams['axes.prop_cycle'].by_key()['color']) # map a color to each telescope type: tel_type_to_color = {} for tel_type in list(set(tel_types)): tel_type_to_color[tel_type] = next(color_cycle) tel_color = [tel_type_to_color[ttype] for ttype in tel_types] patches = [] for x, y, r, c in zip(list(self.tel_coords.x.value), list(self.tel_coords.y.value), list(radius), tel_color): patches.append( Circle( xy=(x, y), radius=r, fill=True, color=c, alpha=alpha, )) # build the legend: legend_elements = [] for ttype in list(set(tel_types)): color = tel_type_to_color[ttype] legend_elements.append( Line2D([0], [0], marker='o', color=color, label=ttype, markersize=10, alpha=alpha, linewidth=0)) plt.legend(handles=legend_elements) self.tel_colors = tel_color self.autoupdate = autoupdate self.telescopes = PatchCollection(patches, match_original=True) self.telescopes.set_linewidth(2.0) self.axes = axes or plt.gca() self.axes.add_collection(self.telescopes) self.axes.set_aspect(1.0) self.axes.set_title(title) self._labels = [] self._quiver = None self.axes.autoscale_view() @property def values(self): """An array containing a value per telescope""" return self.telescopes.get_array() @values.setter def values(self, values): """ set the telescope colors to display """ self.telescopes.set_array(values) self._update() def set_vector_uv(self, u, v, c=None, **kwargs): """ sets the vector field U,V and color for all telescopes Parameters ---------- u: array[num_tels] x-component of direction vector v: array[num_tels] y-component of direction vector c: color or list of colors vector color for each telescope (or one for all) kwargs: extra args passed to plt.quiver(), ignored on subsequent updates """ if c is None: c = self.tel_colors if self._quiver is None: coords = self.tel_coords self._quiver = self.axes.quiver(coords.x, coords.y, u, v, color=c, scale_units='xy', angles='xy', scale=1, **kwargs) else: self._quiver.set_UVC(u, v) def set_vector_rho_phi(self, rho, phi, c=None, **kwargs): """sets the vector field using R, Phi for each telescope Parameters ---------- rho: float or array[float] vector magnitude for each telescope phi: array[Angle] vector angle for each telescope c: color or list of colors vector color for each telescope (or one for all) """ phi = Angle(phi).rad u, v = polar_to_cart(rho, phi) self.set_vector_uv(u, v, c=c, **kwargs) def set_vector_hillas(self, hillas_dict, length, time_gradient, angle_offset): """ Function to set the vector angle and length from a set of Hillas parameters. In order to proper use the arrow on the ground, also a dictionary with the time gradients for the different telescopes is needed. If the gradient is 0 the arrow is not plotted on the ground, whereas if the value of the gradient is negative, the arrow is rotated by 180 degrees (Angle(angle_offset) not added). This plotting behaviour has been tested with the timing_parameters function in ctapipe/image. Parameters ---------- hillas_dict: Dict[int, HillasParametersContainer] mapping of tel_id to Hillas parameters length: Float length of the arrow (in meters) time_gradient: Dict[int, value of time gradient (no units)] dictionary for value of the time gradient for each telescope angle_offset: Float This should be the event.mcheader.run_array_direction[0] parameter """ # rot_angle_ellipse is psi parameter in HillasParametersContainer rho = np.zeros(self.subarray.num_tels) * u.m rot_angle_ellipse = np.zeros(self.subarray.num_tels) * u.deg for tel_id, params in hillas_dict.items(): idx = self.subarray.tel_indices[tel_id] rho[idx] = length * u.m if time_gradient[tel_id] > 0.01: params.psi = Angle(params.psi) angle_offset = Angle(angle_offset) rot_angle_ellipse[ idx] = params.psi + angle_offset + 180 * u.deg elif time_gradient[tel_id] < -0.01: rot_angle_ellipse[idx] = params.psi + angle_offset else: rho[idx] = 0 * u.m self.set_vector_rho_phi(rho=rho, phi=rot_angle_ellipse) def set_line_hillas(self, hillas_dict, range, **kwargs): """ Function to plot a segment of length 2*range for each telescope from a set of Hillas parameters. The segment is centered on the telescope position. A point is added at each telescope position for better visualization. Parameters ---------- hillas_dict: Dict[int, HillasParametersContainer] mapping of tel_id to Hillas parameters range: float half of the length of the segments to be plotted (in meters) """ coords = self.tel_coords c = self.tel_colors r = np.array([-range, range]) for tel_id, params in hillas_dict.items(): idx = self.subarray.tel_indices[tel_id] x_0 = coords[idx].x.to_value(u.m) y_0 = coords[idx].y.to_value(u.m) x = x_0 + np.cos(params.psi) * r y = y_0 + np.sin(params.psi) * r self.axes.plot(x, y, color=c[idx], **kwargs) self.axes.scatter(x_0, y_0, color=c[idx]) def add_labels(self): px = self.tel_coords.x.value py = self.tel_coords.y.value for tel, x, y in zip(self.subarray.tels, px, py): name = str(tel) lab = self.axes.text(x, y, name, fontsize=8, clip_on=True) self._labels.append(lab) def remove_labels(self): for lab in self._labels: lab.remove() self._labels = [] def _update(self): """ signal a redraw if necessary """ if self.autoupdate: plt.draw() def background_contour(self, x, y, background, **kwargs): """ Draw image contours in background of the display, useful when likelihood fitting Parameters ---------- x: ndarray array of image X coordinates y: ndarray array of image Y coordinates background: ndarray Array of image to use in background kwargs: key=value any style keywords to pass to matplotlib """ # use zorder to ensure the contours appear under the telescopes. self.axes.contour(x, y, background, zorder=0, **kwargs)
class CameraDisplay: """ Camera Display using matplotlib. Parameters ---------- geometry : `~ctapipe.instrument.CameraGeometry` Definition of the Camera/Image image: array_like array of values corresponding to the pixels in the CameraGeometry. ax : `matplotlib.axes.Axes` A matplotlib axes object to plot on, or None to create a new one title : str (default "Camera") Title to put on camera plot norm : str or `matplotlib.color.Normalize` instance (default 'lin') Normalization for the color scale. Supported str arguments are - 'lin': linear scale - 'log': logarithmic scale (base 10) cmap : str or `matplotlib.colors.Colormap` (default 'hot') Color map to use (see `matplotlib.cm`) allow_pick : bool (default False) if True, allow user to click and select a pixel autoupdate : bool (default True) redraw automatically (otherwise need to call plt.draw()) autoscale : bool (default True) rescale the vmin/vmax values when the image changes. This is set to False if `set_limits_*` is called to explicity set data limits. antialiased : bool (default True) whether to draw in antialiased mode or not. Notes ----- Speed: CameraDisplay is not intended to be very fast (matplotlib is not a very speed performant graphics library, it is intended for nice output plots). However, most of the slowness of CameraDisplay is in the constructor. Once one is displayed, changing the image that is displayed is relatively fast and efficient. Therefore it is best to initialize an instance, and change the data, rather than generating new CameraDisplays. Pixel Implementation: Pixels are rendered as a `matplotlib.collections.PatchCollection` of Polygons (either 6 or 4 sided). You can access the PatchCollection directly (to e.g. change low-level style parameters) via `CameraDisplay.pixels` Output: Since CameraDisplay uses matplotlib, any display can be saved to any output file supported via plt.savefig(filename). This includes `.pdf` and `.png`. """ def __init__( self, geometry, image=None, ax=None, title=None, norm="lin", cmap=None, allow_pick=False, autoupdate=True, autoscale=True, antialiased=True, ): self.axes = ax if ax is not None else plt.gca() self.geom = geometry self.pixels = None self.colorbar = None self.autoupdate = autoupdate self.autoscale = autoscale self._active_pixel = None self._active_pixel_label = None self._axes_overlays = [] if title is None: title = geometry.cam_id # initialize the plot and generate the pixels as a # RegularPolyCollection patches = [] if not hasattr(self.geom, "mask"): self.geom.mask = np.ones_like(self.geom.pix_x.value, dtype=bool) for xx, yy, aa in zip( u.Quantity(self.geom.pix_x[self.geom.mask]).value, u.Quantity(self.geom.pix_y[self.geom.mask]).value, u.Quantity(np.array(self.geom.pix_area)[self.geom.mask]).value): if self.geom.pix_type.startswith("hex"): rr = sqrt(aa * 2 / 3 / sqrt(3)) + 2*PIXEL_EPSILON poly = RegularPolygon( (xx, yy), 6, radius=rr, orientation=self.geom.pix_rotation.rad, fill=True, ) else: rr = sqrt(aa) + PIXEL_EPSILON poly = Rectangle( (xx-rr/2., yy-rr/2.), width=rr, height=rr, angle=self.geom.pix_rotation.deg, fill=True, ) patches.append(poly) self.pixels = PatchCollection(patches, cmap=cmap, linewidth=0) self.axes.add_collection(self.pixels) self.pixel_highlighting = copy.copy(self.pixels) self.pixel_highlighting.set_facecolor('none') self.pixel_highlighting.set_linewidth(0) self.axes.add_collection(self.pixel_highlighting) # Set up some nice plot defaults self.axes.set_aspect('equal', 'datalim') self.axes.set_title(title) self.axes.set_xlabel("X position ({})".format(self.geom.pix_x.unit)) self.axes.set_ylabel("Y position ({})".format(self.geom.pix_y.unit)) self.axes.autoscale_view() # set up a patch to display when a pixel is clicked (and # pixel_picker is enabled): self._active_pixel = copy.copy(patches[0]) self._active_pixel.set_facecolor('r') self._active_pixel.set_alpha(0.5) self._active_pixel.set_linewidth(2.0) self._active_pixel.set_visible(False) self.axes.add_patch(self._active_pixel) self._active_pixel_label = self.axes.text(self._active_pixel.xy[0], self._active_pixel.xy[1], "0", horizontalalignment='center', verticalalignment='center') self._active_pixel_label.set_visible(False) # enable ability to click on pixel and do something (can be # enabled on-the-fly later as well: if allow_pick: self.enable_pixel_picker() if image is not None: self.image = image else: self.image = np.zeros_like(self.geom.pix_id, dtype=np.float) self.norm = norm def highlight_pixels(self, pixels, color='g', 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 ''' l = np.zeros_like(self.image) l[pixels] = linewidth self.pixel_highlighting.set_linewidth(l) self.pixel_highlighting.set_alpha(alpha) self.pixel_highlighting.set_edgecolor(color) self._update() def enable_pixel_picker(self): """ enable ability to click on pixels """ self.pixels.set_picker(True) # enable click self.pixels.set_pickradius(sqrt(u.Quantity(self.geom.pix_area[0]) .value) / np.pi) self.pixels.set_snap(True) # snap cursor to pixel center self.axes.figure.canvas.mpl_connect('pick_event', self._on_pick) def set_limits_minmax(self, zmin, zmax): """ set the color scale limits from min to max """ self.pixels.set_clim(zmin, zmax) self.autoscale = False self._update() def set_limits_percent(self, percent=95): """ auto-scale the color range to percent of maximum """ zmin = self.pixels.get_array().min() zmax = self.pixels.get_array().max() dz = zmax - zmin frac = percent / 100.0 self.autoscale = False self.set_limits_minmax(zmin, zmax - (1.0 - frac) * dz) @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) - any matplotlib.colors.Normalize instance, e. g. PowerNorm(gamma=-2) ''' return self.pixels.norm @norm.setter def norm(self, norm): if norm == 'lin': self.pixels.norm = Normalize() elif norm == 'log': self.pixels.norm = LogNorm() self.pixels.autoscale() # this is to handle matplotlib bug #5424 elif norm == 'symlog': self.pixels.norm = SymLogNorm(linthresh=1.0) self.pixels.autoscale() elif isinstance(norm, Normalize): self.pixels.norm = norm else: raise ValueError("Unsupported norm: '{}', options are 'lin'," "'log','symlog', or a matplotlib Normalize object" .format(norm)) self.update(force=True) self.pixels.autoscale() @property def cmap(self): """ Color map to use. Either a name or `matplotlib.colors.ColorMap` instance, e.g. from `matplotlib.pyplot.cm` """ return self.pixels.get_cmap() @cmap.setter def cmap(self, cmap): self.pixels.set_cmap(cmap) self._update() @property def image(self): """The image displayed on the camera (1D array of pixel values)""" return self.pixels.get_array() @image.setter def image(self, image): """ Change the image displayed on the Camera. Parameters ---------- image: array_like array of values corresponding to the pixels in the CameraGeometry. """ image = np.asanyarray(image) if image.shape != self.geom.pix_x.shape: raise ValueError( "Image has a different shape {} than the " "given CameraGeometry {}" .format(image.shape, self.geom.pix_x.shape) ) self.pixels.set_array(image[self.geom.mask]) self.pixels.changed() if self.autoscale: self.pixels.autoscale() self._update() def _update(self, force=False): """ signal a redraw if autoupdate is turned on """ if self.autoupdate: self.update(force) def update(self, force=False): """ redraw the display now """ self.axes.figure.canvas.draw() if self.colorbar is not None: if force is True: self.colorbar.update_bruteforce(self.pixels) else: self.colorbar.update_normal(self.pixels) self.colorbar.draw_all() def add_colorbar(self, **kwargs): """ add a colobar to the camera plot kwargs are passed to `figure.colorbar(self.pixels, **kwargs)` See matplotlib documentation for the supported kwargs: http://matplotlib.org/api/figure_api.html#matplotlib.figure.Figure.colorbar """ if self.colorbar is not None: raise ValueError( 'There is already a colorbar attached to this CameraDisplay' ) else: self.colorbar = self.axes.figure.colorbar(self.pixels, **kwargs) self.update() 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(xy=centroid, width=length, height=width, angle=np.degrees(angle), fill=False, **kwargs) self.axes.add_patch(ellipse) self.update() return ellipse def overlay_moments(self, momparams, with_label=True, keep_old=False, **kwargs): """helper to overlay ellipse from a `reco.MomentParameters` structure Parameters ---------- momparams: `reco.MomentParameters` 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() # strip off any units cen_x = u.Quantity(momparams.cen_x).value cen_y = u.Quantity(momparams.cen_y).value length = u.Quantity(momparams.length).value width = u.Quantity(momparams.width).value el = self.add_ellipse(centroid=(cen_x, cen_y), length=length*2, width=width*2, angle=momparams.psi.rad, **kwargs) self._axes_overlays.append(el) if with_label: text = self.axes.text(cen_x, cen_y, ("({:.02f},{:.02f})\n" "[w={:.02f},l={:.02f}]") .format(momparams.cen_x, momparams.cen_y, momparams.width, momparams.length), color=el.get_edgecolor()) self._axes_overlays.append(text) def clear_overlays(self): ''' Remove added overlays from the axes ''' while self._axes_overlays: overlay = self._axes_overlays.pop() overlay.remove() def _on_pick(self, event): """ handler for when a pixel is clicked """ pix_id = event.ind[-1] xx, yy, aa = u.Quantity(self.geom.pix_x[pix_id]).value, \ u.Quantity(self.geom.pix_y[pix_id]).value, \ u.Quantity(np.array(self.geom.pix_area)[pix_id]) if self.geom.pix_type.startswith("hex"): self._active_pixel.xy = (xx, yy) else: rr = sqrt(aa) self._active_pixel.xy = (xx - rr / 2., yy - rr / 2.) self._active_pixel.set_visible(True) self._active_pixel_label.set_x(xx) self._active_pixel_label.set_y(yy) self._active_pixel_label.set_text("{:003d}".format(pix_id)) self._active_pixel_label.set_visible(True) self._update() self.on_pixel_clicked(pix_id) # call user-function def on_pixel_clicked(self, pix_id): """virtual function to overide in sub-classes to do something special when a pixel is clicked """ print("Clicked pixel_id {}".format(pix_id)) def show(self): self.axes.figure.show()
class CameraDisplay: """ Camera Display using matplotlib. Parameters ---------- geometry : `~ctapipe.instrument.CameraGeometry` Definition of the Camera/Image image: array_like array of values corresponding to the pixels in the CameraGeometry. ax : `matplotlib.axes.Axes` A matplotlib axes object to plot on, or None to create a new one title : str (default "Camera") Title to put on camera plot norm : str or `matplotlib.colors.Normalize` instance (default 'lin') Normalization for the color scale. Supported str arguments are - 'lin': linear scale - 'log': logarithmic scale (base 10) cmap : str or `matplotlib.colors.Colormap` (default 'hot') Color map to use (see `matplotlib.cm`) allow_pick : bool (default False) if True, allow user to click and select a pixel autoupdate : bool (default True) redraw automatically (otherwise need to call plt.draw()) autoscale : bool (default True) rescale the vmin/vmax values when the image changes. This is set to False if ``set_limits_*`` is called to explicity set data limits. Notes ----- Speed: CameraDisplay is not intended to be very fast (matplotlib is not a very speed performant graphics library, it is intended for nice output plots). However, most of the slowness of CameraDisplay is in the constructor. Once one is displayed, changing the image that is displayed is relatively fast and efficient. Therefore it is best to initialize an instance, and change the data, rather than generating new CameraDisplays. Pixel Implementation: Pixels are rendered as a `matplotlib.collections.PatchCollection` of Polygons (either 6 or 4 sided). You can access the PatchCollection directly (to e.g. change low-level style parameters) via ``CameraDisplay.pixels`` Output: Since CameraDisplay uses matplotlib, any display can be saved to any output file supported via plt.savefig(filename). This includes ``.pdf`` and ``.png``. """ def __init__( self, geometry, image=None, ax=None, title=None, norm="lin", cmap=None, allow_pick=False, autoupdate=True, autoscale=True, show_frame=True, ): self.axes = ax if ax is not None else plt.gca() self.pixels = None self.colorbar = None self.autoupdate = autoupdate self.autoscale = autoscale self._active_pixel = None self._active_pixel_label = None self._axes_overlays = [] self.geom = geometry if title is None: title = f"{geometry.camera_name}" # initialize the plot and generate the pixels as a # RegularPolyCollection patches = [] if hasattr(self.geom, "mask"): self.mask = self.geom.mask else: self.mask = np.ones_like(self.geom.pix_x.value, dtype=bool) pix_x = self.geom.pix_x.value[self.mask] pix_y = self.geom.pix_y.value[self.mask] pix_width = self.geom.pixel_width.value[self.mask] for x, y, w in zip(pix_x, pix_y, pix_width): if self.geom.pix_type == PixelShape.HEXAGON: r = w / np.sqrt(3) patch = RegularPolygon( (x, y), 6, radius=r, orientation=self.geom.pix_rotation.to_value(u.rad), fill=True, ) elif self.geom.pix_type == PixelShape.CIRCLE: patch = Circle((x, y), radius=w / 2, fill=True) elif self.geom.pix_type == PixelShape.SQUARE: patch = Rectangle( (x - w / 2, y - w / 2), width=w, height=w, angle=self.geom.pix_rotation.to_value(u.deg), fill=True, ) else: raise ValueError( f"Unsupported pixel_shape {self.geom.pix_type}") patches.append(patch) self.pixels = PatchCollection(patches, cmap=cmap, linewidth=0) self.axes.add_collection(self.pixels) self.pixel_highlighting = copy.copy(self.pixels) self.pixel_highlighting.set_facecolor("none") self.pixel_highlighting.set_linewidth(0) self.axes.add_collection(self.pixel_highlighting) # Set up some nice plot defaults self.axes.set_aspect("equal", "datalim") self.axes.set_title(title) self.axes.autoscale_view() if show_frame: self.add_frame_name() # set up a patch to display when a pixel is clicked (and # pixel_picker is enabled): self._active_pixel = copy.copy(patches[0]) self._active_pixel.set_facecolor("r") self._active_pixel.set_alpha(0.5) self._active_pixel.set_linewidth(2.0) self._active_pixel.set_visible(False) self.axes.add_patch(self._active_pixel) if hasattr(self._active_pixel, "xy"): center = self._active_pixel.xy else: center = self._active_pixel.center self._active_pixel_label = self.axes.text(*center, "0", horizontalalignment="center", verticalalignment="center") self._active_pixel_label.set_visible(False) # enable ability to click on pixel and do something (can be # enabled on-the-fly later as well: if allow_pick: self.enable_pixel_picker() if image is not None: self.image = image else: self.image = np.zeros_like(self.geom.pix_id, dtype=np.float64) self.norm = norm self.auto_set_axes_labels() def highlight_pixels(self, pixels, color="g", 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 """ l = np.zeros_like(self.image) l[pixels] = linewidth self.pixel_highlighting.set_linewidth(l) self.pixel_highlighting.set_alpha(alpha) self.pixel_highlighting.set_edgecolor(color) self._update() def enable_pixel_picker(self): """ enable ability to click on pixels """ self.pixels.set_picker(True) self.pixels.set_pickradius(self.geom.pixel_width.value[0] / 2) self.axes.figure.canvas.mpl_connect("pick_event", self._on_pick) def set_limits_minmax(self, zmin, zmax): """ set the color scale limits from min to max """ self.pixels.set_clim(zmin, zmax) self.autoscale = False self._update() def set_limits_percent(self, percent=95): """ auto-scale the color range to percent of maximum """ zmin = np.nanmin(self.pixels.get_array()) zmax = np.nanmax(self.pixels.get_array()) dz = zmax - zmin frac = percent / 100.0 self.autoscale = False self.set_limits_minmax(zmin, zmax - (1.0 - frac) * dz) @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) - any matplotlib.colors.Normalize instance, e. g. PowerNorm(gamma=-2) """ return self.pixels.norm @norm.setter def norm(self, norm): if norm == "lin": self.pixels.norm = Normalize() elif norm == "log": self.pixels.norm = LogNorm() self.pixels.autoscale() # this is to handle matplotlib bug #5424 elif norm == "symlog": self.pixels.norm = SymLogNorm(linthresh=1.0, base=10) self.pixels.autoscale() elif isinstance(norm, Normalize): self.pixels.norm = norm else: raise ValueError( "Unsupported norm: '{}', options are 'lin'," "'log','symlog', or a matplotlib Normalize object".format( norm)) self.update(force=True) self.pixels.autoscale() @property def cmap(self): """ Color map to use. Either name or `matplotlib.colors.Colormap` """ return self.pixels.get_cmap() @cmap.setter def cmap(self, cmap): self.pixels.set_cmap(cmap) self._update() @property def image(self): """The image displayed on the camera (1D array of pixel values)""" return self.pixels.get_array() @image.setter def image(self, image): """ Change the image displayed on the Camera. Parameters ---------- image: array_like array of values corresponding to the pixels in the CameraGeometry. """ image = np.asanyarray(image) if image.shape != self.geom.pix_x.shape: raise ValueError( ("Image has a different shape {} than the " "given CameraGeometry {}").format(image.shape, self.geom.pix_x.shape)) self.pixels.set_array(np.ma.masked_invalid(image[self.mask])) self.pixels.changed() if self.autoscale: self.pixels.autoscale() self._update() def _update(self, force=False): """ signal a redraw if autoupdate is turned on """ if self.autoupdate: self.update(force) def update(self, force=False): """ redraw the display now """ self.axes.figure.canvas.draw() if self.colorbar is not None: if force is True: self.colorbar.update_bruteforce(self.pixels) else: self.colorbar.update_normal(self.pixels) self.colorbar.draw_all() def add_colorbar(self, **kwargs): """ add a colorbar to the camera plot kwargs are passed to ``figure.colorbar(self.pixels, **kwargs)`` See matplotlib documentation for the supported kwargs: http://matplotlib.org/api/figure_api.html#matplotlib.figure.Figure.colorbar """ if self.colorbar is not None: raise ValueError( "There is already a colorbar attached to this CameraDisplay") else: if "ax" not in kwargs: kwargs["ax"] = self.axes self.colorbar = self.axes.figure.colorbar(self.pixels, **kwargs) self.update() 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( xy=centroid, width=length, height=width, angle=np.degrees(angle), fill=False, **kwargs, ) self.axes.add_patch(ellipse) self.update() return ellipse def overlay_moments(self, hillas_parameters, with_label=True, keep_old=False, **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() # strip off any units cen_x = u.Quantity(hillas_parameters.x).value cen_y = u.Quantity(hillas_parameters.y).value length = u.Quantity(hillas_parameters.length).value width = u.Quantity(hillas_parameters.width).value el = self.add_ellipse( centroid=(cen_x, cen_y), length=length * 2, width=width * 2, angle=hillas_parameters.psi.to_value("rad"), **kwargs, ) self._axes_overlays.append(el) if with_label: text = self.axes.text( cen_x, cen_y, "({:.02f},{:.02f})\n[w={:.02f},l={:.02f}]".format( hillas_parameters.x, hillas_parameters.y, hillas_parameters.width, hillas_parameters.length, ), color=el.get_edgecolor(), ) self._axes_overlays.append(text) def clear_overlays(self): """ Remove added overlays from the axes """ while self._axes_overlays: overlay = self._axes_overlays.pop() overlay.remove() def _on_pick(self, event): """ handler for when a pixel is clicked """ pix_id = event.ind[-1] x = self.geom.pix_x[pix_id].value y = self.geom.pix_y[pix_id].value if self.geom.pix_type in (PixelShape.HEXAGON, PixelShape.CIRCLE): self._active_pixel.xy = (x, y) else: w = self.geom.pixel_width.value[0] self._active_pixel.xy = (x - w / 2.0, y - w / 2.0) self._active_pixel.set_visible(True) self._active_pixel_label.set_x(x) self._active_pixel_label.set_y(y) self._active_pixel_label.set_text(f"{pix_id:003d}") self._active_pixel_label.set_visible(True) self._update() self.on_pixel_clicked(pix_id) # call user-function def on_pixel_clicked(self, pix_id): """virtual function to overide in sub-classes to do something special when a pixel is clicked """ print(f"Clicked pixel_id {pix_id}") def show(self): self.axes.figure.show() def auto_set_axes_labels(self): """ set the axes labels based on the Frame attribute""" axes_labels = ("X", "Y") if self.geom.frame is not None: axes_labels = list( self.geom.frame.get_representation_component_names().keys()) self.axes.set_xlabel(f"{axes_labels[0]} ({self.geom.pix_x.unit})") self.axes.set_ylabel(f"{axes_labels[1]} ({self.geom.pix_y.unit})") def add_frame_name(self, color="grey"): """ label the frame type of the display (e.g. CameraFrame) """ frame_name = (self.geom.frame.__class__.__name__ if self.geom.frame is not None else "Unknown Frame") self.axes.text( # position text relative to Axes 1.0, 0.0, frame_name, ha="right", va="bottom", transform=self.axes.transAxes, color=color, fontsize="smaller", )