Coverage for python/lsst/source/injection/inject_engine.py: 66%

423 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-13 10:13 +0000

1# This file is part of source_injection. 

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__ = ["generate_galsim_objects", "inject_galsim_objects_into_exposure"] 

25 

26import numbers 

27import os 

28from collections import Counter 

29from collections.abc import Generator, Iterable 

30from typing import Any 

31 

32import galsim 

33import galsim.errors as galsim_errors 

34import numpy as np 

35import numpy.ma as ma 

36from astropy.io import fits 

37from astropy.table import Table 

38from galsim import GalSimFFTSizeError 

39from pydantic import ConfigDict 

40from pydantic.dataclasses import dataclass 

41 

42import lsst.afw.detection as afwDetection 

43from lsst.afw.geom import SkyWcs 

44from lsst.afw.image import ExposureF, PhotoCalib 

45from lsst.geom import Box2I, Point2D, Point2I, SpherePoint, arcseconds, degrees 

46from lsst.images import BoundsError, MaskPlane 

47from lsst.images._mask import _guess_legacy_plane_map 

48from lsst.images.cells import CellCoadd, CellIJ 

49from lsst.pex.exceptions import InvalidParameterError, LogicError 

50 

51# This is off by default but we want it on since we catch the error 

52galsim_errors.raise_fft_size_error = True 

53 

54 

55def get_object_data(source_data: dict[str, Any], object_class: galsim.GSObject) -> dict[str, Any]: 

56 """Assemble a dictionary of allowed keyword arguments and their 

57 corresponding values to use when constructing a GSObject. 

58 

59 Parameters 

60 ---------- 

61 source_data : `dict` [`str`, `Any`] 

62 Dictionary of source data. 

63 object_class : `galsim.gsobject.GSObject` 

64 Class of GSObject to match against. 

65 

66 Returns 

67 ------- 

68 object_data : `dict` [`str`, `Any`] 

69 Dictionary of source data to pass to the GSObject constructor. 

70 """ 

71 req = getattr(object_class, "_req_params", {}) 

72 opt = getattr(object_class, "_opt_params", {}) 

73 single = getattr(object_class, "_single_params", {}) 

74 

75 object_data = {} 

76 

77 # Check required args. 

78 for key in req: 

79 if key not in source_data: 79 ↛ 80line 79 didn't jump to line 80 because the condition on line 79 was never true

80 raise ValueError(f"Required parameter {key} not found in input catalog.") 

81 object_data[key] = source_data[key] 

82 

83 # Optional args. 

84 for key in opt: 

85 if key in source_data: 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true

86 object_data[key] = source_data[key] 

87 

88 # Single args. 

89 for s in single: 

90 count = 0 

91 for key in s: 

92 if key in source_data: 

93 count += 1 

94 if count > 1: 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true

95 raise ValueError(f"Only one of {s.keys()} allowed for type {object_class}.") 

96 object_data[key] = source_data[key] 

97 if count == 0: 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true

98 raise ValueError(f"One of the args {s.keys()} is required for type {object_class}.") 

99 

100 return object_data 

101 

102 

103def get_shear_data( 

104 source_data: dict[str, Any], 

105 shear_attributes: list[str] = [ 

106 "g1", 

107 "g2", 

108 "g", 

109 "e1", 

110 "e2", 

111 "e", 

112 "eta1", 

113 "eta2", 

114 "eta", 

115 "q", 

116 "beta", 

117 "shear", 

118 ], 

119) -> dict[str, Any]: 

120 """Assemble a dictionary of allowed keyword arguments and their 

121 corresponding values to use when constructing a Shear. 

122 

123 Parameters 

124 ---------- 

125 source_data : `dict` [`str`, `Any`] 

126 Dictionary of source data. 

127 shear_attributes : `list` [`str`], optional 

128 List of allowed shear attributes. 

129 

130 Returns 

131 ------- 

132 shear_data : `dict` [`str`, `Any`] 

133 Dictionary of source data to pass to the Shear constructor. 

134 """ 

135 shear_params = set(shear_attributes) & set(source_data.keys()) 

136 shear_data = {shear_param: source_data[shear_param] for shear_param in shear_params} 

137 # Return an empty dict if all of the values are masked 

138 # This should only be the case for DeltaFunction, but validation of the 

139 # return is left for the caller of this function. 

140 # While None might perhaps have been a better choice here, changing the 

141 # return type is unnecessary given that galsim.Shear can be initialized 

142 # with no args and correctly returns a trivial shear. 

143 if all(np.ma.is_masked(x) for x in shear_data.values()): 

144 return {} 

145 if (beta := shear_data.get("beta")) is not None: 145 ↛ 147line 145 didn't jump to line 147 because the condition on line 145 was always true

146 shear_data["beta"] = beta * galsim.degrees 

147 return shear_data 

148 

149 

150def generate_galsim_objects( 

151 injection_catalog: Table, 

152 photo_calib: PhotoCalib, 

153 wcs: SkyWcs, 

154 fits_alignment: str, 

155 stamp_prefix: str = "", 

156 logger: Any | None = None, 

157) -> Generator[tuple[SpherePoint, Point2D, int, galsim.gsobject.GSObject], None, None]: 

158 """Generate GalSim objects from an injection catalog. 

159 

160 Parameters 

161 ---------- 

162 injection_catalog : `astropy.table.Table` 

163 Table of sources to be injected. 

164 photo_calib : `lsst.afw.image.PhotoCalib` 

165 Photometric calibration used to calibrate injected sources. 

166 wcs : `lsst.afw.geom.SkyWcs` 

167 WCS used to calibrate injected sources. 

168 fits_alignment : `str` 

169 Alignment of the FITS image to the WCS. Allowed values: "wcs", "pixel". 

170 stamp_prefix : `str` 

171 Prefix to add to the stamp name. 

172 logger : `lsst.utils.logging.LsstLogAdapter`, optional 

173 Logger to use for logging messages. 

174 

175 Yields 

176 ------ 

177 sky_coords : `lsst.geom.SpherePoint` 

178 RA/Dec coordinates of the source. 

179 pixel_coords : `lsst.geom.Point2D` 

180 Pixel coordinates of the source. 

181 draw_size : `int` 

182 Size of the stamp to draw. 

183 object : `galsim.gsobject.GSObject` 

184 A fully specified and transformed GalSim object. 

185 """ 

