Coverage for python/lsst/images/cells/_psf.py: 52%

131 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-09-28 09:46 +0000

1# This file is part of lsst-images. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11 

12from __future__ import annotations 

13 

14__all__ = ("CellPointSpreadFunction", "CellPointSpreadFunctionSerializationModel") 

15 

16from functools import cached_property 

17from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload 

18 

19import numpy as np 

20import pydantic 

21 

22from .._cell_grid import CellGrid, CellGridBounds, CellIJ 

23from .._geom import YX, Bounds, BoundsError, Box 

24from .._image import Image 

25from ..psfs import PointSpreadFunction 

26from ..serialization import ( 

27 ArchiveTree, 

28 ArrayReferenceModel, 

29 InlineArrayModel, 

30 InputArchive, 

31 InvalidParameterError, 

32 OutputArchive, 

33) 

34from ..utils import round_half_up 

35 

36if TYPE_CHECKING: 

37 try: 

38 from lsst.cell_coadds import StitchedPsf as LegacyStitchedPsf 

39 except ImportError: 

40 type LegacyStitchedPsf = Any # type: ignore[no-redef] 

41 

42 

43class CellPointSpreadFunction(PointSpreadFunction): 

44 """A PSF model that is at least approximately constant over cells. 

45 

46 Parameters 

47 ---------- 

48 array 

49 A 4-d array of PSF kernel images with with shape 

50 ``(n_cells_y, n_cells_x, psf_shape_y, psf_shape_x)``. 

51 bounds 

52 Description of the cell grid and any missing cells. Array entries for 

53 missing cells should be NaN. 

54 resampling_kernel 

55 Name of the resampling kernel to use when shifting the kernel image 

56 into the stellar image. 

57 

58 Notes 

59 ----- 

60 Unlike most PSF model types, `CellPointSpreadFunction` can be subset via 

61 slicing: 

62 

63 - a bounding `.Box` for a subimage, which returns a new PSF with only the 

64 cells that cover that subimage; 

65 - a `CellIJ` index, which returns the kernel image for that cell. 

66 - 

67 """ 

68 

69 def __init__( 

70 self, 

71 array: np.ndarray, 

72 bounds: CellGridBounds, 

73 resampling_kernel: Literal["lanczos3", "lanczos5"] = "lanczos5", 

74 ) -> None: 

75 self._array = array 

76 self._bounds: CellGridBounds = bounds 

77 self._resampling_kernel = resampling_kernel 

78 

79 @property 

80 def grid(self) -> CellGrid: 

81 """The grid that defines the PSF's cells (`CellGrid`). 

82 

83 Notes 

84 ----- 

85 This is usually (but is not guaranteed to be) the grid for a full 

86 patch, even when the PSF only covers a subimage. 

87 """ 

88 return self._bounds.grid 

89 

90 @property 

91 def bounds(self) -> CellGridBounds: 

92 """The bounds where the PSF can be evaluated (`CellGridBounds`).""" 

93 return self._bounds 

94 

95 @cached_property 

96 def kernel_bbox(self) -> Box: 

97 sy, sx = self._array.shape[2:] 

98 ry = sy // 2 

99 rx = sx // 2 

100 return Box.factory[-ry : ry + 1, -rx : rx + 1] 

101 

102 @overload 

103 def __getitem__(self, bbox: Box) -> CellPointSpreadFunction: ... 103 ↛ exitline 103 didn't return from function '__getitem__' because

104 @overload 

105 def __getitem__(self, index: CellIJ) -> Image: ... 105 ↛ exitline 105 didn't return from function '__getitem__' because

106 

107 def __getitem__(self, key: Box | CellIJ) -> CellPointSpreadFunction | Image: 

108 match key: 

109 case CellIJ(): 

110 if key in self._bounds.missing: 110 ↛ 111line 110 didn't jump to line 111 because the condition on line 110 was never true

111 raise BoundsError(f"Cell {key} is missing for this PSF.") 

