Source code for visualization.plot_camera

"""Plot camera pixel layout and trigger patches.

Model data (camera, trigger tables) are passed in by the caller so that this module does not
import the model package.
"""

import copy
import logging
from itertools import pairwise

import numpy as np
from matplotlib.collections import PatchCollection
from matplotlib.legend_handler import HandlerTuple
from matplotlib.patches import Patch, PathPatch
from matplotlib.transforms import Bbox

from simtools.utils import names
from simtools.utils.names import is_two_mirror_telescope
from simtools.visualization.camera_plot_utils import (
    add_pixel_legend,
    add_pixel_patch_collections,
    create_pixel_patches_by_type,
    pixel_shape,
    setup_camera_axis_properties,
)
from simtools.visualization.matplotlib_backend import lazy_module
from simtools.visualization.matplotlib_backend import pyplot as plt

logger = logging.getLogger(__name__)
mcolors = lazy_module("matplotlib.colors")
GROUP_ID = "group_id"
MEMBER_PIXEL_ID = "pixel_id"
PIXEL_IDS = "pix_id"
TRIGGER_PATCH = "Trigger patch"
_CENTER = "center"


[docs] def plot_pixel_layout(camera, camera_in_sky_coor=False, pixels_id_to_print=50): """ Plot the pixel layout for an observer facing the camera. Includes edge pixels, off pixels, pixel IDs (up to ``pixels_id_to_print``), coordinate axes, the focal length, the average edge radius and the FoV. Parameters ---------- camera : Camera Camera object to plot. camera_in_sky_coor : bool, optional Plot the camera in the sky coordinate system. pixels_id_to_print : int, optional Number of pixel IDs to print in the plot. Returns ------- matplotlib.figure.Figure Figure with the pixel layout. """ logger.info(f"Plotting the {camera.telescope_name} camera") fig, ax = plt.subplots(figsize=(8, 8)) two_mirror = is_two_mirror_telescope(camera.telescope_name) plot_camera = camera if not two_mirror and not camera_in_sky_coor: plot_camera = copy.copy(camera) plot_camera.pixels = {**camera.pixels, "y": [-y for y in camera.pixels["y"]]} on_pixels, edge_pixels, off_pixels = create_pixel_patches_by_type(plot_camera) font_size = 4 if "SCT" in names.get_array_element_type_from_name(camera.telescope_name) else 2 for x, y, pix_id in zip( plot_camera.pixels["x"], plot_camera.pixels["y"], plot_camera.pixels[PIXEL_IDS] ): if pix_id < pixels_id_to_print + 1: plt.text(x, y, pix_id, ha=_CENTER, va=_CENTER, fontsize=font_size) add_pixel_patch_collections(ax, on_pixels, edge_pixels, off_pixels) setup_camera_axis_properties(ax, plot_camera, grid=True, axis_below=True, y_scale_factor=1.42) x_min, x_max = ax.get_xlim() margin = plot_camera.pixels["pixel_diameter"] ax.set_xlim(x_min - margin, x_max + margin) plt.xlabel("Horizontal scale [cm]", fontsize=18, labelpad=0) plt.ylabel("Vertical scale [cm]", fontsize=18, labelpad=0) ax.set_title(f"Pixels layout in {plot_camera.telescope_name} camera", fontsize=15, y=1.02) plt.tick_params(axis="both", which="major", labelsize=15) _plot_axes_def(plot_camera, plt, plot_camera.pixels["rotate_angle"]) if camera_in_sky_coor is None: description = "For an observer looking from secondary to camera" elif camera_in_sky_coor and not two_mirror: description = "For an observer behind the camera looking through" else: description = "For an observer facing the camera" ax.text(0.02, 0.02, description, transform=ax.transAxes, color="black", fontsize=12) fov, r_edge_avg = plot_camera.calc_fov() for y_position, text in ( (0.96, rf"$f_{{\mathrm{{eff}}}} = {plot_camera.focal_length:.3f}\,\mathrm{{cm}}$"), (0.92, rf"Avg. edge radius = {r_edge_avg:.3f}$\,\mathrm{{cm}}$"), (0.88, rf"FoV = {fov:.3f}$\,\mathrm{{deg}}$"), ): ax.text(0.02, y_position, text, transform=ax.transAxes, color="black", fontsize=12) add_pixel_legend(ax, on_pixels, off_pixels) plt.tight_layout() return fig
[docs] def plot_pixel_layout_with_image( camera, image=None, colormap="viridis", norm="lin", ax=None, vmin=None, vmax=None, add_color_bar=True, color_bar_label="Pixel Value", **kwargs, ): """ Plot pixel layout with optional per-pixel values as colors. Used to display DL1 images (e.g., integrated signal, peak timing) on the camera. Parameters ---------- camera : Camera Camera object with pixel geometry. image : np.ndarray, optional Per-pixel values to display as colors (shape: n_pix,). If None, plot layout without colored values. colormap : str, optional Colormap name (default "viridis"). norm : str, optional Normalization type: "lin" (linear), "log", or "symlog" (default "lin"). ax : plt.Axes, optional Existing axes to plot on. If None, create new figure. vmin, vmax : float, optional Value range for normalization. If None, use data range. add_color_bar : bool, optional Whether to add a color bar (default True). color_bar_label : str, optional Label for the color bar (default "Pixel Value"). **kwargs Additional arguments passed to plt.subplots() if ax is None. Returns ------- plt.Figure Figure with the pixel layout and optional image overlay. """ if ax is None: fig, ax = plt.subplots(figsize=kwargs.pop("figsize", (8, 8))) else: fig = ax.figure colors, cmap, norm_obj = _color_normalization(image, colormap, norm, vmin, vmax) has_values = colors is not None and image is not None patches, facecolors, edgecolors, linewidths = [], [], [], [] for i_pix, (x, y) in enumerate(zip(camera.pixels["x"], camera.pixels["y"])): patches.append(pixel_shape(camera, x, y)) edgecolors.append("black") if has_values: facecolors.append(colors[i_pix]) linewidths.append(0.1) else: facecolors.append("none" if camera.pixels["pix_on"][i_pix] else "black") linewidths.append(0.2) ax.add_collection( PatchCollection(patches, facecolor=facecolors, edgecolor=edgecolors, linewidth=linewidths) ) if has_values and add_color_bar: sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm_obj) sm.set_array([]) cbar = fig.colorbar(sm, ax=ax, fraction=0.02, pad=0.05) cbar.set_label(color_bar_label, fontsize=10) setup_camera_axis_properties(ax, camera, grid=True, grid_alpha=0.3, padding=0.1) ax.set_xlabel("x [m]", fontsize=12) ax.set_ylabel("y [m]", fontsize=12) return fig
def _color_normalization(image, color_map, norm_type="lin", vmin=None, vmax=None): """Return RGBA colors, colormap and norm for an image; ``(None, None, None)`` if no image.""" if image is None: return None, None, None norm_classes = {"log": mcolors.LogNorm, "symlog": mcolors.SymLogNorm} norm = norm_classes.get(norm_type, mcolors.Normalize)(vmin=vmin, vmax=vmax) cmap = plt.get_cmap(color_map) return cmap(norm(image)), cmap, norm def _plot_axes_def(camera, plot, rotate_angle): """Plot the Alt/Az, camera and pixel coordinate axis arrows (rotate_angle in radians).""" invert_yaxis = not is_two_mirror_telescope(camera.telescope_name) x_left = 0.8 if invert_yaxis else 0.7 x_pix, y_pix = x_left, 0.12 # The rotation of LST (above 100 degrees) raises the axes; lower the starting point. if np.rad2deg(rotate_angle) > 100: y_pix -= 0.09 x_pix -= 0.05 definitions = ( (r"$x_{\!pix}$", r"$y_{\!pix}$", x_pix, y_pix, rotate_angle - (1 / 2.0) * np.pi, "black"), (r"$x_{\!cam}$", r"$y_{\!cam}$", x_left + 0.15, 0.12, (3 / 2.0) * np.pi, "blue"), ("Alt", "Az", x_left + 0.15, 0.25, (3 / 2.0) * np.pi, "red"), ) for x_title, y_title, x_pos, y_pos, angle, colour in definitions: _plot_one_axis_def(plot, x_title, y_title, x_pos, y_pos, angle, colour, invert_yaxis) def _plot_one_axis_def(plot, x_title, y_title, x_pos, y_pos, rotate_angle, colour, invert_yaxis): """Plot one pair of labelled axis arrows (rotate_angle in radians) on ``plot``.""" r = 0.1 # size of arrow sign = -1.0 if invert_yaxis else 1.0 tips = ( (x_title, sign * r * np.cos(rotate_angle), r * np.sin(rotate_angle)), ( y_title, sign * r * np.cos(np.pi / 2.0 + rotate_angle), r * np.sin(np.pi / 2.0 + rotate_angle), ), ) for title, dx, dy in tips: plot.gca().annotate( title, xy=(x_pos, y_pos), xytext=(x_pos + dx, y_pos + dy), xycoords="axes fraction", ha=_CENTER, va=_CENTER, size="xx-large", arrowprops={ "arrowstyle": "<|-", "shrinkA": 0, "shrinkB": 0, "fc": colour, "ec": colour, }, )
[docs] def trigger_patches_for_pixel(members, pixel_id, camera_pixels): """ Find the distinct trigger-group IDs containing a camera pixel. Parameters ---------- members : astropy.table.Table Trigger memberships with ``group_id`` and ``pixel_id`` columns. pixel_id : int Camera pixel ID. camera_pixels : set of int Pixel IDs present in the camera. Returns ------- list of int Sorted trigger-group IDs. """ if pixel_id not in camera_pixels: raise ValueError(f"Pixel {pixel_id} is not in the camera; choose a model pixel ID") patch_ids = sorted(set(map(int, members[GROUP_ID][members[MEMBER_PIXEL_ID] == pixel_id]))) if not patch_ids: raise ValueError(f"Pixel {pixel_id} belongs to no trigger patches in this model") logger.info("Trigger patches containing pixel %s: %s", pixel_id, patch_ids) return patch_ids
[docs] def read_selected_trigger_patches(definitions, members, patch_ids, camera_pixels): """ Resolve the selected trigger groups into their pixel sets, with validation. Parameters ---------- definitions : astropy.table.Table Trigger group definitions with a ``group_id`` column. members : astropy.table.Table Trigger memberships with ``group_id`` and ``pixel_id`` columns. patch_ids : list of int Selected trigger-group IDs. camera_pixels : set of int Pixel IDs present in the camera. Returns ------- dict Mapping of group ID to its set of pixel IDs. """ if not patch_ids: raise ValueError("Select at least one trigger patch with --trigger_patches") if len(set(patch_ids)) != len(patch_ids): raise ValueError("Trigger patch IDs must be distinct") maximum = max(1, _maximum_patch_membership(members)) if len(patch_ids) > maximum: raise ValueError( f"Select at most {maximum} trigger patches: this model's maximum patches per pixel" ) available = set(definitions[GROUP_ID]) groups = {} for group_id in patch_ids: if group_id not in available: raise ValueError(f"Unknown trigger patch {group_id}; choose a group_id from the model") pixels = set(map(int, members[MEMBER_PIXEL_ID][members[GROUP_ID] == group_id])) if not pixels: raise ValueError(f"{TRIGGER_PATCH} {group_id} has no members in the model") missing = pixels - camera_pixels if missing: raise ValueError( f"{TRIGGER_PATCH} {group_id} references pixels absent from the camera: " f"{sorted(missing)}" ) groups[group_id] = pixels return groups
def _maximum_patch_membership(members): """Count distinct groups per pixel, including each group only once per pixel.""" memberships = {} for row in members: memberships.setdefault(int(row[MEMBER_PIXEL_ID]), set()).add(int(row[GROUP_ID])) return max(map(len, memberships.values()), default=0)
[docs] def plot_selected_trigger_patches(camera, groups, sky_coordinates): """ Draw selected trigger groups on a camera overview and a labelled close-up of their union. Parameters ---------- camera : Camera Camera object. groups : dict Mapping of group ID to its set of pixel IDs (see :func:`read_selected_trigger_patches`). sky_coordinates : bool Plot in sky coordinates (no vertical flip for single-mirror telescopes). Returns ------- matplotlib.figure.Figure The generated figure. """ plot_camera = copy.copy(camera) plot_camera.pixels = dict(camera.pixels) if not is_two_mirror_telescope(camera.telescope_name) and not sky_coordinates: plot_camera.pixels["y"] = -np.asarray(camera.pixels["y"]) memberships, selected_positions = _selected_patch_positions(plot_camera, groups) fig, axes = plt.subplots(1, 2, figsize=(14, 7), layout="constrained") _draw_patch_memberships(axes, plot_camera, groups, memberships) for ax in axes: ax.autoscale_view() ax.set_aspect("equal", adjustable="box") ax.set_xlabel("Camera x [cm]") ax.set_ylabel("Camera y [cm]") axes[0].set_title(f"{camera.telescope_name}: full camera") axes[1].set_title("Selected trigger patches (pixel IDs)") for pixel_id, x, y in selected_positions: axes[1].text(x, y, str(pixel_id), ha=_CENTER, va=_CENTER, fontsize=7) coordinates = np.asarray([(x, y) for _, x, y in selected_positions]) margin = 2 * camera.pixels["pixel_diameter"] axes[1].set_xlim(coordinates[:, 0].min() - margin, coordinates[:, 0].max() + margin) axes[1].set_ylim(coordinates[:, 1].min() - margin, coordinates[:, 1].max() + margin) return fig
def _selected_patch_positions(camera, groups): """Partition camera pixels by their exact membership in the selected groups.""" memberships = {} selected_positions = [] for pixel_id, x, y in zip(camera.pixels[PIXEL_IDS], camera.pixels["x"], camera.pixels["y"]): membership = tuple(group_id for group_id, pixels in groups.items() if pixel_id in pixels) memberships.setdefault(membership, []).append((pixel_id, x, y)) if membership: selected_positions.append((pixel_id, x, y)) return memberships, selected_positions def _draw_patch_memberships(axes, camera, groups, memberships): """Show intersections as stripes retaining each contributing patch's colour.""" palette = ("#0072B2", "#D55E00", "#009E73", "#CC79A7", "#E69F00", "#56B4E9") if len(groups) > len(palette): palette = plt.get_cmap("turbo")(np.linspace(0, 1, len(groups))) colours = dict(zip(groups, palette)) handles = [] for membership, positions in memberships.items(): member_colours = ["0.92"] if membership: member_colours = [colours[group_id] for group_id in membership] label = "Patch " + str(membership[0]) if len(membership) > 1: label = "Patches " + " + ".join(map(str, membership)) handles.append(tuple(Patch(facecolor=colour) for colour in member_colours)) handles[-1][0].set_label(label) for ax in axes: patches, facecolours = _striped_pixel_shapes(camera, positions, member_colours) ax.add_collection(PatchCollection(patches, facecolor=facecolours, edgecolor="none")) outlines = [pixel_shape(camera, x, y) for x, y in zip(camera.pixels["x"], camera.pixels["y"])] for ax in axes: ax.add_collection( PatchCollection(outlines, facecolor="none", edgecolor="0.45", linewidth=0.5) ) axes[0].legend( handles=handles, labels=[handle[0].get_label() for handle in handles], handler_map={tuple: HandlerTuple(ndivide=None, pad=0)}, loc="upper left", fontsize=9, ) def _striped_pixel_shapes(camera, positions, colours): """Clip repeating vertical colour bands to each pixel's actual outline.""" shapes, facecolours = [], [] for _, x, y in positions: pixel = pixel_shape(camera, x, y) path = pixel.get_path().transformed(pixel.get_transform()) if len(colours) == 1: shapes.append(PathPatch(path)) facecolours.append(colours[0]) continue bounds = path.get_extents() edges = np.linspace(bounds.xmin, bounds.xmax, 3 * len(colours) + 1) for index, (left, right) in enumerate(pairwise(edges)): stripe = path.clip_to_bbox(Bbox.from_extents(left, bounds.ymin, right, bounds.ymax)) shapes.append(PathPatch(stripe)) facecolours.append(colours[index % len(colours)]) return shapes, facecolours
[docs] def parse_pixel_ids_to_print(print_pixels_id_arg, camera): """ Parse the print_pixels_id argument into an integer pixel count. Parameters ---------- print_pixels_id_arg : str or int Integer, 0 (suppress) or 'All'. camera : Camera Camera used to determine the total pixel count for 'All'. Returns ------- int Number of pixel IDs to print; -1 when printing should be suppressed. Raises ------ ValueError If the argument is not an integer string or 'All'. """ if str(print_pixels_id_arg).lower() == "all": return camera.get_number_of_pixels() try: n = int(print_pixels_id_arg) except (TypeError, ValueError) as exc: raise ValueError( f"--print_pixels_id must be integer or 'All', got: {print_pixels_id_arg}" ) from exc return -1 if n == 0 else n