1414from astropy .table import Table
1515from astropy .utils import lazyproperty
1616from scipy .sparse import csr_array , lil_array
17- from scipy .spatial import cKDTree
17+ from scipy .spatial import KDTree
1818
1919from ctapipe .coordinates import CameraFrame , get_representation_component_names
2020from 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
4052class 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
0 commit comments