Coverage for python / lsst / cell_coadds / _stitched_coadd.py: 25%

137 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-25 08:41 +0000

1# This file is part of cell_coadds. 

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# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22from __future__ import annotations 

23 

24__all__ = ("StitchedCoadd",) 

25 

26import dataclasses 

27from collections.abc import Iterator, Set 

28from functools import partial 

29from typing import TYPE_CHECKING 

30 

31import numpy as np 

32 

33from lsst.afw.image import CoaddInputs, ExposureF, FilterLabel, PhotoCalib 

34from lsst.afw.table import ExposureTable, Schema 

35from lsst.geom import Box2I, Point2I 

36 

37from ._common_components import CoaddUnits, CommonComponents, CommonComponentsProperties 

38from ._grid_container import GridContainer 

39from ._identifiers import ObservationIdentifiers 

40from ._image_planes import ImagePlanes, ViewImagePlanes 

41from ._stitched_aperture_correction import StitchedApertureCorrection 

42from ._stitched_exposure_catalog import StitchedExposureCatalog 

43from ._stitched_image_planes import StitchedImagePlanes 

44from ._stitched_psf import StitchedPsf 

45from ._uniform_grid import UniformGrid 

46from .typing_helpers import StitchedCoaddApCorrMap 

47 

48if TYPE_CHECKING: 48 ↛ 49line 48 didn't jump to line 49 because the condition on line 48 was never true

49 from ._multiple_cell_coadd import MultipleCellCoadd 

50 

51 

52class StitchedCoadd(StitchedImagePlanes, CommonComponentsProperties): 

53 """A lazy-evaluation coadd that stitches together images from adjacent 

54 cells. 

55 

56 Parameters 

57 ---------- 

58 cell_coadd : `MultipleCellCoadd` 

59 Cell-based coadd to stitch together. 

60 bbox : `Box2I`, optional 

61 The region over which a contiguous coadd is desired. Defaults to 

62 ``cell_coadd.outer_bbox``. 

63 

64 Notes 

65 ----- 

66 This class simply inserts subimages from each cell into the full image, 

67 doing so when an attribute is first accessed to avoid stitching together 

68 planes that may never be accessed. 

69 

70 A `StitchedCoadd` cannot be serialized in FITS format directly. Instead, 

71 the recommended way is to serialize the `MultipleCellCoadd` instance that 

72 was used to construct the object and reconstruct the `StitchedCoadd` by 

73 calling the `stitch` method on it. A less recommended way is to call the 

74 `asExposure` method to get an `lsst.afw.image.Exposure` object and persist 

75 that to the disk. 

76 """ 

77 

78 def __init__(self, cell_coadd: MultipleCellCoadd, *, bbox: Box2I | None = None): 

79 super().__init__() 

80 if bbox is None: 

81 bbox = cell_coadd.outer_bbox 

82 elif not cell_coadd.outer_bbox.contains(bbox): 

83 raise ValueError( 

84 f"Cell coadd inner bounding box {cell_coadd.outer_bbox} does not " 

85 f"contain stitch target area {bbox}." 

86 ) 

87 self._bbox = bbox 

88 self._cell_coadd = cell_coadd 

89 self._psf: StitchedPsf | None = None 

90 self._ap_corr_map: StitchedCoaddApCorrMap | None = None 

91 self._ccds: StitchedExposureCatalog | None = None 

92 self._visits: StitchedExposureCatalog | None = None 

93 self._common = cell_coadd.common 

94 

95 @property 

96 def bbox(self) -> Box2I: 

97 # Docstring inherited. 

98 return self._bbox 

99 

100 @property 

101 def grid(self) -> UniformGrid: 

102 """Object that defines the piecewise grid (of inner cell regions) that 

103 this object stitches together. 

104 

105 This may include cells outside the region covered by these image 

106 planes. 

107 """ 

108 return self._cell_coadd.grid 

109 

110 def _iter_cell_planes(self) -> Iterator[ImagePlanes]: 

111 # Docstring inherited. 

112 x_max, y_max = self.grid.index(self.bbox.getMax()) 

113 

114 for cell in self._cell_coadd.cells.values(): 

115 bbox = cell.inner.bbox 

116 if cell.identifiers.cell.x == 0: 

117 # This is a special case for the first column of cells. 

118 bbox.include(Point2I(cell.outer.bbox.beginX, cell.outer.bbox.centerY)) 

119 elif cell.identifiers.cell.x == x_max: 

120 # This is a special case for the last column of cells. 

121 bbox.include(Point2I(cell.outer.bbox.endX, cell.outer.bbox.centerY)) 

122 

123 if cell.identifiers.cell.y == 0: 

124 # This is a special case for the last row of cells. 

