Coverage for python/lsst/meas/extensions/scarlet/utils.py: 89%

205 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-25 08:22 +0000

1import logging 

2import warnings 

3 

4import lsst.geom as geom 

5import lsst.scarlet.lite as scl 

6import numpy as np 

7from scipy.signal import convolve 

8from lsst.afw.detection import InvalidPsfError, Footprint as afwFootprint 

9from lsst.afw.image import ( 

10 IncompleteDataError, 

11 MultibandExposure, 

12 MultibandImage, 

13 Exposure, 

14) 

15from lsst.afw.image.utils import projectImage 

16from lsst.afw.table import SourceCatalog 

17from lsst.geom import Box2I, Point2D, Point2I 

18from lsst.pipe.base import NoWorkFound 

19 

20logger = logging.getLogger(__name__) 

21 

22__all__ = [ 

23 "defaultBadPixelMasks", 

24 "scarletBoxToBBox", 

25 "bboxToScarletBox", 

26 "nonzeroBandSupport", 

27 "multiband_convolve", 

28 "computePsfKernelImage", 

29 "computeNearestPsf", 

30 "computeNearestPsfMultiBand", 

31 "buildObservation", 

32 "calcChi2", 

33] 

34 

35defaultBadPixelMasks = ["BAD", "NO_DATA", "SAT", "SUSPECT", "EDGE"] 

36 

37 

38def scarletBoxToBBox(box: scl.Box, xy0: geom.Point2I = geom.Point2I()) -> geom.Box2I: 

39 """Convert a scarlet_lite Box into a Box2I. 

40 

41 Parameters 

42 ---------- 

43 box: 

44 The scarlet bounding box to convert. 

45 xy0: 

46 An additional offset to add to the scarlet box. 

47 This is common since scarlet sources have an origin of 

48 `(0,0)` at the lower left corner of the blend while 

49 the blend itself is likely to have an offset in the 

50 `Exposure`. 

51 

52 Returns 

53 ------- 

54 bbox: 

55 The converted bounding box. 

56 """ 

57 xy0 = geom.Point2I(box.origin[-1] + xy0.x, box.origin[-2] + xy0.y) 

58 extent = geom.Extent2I(box.shape[-1], box.shape[-2]) 

59 return geom.Box2I(xy0, extent) 

60 

61 

62def bboxToScarletBox(bbox: geom.Box2I, xy0: geom.Point2I = geom.Point2I()) -> scl.Box: 

63 """Convert a Box2I into a scarlet_lite Box. 

64 

65 Parameters 

66 ---------- 

67 bbox: 

68 The Box2I to convert into a scarlet `Box`. 

69 xy0: 

70 An overall offset to subtract from the `Box2I`. 

71 This is common in blends, where `xy0` is the minimum pixel 

72 location of the blend and `bbox` is the box containing 

73 a source in the blend. 

74 

75 Returns 

76 ------- 

77 box: 

78 A scarlet `Box` that is more useful for slicing image data 

79 as a numpy array. 

80 """ 

81 origin = (bbox.getMinY() - xy0.y, bbox.getMinX() - xy0.x) 

82 return scl.Box((bbox.getHeight(), bbox.getWidth()), origin) 

83 

84 

85def nonzeroBandSupport(data: np.ndarray) -> np.ndarray: 

86 """Return the per-pixel support of a multi-band model. 

87 

88 A pixel is in the support whenever any band's value is non-zero. 

89 This is the canonical "spatial extent of a model across bands" 

90 test; the alternative idioms ``data > 0`` and 

91 ``np.max(data, axis=0) != 0`` either exclude negative-valued 

92 pixels outright or exclude pixels whose largest band value is 

93 exactly zero, both of which under-count the true spatial extent. 

94 

95 Parameters 

96 ---------- 

97 data : 

98 A ``(bands, height, width)`` array of model values. 

99 

100 Returns 

101 ------- 

102 support : 

103 A ``(height, width)`` boolean mask, ``True`` at pixels where 

104 at least one band is non-zero. 

105 """ 

106 return np.any(data != 0, axis=0) 