112 if not self._bounds.contains_cell(key): 112 ↛ 114line 112 didn't jump to line 114 because the condition on line 112 was always true

113 raise BoundsError(f"Cell {key} is out of bounds for this PSF.") 

114 index = key - self._bounds.subgrid_start 

115 try: 

116 return Image(self._array[index.i, index.j], bbox=self.kernel_bbox) 

117 except IndexError: 

118 raise BoundsError(f"Cell {key} is out of bounds for this PSF.") 

119 case Box(): 119 ↛ 122line 119 didn't jump to line 122 because the pattern on line 119 always matched

120 bounds, slices = self._subset_impl(self._bounds, key) 

121 return CellPointSpreadFunction(self._array[slices.y, slices.x, ...].copy(), bounds=bounds) 

122 case _: 

123 raise TypeError("Invalid argument for CellPointSpreadFunction.__getitem__.") 

124 

125 def compute_kernel_image(self, *, x: float, y: float) -> Image: 

126 index = self.grid.index_of(x=round(x), y=round(y)) 

127 try: 

128 return self[index] 

129 except Exception as err: 

130 err.add_note(f"Evaluating cell PSF at x={x}, y={y}.") 

131 raise 

132 

133 def compute_stellar_image(self, *, x: float, y: float) -> Image: 

134 try: 

135 from lsst.afw.math import offsetImage 

136 from lsst.geom import Point2I 

137 except ImportError as err: 

138 err.add_note("CellPointSpreadFunction.compute_stellar_image cannot be used without lsst.afw.") 

139 raise 

140 ix = round_half_up(x) 

141 dx = x - ix 

142 iy = round_half_up(y) 

143 dy = y - iy 

144 kernel_image = self.compute_kernel_image(x=x, y=y) 

145 if dx != 0 or dy != 0: 

146 legacy_result = offsetImage(kernel_image.to_legacy(), dx, dy, self._resampling_kernel, 5) 

147 else: 

148 # This branch is equal to the other up to round-off error, but it's 

149 # convenient nonetheless because it maintains exact compatibility 

150 # with the legacy implementation, where the caching mechanism 

151 # causes the offsetImage call to be skipped. 

152 legacy_result = kernel_image.to_legacy() 

153 legacy_result.setXY0(Point2I(legacy_result.getX0() + ix, legacy_result.getY0() + iy)) 

154 return Image.from_legacy(legacy_result) 

155 

156 def compute_stellar_bbox(self, *, x: float, y: float) -> Box: 

157 # This is obviously inefficient, but it's what afw does, and hence the 

158 # only easy way we've got to replicate what afw does. 

159 return self.compute_stellar_image(x=x, y=y).bbox 

160 

161 def serialize(self, archive: OutputArchive[Any]) -> CellPointSpreadFunctionSerializationModel: 

162 array_model = archive.add_array(self._array) 

163 return CellPointSpreadFunctionSerializationModel(array=array_model, bounds=self.bounds) 

164 

165 @classmethod 

166 def from_legacy( 

167 cls, legacy_psf: LegacyStitchedPsf, bounds: Bounds | None = None 

168 ) -> CellPointSpreadFunction: 

169 # 'bounds' is accepted as an argument only for base-class 

170 # compatibility; we always generate our own bounds. 

171 from lsst.geom import Box2I 

172 

173 grid = CellGrid.from_legacy(legacy_psf.grid) 

174 # Start with bounds that cover the entire grid. 

175 bounds = CellGridBounds(grid=grid, bbox=grid.bbox) 

176 # Shrink bounds to just the bbox where we have data. 

177 legacy_bbox = Box2I() 

178 for legacy_index in legacy_psf.images.keys(): 

179 legacy_bbox.include(legacy_psf.grid.bbox_of(legacy_index)) 

180 bounds = bounds[Box.from_legacy(legacy_bbox)] 

181 # Allocate and populate the array. 