186 if logger: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true

187 source_types = Counter(injection_catalog["source_type"]) # type: ignore 

188 grammar0 = "source" if len(injection_catalog) == 1 else "sources" 

189 grammar1 = "type" if len(source_types) == 1 else "types" 

190 logger.info( 

191 "Generating %d injection %s consisting of %d unique %s: %s.", 

192 len(injection_catalog), 

193 grammar0, 

194 len(source_types), 

195 grammar1, 

196 ", ".join(f"{k}({v})" for k, v in source_types.items()), 

197 ) 

198 for source_data_full in injection_catalog: 

199 items = dict(source_data_full).items() # type: ignore 

200 source_data = {k: v for (k, v) in items if v is not ma.masked} 

201 try: 

202 sky_coords = SpherePoint(float(source_data["ra"]), float(source_data["dec"]), degrees) 

203 except KeyError: 

204 sky_coords = wcs.pixelToSky(float(source_data["x"]), float(source_data["y"])) 

205 try: 

206 pixel_coords = Point2D(source_data["x"], source_data["y"]) 

207 except KeyError: 

208 pixel_coords = wcs.skyToPixel(sky_coords) 

209 try: 

210 inst_flux = photo_calib.magnitudeToInstFlux(source_data["mag"], pixel_coords) 

211 except LogicError: 

212 continue 

213 try: 

214 draw_size = int(source_data["draw_size"]) 

215 except KeyError: 

216 draw_size = 0 

217 

218 if source_data["source_type"] == "Stamp": 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true

219 stamp_file = stamp_prefix + source_data["stamp"] 

220 object = make_galsim_stamp(stamp_file, fits_alignment, wcs, sky_coords, inst_flux) 

221 elif source_data["source_type"] == "Trail": 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true

222 object = make_galsim_trail(source_data, wcs, sky_coords, inst_flux) 

223 else: 

224 object = make_galsim_object(source_data, source_data["source_type"], inst_flux, logger) 

225 

226 yield sky_coords, pixel_coords, draw_size, object 

227 

228 

229def make_galsim_object( 

230 source_data: dict[str, Any], 

231 source_type: str, 

232 inst_flux: float, 

233 logger: Any | None = None, 

234) -> galsim.gsobject.GSObject: 

235 """Make a generic GalSim object from a collection of source data. 

236 

237 Parameters 

238 ---------- 

239 source_data : `dict` [`str`, `Any`] 

240 Dictionary of source data. 

241 source_type : `str` 

242 Type of the source, corresponding to a GalSim class. 

243 inst_flux : `float` 

244 Instrumental flux of the source. 

245 logger : `~lsst.utils.logging.LsstLogAdapter`, optional 

246 

247 Returns 

248 ------- 

249 object : `galsim.gsobject.GSObject` 

250 A fully specified and transformed GalSim object. 

251 """ 

252 # Populate the non-shear and non-flux parameters. 

253 object_class = getattr(galsim, source_type) 

254 object_data = get_object_data(source_data, object_class) 

255 object = object_class(**object_data) 

256 # Create a version of the object with an area-preserving shear applied. 

257 shear_data = get_shear_data(source_data) 

258 if shear_data: 

259 try: 

260 object = object.shear(**shear_data) 

261 except TypeError as err: 

262 if logger: 

263 logger.warning("Cannot apply shear to GalSim object: %s", err) 

264 pass 

265 # Apply the instrumental flux and return. 

266 object = object.withFlux(inst_flux) 

267 return object 

268 

269 

270def make_galsim_trail( 

271 source_data: dict[str, Any], 

272 wcs: SkyWcs, 

273 sky_coords: SpherePoint, 

274 inst_flux: float, 

275 trail_thickness: float = 1e-6, 

276) -> galsim.gsobject.GSObject: 

277 """Make a trail with GalSim from a collection of source data. 

278 

279 Parameters 

280 ---------- 

281 source_data : `dict` [`str`, `Any`] 

282 Dictionary of source data. 

283 wcs : `lsst.afw.geom.SkyWcs` 

284 World coordinate system. 

285 sky_coords : `lsst.geom.SpherePoint` 

286 Sky coordinates of the source. 

287 inst_flux : `float` 

288 Instrumental flux of the source. 

289 trail_thickness : `float` 

290 Thickness of the trail in pixels. 

291 

292 Returns 

293 ------- 

294 object : `galsim.gsobject.GSObject` 

295 A fully specified and transformed GalSim object. 

296 """ 

297 # Make a 'thin' box to mimic a line surface brightness profile of default 

298 # thickness = 1e-6 (i.e., much thinner than a pixel) 

299 object = galsim.Box(source_data["trail_length"], trail_thickness) 

300 try: 

301 object = object.rotate(source_data["beta"] * galsim.degrees) 

302 except KeyError: 

303 pass 

304 object = object.withFlux(inst_flux * source_data["trail_length"]) # type: ignore 

305 # GalSim objects are assumed to be in sky-coords. As we want the trail to 

306 # appear as defined above in image-coords, we must transform it here. 

307 linear_wcs = wcs.linearizePixelToSky(sky_coords, arcseconds) 

308 mat = linear_wcs.getMatrix() 

309 object = object.transform(mat[0, 0], mat[0, 1], mat[1, 0], mat[1, 1]) 

310 # Rescale flux; transformation preserves surface brightness, not flux. 

311 object *= 1.0 / np.abs(mat[0, 0] * mat[1, 1] - mat[0, 1] * mat[1, 0]) 

312 return object # type: ignore 

313 

314 

315def make_galsim_stamp( 

316 stamp_file: str, 

317 fits_alignment: str, 

318 wcs: SkyWcs, 

319 sky_coords: SpherePoint, 

320 inst_flux: float, 

321) -> galsim.gsobject.GSObject: 