125 bbox.include(Point2I(cell.outer.bbox.centerX, cell.outer.bbox.beginY)) 

126 elif cell.identifiers.cell.y == y_max: 

127 # This is a special case for the first row of cells. 

128 bbox.include(Point2I(cell.outer.bbox.centerX, cell.outer.bbox.endY)) 

129 

130 bbox.clip(cell.outer.bbox) 

131 make_view = partial(cell.make_view, bbox=bbox) 

132 yield ViewImagePlanes(cell.outer, bbox=bbox, make_view=make_view) 

133 

134 @property 

135 def n_noise_realizations(self) -> int: 

136 # Docstring inherited. 

137 return self._cell_coadd.n_noise_realizations 

138 

139 @property 

140 def mask_fraction_names(self) -> Set[str]: 

141 # Docstring inherited. 

142 return self._cell_coadd.mask_fraction_names 

143 

144 @property 

145 def psf(self) -> StitchedPsf: 

146 """The piecewise PSF of this image.""" 

147 if self._psf is None: 

148 self._psf = StitchedPsf( 

149 self._cell_coadd.cells.rebuild_transformed(lambda cell: cell.psf_image.convertD()), 

150 self._cell_coadd.grid, 

151 ) 

152 return self._psf 

153 

154 @property 

155 def common(self) -> CommonComponents: 

156 # Docstring inherited. 

157 return self._cell_coadd.common 

158 

159 def set_cell_edges(self, *, edge_width: int = 1, edge_mask_name: str = "CELL_EDGE") -> None: 

160 """Set a mask bit indicating the inner cell edges. 

161 

162 Parameters 

163 ---------- 

164 edge_width : `int`, optional 

165 The width of the edge region to flag, in pixels. 

166 edge_mask_name : `str`, optional 

167 The name of the mask plane to add for the edge region. 

168 """ 

169 for cell in self._cell_coadd.cells.values(): 

170 cell._set_cell_edges(edge_width=edge_width, edge_mask_name=edge_mask_name) 

171 

172 def _make_coadd_inputs(self) -> CoaddInputs: 

173 """Make a coadd inputs object for this stitched coadd. 

174 

175 Returns 

176 ------- 

177 coadd_inputs : `lsst.afw.image.CoaddInputs` 

178 The coadd inputs for this stitched coadd. 

179 """ 

180 visit_schema = ExposureTable.makeMinimalSchema() 

181 visit_schema.addField("visit", type=np.int64, doc="Visit ID") 

182 

183 ccd_schema = Schema(visit_schema) 

184 ccd_schema.addField("ccd", type=np.int32, doc="Detector ID") 

185 ccd_schema.addField("weight", type=np.float32, doc="Weight for this (visit, detector) in the coadd.") 

186 

187 coadd_inputs = CoaddInputs(visit_schema, ccd_schema) 

188 

189 for obs_id in self.visits: 

190 visit_record = coadd_inputs.visits.addNew() 

191 visit_record["visit"] = obs_id.visit 

192 visit_record.setBBox(Box2I()) # Dummy bbox; not used. 

193 

194 # Pre-process the weights to avoid redundant lookups and matching. 

195 weights: dict[ObservationIdentifiers, float] = {} 

196 for cell in self._cell_coadd.cells.values(): 

197 for obs_id, coadd_input in cell.inputs.items(): 

198 weights[obs_id] = coadd_input.weight 

199 if len(weights) == len(self._cell_coadd.common.visit_polygons): 

200 break 

201 

202 for obs_id, polygon in self._cell_coadd.common.visit_polygons.items(): 

203 ccd_record = coadd_inputs.ccds.addNew() 

204 ccd_record["visit"] = obs_id.visit 

205 ccd_record["ccd"] = obs_id.detector 

206 # ObservationIdentifiers in the inputs is a proper subset of those 

207 # denoting the visit polygons. For edgeless coadds, some 

208 # identifiers may not be present in weights and we take that to 

209 # mean that they were excluded from the coadds (and hence have 

210 # zero weight). 

211 ccd_record["weight"] = weights.get(obs_id, 0.0) 

212 ccd_record.setBBox(Box2I()) # Dummy bbox; not used. 

213 ccd_record.validPolygon = polygon 

214 

215 return coadd_inputs 

216 

217 def asExposure( 

218 self, 

219 *, 

220 noise_index: int | None = None, 

221 ) -> ExposureF: 