107 

108 

109def multiband_convolve(images: np.ndarray, psfs: np.ndarray) -> np.ndarray: 

110 """Convolve a multi-band image with the PSF in each band. 

111 

112 `images` and `psfs` should have dimensions `(bands, height, width)`. 

113 

114 Parameters 

115 ---------- 

116 images : 

117 The multi-band images to convolve. 

118 psfs : 

119 The PSF for each band. 

120 

121 Returns 

122 ------- 

123 result : 

124 The convolved images. 

125 """ 

126 result = np.zeros(images.shape, dtype=images.dtype) 

127 for bidx, (image, psf) in enumerate(zip(images, psfs, strict=True)): 

128 result[bidx] = convolve(image, psf, mode="same") 

129 return result 

130 

131 

132def computePsfKernelImage(mExposure, psfCenter, catalog=None): 

133 """Compute the PSF kernel image and update the multiband exposure 

134 if not all of the PSF images could be computed. 

135 

136 Parameters 

137 ---------- 

138 psfCenter : `tuple` or `Point2I` or `Point2D` 

139 The location `(x, y)` used as the center of the PSF. 

140 catalog : 

141 Deprecated and ignored. Retained for signature stability; will 

142 be removed after v31. Passing a non-``None`` value emits a 

143 ``FutureWarning``. For nearest-PSF fallback at a different 

144 location, call ``computeNearestPsfMultiBand`` instead. 

145 

146 Returns 

147 ------- 

148 psfModels : `np.ndarray` 

149 The multiband PSF image 

150 mExposure : `MultibandExposure` 

151 The exposure, updated to only use bands that 

152 successfully generated a PSF image. 

153 """ 

154 if catalog is not None: 

155 warnings.warn( 

156 "The `catalog` parameter to `computePsfKernelImage` is " 

157 "deprecated and ignored; it will be removed after v31. " 

158 "For nearest-PSF fallback, use `computeNearestPsfMultiBand`.", 

159 FutureWarning, stacklevel=2, 

160 ) 

161 if not isinstance(psfCenter, geom.Point2D): 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true

162 psfCenter = geom.Point2D(*psfCenter) 

163 

164 try: 

165 psfModels = mExposure.computePsfKernelImage(psfCenter) 

166 except IncompleteDataError as e: 

167 psfModels = e.partialPsf 

168 if psfModels is None: 168 ↛ 171line 168 didn't jump to line 171 because the condition on line 168 was always true

169 return None, None 

170 # Use only the bands that successfully generated a PSF image. 

171 bands = psfModels.bands 

172 mExposure = mExposure[bands,] 

173 if len(bands) == 1: 

174 # Only a single band generated a PSF, so the MultibandExposure 

175 # became a single band ExposureF. 

176 # Convert the result back into a MultibandExposure. 

177 mExposure = MultibandExposure.fromExposures(bands, [mExposure]) 

178 return psfModels.array, mExposure 

179 

180 

181def computeNearestPsf( 

182 calexp: Exposure, 

183 catalog: SourceCatalog, 

184 band: str | None = None, 

185 psfCenter: Point2D | None = None, 

186) -> tuple[np.ndarray, Point2D, float] | tuple[None, None, None]: 

187 """Create a PSF image at the nearest valid location 

188 

189 Sometimes not all locations in an image can generate a PSF image so the 

190 source catalog is used to find the nearest valid location. 

191 

192 Parameters 

193 ---------- 

194 calexp : 

195 The exposure. 

196 catalog : 

197 The catalog. 

198 band : 

199 The band of the exposure used to filter the catalog by only 

200 selecting sources that have a 

201 If band is ``None`` then the full catalog is used. 

202 psfCenter : 

203 The location of the PSF image. 

204 If no location is provided, the center of the exposure is used. 

205 

206 Returns 

207 ------- 

208 psf : 

209 The PSF image. 

210 location : 

211 The location of the PSF image. 

212 diff : 

213 The difference between the requested location and the 

214 nearest valid location. 

215 """ 

216 if psfCenter is None: 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true