322 """Make a postage stamp with GalSim from a FITS file. 

323 

324 Parameters 

325 ---------- 

326 stamp_file : `str` 

327 Path to the FITS file containing the postage stamp. 

328 fits_alignment : `str` 

329 Alignment of the FITS image to the WCS. Allowed values: "wcs", "pixel". 

330 wcs : `lsst.afw.geom.SkyWcs` 

331 World coordinate system. 

332 sky_coords : `lsst.geom.SpherePoint` 

333 Sky coordinates of the source. 

334 inst_flux : `float` 

335 Instrumental flux of the source. 

336 

337 Returns 

338 ------- 

339 object: `galsim.gsobject.GSObject` 

340 A fully specified and transformed GalSim object. 

341 """ 

342 stamp_file = stamp_file.strip() 

343 if os.path.exists(stamp_file): 

344 with fits.open(stamp_file) as hdul: 

345 hdu_images = [hdu.is_image and hdu.size > 0 for hdu in hdul] # type: ignore 

346 if any(hdu_images): 

347 stamp_data = galsim.fits.read(stamp_file, read_header=True, hdu=np.where(hdu_images)[0][0]) 

348 else: 

349 raise RuntimeError(f"Cannot find image in input FITS file {stamp_file}.") 

350 match fits_alignment: 

351 case "wcs": 

352 # galsim.fits.read will always attach a WCS to its output. 

353 # If it can't find a WCS in the FITS header, then it 

354 # defaults to scale = 1.0 arcsec / pix. If that's the scale 

355 # then we need to check if it was explicitly set or if it's 

356 # just the default. If it's just the default then we should 

357 # raise an exception. 

358 if is_wcs_galsim_default(stamp_data.wcs, stamp_data.header): # type: ignore 

359 raise RuntimeError(f"Cannot find WCS in input FITS file {stamp_file}") 

360 case "pixel": 

361 # We need to set stamp_data.wcs to the local target WCS. 

362 linear_wcs = wcs.linearizePixelToSky(sky_coords, arcseconds) 

363 mat = linear_wcs.getMatrix() 

364 stamp_data.wcs = galsim.JacobianWCS( # type: ignore 

365 mat[0, 0], mat[0, 1], mat[1, 0], mat[1, 1] 

366 ) 

367 object = galsim.InterpolatedImage(stamp_data, calculate_stepk=False) 

368 object = object.withFlux(inst_flux) 

369 return object # type: ignore 

370 else: 

371 raise RuntimeError(f"Cannot locate input FITS postage stamp {stamp_file}.") 

372 

373 

374def is_wcs_galsim_default( 

375 wcs: galsim.fitswcs.GSFitsWCS, 

376 hdr: galsim.fits.FitsHeader, 

377) -> bool: 

378 """Decide if wcs = galsim.PixelScale(1.0) is explicitly present in header, 

379 or if it's just the GalSim default. 

380 

381 Parameters 

382 ---------- 

383 wcs : galsim.fitswcs.GSFitsWCS 

384 Potentially default WCS. 

385 hdr : galsim.fits.FitsHeader 

386 Header as read in by GalSim. 

387 

388 Returns 

389 ------- 

390 is_default : bool 

391 True if default, False if explicitly set in header. 

392 """ 

393 if wcs != galsim.PixelScale(1.0): 

394 return False 

395 if hdr.get("GS_WCS") is not None: 

396 return False 

397 if hdr.get("CTYPE1", "LINEAR") == "LINEAR": 

398 return not any(k in hdr for k in ["CD1_1", "CDELT1"]) 

399 for wcs_type in galsim.fitswcs.fits_wcs_types: 

400 # If one of these succeeds, then assume result is explicit. 

401 try: 

402 wcs_type._readHeader(hdr) 

403 return False 

404 except Exception: 

405 pass 

406 else: 

407 return not any(k in hdr for k in ["CD1_1", "CDELT1"]) 

408 

409 

410def infer_gain_from_image( 

411 exposure: ExposureF, 

412 bad_mask_names: list[str] | None = None, 

413 logger: Any | None = None, 

414) -> float: 

415 """Fit a straight line to image flux vs. estimated variance in each pixel 

416 of an exposure, and from that infer an estimated average gain. 

417 

418 Parameters 

419 ---------- 

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

421 The exposure to inject synthetic sources into. 

422 bad_mask_names : `list` [`str`], optional 

423 Names of any mask plane bits that could indicate a pathological flux 

424 vs. variance relationship. 

425 logger : `lsst.utils.logging.LsstLogAdapter`, optional 

426 Logger to use for logging messages. 

427 

428 Returns 

429 ------- 

430 gain : `float` 

431 The estimated average gain across all pixels. 

432 """ 

433 image_arr = exposure.image.array 

434 var_arr = exposure.variance.array 

435 good = np.isfinite(image_arr) & np.isfinite(var_arr) 

436 

437 mask = exposure.mask 

438 # Only look at mask names in the mask_plane_dict. 

439 # Otherwise, getPlaneBitMask throws an exception. 

440 mask_plane_dict = mask.getMaskPlaneDict() 

441 if bad_mask_names is None: 

442 bad_mask_names = [] 

443 missing = set(bad_mask_names) - set(mask_plane_dict) 

444 if missing and logger: 

445 logger.warning("Mask planes not found in exposure (skipping): %s", missing) 

446 bad_mask_names = [name for name in bad_mask_names if name in mask_plane_dict] 

447 if len(bad_mask_names) > 0: 

448 bad_mask_bit_mask = mask.getPlaneBitMask(bad_mask_names) 

449 good &= (mask.array.astype(int) & bad_mask_bit_mask) == 0 

450 

451 # Check for sufficient points to fit. 

452 n_good = int(np.count_nonzero(good)) 

453 if n_good < 2: 

454 if logger: 

455 logger.warning("Too few valid pixels (%d) to infer gain from image; returning NaN.", n_good) 

456 return float("nan") 

457 

458 fit = np.polyfit(image_arr[good], var_arr[good], deg=1) 

459 slope = fit[0] 

460 # The gain is 1 / slope, so a non-positive or non-finite slope gives a 

461 # non-physical gain. Flag it as NaN rather than propagate a negative, 

462 # zero, or infinite gain into the noise model. 

463 if not np.isfinite(slope) or slope <= 0: 463 ↛ 464line 463 didn't jump to line 464 because the condition on line 463 was never true

464 if logger: 

465 logger.warning("Inferred a non-physical variance-vs-flux slope (%g); returning NaN gain.", slope) 

466 return float("nan") 

467 return 1.0 / slope 

