Skip to content

Commit

Permalink
Merge pull request #2347 from cta-observatory/overlay_telescope_frame
Browse files Browse the repository at this point in the history
Add support for hillas parameters in TelescopeFrame to CameraDisplay
  • Loading branch information
maxnoe authored Jun 14, 2023
2 parents bb887b5 + 0bc6b46 commit 8759143
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 61 deletions.
87 changes: 45 additions & 42 deletions ctapipe/visualization/bokeh.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,30 @@
import sys
import tempfile
from abc import ABCMeta
import matplotlib.pyplot as plt
from matplotlib.colors import to_hex

import astropy.units as u
import matplotlib.pyplot as plt
import numpy as np

from bokeh.io import output_notebook, push_notebook, show, output_file
from bokeh.plotting import figure
from bokeh.io import output_file, output_notebook, push_notebook, show
from bokeh.models import (
ColumnDataSource,
TapTool,
BoxZoomTool,
CategoricalColorMapper,
ColorBar,
LinearColorMapper,
LogColorMapper,
ColumnDataSource,
ContinuousColorMapper,
CategoricalColorMapper,
HoverTool,
BoxZoomTool,
Ellipse,
HoverTool,
Label,
LinearColorMapper,
LogColorMapper,
TapTool,
)
from bokeh.palettes import Viridis256, Magma256, Inferno256, Greys256, d3
import astropy.units as u
from bokeh.palettes import Greys256, Inferno256, Magma256, Viridis256, d3
from bokeh.plotting import figure
from matplotlib.colors import to_hex

from ..instrument import CameraGeometry, PixelShape

from .utils import build_hillas_overlay

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

Expand Down Expand Up @@ -66,10 +65,11 @@ def generate_hex_vertices(geom):
phi += geom.pix_rotation.rad + np.deg2rad(30)

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

x = geom.pix_x.value
y = geom.pix_y.value
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],
Expand All @@ -79,9 +79,10 @@ def generate_hex_vertices(geom):

def generate_square_vertices(geom):
"""Generate vertices of pixels for a square grid camera geometry"""
width = geom.pixel_width.value / 2
x = geom.pix_x.value
y = geom.pix_y.value
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])
Expand Down Expand Up @@ -327,15 +328,18 @@ def _init_datasource(self, image=None):
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, y = self._geometry.pix_x.value, self._geometry.pix_y.value
data["radius"] = self._geometry.pixel_width / 2
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}"
Expand Down Expand Up @@ -464,7 +468,7 @@ def add_ellipse(self, centroid, length, width, angle, asymmetry=0.0, **kwargs):
return ellipse

def overlay_moments(
self, hillas_parameters, with_label=True, keep_old=False, **kwargs
self, hillas_parameters, with_label=True, keep_old=False, n_sigma=1, **kwargs
):
"""helper to overlay ellipse from a `HillasParametersContainer` structure
Expand All @@ -483,30 +487,29 @@ def overlay_moments(
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
params = build_hillas_overlay(
hillas_parameters,
self._unit,
n_sigma=n_sigma,
with_label=with_label,
)

el = self.add_ellipse(
centroid=(cen_x, cen_y),
length=length * 2,
width=width * 2,
angle=hillas_parameters.psi.to_value(u.rad),
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=cen_x,
y=cen_y,
text="({:.02f},{:.02f})\n[w={:.02f},l={:.02f}]".format(
hillas_parameters.x,
hillas_parameters.y,
hillas_parameters.width,
hillas_parameters.length,
),
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")
Expand Down Expand Up @@ -644,7 +647,7 @@ def _init_datasource(self, subarray, values, *, radius, frame, scale, alpha):
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_area = telescope.optics.mirror_area.to_value(u.m**2)
mirror_radii[i] = np.sqrt(mirror_area) / np.pi

if values is None:
Expand Down
39 changes: 21 additions & 18 deletions ctapipe/visualization/mpl_camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from ..coordinates import get_representation_component_names
from ..instrument import PixelShape
from .utils import build_hillas_overlay

__all__ = ["CameraDisplay"]

Expand Down Expand Up @@ -459,7 +460,7 @@ def overlay_coordinate(self, coord, keep_old=False, **kwargs):
self._axes_overlays.append(plot)

def overlay_moments(
self, hillas_parameters, with_label=True, keep_old=False, **kwargs
self, hillas_parameters, with_label=True, keep_old=False, n_sigma=1, **kwargs
):
"""helper to overlay ellipse from a `~ctapipe.containers.HillasParametersContainer` structure
Expand All @@ -471,40 +472,42 @@ def overlay_moments(
If True, show coordinates of centroid and width and length
keep_old: bool
If True, to not remove old overlays
n_sigma: float
How many sigmas to use for the ellipse
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).to_value(self.unit)
cen_y = u.Quantity(hillas_parameters.y).to_value(self.unit)
length = u.Quantity(hillas_parameters.length).to_value(self.unit)
width = u.Quantity(hillas_parameters.width).to_value(self.unit)
params = build_hillas_overlay(
hillas_parameters,
self.unit,
n_sigma=n_sigma,
with_label=with_label,
)