217 psfCenter = calexp.getBBox().getCenter() 

218 

219 if not isinstance(psfCenter, geom.Point2D): 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true

220 psfCenter = geom.Point2D(*psfCenter) 

221 

222 try: 

223 psf = calexp.getPsf().computeKernelImage(psfCenter) 

224 return psf, psfCenter, 0 

225 except InvalidPsfError: 

226 pass 

227 

228 xc, yc = psfCenter 

229 

230 # Only select records that have detections in this band 

231 if band is not None: 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true

232 sources = catalog[catalog[f'merge_footprint_{band}']] 

233 else: 

234 sources = catalog 

235 

236 # Get the peaks of all of the sources 

237 x = [] 

238 y = [] 

239 for src in sources: 

240 for peak in src.getFootprint().peaks: 

241 if band is None or peak[f'merge_peak_{band}']: 241 ↛ 240line 241 didn't jump to line 240 because the condition on line 241 was always true

242 x.append(peak['i_x']) 

243 y.append(peak['i_y']) 

244 x = np.array(x) 

245 y = np.array(y) 

246 

247 # Sort the peaks based on their distance to the location 

248 diff_x = x - xc 

249 diff_y = y - yc 

250 sorted_indices = np.argsort(diff_x**2 + diff_y**2) 

251 

252 # Iterate over sources until a location is found that can generate a PSF 

253 psf = None 

254 for ref_index in sorted_indices: 

255 try: 

256 psf = calexp.getPsf().computeKernelImage(Point2D(x[ref_index], y[ref_index])) 

257 break 

258 except InvalidPsfError: 

259 pass 

260 if psf is None: 

261 return None, None, None 

262 newLocation = Point2D(x[ref_index], y[ref_index]) 

263 diff = np.sqrt(diff_x[ref_index]**2 + diff_y[ref_index]**2) 

264 

265 return psf, newLocation, diff 

266 

267 

268def _sortedCatalogPositions( 

269 catalog: SourceCatalog | None, 

270 psfCenter: Point2D, 

271) -> list[Point2D]: 

272 """Catalog peak positions, sorted by distance from ``psfCenter``.""" 

273 if catalog is None: 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true

274 return [] 

275 xs: list[float] = [] 

276 ys: list[float] = [] 

277 for src in catalog: 

278 for peak in src.getFootprint().peaks: 

279 xs.append(peak["i_x"]) 

280 ys.append(peak["i_y"]) 

281 if not xs: 

282 return [] 

283 xs_a = np.asarray(xs) 

284 ys_a = np.asarray(ys) 

285 xc, yc = psfCenter 

286 order = np.argsort((xs_a - xc) ** 2 + (ys_a - yc) ** 2) 

287 return [Point2D(float(xs_a[i]), float(ys_a[i])) for i in order] 

288 

289 

290def computeNearestPsfMultiBand( 

291 mExposure: MultibandExposure, 

292 psfCenter: tuple[int, int] | geom.Point2I | geom.Point2D, 

293 catalog: SourceCatalog | None, 

294) -> tuple[MultibandImage | None, MultibandExposure | None]: 

295 """Compute a multiband PSF kernel image at or near the requested center. 

296 

297 The PSF kernel is computed at ``psfCenter`` in every band where the 

298 PSF model is valid there — this is the hot path and the only work 

299 done when no fallback is needed. For any band whose PSF is invalid 

300 at ``psfCenter``, the function walks ``catalog`` peak positions 

301 sorted by distance from the requested center and accepts the first 

302 position that works in every failing band as a common fallback. 

303 If that fallback location also works in the bands that already 

304 succeeded at the center, the function "upgrades" by sampling every 

305 band at that one location, so the multiband PSF is genuinely at a 

306 single sky point; otherwise the successful bands keep their center 

307 PSF and only the failing bands use the common fallback, and a 

308 warning is logged. When no single fallback works for every failing 

309 band, each failing band falls back to its own nearest valid 

310 catalog position (per-band fallback), and a warning is logged. 

311 Bands with no valid PSF anywhere are dropped from the returned 

312 multiband exposure, matching the historical incomplete-PSF 

313 behavior. 

314 

315 PSF computations are not duplicated: each ``(band, position)`` pair 

316 is evaluated at most once, and at function return the kept PSFs are 

317 held one per band. 

318 

319 Parameters 

320 ---------- 

321 mExposure : 

322 The multi-band exposure. 

323 psfCenter : 

324 The location ``(x, y)`` used as the center of the PSF. 

325 catalog : 

326 Source catalog whose peak positions are candidate fallback 

327 locations. If `None`, no fallback search is performed and a 

328 band whose PSF is invalid at ``psfCenter`` is dropped. 

329 

330 Returns 

331 ------- 

332 mPsf : 

333 The multiband PSF kernel image, or `None` if no band produced a 

334 valid PSF. 

335 mExposure : 

336 The input exposure restricted to bands that produced a valid 

337 PSF. 

338 """ 