182 psf_image_size_y, psf_image_size_x = legacy_psf.images.arbitrary.array.shape 

183 array = np.zeros( 

184 (bounds.subgrid_size.i, bounds.subgrid_size.j, psf_image_size_y, psf_image_size_x), 

185 dtype=np.float64, 

186 ) 

187 missing: set[CellIJ] = set() 

188 for cell_index in bounds.cell_indices(): 

189 legacy_index = cell_index.to_legacy() 

190 array_index = cell_index - bounds.subgrid_start 

191 if legacy_index in legacy_psf.images: 

192 array[array_index.i, array_index.j] = legacy_psf.images[legacy_index].array 

193 else: 

194 array[array_index.i, array_index.j] = np.nan 

195 missing.add(cell_index) 

196 # Modify the bounds one last time to account for missing cells. 

197 bounds = CellGridBounds(grid=grid, bbox=bounds.bbox, missing=frozenset(missing)) 

198 return cls(array, bounds=bounds) 

199 

200 def to_legacy(self) -> LegacyStitchedPsf: 

201 """Convert to a legacy `lsst.cell_coadds.StitchedPsf` object.""" 

202 from lsst.afw.image import ImageD as LegacyImageD 

203 from lsst.cell_coadds import GridContainer as LegacyGridContainer 

204 from lsst.cell_coadds import StitchedPsf as LegacyStitchedPsf 

205 

206 grid = self.grid.to_legacy() 

207 gc = LegacyGridContainer[LegacyImageD](grid.shape) 

208 for cell_index in self.bounds.cell_indices(): 

209 gc[cell_index.to_legacy()] = self[cell_index].to_legacy() 

210 return LegacyStitchedPsf(gc, grid) 

211 

212 @staticmethod 

213 def _subset_impl(bounds: CellGridBounds, bbox: Box) -> tuple[CellGridBounds, YX[slice]]: 

214 subset_bounds = bounds[bbox] 

215 start = subset_bounds.subgrid_start - bounds.subgrid_start 

216 stop = subset_bounds.subgrid_stop - bounds.subgrid_start 

217 return subset_bounds, YX(y=slice(start.i, stop.i), x=slice(start.j, stop.j)) 

218 

219 

220class CellPointSpreadFunctionSerializationModel(ArchiveTree): 

221 """Model used to serialize CellPointSpreadFunction objects.""" 

222 

223 SCHEMA_NAME: ClassVar[str] = "cell_psf" 

224 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

225 MIN_READ_VERSION: ClassVar[int] = 1 

226 PUBLIC_TYPE: ClassVar[type] = CellPointSpreadFunction 

227 

228 array: ArrayReferenceModel | InlineArrayModel = pydantic.Field( 

229 description=( 

230 "A 4-d array of PSF kernel images with with shape " 

231 "(n_cells_y, n_cells_x, psf_shape_y, psf_shape_x)." 

232 ) 

233 ) 

234 bounds: CellGridBounds = pydantic.Field( 

235 description=( 

236 "Description of the cell grid and any missing cells. Array entries for " 

237 "missing cells should be NaN." 

238 ) 

239 ) 

240 

241 def deserialize( 

242 self, archive: InputArchive[Any], *, bbox: Box | None = None, **kwargs: Any 

243 ) -> CellPointSpreadFunction: 

244 if kwargs: 244 ↛ 245line 244 didn't jump to line 245 because the condition on line 244 was never true

245 raise InvalidParameterError( 

246 f"Unrecognized parameters for CellPointSpreadFunction: {set(kwargs.keys())}." 

247 ) 

248 bounds = self.bounds 

249 if bbox is not None: 

250 bounds, slices = CellPointSpreadFunction._subset_impl(bounds, bbox) 

251 array = archive.get_array(self.array, slices=slices) 

252 else: 

253 array = archive.get_array(self.array) 

254 return CellPointSpreadFunction(array, bounds)