468 

469 

470def get_gain_map( 

471 exposure: ExposureF, 

472 bad_mask_names: list[str] | None = None, 

473 logger: Any | None = None, 

474) -> galsim.Image: 

475 """Retrieve gains for each pixel. 

476 

477 Parameters 

478 ---------- 

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

480 The exposure to inject synthetic sources into. 

481 bad_mask_names : `list` [`str`], optional 

482 Names of any mask plane bits that could indicate a pathological flux 

483 vs. variance relationship. 

484 logger : `lsst.utils.logging.LsstLogAdapter`, optional 

485 Logger to use for logging messages. 

486 

487 Returns 

488 ------- 

489 gain_map : `galsim.Image` 

490 The gain to use in each pixel. 

491 """ 

492 full_bbox = exposure.getBBox() 

493 try: 

494 amplifiers = exposure.getDetector().getAmplifiers() 

495 bboxes = [amplifier.getBBox() for amplifier in amplifiers] if amplifiers else None 

496 except AttributeError: 

497 bboxes = None 

498 if not bboxes: 498 ↛ 501line 498 didn't jump to line 501 because the condition on line 498 was always true

499 bboxes = [full_bbox] 

500 

501 gains = np.array( 

502 [infer_gain_from_image(exposure.subset(bbox), bad_mask_names, logger) for bbox in bboxes], 

503 dtype=float, 

504 ) 

505 

506 # Catch unphysical values and replace with fallback values 

507 usable = np.isfinite(gains) & (gains > 0) 

508 if not np.all(usable): 

509 if np.any(usable): 509 ↛ 512line 509 didn't jump to line 512 because the condition on line 509 was never true

510 # Use the median of the regions that did fit (e.g. the other 

511 # amplifiers of the same detector). 

512 fallback = float(np.median(gains[usable])) 

513 else: 

514 # Nothing fit. Try a single fit over the whole exposure (which can 

515 # succeed even when individual sub-regions do not), then fall back 

516 # to unit gain as a last resort. 

517 fallback = 1.0 

518 if len(bboxes) > 1: 518 ↛ 519line 518 didn't jump to line 519 because the condition on line 518 was never true

519 whole = infer_gain_from_image(exposure, bad_mask_names, logger) 

520 if np.isfinite(whole) and whole > 0: 

521 fallback = whole 

522 if logger: 522 ↛ 523line 522 didn't jump to line 523 because the condition on line 522 was never true

523 logger.warning( 

524 "Could not infer gain for %d of %d region(s); using fallback gain %g.", 

525 int(np.count_nonzero(~usable)), 

526 len(gains), 

527 fallback, 

528 ) 

529 gains[~usable] = fallback 

530 

531 full_bounds = galsim.BoundsI(full_bbox.minX, full_bbox.maxX, full_bbox.minY, full_bbox.maxY) 

532 gain_map = galsim.Image(full_bounds, dtype=float) 

533 for bbox, gain in zip(bboxes, gains): 

534 bounds = galsim.BoundsI(bbox.minX, bbox.maxX, bbox.minY, bbox.maxY) 

535 gain_map[bounds].array[:] = 1.0 / gain 

536 

537 return gain_map 

538 

539 

540def add_noise_to_galsim_image( 

541 image_template: galsim.Image, 

542 var_template: galsim.Image, 

543 is_coadd: bool, 

544 gain_map: galsim.Image, 

545 object_common_bounds: galsim.BoundsI, 

546 noise_seed: int | None = None, 

547 logger: Any | None = None, 

548) -> tuple[galsim.Image, galsim.Image]: 

549 """Add noise to a supplied image, and adjust the estimated variance 

550 accordingly. If the image represents a coadd, add Gaussian noise. 

551 Otherwise add Poisson shot noise. 

552 

553 Parameters 

554 ---------- 

555 image_template : `galsim.Image` 

556 The image to add noise to. 

557 var_template : `galsim.Image` 

558 The variance to use for the noise generating process in each pixel. 

559 is_coadd : `bool` 

560 Whether the exposure is a coadd. If True, Gaussian noise is used; 

561 if False, Poisson shot noise is used. 

562 gain_map : `galsim.Image` 

563 The gain to use in each pixel of the full exposure. 

564 object_common_bounds : `galsim.BoundsI` 

565 Specific region of the full exposure to which this image belongs. 

566 noise_seed : `int`, optional 

567 Random number generator seed to use for the noise realization. 

568 logger : `lsst.utils.logging.LsstLogAdapter`, optional 

569 Logger to use for logging messages. 

570 

571 Returns 

572 ------- 

573 image_template : `galsim.Image` 

574 The image with noise added. 

575 var_template : `galsim.Image` 

576 The estimated variance, adjusted in response to the added noise. 

577 """ 

578 noise_template = var_template.copy() 

579 negative_variance = var_template.array < 0 

580 nonfinite_variance = ~np.isfinite(var_template.array) 

581 if np.any(negative_variance): 

582 if logger: 

583 logger.debug("Setting negative-variance pixels to 0 variance for noise generation.") 

584 noise_template.array[negative_variance] = 0 

585 if np.any(nonfinite_variance): 

586 if logger: 

587 logger.debug("Setting non-finite-variance pixels to 0 variance for noise generation.") 

588 noise_template.array[nonfinite_variance] = 0 

589 

590 rng = galsim.BaseDeviate(noise_seed) 

591 

592 if not is_coadd: 

593 # Treat noise_template, which is the expected amount of 

594 # injected flux divided by the gain, as the Poisson mean of 

595 # the number of photons collected by each pixel. 

596 noise = galsim.PoissonNoise(rng) 

597 noise_template.addNoise(noise) 

598 # Scale the photon count by the gain to get the amount of 

599 # image flux to inject. 

600 image_template = noise_template.copy() 

601 image_template /= gain_map[object_common_bounds] 

602 # Restore the original variance values 

603 # of the bad-variance pixels. 

604 bad_var = negative_variance | nonfinite_variance 

605 noise_template.array[bad_var] = var_template.array[bad_var] 

606 var_template = noise_template 

607 else: 

608 noise = galsim.VariableGaussianNoise(rng, noise_template) 

609 image_template.addNoise(noise) 

610 var_template = image_template.copy() 