339 if not isinstance(psfCenter, Point2D): 339 ↛ 340line 339 didn't jump to line 340 because the condition on line 339 was never true

340 psfCenter = Point2D(*psfCenter) 

341 

342 bands = tuple(mExposure.bands) 

343 

344 # Stage 1: try every band at the requested center. 

345 psfs: dict = {} 

346 locations: dict[str, Point2D] = {} 

347 failingBands: list[str] = [] 

348 for band in bands: 

349 try: 

350 psfs[band] = mExposure[band,].getPsf().computeKernelImage(psfCenter) 

351 locations[band] = psfCenter 

352 except InvalidPsfError: 

353 failingBands.append(band) 

354 

355 if failingBands: 

356 # Stage 2: walk catalog candidates sorted by distance from the 

357 # requested center and accept the first that works for every 

358 # failing band. The bands that already succeeded at the center 

359 # are not part of the search — their PSFs are already at a 

360 # strictly better position than any fallback could provide. 

361 candidates = _sortedCatalogPositions(catalog, psfCenter) 

362 commonLocation: Point2D | None = None 

363 commonPsfs: dict = {} 

364 # The closest valid PSF found so far for each failing band, 

365 # populated as we walk the candidate list. If no single 

366 # candidate works for every failing band, these are the per- 

367 # band fallbacks. Each ``(band, position)`` is evaluated at 

368 # most once across the walk. 

369 closestPsfs: dict = {} 

370 closestLocations: dict = {} 

371 for pos in candidates: 

372 tentative: dict = {} 

373 allOk = True 

374 for band in failingBands: 

375 try: 

376 psf = ( 

377 mExposure[band,].getPsf().computeKernelImage(pos) 

378 ) 

379 except InvalidPsfError: 

380 # Mark the candidate as not common, but keep trying 

381 # the remaining bands at this candidate so a band 

382 # that *is* valid here still gets the chance to be 

383 # cached at its true closest position. 

384 allOk = False 

385 continue 

386 tentative[band] = psf 

387 if band not in closestPsfs: 

388 closestPsfs[band] = psf 

389 closestLocations[band] = pos 

390 if allOk: 

391 commonLocation = pos 

392 commonPsfs = tentative 

393 break 

394 

395 if commonLocation is not None: 

396 # Stage 2 upgrade: if the common fallback is also valid in 

397 # the bands that succeeded at the center, switch every band 

398 # to it so the multiband PSF is sampled at a single sky 

399 # location. Otherwise, keep center PSFs for the successful 

400 # bands and use the fallback only for the failing ones. 

401 upgradePsfs: dict = {} 

402 canUpgrade = True 

403 for band in psfs: 

404 try: 

405 upgradePsfs[band] = ( 

406 mExposure[band,].getPsf().computeKernelImage(commonLocation) 

407 ) 

408 except InvalidPsfError: 

409 canUpgrade = False 

410 break 

411 

412 if canUpgrade: 

413 psfs = {**upgradePsfs, **commonPsfs} 

414 else: 

415 logger.warning( 

416 "Multiband PSF falls back at two locations: bands %s at " 

417 "the requested center %s; bands %s at %s.", 

418 list(psfs), psfCenter, failingBands, commonLocation, 

419 ) 

