Skip to content

Commit 665d3a3

Browse files
authored
Merge pull request #2932 from cta-observatory/camera-pixel-grid-type
Implement square pixels on a hexgrid
2 parents bbb5a06 + 70d1df2 commit 665d3a3

7 files changed

Lines changed: 161 additions & 47 deletions

File tree

docs/changes/2932.feature.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add a new attribute ``grid_type`` to the ``CameraGeometry`` to
2+
add the possibility of handling cameras with square pixels using hexagonal grids.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ filterwarnings = [
176176
"ignore:'setParseAction':DeprecationWarning:matplotlib",
177177
"ignore:'endQuoteChar':DeprecationWarning:matplotlib",
178178
"ignore:'unquoteResults':DeprecationWarning:matplotlib",
179+
"ignore:'enablePackrat':DeprecationWarning:matplotlib",
179180
"ignore:'parseAll':DeprecationWarning:pyparsing",
180181
]
181182
norecursedirs = [

src/ctapipe/instrument/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
from .atmosphere import get_atmosphere_profile_functions
2-
from .camera import CameraDescription, CameraGeometry, CameraReadout, PixelShape
2+
from .camera import (
3+
CameraDescription,
4+
CameraGeometry,
5+
CameraReadout,
6+
PixelGridType,
7+
PixelShape,
8+
)
39
from .guess import guess_telescope
410
from .optics import (
511
ComaPSFModel,
@@ -21,6 +27,7 @@
2127
"get_atmosphere_profile_functions",
2228
"guess_telescope",
2329
"OpticsDescription",
30+
"PixelGridType",
2431
"PixelShape",
2532
"SubarrayDescription",
2633
"TelescopeDescription",

src/ctapipe/instrument/camera/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
from .description import CameraDescription # noqa: F401
2-
from .geometry import CameraGeometry, PixelShape, UnknownPixelShapeWarning # noqa: F401
2+
from .geometry import (
3+
CameraGeometry, # noqa: F401
4+
PixelGridType, # noqa: F401
5+
PixelShape, # noqa: F401
6+
UnknownPixelShapeWarning, # noqa: F401
7+
)
38
from .readout import CameraReadout # noqa: F401
49

510
# commented out due to sphinx issue with classes being defined in 3 places

src/ctapipe/instrument/camera/geometry.py

Lines changed: 92 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from astropy.table import Table
1515
from astropy.utils import lazyproperty
1616
from scipy.sparse import csr_array, lil_array
17-
from scipy.spatial import cKDTree
17+
from scipy.spatial import KDTree
1818

1919
from ctapipe.coordinates import CameraFrame, get_representation_component_names
2020
from ctapipe.utils import get_table_dataset
@@ -36,6 +36,18 @@ def _distance(x1, y1, x2, y2):
3636
return np.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)
3737

3838

39+
@unique
40+
class PixelGridType(Enum):
41+
"""Grid type for pixel coordinate grids."""
42+
43+
#: Regular square grid with minimal gaps
44+
REGULAR_SQUARE = "square"
45+
#: Regular hexagonal grid
46+
REGULAR_HEX = "hex"
47+
#: Everything else
48+
IRREGULAR = "irregular"
49+
50+
3951
@unique
4052
class PixelShape(Enum):
4153
"""Supported Pixel Shapes Enum"""
@@ -100,6 +112,10 @@ class CameraGeometry:
100112
position of each pixel (y-coordinate)
101113
pix_area : u.Quantity
102114
surface area of each pixel
115+
grid_type : None | PixelGridType
116+
The grid type of the pixel grid. By default, square pixels
117+
are assumed to be on a regular square grid and circular and hexagonal
118+
pixels are assumed to be on a regular hex grid.
103119
pix_type : PixelShape
104120
either 'rectangular' or 'hexagonal'
105121
pix_rotation : u.Quantity[angle]
@@ -118,8 +134,8 @@ class CameraGeometry:
118134
Frame in which the pixel coordinates are defined (after applying cam_rotation)
119135
"""
120136

121-
CURRENT_TAB_VERSION = "2.0"
122-
SUPPORTED_TAB_VERSIONS = {"1.0", "1", "1.1", "2.0"}
137+
CURRENT_TAB_VERSION = "2.1"
138+
SUPPORTED_TAB_VERSIONS = {"1.0", "1", "1.1", "2.0", "2.1"}
123139

124140
def __init__(
125141
self,
@@ -128,7 +144,8 @@ def __init__(
128144
pix_x,
129145
pix_y,
130146
pix_area,
131-
pix_type,
147+
pix_type: PixelShape,
148+
grid_type: None | PixelGridType | str = None,
132149
pix_rotation=0 * u.deg,
133150
cam_rotation=0 * u.deg,
134151
neighbors=None,
@@ -140,52 +157,19 @@ def __init__(
140157
self.n_pixels = len(pix_id)
141158
self.unit = pix_x.unit
142159
self.pix_id = np.array(pix_id)
143-
144-
if _validate:
145-
self.pix_id = np.array(self.pix_id)
146-
147-
if self.pix_id.ndim != 1:
148-
raise ValueError(
149-
f"Pixel coordinates must be 1 dimensional, got {pix_id.ndim}"
150-
)
151-
152-
shape = (self.n_pixels,)
153-
154-
if pix_x.shape != shape:
155-
raise ValueError(
156-
f"pix_x has wrong shape: {pix_x.shape}, expected {shape}"
157-
)
158-
if pix_y.shape != shape:
159-
raise ValueError(
160-
f"pix_y has wrong shape: {pix_y.shape}, expected {shape}"
161-
)
162-
if pix_area.shape != shape:
163-
raise ValueError(
164-
f"pix_area has wrong shape: {pix_area.shape}, expected {shape}"
165-
)
166-
167-
if isinstance(pix_type, str):
168-
pix_type = PixelShape.from_string(pix_type)
169-
elif not isinstance(pix_type, PixelShape):
170-
raise TypeError(
171-
f"pix_type must be a PixelShape or the name of a PixelShape, got {pix_type}"
172-
)
173-
174-
if not isinstance(pix_rotation, Angle):
175-
pix_rotation = Angle(pix_rotation)
176-
177-
if not isinstance(cam_rotation, Angle):
178-
cam_rotation = Angle(cam_rotation)
179-
180160
self.pix_x = pix_x
181161
self.pix_y = pix_y.to(self.unit)
182162
self.pix_area = pix_area.to(self.unit**2)
183163
self.pix_type = pix_type
164+
self.grid_type = grid_type
184165
self.pix_rotation = pix_rotation
185166
self.cam_rotation = cam_rotation
186167
self._neighbors = neighbors
187168
self.frame = frame
188169

170+
if _validate:
171+
self._validate()
172+
189173
if neighbors is not None:
190174
if isinstance(neighbors, list):
191175
lil = lil_array((self.n_pixels, self.n_pixels), dtype=bool)
@@ -201,6 +185,59 @@ def __init__(
201185
# cache border pixel mask per instance
202186
self._border_cache = {}
203187

188+
def _validate(self):
189+
self.pix_id = np.array(self.pix_id)
190+
191+
if self.pix_id.ndim != 1:
192+
raise ValueError(
193+
f"Pixel coordinates must be 1 dimensional, got {self.pix_id.ndim}"
194+
)
195+
196+
shape = (self.n_pixels,)
197+
198+
if self.pix_x.shape != shape:
199+
raise ValueError(
200+
f"pix_x has wrong shape: {self.pix_x.shape}, expected {shape}"
201+
)
202+
if self.pix_y.shape != shape:
203+
raise ValueError(
204+
f"pix_y has wrong shape: {self.pix_y.shape}, expected {shape}"
205+
)
206+
if self.pix_area.shape != shape:
207+
raise ValueError(
208+
f"pix_area has wrong shape: {self.pix_area.shape}, expected {shape}"
209+
)
210+
211+
if isinstance(self.pix_type, str):
212+
self.pix_type = PixelShape.from_string(self.pix_type)
213+
elif not isinstance(self.pix_type, PixelShape):
214+
raise TypeError(
215+
f"pix_type must be a PixelShape or the name of a PixelShape, got {self.pix_type}"
216+
)
217+
218+
# if grid_type is not given, deduce grid type from pix type assuming regular grids
219+
self.grid_type = self._get_grid_type(self.grid_type, self.pix_type)
220+
221+
if not isinstance(self.pix_rotation, Angle):
222+
self.pix_rotation = Angle(self.pix_rotation)
223+
224+
if not isinstance(self.cam_rotation, Angle):
225+
self.cam_rotation = Angle(self.cam_rotation)
226+
227+
@staticmethod
228+
def _get_grid_type(grid_type: PixelGridType | str | None, pixel_shape: PixelShape):
229+
if isinstance(grid_type, PixelGridType):
230+
return grid_type
231+
232+
if grid_type is None:
233+
# backwards compatibility: assume square grid for square pixels, hexgrid for anything else
234+
if pixel_shape is PixelShape.SQUARE:
235+
return PixelGridType.REGULAR_SQUARE
236+
237+
return PixelGridType.REGULAR_HEX
238+
239+
return PixelGridType(grid_type)
240+
204241
def __eq__(self, other):
205242
if not isinstance(other, CameraGeometry):
206243
return NotImplemented
@@ -323,6 +360,7 @@ def transform_to(self, frame: BaseCoordinateFrame):
323360
pix_y=trans_y,
324361
pix_area=pix_area,
325362
pix_type=cam.pix_type,
363+
grid_type=cam.grid_type,
326364
pix_rotation=pix_rotation,
327365
cam_rotation=cam_rotation,
328366
neighbors=cam._neighbors,
@@ -352,6 +390,7 @@ def __getitem__(self, slice_):
352390
pix_y=self.pix_y[slice_],
353391
pix_area=self.pix_area[slice_],
354392
pix_type=self.pix_type,
393+
grid_type=self.grid_type,
355394
pix_rotation=self.pix_rotation,
356395
cam_rotation=self.cam_rotation,
357396
neighbors=None,
@@ -425,7 +464,7 @@ def _kdtree(self):
425464
"""
426465

427466
pixel_centers = np.column_stack([self.pix_x.value, self.pix_y.value])
428-
return cKDTree(pixel_centers)
467+
return KDTree(pixel_centers)
429468

430469
@lazyproperty
431470
def _all_pixel_areas_equal(self):
@@ -621,6 +660,7 @@ def to_table(self):
621660
names=["pix_id", "pix_x", "pix_y", "pix_area"],
622661
meta=dict(
623662
PIX_TYPE=self.pix_type.value,
663+
GRID=self.grid_type.value,
624664
TAB_TYPE="ctapipe.instrument.CameraGeometry",
625665
TAB_VER=self.CURRENT_TAB_VERSION,
626666
CAM_ID=self.name,
@@ -669,6 +709,7 @@ def from_table(cls, url_or_table, **kwargs):
669709
pix_y=tab["pix_y"].quantity,
670710
pix_area=tab["pix_area"].quantity,
671711
pix_type=tab.meta["PIX_TYPE"],
712+
grid_type=tab.meta.get("GRID"),
672713
pix_rotation=Angle(tab.meta["PIX_ROT"], u.deg),
673714
cam_rotation=Angle(tab.meta["CAM_ROT"], u.deg),
674715
)
@@ -722,15 +763,17 @@ def calc_pixel_neighbors(self, diagonal=False):
722763
if self.n_pixels <= 1:
723764
return csr_array(np.ones((self.n_pixels, self.n_pixels), dtype=bool))
724765

725-
# assume circle pixels are also on a hex grid
726-
if self.pix_type in (PixelShape.HEXAGON, PixelShape.CIRCLE):
766+
if self.grid_type is PixelGridType.REGULAR_HEX:
727767
max_neighbors = 6
728768
# on a hexgrid, the closest pixel in the second circle is
729769
# the diameter of the hexagon plus the inradius away
730770
# in units of the diameter, this is 1 + np.sqrt(3) / 4 = 1.433
731771
radius = 1.4
732772
norm = 2 # use L2 norm for hex
733-
else:
773+
# for square pixels on a hexgrid, the grid is stretched
774+
if self.pix_type is PixelShape.SQUARE:
775+
radius *= np.sqrt(5) / 2
776+
elif self.grid_type is PixelGridType.REGULAR_SQUARE:
734777
# if diagonal should count as neighbor, we
735778
# need to find at most 8 neighbors with a max L2 distance
736779
# < than 2 * the pixel size, else 4 neighbors with max L1 distance
@@ -745,6 +788,10 @@ def calc_pixel_neighbors(self, diagonal=False):
745788
max_neighbors = 4
746789
radius = 1.5
747790
norm = 1
791+
else:
792+
raise ValueError(
793+
"Automatic computation of pixel neighbors only implemented for regular square and hex grids"
794+
)
748795

749796
distances, neighbor_candidates = self._kdtree.query(
750797
self._kdtree.data, k=max_neighbors + 1, p=norm

src/ctapipe/instrument/camera/tests/test_geometry.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,3 +407,19 @@ def test_empty(prod5_lst):
407407
empty = geometry[mask]
408408

409409
assert empty.neighbor_matrix.shape == (0, 0)
410+
411+
412+
def test_squre_pixels_on_hexgrid(geometry_hexgrid_square_pixels):
413+
geom = geometry_hexgrid_square_pixels
414+
415+
border = geom.get_border_pixel_mask()
416+
# 16 by 16 pixels means 60 border pixels 4 * 14 edge pixels + 4 corrner pixels
417+
assert np.count_nonzero(border) == 4 * 14 + 4
418+
419+
n_neighbors = np.count_nonzero(geom.neighbor_matrix, axis=0)
420+
np.testing.assert_array_equal(n_neighbors[~border], 6)
421+
422+
table = geom.to_table()
423+
assert table.meta["GRID"] == "hex"
424+
geom_from_table = CameraGeometry.from_table(table)
425+
assert geom_from_table == geom

src/ctapipe/instrument/conftest.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import os
66
import shutil
77

8+
import astropy.units as u
9+
import numpy as np
810
import pytest
911

1012
from ctapipe.utils.filelock import FileLock
@@ -42,3 +44,37 @@ def svc_path(instrument_dir):
4244
del os.environ["CTAPIPE_SVC_PATH"]
4345
else:
4446
os.environ["CTAPIPE_SVC_PATH"] = before
47+
48+
49+
@pytest.fixture()
50+
def geometry_hexgrid_square_pixels():
51+
"""A camera with square pixels on a hexagonal grid"""
52+
from ctapipe.coordinates import CameraFrame
53+
from ctapipe.instrument import CameraGeometry, PixelGridType, PixelShape
54+
55+
size = 22
56+
pix_id = np.arange(256)
57+
58+
coords = np.arange(-7.5 * size, 7.6 * size, size)
59+
pix_x, pix_y = np.meshgrid(coords, coords)
60+
61+
# offset every second row by half the pixel size
62+
pix_x[::2] += 0.5 * size
63+
pix_x = pix_x.ravel() * u.mm
64+
pix_y = pix_y.ravel() * u.mm
65+
66+
# introduce some gaps
67+
pix_area = (size / 1.05) ** 2
68+
pix_area = np.full(len(pix_id), pix_area) * u.mm**2
69+
70+
geom = CameraGeometry(
71+
pix_id=pix_id,
72+
pix_x=pix_x,
73+
pix_y=pix_y,
74+
pix_area=pix_area,
75+
pix_type=PixelShape.SQUARE,
76+
grid_type=PixelGridType.REGULAR_HEX,
77+
name="HEXSQUARECAM",
78+
frame=CameraFrame(focal_length=10 * u.m),
79+
)
80+
return geom

0 commit comments

Comments
 (0)