611 var_template *= gain_map[object_common_bounds] 

612 

613 return image_template, var_template 

614 

615 

616def _get_galsim_psf( 

617 *, 

618 psf: afwDetection.Psf, 

619 pixel_coords: Point2D, 

620 bbox: Box2I, 

621 calib_flux_radius: float, 

622 galsim_wcs: galsim.JacobianWCS, 

623 sky_coords: SpherePoint | None = None, 

624 apply_aperture_correction: bool = True, 

625): 

626 """Return the GalSim PSF at given coordinates within a bbox. 

627 

628 Parameters 

629 ---------- 

630 psf 

631 The coadd PSF object to evaluate the PSF with. 

632 pixel_coords 

633 The pixel coordinates to get the PSF for. 

634 bbox 

635 A bounding box to search for an alternative PSF for. If pixel_coords 

636 lies outside the box and PSF evaluation fails, it will retry using the 

637 nearest point to pixel_coords within the box. 

638 calib_flux_radius 

639 The radius to use to compute the aperture flux. 

640 galsim_wcs 

641 The GalSim WCS to pass to the `galsim.InterpolatedImage` constructor. 

642 sky_coords 

643 The sky coordinates of pixel_coords. Only used for 

644 apply_aperture_correction 

645 If True, the PSF values will be divided by the aperture flux at the 

646 average position of psf. 

647 

648 Returns 

649 ------- 

650 galsim_psf 

651 The GalSim PSF as a `galsim.InterpolatedImage`. 

652 """ 

653 error_args = [] 

654 try: 

655 psf_array = psf.computeKernelImage(pixel_coords).array 

656 except InvalidParameterError: 

657 # Try mapping to nearest point contained in bbox. 

658 contained_point = Point2D( 

659 np.clip(pixel_coords.x, bbox.minX, bbox.maxX), np.clip(pixel_coords.y, bbox.minY, bbox.maxY) 

660 ) 

661 if pixel_coords == contained_point: # no difference, so skip immediately 

662 error_args = ["Cannot compute PSF for object at %s; flagging and skipping.", sky_coords] 

663 # Otherwise, try again with new point. 

664 try: 

665 psf_array = psf.computeKernelImage(contained_point).array 

666 except InvalidParameterError: 

667 error_args = ["Cannot compute PSF for object at %s; flagging and skipping.", sky_coords] 

668 return None, error_args 

669 

670 # Compute the aperture corrected PSF interpolated image. 

671 # Normalize first 

672 # TODO: Should also deal with negative pixel values here as they are 

673 # not valid for a PSF. 

674 psf_array /= np.sum(psf_array) 

675 if apply_aperture_correction: 675 ↛ 679line 675 didn't jump to line 679 because the condition on line 675 was always true

676 # TODO: Review whether we want to at least make this optional 

677 aperture_correction = psf.computeApertureFlux(calib_flux_radius, psf.getAveragePosition()) 

678 psf_array /= aperture_correction 

679 galsim_psf = galsim.InterpolatedImage(galsim.Image(psf_array), wcs=galsim_wcs) 

680 

681 return galsim_psf, error_args 

682 

683 

684@dataclass(config=ConfigDict(arbitrary_types_allowed=True), kw_only=True) 

685class CellBoundsInfo: 

686 aperture_correction: float = 1.0 

687 cell_ij: CellIJ 

688 galsim_psf: galsim.InterpolatedImage 

689 

690 

691def _inject_galsim_object_into_bounds( 

692 *, 

693 convolved_object, 

694 position_d, 

695 position_i, 

696 object_common_bounds, 

697 full_bounds, 

698 galsim_image: galsim.Image, 

699 galsim_variance: galsim.Image, 

700 galsim_wcs, 

701 gain_map, 

702 exposure, 

703 inject_variance: bool, 

704 add_noise: bool, 

705 noise_seed: int, 

706 is_coadd: bool, 

707 injection_core_size, 

708 mask_plane_name, 

709 mask_plane_core_name, 

710 logger, 

711): 

712 common_image = galsim_image[object_common_bounds] 

713 common_variance = galsim_variance[object_common_bounds] 

714 

715 offset = position_d - object_common_bounds.true_center 

716 # Note, for preliminary_visit_image injection, pixel is already 

717 # part of the PSF and for coadd injection, it's incorrect to 

718 # include the output pixel. 

719 # So for both cases, we draw using method='no_pixel'. 

720 image_template = common_image.copy() 

721 image_template = convolved_object.drawImage( 

722 image_template, add_to_image=False, offset=offset, wcs=galsim_wcs, method="no_pixel" 

723 ) 

724 

725 # The amount of additional variance to inject, 

726 # if inject_variance is true. 

727 var_template = image_template.copy() 

728 var_template *= gain_map[object_common_bounds] 

729 

730 if add_noise: 730 ↛ 731line 730 didn't jump to line 731 because the condition on line 730 was never true

731 image_template, var_template = add_noise_to_galsim_image( 

732 image_template, 

733 var_template, 

734 is_coadd, 

735 gain_map, 

736 object_common_bounds, 

737 noise_seed, 

738 logger, 

739 ) 

740 

741 common_image += image_template 

742 if inject_variance: 742 ↛ 745line 742 didn't jump to line 745 because the condition on line 742 was always true

743 common_variance += var_template 

744 

745 common_box = Box2I( 

746 Point2I(object_common_bounds.xmin, object_common_bounds.ymin), 

747 Point2I(object_common_bounds.xmax, object_common_bounds.ymax), 

748 ) 

749 bitvalue = exposure.mask.getPlaneBitMask(mask_plane_name) 

750 exposure[common_box].mask.array |= bitvalue 

751 # Add a configurable pixel mask centered on the object. The mask must be 

752 # large enough to always identify the core/peak of the injected 

753 # source, but small enough that it rarely overlaps real sources. 