420 for band in failingBands: 

421 psfs[band] = commonPsfs[band] 

422 else: 

423 # No common fallback location: use the per-band closest 

424 # PSFs that the candidate walk already collected. Bands 

425 # without any valid PSF in the walk are absent from 

426 # closestPsfs and will be dropped below. 

427 psfs.update(closestPsfs) 

428 locations.update(closestLocations) 

429 if any(b in psfs for b in failingBands): 

430 logger.warning( 

431 "Multiband PSF: no single fallback location works for " 

432 "every band; per-band fallback locations %s.", 

433 {b: locations[b] for b in bands if b in psfs}, 

434 ) 

435 

436 if len(psfs) == 0: 

437 return None, None 

438 

439 # Project each kept band's PSF onto the union bbox so the multiband 

440 # image has consistent shape across bands. 

441 left = np.min([psf.getBBox().getMinX() for psf in psfs.values()]) 

442 bottom = np.min([psf.getBBox().getMinY() for psf in psfs.values()]) 

443 right = np.max([psf.getBBox().getMaxX() for psf in psfs.values()]) 

444 top = np.max([psf.getBBox().getMaxY() for psf in psfs.values()]) 

445 bbox = Box2I(Point2I(left, bottom), Point2I(right, top)) 

446 

447 # Ensure that the returned multiband PSF and exposure only contain 

448 # the bands for which a valid PSF was found, in the same order as the 

449 # input exposure's bands. 

450 bandsKept = tuple(b for b in bands if b in psfs) 

451 psf_images = [projectImage(psfs[b], bbox) for b in bandsKept] 

452 mPsf = MultibandImage.fromImages(bandsKept, psf_images) 

453 

454 if len(bandsKept) < len(bands): 

455 mExposure = mExposure[bandsKept,] 

456 if len(bandsKept) == 1: 456 ↛ 459line 456 didn't jump to line 459 because the condition on line 456 was never true

457 # ``mExposure[(band,),]`` for a single band returns an 

458 # ExposureF rather than a MultibandExposure; wrap it back. 

459 mExposure = MultibandExposure.fromExposures(bandsKept, [mExposure]) 

460 

461 return mPsf.array, mExposure 

462 

463 

464def buildObservation( 

465 modelPsf: np.ndarray, 

466 psfCenter: tuple[int, int] | geom.Point2I | geom.Point2D, 

467 mExposure: MultibandExposure, 

468 badPixelMasks: list[str] | None = None, 

469 footprint: afwFootprint = None, 

470 useWeights: bool = True, 

471 convolutionType: str = "real", 

472 catalog: SourceCatalog | None = None, 

473) -> scl.Observation: 

474 """Generate an Observation from a set of arguments. 

475 

476 Make the generation and reconstruction of a scarlet model consistent 

477 by building an `Observation` from a set of arguments. 

478 

479 Parameters 

480 ---------- 

481 modelPsf : 

482 The 2D model of the PSF in the partially deconvolved space. 

483 psfCenter : 

484 The location `(x, y)` used as the center of the PSF. 

485 mExposure : 

486 The multi-band exposure that the model represents. 

487 If `mExposure` is `None` then no image, variance, or weights are 

488 attached to the observation. 

489 footprint : 

490 The footprint that is being fit. 

491 If `footprint` is `None` then the weights are not updated to mask 

492 out pixels not contained in the footprint. 

493 badPixelMasks : 

494 The keys from the bit mask plane used to mask out pixels 

495 during the fit. 

496 If `badPixelMasks` is `None` then the default values from 

497 `ScarletDeblendConfig.badMask` are used. 

498 useWeights : 

499 Whether or not fitting should use inverse variance weights to 

500 calculate the log-likelihood. 

501 convolutionType : 

502 The type of convolution to use (either "real" or "fft"). 

503 When reconstructing an image it is advised to use "real" to avoid 

504 polluting the footprint with artifacts from the fft. 

505 catalog : 

506 A source catalog to use for PSFs that cannot be determined at 

507 the center of the image. 

508 

509 Returns 

510 ------- 

511 observation: 

512 The observation constructed from the input parameters. 

513 """ 