el = self.add_ellipse(
centroid=(cen_x, cen_y),
length=length * 2,
width=width * 2,
angle=hillas_parameters.psi.to_value(u.rad),
centroid=(params["cog_x"], params["cog_y"]),
length=n_sigma * params["length"] * 2,
width=n_sigma * params["width"] * 2,
angle=params["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(
hillas_parameters.x,
hillas_parameters.y,
hillas_parameters.width,
hillas_parameters.length,
),
params["label_x"],
params["label_y"],
params["text"],
color=el.get_edgecolor(),
va="bottom",
ha="center",
rotation=params["rotation"],
rotation_mode="anchor",
)

self._axes_overlays.append(text)
Expand Down
19 changes: 18 additions & 1 deletion ctapipe/visualization/tests/test_mpl.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def test_camera_display_single(prod5_lst_cam, tmp_path):
fig.savefig(tmp_path / "result.png")


def test_hillas_overlay(prod5_lst_cam, tmp_path):
def test_hillas_overlay_camera_frame(prod5_lst_cam, tmp_path):
from ctapipe.visualization import CameraDisplay

fig, ax = plt.subplots()
Expand All @@ -103,6 +103,23 @@ def test_hillas_overlay(prod5_lst_cam, tmp_path):
fig.savefig(tmp_path / "result.png")


def test_hillas_overlay(prod5_lst_cam, tmp_path):
from ctapipe.visualization import CameraDisplay

fig, ax = plt.subplots()
disp = CameraDisplay(prod5_lst_cam.transform_to(TelescopeFrame()), ax=ax)
hillas = HillasParametersContainer(
fov_lon=0.1 * u.deg,
fov_lat=-0.1 * u.deg,
length=0.5 * u.deg,
width=0.2 * u.deg,
psi=120 * u.deg,
)

disp.overlay_moments(hillas, color="w")
fig.savefig(tmp_path / "result.png")


@pytest.mark.parametrize("pix_type", PixelShape.__members__.values())
def test_pixel_shapes(pix_type, prod5_lst_cam, tmp_path):
"""test CameraDisplay functionality"""
Expand Down
84 changes: 84 additions & 0 deletions ctapipe/visualization/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import astropy.units as u
import numpy as np
from astropy.coordinates import Angle

from ..containers import CameraHillasParametersContainer, HillasParametersContainer


def build_hillas_overlay(hillas, unit, with_label=True, n_sigma=1):
"""
Get position, rotation and text for the hillas parameters label
"""
try:
length = hillas.length.to_value(unit)
width = hillas.width.to_value(unit)
except u.UnitsError:
raise ValueError("hillas must be in same frame as geometry")

# strip off any units
if isinstance(hillas, HillasParametersContainer):
cog_x = hillas.fov_lon.to_value(unit)
cog_y = hillas.fov_lat.to_value(unit)
elif isinstance(hillas, CameraHillasParametersContainer):
cog_x = hillas.x.to_value(unit)
cog_y = hillas.y.to_value(unit)
else:
raise TypeError(
"hillas must be a (Camera)HillasParametersContainer" f", got: {hillas} "
)

psi_rad = hillas.psi.to_value(u.rad)
psi_deg = Angle(hillas.psi).wrap_at(180 * u.deg).to_value(u.deg)

ret = dict(
cog_x=cog_x,
cog_y=cog_y,
width=width,
length=length,
psi_rad=psi_rad,
)

if not with_label:
return ret

# the following code dealing with x, y, angle
# results in the minimal rotation of the text and puts the
# label just outside the ellipse
if psi_deg < -135:
psi_deg += 180
psi_rad += np.pi
elif psi_deg > 135:
psi_deg -= 180
psi_rad -= np.pi

if -45 < psi_deg <= 45:
r = 1.2 * n_sigma * width
label_x = cog_x + r * np.cos(psi_rad + 0.5 * np.pi)
label_y = cog_y + r * np.sin(psi_rad + 0.5 * np.pi)
rotation = psi_deg
elif 45 < psi_deg <= 135:
r = 1.2 * n_sigma * length
label_x = cog_x + r * np.cos(psi_rad)
label_y = cog_y + r * np.sin(psi_rad)
rotation = psi_deg - 90
else:
r = 1.2 * n_sigma * length
label_x = cog_x - r * np.cos(psi_rad)
label_y = cog_y - r * np.sin(psi_rad)
rotation = psi_deg + 90

ret["rotation"] = rotation
ret["label_x"] = label_x
ret["label_y"] = label_y

if unit == u.deg:
sep = ""
else:
sep = " "

ret["text"] = (
f"({cog_x:.2f}{sep}{unit:unicode}, {cog_y:.2f}{sep}{unit:unicode})\n"
f"[w={width:.2f}{sep}{unit:unicode},l={length:.2f}{sep}{unit:unicode}]"
)

return ret
3 changes: 3 additions & 0 deletions docs/changes/2347.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Add support for Hillas parameters in ``TelescopeFrame`` to
``CameraDisplay.overlay_moments`` and make sure that the
label text does not overlap with the ellipse.

0 comments on commit 8759143

Please sign in to comment.