222 """Return an `lsst.afw.image.Exposure` view of this piecewise image. 

223 

224 Parameters 

225 ---------- 

226 noise_index : `int` or `None`, optional 

227 If specified, return an exposure containing the specified noise 

228 realization instead of the data image. 

229 

230 Returns 

231 ------- 

232 exposure : `lsst.afw.image.ExposureF` 

233 The stitched exposure. 

234 

235 Notes 

236 ----- 

237 The returned exposure will have its coaddInputs set. However, its 

238 internal representation is very different from those that are genuine 

239 afw objects. The records in the `ccds` and `visits` tables do not 

240 contain the individual bounding boxes and WCSs. Instead, the 

241 validPolygon is already in the coadd coordinates. To query the subset 

242 of inputs, use the `subset_containing_ccds` and 

243 `subset_containing_visits` methods on the returned exposure's 

244 `getInfo().getCoaddInputs()` object. 

245 """ 

246 result = ExposureF(self.asMaskedImage(noise_index=noise_index)) 

247 result.getInfo().setCoaddInputs(self._make_coadd_inputs()) 

248 # Exposure components derived from "common" components are all simple. 

249 result.setWcs(self._cell_coadd.wcs) 

250 result.setFilter(FilterLabel(band=self.band)) 

251 if self.units is CoaddUnits.nJy: 

252 result.setPhotoCalib(PhotoCalib(1.0)) 

253 result.metadata["BUNIT"] = "nJy" 

254 

255 # We can't do result.setId here, because: 

256 # 

257 # - we don't know whether this should be a packed tract+patch+band ID 

258 # or just a tract+patch ID; 

259 # 

260 # - we don't know how to pack the information we have anyway. 

261 # 

262 # Maybe DM-31924 will provide a solution to at least the latter. 

263 # result.setId(self._cell_coadd.identifiers.patch) 

264 

265 # We could add CoaddInputs here, but without WCS, PSF, etc in them; 

266 # it's not clear that's good enough or even useful, given that the cell 

267 # provide a much more clean view of what the inputs are at any given 

268 # point. 

269 

270 # PSF is the first of many components that need piecewise 

271 # implementations. More to do here for at least aperture corrections 

272 # and transmission curves. 

273 result.setPsf(self.psf) 

274 

275 return result 

276 

277 @property 

278 def ap_corr_map(self) -> StitchedCoaddApCorrMap: 

279 """Stitch the aperture correction maps from the cell coadd. 

280 

281 This converts the aperture correction maps from each cell into a single 

282 `ApCorrMap` that quacks like `lsst.afw.image.ApCorrMap`. The resulting 

283 object has the fields to correct as keys and a 

284 `StitchedApertureCorrection` for each field. 

285 

286 Notes 

287 ----- 

288 These cannot be attached to an `~lsst.afw.image.Exposure` object. 

289 """ 

290 if self._ap_corr_map is None: 

291 ap_corr_map: dict[str, StitchedApertureCorrection] = {} 

292 field_names = self._cell_coadd.cells.arbitrary.aperture_corrected_algorithms 

293 for field_name in field_names: 

294 gc = GridContainer[float](shape=self.grid.shape) 

295 for scc in self._cell_coadd.cells.values(): 

296 gc[scc.identifiers.cell] = scc.aperture_correction_map[field_name] 

297 ap_corr_map[field_name] = StitchedApertureCorrection(self.grid, gc) 

298 

299 self._ap_corr_map = ap_corr_map 

300 

301 return self._ap_corr_map 

302 

303 @property 

304 def ccds(self) -> StitchedExposureCatalog: 

305 """An ExposureCatalog-like object that contains the information about 

306 the ``ObservationIdentifiers`` at any point on the coadd. It can also 

307 be used as an iterable to iterate over the unique (visit, detector) 

308 combinations. 

309 """ 

310 if self._ccds is None: 

311 gc = GridContainer[tuple[ObservationIdentifiers, ...]](shape=self.grid.shape) 

312 for idx, scc in self._cell_coadd.cells.items(): 

313 gc[idx] = tuple(scc.inputs.keys()) 

314 self._ccds = StitchedExposureCatalog(self.grid, gc) 

315 

316 return self._ccds 

317 

318 @property 

319 def visits(self) -> StitchedExposureCatalog: 

320 """An ExposureCatalog-like object that contains the information about 

321 the ``ObservationIdentifiers`` at any point on the coadd. It can also 

322 be used as an iterable to iterate over the unique visits. 

323 """ 

324 if self._visits is None: 

325 gc = GridContainer[tuple[ObservationIdentifiers, ...]](shape=self.grid.shape) 

326 for idx, scc in self._cell_coadd.cells.items(): 

327 gc[idx] = tuple( 

328 sorted({dataclasses.replace(ccd_input, detector=-1) for ccd_input in scc.inputs}) 

329 ) 

330 self._visits = StitchedExposureCatalog(self.grid, gc) 

331 

332 return self._visits