514 # Initialize the observed PSFs 

515 if not isinstance(psfCenter, geom.Point2D): 

516 psfCenter = geom.Point2D(*psfCenter) 

517 if catalog is None: 

518 psfModels, mExposure = computePsfKernelImage(mExposure, psfCenter) 

519 else: 

520 psfModels, mExposure = computeNearestPsfMultiBand(mExposure, psfCenter, catalog) 

521 

522 if psfModels is None: 

523 raise NoWorkFound("No valid PSF could be obtained for building the observation") 

524 

525 # Use the inverse variance as the weights 

526 if useWeights: 526 ↛ 534line 526 didn't jump to line 534 because the condition on line 526 was always true

527 # Zero/NaN variance produces inf/NaN weights here; the next line 

528 # zeros them deliberately. Silence the spurious RuntimeWarnings 

529 # the division would otherwise emit on those pixels. 

530 with np.errstate(divide="ignore", invalid="ignore"): 

531 weights = 1 / mExposure.variance.array 

532 weights[~np.isfinite(weights)] = 0 

533 else: 

534 weights = np.ones_like(mExposure.image.array) 

535 

536 # Mask out bad pixels 

537 if badPixelMasks is None: 

538 badPixelMasks = defaultBadPixelMasks 

539 badPixels = mExposure.mask.getPlaneBitMask(badPixelMasks) 

540 mask = mExposure.mask.array & badPixels 

541 weights[mask > 0] = 0 

542 

543 if footprint is not None: 543 ↛ 545line 543 didn't jump to line 545 because the condition on line 543 was never true

544 # Mask out the pixels outside the footprint 

545 weights *= footprint.spans.asArray() 

546 

547 # Mask out non-finite pixels 

548 image = mExposure.image.array.copy() 

549 weights[~np.isfinite(image)] = 0 

550 image[~np.isfinite(image)] = 0 

551 

552 return scl.Observation( 

553 images=image, 

554 variance=mExposure.variance.array, 

555 weights=weights, 

556 psfs=psfModels, 

557 model_psf=modelPsf[None, :, :], 

558 convolution_mode=convolutionType, 

559 bands=mExposure.bands, 

560 bbox=bboxToScarletBox(mExposure.getBBox()), 

561 ) 

562 

563 

564def calcChi2( 

565 model: scl.Image, 

566 observation: scl.Observation, 

567 footprint: np.ndarray | None = None, 

568 doConvolve: bool = True, 

569) -> scl.Image: 

570 """Calculate the chi2 image for a model. 

571 

572 Parameters 

573 ---------- 

574 model : 

575 The model used to calculate the chi2. 

576 observation : 

577 The observation used to calculate the chi2. 

578 footprint : 

579 The footprint to use when calculating the chi2. 

580 If `footprint` is `None` then the footprint is calculated 

581 to be the pixels where the model is greater than 0. 

582 doConvolve : 

583 Whether or not to convolve the model with the PSF. 

584 

585 Returns 

586 ------- 

587 chi2 : 

588 The chi2/pixel image for the model. 

589 """ 

590 if doConvolve: 590 ↛ 592line 590 didn't jump to line 592 because the condition on line 590 was always true

591 model = observation.convolve(model) 

592 if footprint is None: 592 ↛ 593line 592 didn't jump to line 593 because the condition on line 592 was never true

593 footprint = model.data > 0 

594 bbox = model.bbox 

595 nBands = len(observation.images.bands) 

596 residual = (observation.images[:, bbox].data - model.data) * footprint 

597 cuts = observation.variance[:, bbox].data != 0 

598 chi2Data = np.zeros(residual.shape, dtype=residual.dtype) 

599 chi2Data[cuts] = residual[cuts]**2 / observation.variance[:, bbox].data[cuts] / nBands 

600 chi2 = scl.Image( 

601 chi2Data, 

602 bands=model.bands, 

603 yx0=model.yx0, 

604 ) 

605 return chi2