754 sub_bounds_core = galsim.BoundsI(position_i).withBorder(injection_core_size // 2) 

755 object_common_bounds_core = full_bounds & sub_bounds_core 

756 if object_common_bounds_core.area() > 0: 

757 common_box_core = Box2I( 

758 Point2I(object_common_bounds_core.xmin, object_common_bounds_core.ymin), 

759 Point2I(object_common_bounds_core.xmax, object_common_bounds_core.ymax), 

760 ) 

761 bitvalue_core = exposure.mask.getPlaneBitMask(mask_plane_core_name) 

762 exposure[common_box_core].mask.array |= bitvalue_core 

763 

764 

765def inject_galsim_objects_into_exposure( 

766 exposure: ExposureF | CellCoadd, 

767 objects: Iterable[tuple[SpherePoint, Point2D, int, galsim.gsobject.GSObject], None, None], 

768 mask_plane_name: str = "INJECTED", 

769 calib_flux_radius: float = 12.0, 

770 draw_size_max: int = 1000, 

771 inject_variance: bool = True, 

772 add_noise: bool = True, 

773 noise_seed: int = 0, 

774 bad_mask_names: list[str] | None = None, 

775 injection_core_size: int = 3, 

776 logger: Any | None = None, 

777) -> tuple[list[int], list[galsim.BoundsI], list[bool], list[bool]]: 

778 """Inject sources into given exposure using GalSim. 

779 

780 Parameters 

781 ---------- 

782 exposure : `lsst.afw.image.ExposureF` or `lsst.images.cells.CellCoadd` 

783 The exposure to inject synthetic sources into. 

784 objects : `Iterable` [`tuple`, None, None] 

785 An iterator of tuples that contains (or generates) locations and object 

786 surface brightness profiles to inject. The tuples should contain the 

787 following elements: `lsst.geom.SpherePoint`, `lsst.geom.Point2D`, 

788 `int`, `galsim.gsobject.GSObject`. 

789 mask_plane_name : `str` 

790 Name of the mask plane to use for the injected sources. 

791 calib_flux_radius : `float` 

792 Radius in pixels to use for the flux calibration. This is used to 

793 produce the correct instrumental fluxes within the radius. The value 

794 should match that of the field defined in slot_CalibFlux_instFlux. 

795 draw_size_max : `int` 

796 Maximum allowed size of the drawn object. If the object is larger than 

797 this, the draw size will be clipped to this size. 

798 inject_variance : `bool` 

799 Whether, when injecting flux into the image plane, to inject a 

800 corresponding amount of variance into the variance plane. 

801 add_noise : `bool` 

802 Whether to randomly vary the amount of injected image flux in each 

803 pixel by an amount consistent with the amount of injected variance. 

804 noise_seed : `int` 

805 Seed for generating the noise of the first injected object. The seed 

806 actually used increases by 1 for each subsequent object, to ensure 

807 independent noise realizations. 

808 injection_core_size : `int` 

809 Size of the box around each injected source to mark in the injected 

810 core mask. The value must be a positive odd integer. 

811 bad_mask_names : `list[str]`, optional 

812 List of mask plane names indicating pixels to ignore when fitting flux 

813 vs variance in preparation for variance plane modification. If None, 

814 then the all pixels are used. 

815 logger : `lsst.utils.logging.LsstLogAdapter`, optional 

816 Logger to use for logging messages. 

817 

818 Returns 

819 ------- 

820 draw_sizes : `list` [`int`] 

821 Draw sizes of the injected sources. 

822 common_bounds : `list` [`galsim.BoundsI`] 

823 Common bounds of the drawn objects. 

824 fft_size_errors : `list` [`bool`] 

825 Boolean flags indicating whether a GalSimFFTSizeError was raised. 

826 psf_compute_errors : `list` [`bool`] 

827 Boolean flags indicating whether a PSF computation error was raised. 

828 """ 

829 is_cell = isinstance(exposure, CellCoadd) 

830 if not is_cell and not isinstance(exposure, ExposureF): 830 ↛ 831line 830 didn't jump to line 831 because the condition on line 830 was never true

831 raise TypeError(f"Unsupported {type(exposure)=}") 

832 if ( 

833 not isinstance(injection_core_size, numbers.Number) 

834 or not (injection_core_size >= 1) 

835 or not (injection_core_size % 2 == 1) 

836 ): 

837 raise ValueError(f"{injection_core_size=} must be a positive odd integer") 

838 

839 if is_cell: 

840 cell_coadd = exposure 

841 cell_psf = cell_coadd.psf 

842 exposure = cell_coadd.to_legacy() 

843 is_coadd = True 

844 else: 

845 is_coadd = exposure.info.getCoaddInputs() is not None 

846 

847 exposure.mask.addMaskPlane(mask_plane_name) 

848 mask_plane_core_name = mask_plane_name + "_CORE" 

849 exposure.mask.addMaskPlane(mask_plane_core_name) 

850 wcs = exposure.getWcs() 

851 bbox = exposure.getBBox() 

852 psf = exposure.getPsf() 

853 if logger: 853 ↛ 854line 853 didn't jump to line 854 because the condition on line 853 was never true

854 logger.info( 

855 "Adding %s and %s mask planes to the exposure.", 

856 mask_plane_name, 

857 mask_plane_core_name, 

858 ) 

859 full_bounds = galsim.BoundsI(bbox.minX, bbox.maxX, bbox.minY, bbox.maxY) 

860 galsim_image = galsim.Image(exposure.image.array, bounds=full_bounds) 

861 galsim_variance = galsim.Image(exposure.variance.array, bounds=full_bounds) 

862 pixel_scale = wcs.getPixelScale(bbox.getCenter()).asArcseconds() 

863 

864 if is_cell: 

865 all_bounds: list[tuple[galsim.BoundsI, CellBoundsInfo | None]] = [] 

866 cell_grid = cell_coadd.grid 

867 contributions = cell_coadd.provenance.contributions 

868 n_i, n_j = cell_grid.grid_size.as_tuple() 

869 

870 fallback_psf = None 

871 cell_inputs: dict[CellIJ, str] = {} 

872 

873 # Iterate over all cells and find the ones with good PSFs 

874 for cell_i in range(n_i): 

875 for cell_j in range(n_j): 

876 cell_ij = CellIJ(cell_i, cell_j) 

877 bbox_cell = cell_grid.bbox_of(cell_ij) 

878 bbox_cell_legacy = bbox_cell.to_legacy() 

879 cen_bbox = bbox_cell_legacy.getCenter() 

880 cen_x, cen_y = cen_bbox 

881 try: 

882 if cell_ij in cell_coadd.bounds.missing: 

883 continue 

884 is_cell_input = (contributions["cell_i"] == cell_i) & (contributions["cell_j"] == cell_j) 

885 # Return a string of sorted visits to efficiently count 

886 # unique combinations 

887 cell_inputs[cell_ij] = ",".join( 

888 str(visit) for visit in sorted(set(contributions["visit"][is_cell_input])) 

889 ) 

890 

891 # Use the native cell coadd PSF methods rather than 

892 # _get_galsim_psf which uses the to_legacy PSF 

893 galsim_psf = cell_psf.compute_kernel_image(x=cen_x, y=cen_y).array 

894 mat = wcs.linearizePixelToSky(wcs.pixelToSky(x=cen_x, y=cen_y), arcseconds).getMatrix() 

895 galsim_wcs = galsim.JacobianWCS(mat[0, 0], mat[0, 1], mat[1, 0], mat[1, 1]) 

896 aperture_correction = psf.computeApertureFlux(calib_flux_radius, cen_bbox) 

897 galsim_psf = galsim.InterpolatedImage( 

898 galsim.Image(galsim_psf / (np.sum(galsim_psf) * aperture_correction)), 

899 wcs=galsim_wcs, 

900 ) 

901 bounds_cell = galsim.BoundsI( 

902 bbox_cell.min.x, 

903 bbox_cell.max.x, 

904 bbox_cell.min.y, 

905 bbox_cell.max.y, 

906 ) 

907 bounds_info = CellBoundsInfo( 

908 aperture_correction=aperture_correction, 

909 cell_ij=cell_ij, 

910 galsim_psf=galsim_psf, 

911 ) 

912 all_bounds.append((bounds_cell, bounds_info)) 

913 if fallback_psf is None: 

914 fallback_psf = galsim_psf 

915 except BoundsError: 

916 # TODO: Should this ever happen? If it does, it probably 

917 # means that cell_coadd.bounds.missing is wrong. 

918 pass 

919 cell_visits, cell_visits_index = np.unique(list(cell_inputs.values()), return_inverse=True) 

920 # Store the index of the cell_visits to efficiently check if 

921 # they're identical 

922 cell_inputs_indices = dict(zip(cell_inputs.keys(), cell_visits_index, strict=True)) 

923 # Keep a dict for PSF/bounds retrieval prior to iteration over cells 

924 cell_bounds = {bounds_info.cell_ij: bounds_info for _, bounds_info in all_bounds} 

925 else: 

926 all_bounds = [(full_bounds, None)] 

927 

928 # TODO: Refactor to not take exposure? 

929 gain_map = get_gain_map(exposure, bad_mask_names, logger) 

930 

931 draw_sizes: list[int] = [] 

932 common_bounds: list[galsim.BoundsI] = [] 

933 fft_size_errors: list[bool] = [] 

934 psf_compute_errors: list[bool] = [] 

935 for i, (sky_coords, pixel_coords, draw_size, object) in enumerate(objects): 

936 # Instantiate default returns in case of early exit from this loop. 

937 draw_sizes.append(0) 

938 common_bounds.append(galsim.BoundsI()) 

939 fft_size_errors.append(False) 

940 psf_compute_errors.append(False) 

941 

942 # Get spatial coordinates and WCS. 

943 posd = galsim.PositionD(pixel_coords.x, pixel_coords.y) 

944 posi = galsim.PositionI(pixel_coords.x // 1, pixel_coords.y // 1) 

945 if logger: 945 ↛ 946line 945 didn't jump to line 946 because the condition on line 945 was never true

946 logger.debug(f"Injecting synthetic source at {pixel_coords}.") 

947 mat = wcs.linearizePixelToSky(sky_coords, arcseconds).getMatrix() 

948 galsim_wcs = galsim.JacobianWCS(mat[0, 0], mat[0, 1], mat[1, 0], mat[1, 1]) 

949 

950 # This check is here because sometimes the WCS is multivalued and 

951 # objects that should not be included were being included. 

952 galsim_pixel_scale = np.sqrt(galsim_wcs.pixelArea()) 

953 if galsim_pixel_scale < pixel_scale / 2 or galsim_pixel_scale > pixel_scale * 2: 953 ↛ 954line 953 didn't jump to line 954 because the condition on line 953 was never true

954 continue 

955 

956 # Get the fallback PSF (if available) to compute the injection box. 

957 # Even if the PSF at the centroid of an object is not valid, nearby 

958 # cells might be fine. For example, cells with saturated stars can 

959 # end up with no visits below the masked pixel threshold and no PSF. 

960 # 

961 # Note that _get_galsim_psf already implements a fallback of getting 

962 # the PSF at the nearest edge of the bbox for objects whose centroids 

963 # land outside the exposure's bbox. Non-cell coadds and visits could 

964 # also do something similar by searching for the nearest valid PSF, 

965 # but that's not as trivial as picking an "average" cell. 

966 if is_cell: 

967 cell_ij_cen = cell_grid.index_of(x=pixel_coords.x, y=pixel_coords.y) 

968 galsim_psf_centroid = cell_bounds.get(cell_ij_cen) 

969 if galsim_psf_centroid is None: 

970 galsim_psf_centroid, psf_error_args = fallback_psf, [] 

971 cell_psf_index_cen = None 

972 else: 

973 galsim_psf_centroid = galsim_psf_centroid.galsim_psf 

974 cell_psf_index_cen = cell_inputs_indices[cell_ij_cen] 

975 else: 

976 # Get the PSF at the centroid of the object 

977 galsim_psf_centroid, psf_error_args = _get_galsim_psf( 

978 psf=psf, 

979 pixel_coords=pixel_coords, 

980 bbox=bbox, 

981 calib_flux_radius=calib_flux_radius, 

982 galsim_wcs=galsim_wcs, 

983 sky_coords=sky_coords, 

984 ) 

985 if galsim_psf_centroid is None: 985 ↛ 986line 985 didn't jump to line 986 because the condition on line 985 was never true

986 psf_compute_errors[i] = True 

987 if logger: 

988 logger.debug(*psf_error_args) 

989 continue 

990 

991 # Convolve the object with the PSF and generate draw size. 

992 conv = galsim.Convolve(object, galsim_psf_centroid) 

993 if draw_size == 0: 993 ↛ 995line 993 didn't jump to line 995 because the condition on line 993 was always true

994 draw_size = conv.getGoodImageSize(galsim_wcs.minLinearScale()) # type: ignore 

995 injection_draw_size = int(draw_size) 

996 injection_core_size_obj = injection_core_size 

997 if draw_size_max > 0 and injection_draw_size > draw_size_max: 997 ↛ 998line 997 didn't jump to line 998 because the condition on line 997 was never true

998 if logger: 

999 logger.warning( 

1000 "Clipping draw size for object at %s from %d to %d pixels.", 

1001 sky_coords, 

1002 injection_draw_size, 

1003 draw_size_max, 

1004 ) 

1005 injection_draw_size = draw_size_max 

1006 draw_sizes[i] = injection_draw_size 

1007 if injection_core_size_obj > injection_draw_size: 1007 ↛ 1008line 1007 didn't jump to line 1008 because the condition on line 1007 was never true

1008 if logger: 

1009 logger.debug( 

1010 "Clipping core size for object at %s from %d to %d pixels.", 

1011 sky_coords, 

1012 injection_core_size_obj, 

1013 injection_draw_size, 

1014 ) 

1015 injection_core_size_obj = injection_draw_size 

1016 sub_bounds = galsim.BoundsI(posi).withBorder(injection_draw_size // 2) 

1017 

1018 # These bounds may not be the same as what would be derived from 

1019 # per-cell PSFs for large objects, but re-calculating them for every 

1020 # cell is not worthwhile 

1021 if is_cell: 

1022 object_common_bounds = full_bounds & sub_bounds 

1023 if object_common_bounds.area() > 0: 1023 ↛ 1026line 1023 didn't jump to line 1026 because the condition on line 1023 was always true

1024 common_bounds[i] = object_common_bounds # type: ignore 

1025 

1026 area_to_inject = 0 

1027 area_injected = 0 

1028 bounds_fft_size_errors = {} 

1029 for idx_bound, (injection_bounds, bounds_info) in enumerate(all_bounds): 

1030 object_common_bounds = injection_bounds & sub_bounds 

1031 

1032 # Inject the source if there is any overlap. 

1033 if (common_area := object_common_bounds.area()) > 0: 

1034 area_to_inject += common_area 

1035 if is_cell: 

1036 # Use the per-cell PSF 

1037 galsim_psf = bounds_info.galsim_psf 

1038 assert galsim_psf is not None 

1039 if bounds_info.cell_ij == cell_ij_cen: 

1040 # These have to be one and the same if the cell is not 

1041 # missing, which it can't be if it's in all_bounds 

1042 assert galsim_psf is galsim_psf_centroid 

1043 elif cell_psf_index_cen == cell_inputs_indices[bounds_info.cell_ij]: 

1044 # Use the PSF from the cell containing the centroid 

1045 assert galsim_psf is not galsim_psf_centroid 

1046 galsim_psf = galsim_psf_centroid 

1047 conv = galsim.Convolve(object, galsim_psf) 

1048 else: 

1049 common_bounds[i] = object_common_bounds # type: ignore 

1050 try: 

1051 if is_cell: 

1052 # only the cell containing the centroid should have 

1053 # the core mask plane set 

1054 injection_core_bounds = full_bounds & object_common_bounds.withBorder( 

1055 injection_core_size // 2 

1056 ) 

1057 else: 

1058 injection_core_bounds = full_bounds 

1059 _inject_galsim_object_into_bounds( 

1060 convolved_object=conv, 

1061 position_d=posd, 

1062 position_i=posi, 

1063 object_common_bounds=object_common_bounds, 

1064 full_bounds=injection_core_bounds, 

1065 galsim_image=galsim_image, 

1066 galsim_variance=galsim_variance, 

1067 galsim_wcs=galsim_wcs, 

1068 gain_map=gain_map, 

1069 exposure=exposure, 

1070 inject_variance=inject_variance, 

1071 add_noise=add_noise, 

1072 noise_seed=noise_seed, 

1073 is_coadd=is_coadd, 

1074 injection_core_size=injection_core_size_obj, 

1075 mask_plane_name=mask_plane_name, 

1076 mask_plane_core_name=mask_plane_core_name, 

1077 logger=logger, 

1078 ) 

1079 area_injected += common_area 

1080 except GalSimFFTSizeError as err: 

1081 bounds_fft_size_errors[idx_bound] = err.size 

1082 if logger and (area_to_inject == 0): 1082 ↛ 1083line 1082 didn't jump to line 1083 because the condition on line 1082 was never true

1083 logger.debug("No area overlap for object at %s; flagging and skipping.", sky_coords) 

1084 if bounds_fft_size_errors: 1084 ↛ 1085line 1084 didn't jump to line 1085 because the condition on line 1084 was never true

1085 fft_size_errors[i] = True 

1086 if logger: 

1087 logger.debug( 

1088 "GalSimFFTSizeError raised for object at index=%d and coords %s;" 

1089 " flagging and skipping.\nbounds_fft_size_errors=%s", 

1090 i, 

1091 sky_coords, 

1092 bounds_fft_size_errors, 

1093 ) 

1094 

1095 # lsst.images and afw masks are incompatible due to dtypes alone, and so 

1096 # they have to be converted explicitly (also to add plane descriptions) 

1097 if is_cell: 

1098 plane_map = _guess_legacy_plane_map(exposure.mask.getMaskPlaneDict()) 

1099 plane_map.update( 

1100 { 

1101 mask_plane_name: MaskPlane( 

1102 name=mask_plane_name, 

1103 description="Pixel had at least one source injected over it", 

1104 ), 

1105 mask_plane_core_name: MaskPlane( 

1106 name=mask_plane_core_name, 

1107 description=f"Pixel center is within the core region" 

1108 f" ({injection_core_size}x{injection_core_size} box) of an injected source", 

1109 ), 

1110 } 

1111 ) 

1112 converted = cell_coadd.mask.from_legacy( 

1113 exposure.mask, 

1114 plane_map=plane_map, 

1115 sky_projection=cell_coadd.sky_projection, 

1116 ) 

1117 cell_coadd.mask = converted 

1118 return draw_sizes, common_bounds, fft_size_errors, psf_compute_errors