Coverage for python/lsst/meas/extensions/scarlet/scarletDeblendTask.py: 16%

463 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-04-25 00:40 -0700

1# This file is part of meas_extensions_scarlet. 

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 dataclasses import dataclass 

23from functools import partial 

24import logging 

25import numpy as np 

26import scarlet 

27from scarlet.psf import ImagePSF, GaussianPSF 

28from scarlet import Blend, Frame, Observation 

29from scarlet.renderer import ConvolutionRenderer 

30from scarlet.detect import get_detect_wavelets 

31from scarlet.initialization import init_all_sources 

32from scarlet import lite 

33 

34import lsst.pex.config as pexConfig 

35import lsst.pipe.base as pipeBase 

36import lsst.geom as geom 

37import lsst.afw.geom.ellipses as afwEll 

38import lsst.afw.image as afwImage 

39import lsst.afw.detection as afwDet 

40import lsst.afw.table as afwTable 

41from lsst.utils.logging import PeriodicLogger 

42from lsst.utils.timer import timeMethod 

43from lsst.afw.image.exposure._multiband import IncompleteDataError 

44 

45from .source import bboxToScarletBox 

46from .io import ScarletModelData, scarletToData, scarletLiteToData 

47 

48# Scarlet and proxmin have a different definition of log levels than the stack, 

49# so even "warnings" occur far more often than we would like. 

50# So for now we only display scarlet and proxmin errors, as all other 

51# scarlet outputs would be considered "TRACE" by our standards. 

52scarletLogger = logging.getLogger("scarlet") 

53scarletLogger.setLevel(logging.ERROR) 

54proxminLogger = logging.getLogger("proxmin") 

55proxminLogger.setLevel(logging.ERROR) 

56 

57__all__ = ["deblend", "deblend_lite", "ScarletDeblendConfig", "ScarletDeblendTask"] 

58 

59logger = logging.getLogger(__name__) 

60 

61 

62class ScarletGradientError(Exception): 

63 """An error occurred during optimization 

64 

65 This error occurs when the optimizer encounters 

66 a NaN value while calculating the gradient. 

67 """ 

68 def __init__(self, iterations, sources): 

69 self.iterations = iterations 

70 self.sources = sources 

71 msg = ("ScalarGradientError in iteration {0}. " 

72 "NaN values introduced in sources {1}") 

73 self.message = msg.format(iterations, sources) 

74 

75 def __str__(self): 

76 return self.message 

77 

78 

79def _checkBlendConvergence(blend, f_rel): 

80 """Check whether or not a blend has converged 

81 """ 

82 deltaLoss = np.abs(blend.loss[-2] - blend.loss[-1]) 

83 convergence = f_rel * np.abs(blend.loss[-1]) 

84 return deltaLoss < convergence 

85 

86 

87def isPseudoSource(source, pseudoColumns): 

88 """Check if a source is a pseudo source. 

89 

90 This is mostly for skipping sky objects, 

91 but any other column can also be added to disable 

92 deblending on a parent or individual source when 

93 set to `True`. 

94 

95 Parameters 

96 ---------- 

97 source : `lsst.afw.table.source.source.SourceRecord` 

98 The source to check for the pseudo bit. 

99 pseudoColumns : `list` of `str` 

100 A list of columns to check for pseudo sources. 

101 """ 

102 isPseudo = False 

103 for col in pseudoColumns: 

104 try: 

105 isPseudo |= source[col] 

106 except KeyError: 

107 pass 

108 return isPseudo 

109 

110 

111def computePsfKernelImage(mExposure, psfCenter): 

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

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

114 

115 Parameters 

116 ---------- 

117 psfCenter : `tuple` or `Point2I` or `Point2D` 

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

119 

120 Returns 

121 ------- 

122 psfModels : `np.ndarray` 

123 The multiband PSF image 

124 mExposure : `MultibandExposure` 

125 The exposure, updated to only use bands that 

126 successfully generated a PSF image. 

127 """ 

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

129 psfCenter = geom.Point2D(*psfCenter) 

130 

131 try: 

132 psfModels = mExposure.computePsfKernelImage(psfCenter) 

133 except IncompleteDataError as e: 

134 psfModels = e.partialPsf 

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

136 bands = psfModels.filters 

137 mExposure = mExposure[bands, ] 

138 if len(bands) == 1: 

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

140 # became a single band ExposureF. 

141 # Convert the result back into a MultibandExposure. 

142 mExposure = afwImage.MultibandExposure.fromExposures(bands, [mExposure]) 

143 return psfModels.array, mExposure 

144 

145 

146def deblend(mExposure, footprint, config, spectrumInit): 

147 """Deblend a parent footprint 

148 

149 Parameters 

150 ---------- 

151 mExposure : `lsst.image.MultibandExposure` 

152 The multiband exposure containing the image, 

153 mask, and variance data. 

154 footprint : `lsst.detection.Footprint` 

155 The footprint of the parent to deblend. 

156 config : `ScarletDeblendConfig` 

157 Configuration of the deblending task. 

158 spectrumInit : `bool` 

159 Whether or not to initialize the spectrum. 

160 

161 Returns 

162 ------- 

163 blendData : `lsst.meas.extensions.scarlet.io.ScarletBlendData` 

164 The persistable representation of a `scarlet.Blend`. 

165 skipped : `list` of `int` 

166 The indices of any children that failed to initialize 

167 and were skipped. 

168 """ 

169 # Extract coordinates from each MultiColorPeak 

170 bbox = footprint.getBBox() 

171 

172 # Create the data array from the masked images 

173 images = mExposure.image[:, bbox].array 

174 

175 # Use the inverse variance as the weights 

176 if config.useWeights: 

177 weights = 1/mExposure.variance[:, bbox].array 

178 else: 

179 weights = np.ones_like(images) 

180 badPixels = mExposure.mask.getPlaneBitMask(config.badMask) 

181 mask = mExposure.mask[:, bbox].array & badPixels 

182 weights[mask > 0] = 0 

183 

184 # Mask out the pixels outside the footprint 

185 weights *= footprint.spans.asArray() 

186 

187 psfCenter = footprint.getCentroid() 

188 psfs = mExposure.computePsfKernelImage(psfCenter).astype(np.float32) 

189 psfs = ImagePSF(psfs) 

190 model_psf = GaussianPSF(sigma=(config.modelPsfSigma,)*len(mExposure.filters)) 

191 

192 frame = Frame(images.shape, psf=model_psf, channels=mExposure.filters) 

193 observation = Observation(images, psf=psfs, weights=weights, channels=mExposure.filters) 

194 if config.convolutionType == "fft": 

195 observation.match(frame) 

196 elif config.convolutionType == "real": 

197 renderer = ConvolutionRenderer(observation, frame, convolution_type="real") 

198 observation.match(frame, renderer=renderer) 

199 else: 

200 raise ValueError("Unrecognized convolution type {}".format(config.convolutionType)) 

201 

202 assert config.sourceModel in ["single", "double", "compact", "fit"] 

203 

204 # Set the appropriate number of components 

205 if config.sourceModel == "single": 

206 maxComponents = 1 

207 elif config.sourceModel == "double": 

208 maxComponents = 2 

209 elif config.sourceModel == "compact": 

210 maxComponents = 0 

211 elif config.sourceModel == "point": 

212 raise NotImplementedError("Point source photometry is currently not implemented") 

213 elif config.sourceModel == "fit": 

214 # It is likely in the future that there will be some heuristic 

215 # used to determine what type of model to use for each source, 

216 # but that has not yet been implemented (see DM-22551) 

217 raise NotImplementedError("sourceModel 'fit' has not been implemented yet") 

218 

219 # Convert the centers to pixel coordinates 

220 xmin = bbox.getMinX() 

221 ymin = bbox.getMinY() 

222 centers = [ 

223 np.array([peak.getIy() - ymin, peak.getIx() - xmin], dtype=int) 

224 for peak in footprint.peaks 

225 if not isPseudoSource(peak, config.pseudoColumns) 

226 ] 

227 

228 # Only deblend sources that can be initialized 

229 sources, skipped = init_all_sources( 

230 frame=frame, 

231 centers=centers, 

232 observations=observation, 

233 thresh=config.morphThresh, 

234 max_components=maxComponents, 

235 min_snr=config.minSNR, 

236 shifting=False, 

237 fallback=config.fallback, 

238 silent=config.catchFailures, 

239 set_spectra=spectrumInit, 

240 ) 

241 

242 # Attach the peak to all of the initialized sources 

243 srcIndex = 0 

244 for k, center in enumerate(centers): 

245 if k not in skipped: 

246 # This is just to make sure that there isn't a coding bug 

247 assert np.all(sources[srcIndex].center == center) 

248 # Store the record for the peak with the appropriate source 

249 sources[srcIndex].detectedPeak = footprint.peaks[k] 

250 srcIndex += 1 

251 

252 # Create the blend and attempt to optimize it 

253 blend = Blend(sources, observation) 

254 try: 

255 blend.fit(max_iter=config.maxIter, e_rel=config.relativeError) 

256 except ArithmeticError: 

257 # This occurs when a gradient update produces a NaN value 

258 # This is usually due to a source initialized with a 

259 # negative SED or no flux, often because the peak 

260 # is a noise fluctuation in one band and not a real source. 

261 iterations = len(blend.loss) 

262 failedSources = [] 

263 for k, src in enumerate(sources): 

264 if np.any(~np.isfinite(src.get_model())): 

265 failedSources.append(k) 

266 raise ScarletGradientError(iterations, failedSources) 

267 

268 # Store the location of the PSF center for storage 

269 blend.psfCenter = (psfCenter.x, psfCenter.y) 

270 

271 return blend, skipped 

272 

273 

274def buildLiteObservation( 

275 modelPsf, 

276 psfCenter, 

277 mExposure, 

278 footprint=None, 

279 badPixelMasks=None, 

280 useWeights=True, 

281 convolutionType="real", 

282): 

283 """Generate a LiteObservation from a set of parameters. 

284 

285 Make the generation and reconstruction of a scarlet model consistent 

286 by building a `LiteObservation` from a set of parameters. 

287 

288 Parameters 

289 ---------- 

290 modelPsf : `numpy.ndarray` 

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

292 psfCenter : `tuple` or `Point2I` or `Point2D` 

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

294 mExposure : `lsst.afw.image.multiband.MultibandExposure` 

295 The multi-band exposure that the model represents. 

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

297 attached to the observation. 

298 footprint : `lsst.afw.detection.Footprint` 

299 The footprint that is being fit. 

300 If `Footprint` is `None` then the weights are not updated to mask 

301 out pixels not contained in the footprint. 

302 badPixelMasks : `list` of `str` 

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

304 during the fit. 

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

306 `ScarletDeblendConfig.badMask` is used. 

307 useWeights : `bool` 

308 Whether or not fitting should use inverse variance weights to 

309 calculate the log-likelihood. 

310 convolutionType : `str` 

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

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

313 polluting the footprint with 

314 

315 Returns 

316 ------- 

317 observation : `scarlet.lite.LiteObservation` 

318 The observation constructed from the input parameters. 

319 """ 

320 # Initialize the observed PSFs 

321 psfModels, mExposure = computePsfKernelImage(mExposure, psfCenter) 

322 

323 # Use the inverse variance as the weights 

324 if useWeights: 

325 weights = 1/mExposure.variance.array 

326 else: 

327 # Mask out bad pixels 

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

329 if badPixelMasks is None: 

330 badPixelMasks = ScarletDeblendConfig().badMask 

331 badPixels = mExposure.mask.getPlaneBitMask(badPixelMasks) 

332 mask = mExposure.mask.array & badPixels 

333 weights[mask > 0] = 0 

334 

335 if footprint is not None: 

336 # Mask out the pixels outside the footprint 

337 weights *= footprint.spans.asArray() 

338 

339 observation = lite.LiteObservation( 

340 images=mExposure.image.array, 

341 variance=mExposure.variance.array, 

342 weights=weights, 

343 psfs=psfModels, 

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

345 convolution_mode=convolutionType, 

346 ) 

347 

348 # Store the bands used to create the observation 

349 observation.bands = mExposure.filters 

350 return observation 

351 

352 

353def deblend_lite(mExposure, modelPsf, footprint, config, spectrumInit, wavelets=None): 

354 """Deblend a parent footprint 

355 

356 Parameters 

357 ---------- 

358 mExposure : `lsst.image.MultibandExposure` 

359 - The multiband exposure containing the image, 

360 mask, and variance data 

361 footprint : `lsst.detection.Footprint` 

362 - The footprint of the parent to deblend 

363 config : `ScarletDeblendConfig` 

364 - Configuration of the deblending task 

365 

366 Returns 

367 ------- 

368 blend : `scarlet.lite.Blend` 

369 The blend this is to be deblended 

370 skippedSources : `list[int]` 

371 Indices of sources that were skipped due to no flux. 

372 This usually means that a source was a spurrious detection in one 

373 band that should not have been included in the merged catalog. 

374 skippedBands : `list[str]` 

375 Bands that were skipped because a PSF could not be generated for them. 

376 """ 

377 # Extract coordinates from each MultiColorPeak 

378 bbox = footprint.getBBox() 

379 psfCenter = footprint.getCentroid() 

380 

381 observation = buildLiteObservation( 

382 modelPsf=modelPsf, 

383 psfCenter=psfCenter, 

384 mExposure=mExposure[:, bbox], 

385 footprint=footprint, 

386 badPixelMasks=config.badMask, 

387 useWeights=config.useWeights, 

388 convolutionType=config.convolutionType, 

389 ) 

390 

391 # Convert the centers to pixel coordinates 

392 xmin = bbox.getMinX() 

393 ymin = bbox.getMinY() 

394 centers = [ 

395 np.array([peak.getIy() - ymin, peak.getIx() - xmin], dtype=int) 

396 for peak in footprint.peaks 

397 if not isPseudoSource(peak, config.pseudoColumns) 

398 ] 

399 

400 # Initialize the sources 

401 if config.morphImage == "chi2": 

402 sources = lite.init_all_sources_main( 

403 observation, 

404 centers, 

405 min_snr=config.minSNR, 

406 thresh=config.morphThresh, 

407 ) 

408 elif config.morphImage == "wavelet": 

409 _bbox = bboxToScarletBox(len(mExposure.filters), bbox, bbox.getMin()) 

410 _wavelets = wavelets[(slice(None), *_bbox[1:].slices)] 

411 sources = lite.init_all_sources_wavelets( 

412 observation, 

413 centers, 

414 use_psf=False, 

415 wavelets=_wavelets, 

416 min_snr=config.minSNR, 

417 ) 

418 else: 

419 raise ValueError("morphImage must be either 'chi2' or 'wavelet'.") 

420 

421 # Set the optimizer 

422 if config.optimizer == "adaprox": 

423 parameterization = partial( 

424 lite.init_adaprox_component, 

425 bg_thresh=config.backgroundThresh, 

426 max_prox_iter=config.maxProxIter, 

427 ) 

428 elif config.optimizer == "fista": 

429 parameterization = partial( 

430 lite.init_fista_component, 

431 bg_thresh=config.backgroundThresh, 

432 ) 

433 else: 

434 raise ValueError("Unrecognized optimizer. Must be either 'adaprox' or 'fista'.") 

435 sources = lite.parameterize_sources(sources, observation, parameterization) 

436 

437 # Attach the peak to all of the initialized sources 

438 for k, center in enumerate(centers): 

439 # This is just to make sure that there isn't a coding bug 

440 if len(sources[k].components) > 0 and np.any(sources[k].center != center): 

441 raise ValueError("Misaligned center, expected {center} but got {sources[k].center}") 

442 # Store the record for the peak with the appropriate source 

443 sources[k].detectedPeak = footprint.peaks[k] 

444 

445 blend = lite.LiteBlend(sources, observation) 

446 

447 # Initialize each source with its best fit spectrum 

448 if spectrumInit: 

449 blend.fit_spectra() 

450 

451 # Set the sources that could not be initialized and were skipped 

452 skippedSources = [src for src in sources if src.is_null] 

453 

454 blend.fit( 

455 max_iter=config.maxIter, 

456 e_rel=config.relativeError, 

457 min_iter=config.minIter, 

458 reweight=False, 

459 ) 

460 

461 # Store the location of the PSF center for storage 

462 blend.psfCenter = (psfCenter.x, psfCenter.y) 

463 

464 # Calculate the bands that were skipped 

465 skippedBands = [band for band in mExposure.filters if band not in observation.bands] 

466 

467 return blend, skippedSources, skippedBands 

468 

469 

470@dataclass 

471class DeblenderMetrics: 

472 """Metrics and measurements made on single sources. 

473 

474 Store deblender metrics to be added as attributes to a scarlet source 

475 before it is converted into a `SourceRecord`. 

476 When DM-34414 is finished this class will be eliminated and the metrics 

477 will be added to the schema using a pipeline task that calculates them 

478 from the stored deconvolved models. 

479 

480 All of the parameters are one dimensional numpy arrays, 

481 with an element for each band in the observed images. 

482 

483 `maxOverlap` is useful as a metric for determining how blended a source 

484 is because if it only overlaps with other sources at or below 

485 the noise level, it is likely to be a mostly isolated source 

486 in the deconvolved model frame. 

487 

488 `fluxOverlapFraction` is potentially more useful than the canonical 

489 "blendedness" (or purity) metric because it accounts for potential 

490 biases created during deblending by not weighting the overlapping 

491 flux with the flux of this sources model. 

492 

493 Attributes 

494 ---------- 

495 maxOverlap : `numpy.ndarray` 

496 The maximum overlap that the source has with its neighbors in 

497 a single pixel. 

498 fluxOverlap : `numpy.ndarray` 

499 The total flux from neighbors overlapping with the current source. 

500 fluxOverlapFraction : `numpy.ndarray` 

501 The fraction of `flux from neighbors/source flux` for a 

502 given source within the source's footprint. 

503 blendedness : `numpy.ndarray` 

504 The metric for determining how blended a source is using the 

505 Bosch et al. 2018 metric for "blendedness." Note that some 

506 surveys use the term "purity," which is `1-blendedness`. 

507 """ 

508 maxOverlap: np.array 

509 fluxOverlap: np.array 

510 fluxOverlapFraction: np.array 

511 blendedness: np.array 

512 

513 

514def setDeblenderMetrics(blend): 

515 """Set metrics that can be used to evalute the deblender accuracy 

516 

517 This function calculates the `DeblenderMetrics` for each source in the 

518 blend, and assigns it to that sources `metrics` property in place. 

519 

520 Parameters 

521 ---------- 

522 blend : `scarlet.lite.Blend` 

523 The blend containing the sources to measure. 

524 """ 

525 # Store the full model of the scene for comparison 

526 blendModel = blend.get_model() 

527 for k, src in enumerate(blend.sources): 

528 # Extract the source model in the full bounding box 

529 model = src.get_model(bbox=blend.bbox) 

530 # The footprint is the 2D array of non-zero pixels in each band 

531 footprint = np.bitwise_or.reduce(model > 0, axis=0) 

532 # Calculate the metrics. 

533 # See `DeblenderMetrics` for a description of each metric. 

534 neighborOverlap = (blendModel-model) * footprint[None, :, :] 

535 maxOverlap = np.max(neighborOverlap, axis=(1, 2)) 

536 fluxOverlap = np.sum(neighborOverlap, axis=(1, 2)) 

537 fluxModel = np.sum(model, axis=(1, 2)) 

538 fluxOverlapFraction = np.zeros((len(model), ), dtype=float) 

539 isFinite = fluxModel > 0 

540 fluxOverlapFraction[isFinite] = fluxOverlap[isFinite]/fluxModel[isFinite] 

541 blendedness = 1 - np.sum(model*model, axis=(1, 2))/np.sum(blendModel*model, axis=(1, 2)) 

542 src.metrics = DeblenderMetrics(maxOverlap, fluxOverlap, fluxOverlapFraction, blendedness) 

543 

544 

545class ScarletDeblendConfig(pexConfig.Config): 

546 """MultibandDeblendConfig 

547 

548 Configuration for the multiband deblender. 

549 The parameters are organized by the parameter types, which are 

550 - Stopping Criteria: Used to determine if the fit has converged 

551 - Position Fitting Criteria: Used to fit the positions of the peaks 

552 - Constraints: Used to apply constraints to the peaks and their components 

553 - Other: Parameters that don't fit into the above categories 

554 """ 

555 # Stopping Criteria 

556 minIter = pexConfig.Field(dtype=int, default=1, 

557 doc="Minimum number of iterations before the optimizer is allowed to stop.") 

558 maxIter = pexConfig.Field(dtype=int, default=300, 

559 doc=("Maximum number of iterations to deblend a single parent")) 

560 relativeError = pexConfig.Field(dtype=float, default=1e-2, 

561 doc=("Change in the loss function between iterations to exit fitter. " 

562 "Typically this is `1e-2` if measurements will be made on the " 

563 "flux re-distributed models and `1e-4` when making measurements " 

564 "on the models themselves.")) 

565 

566 # Constraints 

567 morphThresh = pexConfig.Field(dtype=float, default=1, 

568 doc="Fraction of background RMS a pixel must have" 

569 "to be included in the initial morphology") 

570 # Lite Parameters 

571 # All of these parameters (except version) are only valid if version='lite' 

572 version = pexConfig.ChoiceField( 

573 dtype=str, 

574 default="lite", 

575 allowed={ 

576 "scarlet": "main scarlet version (likely to be deprecated soon)", 

577 "lite": "Optimized version of scarlet for survey data from a single instrument", 

578 }, 

579 doc="The version of scarlet to use.", 

580 ) 

581 optimizer = pexConfig.ChoiceField( 

582 dtype=str, 

583 default="adaprox", 

584 allowed={ 

585 "adaprox": "Proximal ADAM optimization", 

586 "fista": "Accelerated proximal gradient method", 

587 }, 

588 doc="The optimizer to use for fitting parameters and is only used when version='lite'", 

589 ) 

590 morphImage = pexConfig.ChoiceField( 

591 dtype=str, 

592 default="chi2", 

593 allowed={ 

594 "chi2": "Initialize sources on a chi^2 image made from all available bands", 

595 "wavelet": "Initialize sources using a wavelet decomposition of the chi^2 image", 

596 }, 

597 doc="The type of image to use for initializing the morphology. " 

598 "Must be either 'chi2' or 'wavelet'. " 

599 ) 

600 backgroundThresh = pexConfig.Field( 

601 dtype=float, 

602 default=0.25, 

603 doc="Fraction of background to use for a sparsity threshold. " 

604 "This prevents sources from growing unrealistically outside " 

605 "the parent footprint while still modeling flux correctly " 

606 "for bright sources." 

607 ) 

608 maxProxIter = pexConfig.Field( 

609 dtype=int, 

610 default=1, 

611 doc="Maximum number of proximal operator iterations inside of each " 

612 "iteration of the optimizer. " 

613 "This config field is only used if version='lite' and optimizer='adaprox'." 

614 ) 

615 waveletScales = pexConfig.Field( 

616 dtype=int, 

617 default=5, 

618 doc="Number of wavelet scales to use for wavelet initialization. " 

619 "This field is only used when `version`='lite' and `morphImage`='wavelet'." 

620 ) 

621 

622 # Other scarlet paremeters 

623 useWeights = pexConfig.Field( 

624 dtype=bool, default=True, 

625 doc=("Whether or not use use inverse variance weighting." 

626 "If `useWeights` is `False` then flat weights are used")) 

627 modelPsfSize = pexConfig.Field( 

628 dtype=int, default=11, 

629 doc="Model PSF side length in pixels") 

630 modelPsfSigma = pexConfig.Field( 

631 dtype=float, default=0.8, 

632 doc="Define sigma for the model frame PSF") 

633 minSNR = pexConfig.Field( 

634 dtype=float, default=50, 

635 doc="Minimum Signal to noise to accept the source." 

636 "Sources with lower flux will be initialized with the PSF but updated " 

637 "like an ordinary ExtendedSource (known in scarlet as a `CompactSource`).") 

638 saveTemplates = pexConfig.Field( 

639 dtype=bool, default=True, 

640 doc="Whether or not to save the SEDs and templates") 

641 processSingles = pexConfig.Field( 

642 dtype=bool, default=True, 

643 doc="Whether or not to process isolated sources in the deblender") 

644 convolutionType = pexConfig.Field( 

645 dtype=str, default="fft", 

646 doc="Type of convolution to render the model to the observations.\n" 

647 "- 'fft': perform convolutions in Fourier space\n" 

648 "- 'real': peform convolutions in real space.") 

649 sourceModel = pexConfig.Field( 

650 dtype=str, default="double", 

651 doc=("How to determine which model to use for sources, from\n" 

652 "- 'single': use a single component for all sources\n" 

653 "- 'double': use a bulge disk model for all sources\n" 

654 "- 'compact': use a single component model, initialzed with a point source morphology, " 

655 " for all sources\n" 

656 "- 'point': use a point-source model for all sources\n" 

657 "- 'fit: use a PSF fitting model to determine the number of components (not yet implemented)"), 

658 deprecated="This field will be deprecated when the default for `version` is changed to `lite`.", 

659 ) 

660 setSpectra = pexConfig.Field( 

661 dtype=bool, default=True, 

662 doc="Whether or not to solve for the best-fit spectra during initialization. " 

663 "This makes initialization slightly longer, as it requires a convolution " 

664 "to set the optimal spectra, but results in a much better initial log-likelihood " 

665 "and reduced total runtime, with convergence in fewer iterations." 

666 "This option is only used when " 

667 "peaks*area < `maxSpectrumCutoff` will use the improved initialization.") 

668 

669 # Mask-plane restrictions 

670 badMask = pexConfig.ListField( 

671 dtype=str, default=["BAD", "CR", "NO_DATA", "SAT", "SUSPECT", "EDGE"], 

672 doc="Whether or not to process isolated sources in the deblender") 

673 statsMask = pexConfig.ListField(dtype=str, default=["SAT", "INTRP", "NO_DATA"], 

674 doc="Mask planes to ignore when performing statistics") 

675 maskLimits = pexConfig.DictField( 

676 keytype=str, 

677 itemtype=float, 

678 default={}, 

679 doc=("Mask planes with the corresponding limit on the fraction of masked pixels. " 

680 "Sources violating this limit will not be deblended. " 

681 "If the fraction is `0` then the limit is a single pixel."), 

682 ) 

683 

684 # Size restrictions 

685 maxNumberOfPeaks = pexConfig.Field( 

686 dtype=int, default=200, 

687 doc=("Only deblend the brightest maxNumberOfPeaks peaks in the parent" 

688 " (<= 0: unlimited)")) 

689 maxFootprintArea = pexConfig.Field( 

690 dtype=int, default=100_000, 

691 doc=("Maximum area for footprints before they are ignored as large; " 

692 "non-positive means no threshold applied")) 

693 maxAreaTimesPeaks = pexConfig.Field( 

694 dtype=int, default=10_000_000, 

695 doc=("Maximum rectangular footprint area * nPeaks in the footprint. " 

696 "This was introduced in DM-33690 to prevent fields that are crowded or have a " 

697 "LSB galaxy that causes memory intensive initialization in scarlet from dominating " 

698 "the overall runtime and/or causing the task to run out of memory. " 

699 "(<= 0: unlimited)") 

700 ) 

701 maxFootprintSize = pexConfig.Field( 

702 dtype=int, default=0, 

703 doc=("Maximum linear dimension for footprints before they are ignored " 

704 "as large; non-positive means no threshold applied")) 

705 minFootprintAxisRatio = pexConfig.Field( 

706 dtype=float, default=0.0, 

707 doc=("Minimum axis ratio for footprints before they are ignored " 

708 "as large; non-positive means no threshold applied")) 

709 maxSpectrumCutoff = pexConfig.Field( 

710 dtype=int, default=1_000_000, 

711 doc=("Maximum number of pixels * number of sources in a blend. " 

712 "This is different than `maxFootprintArea` because this isn't " 

713 "the footprint area but the area of the bounding box that " 

714 "contains the footprint, and is also multiplied by the number of" 

715 "sources in the footprint. This prevents large skinny blends with " 

716 "a high density of sources from running out of memory. " 

717 "If `maxSpectrumCutoff == -1` then there is no cutoff.") 

718 ) 

719 # Failure modes 

720 fallback = pexConfig.Field( 

721 dtype=bool, default=True, 

722 doc="Whether or not to fallback to a smaller number of components if a source does not initialize" 

723 ) 

724 notDeblendedMask = pexConfig.Field( 

725 dtype=str, default="NOT_DEBLENDED", optional=True, 

726 doc="Mask name for footprints not deblended, or None") 

727 catchFailures = pexConfig.Field( 

728 dtype=bool, default=True, 

729 doc=("If True, catch exceptions thrown by the deblender, log them, " 

730 "and set a flag on the parent, instead of letting them propagate up")) 

731 

732 # Other options 

733 columnInheritance = pexConfig.DictField( 

734 keytype=str, itemtype=str, default={ 

735 "deblend_nChild": "deblend_parentNChild", 

736 "deblend_nPeaks": "deblend_parentNPeaks", 

737 "deblend_spectrumInitFlag": "deblend_spectrumInitFlag", 

738 "deblend_blendConvergenceFailedFlag": "deblend_blendConvergenceFailedFlag", 

739 }, 

740 doc="Columns to pass from the parent to the child. " 

741 "The key is the name of the column for the parent record, " 

742 "the value is the name of the column to use for the child." 

743 ) 

744 pseudoColumns = pexConfig.ListField( 

745 dtype=str, default=['merge_peak_sky', 'sky_source'], 

746 doc="Names of flags which should never be deblended." 

747 ) 

748 

749 # Logging option(s) 

750 loggingInterval = pexConfig.Field( 

751 dtype=int, default=600, 

752 doc="Interval (in seconds) to log messages (at VERBOSE level) while deblending sources.", 

753 deprecated="This field is no longer used and will be removed in v25.", 

754 ) 

755 # Testing options 

756 # Some obs packages and ci packages run the full pipeline on a small 

757 # subset of data to test that the pipeline is functioning properly. 

758 # This is not meant as scientific validation, so it can be useful 

759 # to only run on a small subset of the data that is large enough to 

760 # test the desired pipeline features but not so long that the deblender 

761 # is the tall pole in terms of execution times. 

762 useCiLimits = pexConfig.Field( 

763 dtype=bool, default=False, 

764 doc="Limit the number of sources deblended for CI to prevent long build times") 

765 ciDeblendChildRange = pexConfig.ListField( 

766 dtype=int, default=[5, 10], 

767 doc="Only deblend parent Footprints with a number of peaks in the (inclusive) range indicated." 

768 "If `useCiLimits==False` then this parameter is ignored.") 

769 ciNumParentsToDeblend = pexConfig.Field( 

770 dtype=int, default=10, 

771 doc="Only use the first `ciNumParentsToDeblend` parent footprints with a total peak count " 

772 "within `ciDebledChildRange`. " 

773 "If `useCiLimits==False` then this parameter is ignored.") 

774 

775 

776class ScarletDeblendTask(pipeBase.Task): 

777 """ScarletDeblendTask 

778 

779 Split blended sources into individual sources. 

780 

781 This task has no return value; it only modifies the SourceCatalog in-place. 

782 """ 

783 ConfigClass = ScarletDeblendConfig 

784 _DefaultName = "scarletDeblend" 

785 

786 def __init__(self, schema, peakSchema=None, **kwargs): 

787 """Create the task, adding necessary fields to the given schema. 

788 

789 Parameters 

790 ---------- 

791 schema : `lsst.afw.table.schema.schema.Schema` 

792 Schema object for measurement fields; will be modified in-place. 

793 peakSchema : `lsst.afw.table.schema.schema.Schema` 

794 Schema of Footprint Peaks that will be passed to the deblender. 

795 Any fields beyond the PeakTable minimal schema will be transferred 

796 to the main source Schema. If None, no fields will be transferred 

797 from the Peaks. 

798 filters : list of str 

799 Names of the filters used for the eposures. This is needed to store 

800 the SED as a field 

801 **kwargs 

802 Passed to Task.__init__. 

803 """ 

804 pipeBase.Task.__init__(self, **kwargs) 

805 

806 peakMinimalSchema = afwDet.PeakTable.makeMinimalSchema() 

807 if peakSchema is None: 

808 # In this case, the peakSchemaMapper will transfer nothing, but 

809 # we'll still have one 

810 # to simplify downstream code 

811 self.peakSchemaMapper = afwTable.SchemaMapper(peakMinimalSchema, schema) 

812 else: 

813 self.peakSchemaMapper = afwTable.SchemaMapper(peakSchema, schema) 

814 for item in peakSchema: 

815 if item.key not in peakMinimalSchema: 

816 self.peakSchemaMapper.addMapping(item.key, item.field) 

817 # Because SchemaMapper makes a copy of the output schema 

818 # you give its ctor, it isn't updating this Schema in 

819 # place. That's probably a design flaw, but in the 

820 # meantime, we'll keep that schema in sync with the 

821 # peakSchemaMapper.getOutputSchema() manually, by adding 

822 # the same fields to both. 

823 schema.addField(item.field) 

824 assert schema == self.peakSchemaMapper.getOutputSchema(), "Logic bug mapping schemas" 

825 self._addSchemaKeys(schema) 

826 self.schema = schema 

827 self.toCopyFromParent = [item.key for item in self.schema 

828 if item.field.getName().startswith("merge_footprint")] 

829 

830 def _addSchemaKeys(self, schema): 

831 """Add deblender specific keys to the schema 

832 """ 

833 # Parent (blend) fields 

834 self.runtimeKey = schema.addField('deblend_runtime', type=np.float32, doc='runtime in ms') 

835 self.iterKey = schema.addField('deblend_iterations', type=np.int32, doc='iterations to converge') 

836 self.nChildKey = schema.addField('deblend_nChild', type=np.int32, 

837 doc='Number of children this object has (defaults to 0)') 

838 self.nPeaksKey = schema.addField("deblend_nPeaks", type=np.int32, 

839 doc="Number of initial peaks in the blend. " 

840 "This includes peaks that may have been culled " 

841 "during deblending or failed to deblend") 

842 # Skipped flags 

843 self.deblendSkippedKey = schema.addField('deblend_skipped', type='Flag', 

844 doc="Deblender skipped this source") 

845 self.isolatedParentKey = schema.addField('deblend_isolatedParent', type='Flag', 

846 doc='The source has only a single peak ' 

847 'and was not deblended') 

848 self.pseudoKey = schema.addField('deblend_isPseudo', type='Flag', 

849 doc='The source is identified as a "pseudo" source and ' 

850 'was not deblended') 

851 self.tooManyPeaksKey = schema.addField('deblend_tooManyPeaks', type='Flag', 

852 doc='Source had too many peaks; ' 

853 'only the brightest were included') 

854 self.tooBigKey = schema.addField('deblend_parentTooBig', type='Flag', 

855 doc='Parent footprint covered too many pixels') 

856 self.maskedKey = schema.addField('deblend_masked', type='Flag', 

857 doc='Parent footprint had too many masked pixels') 

858 # Convergence flags 

859 self.sedNotConvergedKey = schema.addField('deblend_sedConvergenceFailed', type='Flag', 

860 doc='scarlet sed optimization did not converge before' 

861 'config.maxIter') 

862 self.morphNotConvergedKey = schema.addField('deblend_morphConvergenceFailed', type='Flag', 

863 doc='scarlet morph optimization did not converge before' 

864 'config.maxIter') 

865 self.blendConvergenceFailedFlagKey = schema.addField('deblend_blendConvergenceFailedFlag', 

866 type='Flag', 

867 doc='at least one source in the blend' 

868 'failed to converge') 

869 # Error flags 

870 self.deblendFailedKey = schema.addField('deblend_failed', type='Flag', 

871 doc="Deblending failed on source") 

872 self.deblendErrorKey = schema.addField('deblend_error', type="String", size=25, 

873 doc='Name of error if the blend failed') 

874 self.incompleteDataKey = schema.addField('deblend_incompleteData', type='Flag', 

875 doc='True when a blend has at least one band ' 

876 'that could not generate a PSF and was ' 

877 'not included in the model.') 

878 # Deblended source fields 

879 self.peakCenter = afwTable.Point2IKey.addFields(schema, name="deblend_peak_center", 

880 doc="Center used to apply constraints in scarlet", 

881 unit="pixel") 

882 self.peakIdKey = schema.addField("deblend_peakId", type=np.int32, 

883 doc="ID of the peak in the parent footprint. " 

884 "This is not unique, but the combination of 'parent'" 

885 "and 'peakId' should be for all child sources. " 

886 "Top level blends with no parents have 'peakId=0'") 

887 self.modelCenterFlux = schema.addField('deblend_peak_instFlux', type=float, units='count', 

888 doc="The instFlux at the peak position of deblended mode") 

889 self.modelTypeKey = schema.addField("deblend_modelType", type="String", size=25, 

890 doc="The type of model used, for example " 

891 "MultiExtendedSource, SingleExtendedSource, PointSource") 

892 self.parentNPeaksKey = schema.addField("deblend_parentNPeaks", type=np.int32, 

893 doc="deblend_nPeaks from this records parent.") 

894 self.parentNChildKey = schema.addField("deblend_parentNChild", type=np.int32, 

895 doc="deblend_nChild from this records parent.") 

896 self.scarletFluxKey = schema.addField("deblend_scarletFlux", type=np.float32, 

897 doc="Flux measurement from scarlet") 

898 self.scarletLogLKey = schema.addField("deblend_logL", type=np.float32, 

899 doc="Final logL, used to identify regressions in scarlet.") 

900 self.edgePixelsKey = schema.addField('deblend_edgePixels', type='Flag', 

901 doc='Source had flux on the edge of the parent footprint') 

902 self.scarletSpectrumInitKey = schema.addField("deblend_spectrumInitFlag", type='Flag', 

903 doc="True when scarlet initializes sources " 

904 "in the blend with a more accurate spectrum. " 

905 "The algorithm uses a lot of memory, " 

906 "so large dense blends will use " 

907 "a less accurate initialization.") 

908 self.nComponentsKey = schema.addField("deblend_nComponents", type=np.int32, 

909 doc="Number of components in a ScarletLiteSource. " 

910 "If `config.version != 'lite'`then " 

911 "this column is set to zero.") 

912 self.psfKey = schema.addField('deblend_deblendedAsPsf', type='Flag', 

913 doc='Deblender thought this source looked like a PSF') 

914 self.coverageKey = schema.addField('deblend_dataCoverage', type=np.float32, 

915 doc='Fraction of pixels with data. ' 

916 'In other words, 1 - fraction of pixels with NO_DATA set.') 

917 # Blendedness/classification metrics 

918 self.maxOverlapKey = schema.addField("deblend_maxOverlap", type=np.float32, 

919 doc="Maximum overlap with all of the other neighbors flux " 

920 "combined." 

921 "This is useful as a metric for determining how blended a " 

922 "source is because if it only overlaps with other sources " 

923 "at or below the noise level, it is likely to be a mostly " 

924 "isolated source in the deconvolved model frame.") 

925 self.fluxOverlapKey = schema.addField("deblend_fluxOverlap", type=np.float32, 

926 doc="This is the total flux from neighboring objects that " 

927 "overlaps with this source.") 

928 self.fluxOverlapFractionKey = schema.addField("deblend_fluxOverlapFraction", type=np.float32, 

929 doc="This is the fraction of " 

930 "`flux from neighbors/source flux` " 

931 "for a given source within the source's" 

932 "footprint.") 

933 self.blendednessKey = schema.addField("deblend_blendedness", type=np.float32, 

934 doc="The Bosch et al. 2018 metric for 'blendedness.' ") 

935 

936 @timeMethod 

937 def run(self, mExposure, mergedSources): 

938 """Get the psf from each exposure and then run deblend(). 

939 

940 Parameters 

941 ---------- 

942 mExposure : `MultibandExposure` 

943 The exposures should be co-added images of the same 

944 shape and region of the sky. 

945 mergedSources : `SourceCatalog` 

946 The merged `SourceCatalog` that contains parent footprints 

947 to (potentially) deblend. 

948 

949 Returns 

950 ------- 

951 templateCatalogs: dict 

952 Keys are the names of the filters and the values are 

953 `lsst.afw.table.source.source.SourceCatalog`'s. 

954 These are catalogs with heavy footprints that are the templates 

955 created by the multiband templates. 

956 """ 

957 return self.deblend(mExposure, mergedSources) 

958 

959 @timeMethod 

960 def deblend(self, mExposure, catalog): 

961 """Deblend a data cube of multiband images 

962 

963 Parameters 

964 ---------- 

965 mExposure : `MultibandExposure` 

966 The exposures should be co-added images of the same 

967 shape and region of the sky. 

968 catalog : `SourceCatalog` 

969 The merged `SourceCatalog` that contains parent footprints 

970 to (potentially) deblend. The new deblended sources are 

971 appended to this catalog in place. 

972 

973 Returns 

974 ------- 

975 catalogs : `dict` or `None` 

976 Keys are the names of the filters and the values are 

977 `lsst.afw.table.source.source.SourceCatalog`'s. 

978 These are catalogs with heavy footprints that are the templates 

979 created by the multiband templates. 

980 """ 

981 import time 

982 

983 # Cull footprints if required by ci 

984 if self.config.useCiLimits: 

985 self.log.info("Using CI catalog limits, the original number of sources to deblend was %d.", 

986 len(catalog)) 

987 # Select parents with a number of children in the range 

988 # config.ciDeblendChildRange 

989 minChildren, maxChildren = self.config.ciDeblendChildRange 

990 nPeaks = np.array([len(src.getFootprint().peaks) for src in catalog]) 

991 childrenInRange = np.where((nPeaks >= minChildren) & (nPeaks <= maxChildren))[0] 

992 if len(childrenInRange) < self.config.ciNumParentsToDeblend: 

993 raise ValueError("Fewer than ciNumParentsToDeblend children were contained in the range " 

994 "indicated by ciDeblendChildRange. Adjust this range to include more " 

995 "parents.") 

996 # Keep all of the isolated parents and the first 

997 # `ciNumParentsToDeblend` children 

998 parents = nPeaks == 1 

999 children = np.zeros((len(catalog),), dtype=bool) 

1000 children[childrenInRange[:self.config.ciNumParentsToDeblend]] = True 

1001 catalog = catalog[parents | children] 

1002 # We need to update the IdFactory, otherwise the the source ids 

1003 # will not be sequential 

1004 idFactory = catalog.getIdFactory() 

1005 maxId = np.max(catalog["id"]) 

1006 idFactory.notify(maxId) 

1007 

1008 self.log.info("Deblending %d sources in %d exposure bands", len(catalog), len(mExposure)) 

1009 periodicLog = PeriodicLogger(self.log) 

1010 

1011 # Create a set of wavelet coefficients if using wavelet initialization 

1012 if self.config.version == "lite" and self.config.morphImage == "wavelet": 

1013 images = mExposure.image.array 

1014 variance = mExposure.variance.array 

1015 wavelets = get_detect_wavelets(images, variance, scales=self.config.waveletScales) 

1016 else: 

1017 wavelets = None 

1018 

1019 # Add the NOT_DEBLENDED mask to the mask plane in each band 

1020 if self.config.notDeblendedMask: 

1021 for mask in mExposure.mask: 

1022 mask.addMaskPlane(self.config.notDeblendedMask) 

1023 

1024 # Initialize the persistable data model 

1025 modelPsf = lite.integrated_circular_gaussian(sigma=self.config.modelPsfSigma) 

1026 dataModel = ScarletModelData(modelPsf) 

1027 

1028 nParents = len(catalog) 

1029 nDeblendedParents = 0 

1030 skippedParents = [] 

1031 for parentIndex in range(nParents): 

1032 parent = catalog[parentIndex] 

1033 foot = parent.getFootprint() 

1034 bbox = foot.getBBox() 

1035 peaks = foot.getPeaks() 

1036 

1037 # Since we use the first peak for the parent object, we should 

1038 # propagate its flags to the parent source. 

1039 parent.assign(peaks[0], self.peakSchemaMapper) 

1040 

1041 # Block of conditions for skipping a parent with multiple children 

1042 if (skipArgs := self._checkSkipped(parent, mExposure)) is not None: 

1043 self._skipParent(parent, *skipArgs) 

1044 skippedParents.append(parentIndex) 

1045 continue 

1046 

1047 nDeblendedParents += 1 

1048 self.log.trace("Parent %d: deblending %d peaks", parent.getId(), len(peaks)) 

1049 # Run the deblender 

1050 blendError = None 

1051 

1052 # Choose whether or not to use improved spectral initialization. 

1053 # This significantly cuts down on the number of iterations 

1054 # that the optimizer needs and usually results in a better 

1055 # fit. 

1056 # But using least squares on a very large blend causes memory 

1057 # issues, so it is not done for large blends 

1058 if self.config.setSpectra: 

1059 if self.config.maxSpectrumCutoff <= 0: 

1060 spectrumInit = True 

1061 else: 

1062 spectrumInit = len(foot.peaks) * bbox.getArea() < self.config.maxSpectrumCutoff 

1063 else: 

1064 spectrumInit = False 

1065 

1066 try: 

1067 t0 = time.monotonic() 

1068 # Build the parameter lists with the same ordering 

1069 if self.config.version == "scarlet": 

1070 blend, skippedSources = deblend(mExposure, foot, self.config, spectrumInit) 

1071 skippedBands = [] 

1072 elif self.config.version == "lite": 

1073 blend, skippedSources, skippedBands = deblend_lite( 

1074 mExposure=mExposure, 

1075 modelPsf=modelPsf, 

1076 footprint=foot, 

1077 config=self.config, 

1078 spectrumInit=spectrumInit, 

1079 wavelets=wavelets, 

1080 ) 

1081 tf = time.monotonic() 

1082 runtime = (tf-t0)*1000 

1083 converged = _checkBlendConvergence(blend, self.config.relativeError) 

1084 # Store the number of components in the blend 

1085 if self.config.version == "lite": 

1086 nComponents = len(blend.components) 

1087 else: 

1088 nComponents = 0 

1089 nChild = len(blend.sources) 

1090 parent.set(self.incompleteDataKey, len(skippedBands) > 0) 

1091 # Catch all errors and filter out the ones that we know about 

1092 except Exception as e: 

1093 print("deblend failed") 

1094 print(e) 

1095 blendError = type(e).__name__ 

1096 if isinstance(e, ScarletGradientError): 

1097 parent.set(self.iterKey, e.iterations) 

1098 else: 

1099 blendError = "UnknownError" 

1100 if self.config.catchFailures: 

1101 # Make it easy to find UnknownErrors in the log file 

1102 self.log.warn("UnknownError") 

1103 import traceback 

1104 traceback.print_exc() 

1105 else: 

1106 raise 

1107 

1108 self._skipParent( 

1109 parent=parent, 

1110 skipKey=self.deblendFailedKey, 

1111 logMessage=f"Unable to deblend source {parent.getId}: {blendError}", 

1112 ) 

1113 parent.set(self.deblendErrorKey, blendError) 

1114 skippedParents.append(parentIndex) 

1115 continue 

1116 

1117 # Update the parent record with the deblending results 

1118 if self.config.version == "scarlet": 

1119 logL = -blend.loss[-1] + blend.observations[0].log_norm 

1120 elif self.config.version == "lite": 

1121 logL = blend.loss[-1] 

1122 self._updateParentRecord( 

1123 parent=parent, 

1124 nPeaks=len(peaks), 

1125 nChild=nChild, 

1126 nComponents=nComponents, 

1127 runtime=runtime, 

1128 iterations=len(blend.loss), 

1129 logL=logL, 

1130 spectrumInit=spectrumInit, 

1131 converged=converged, 

1132 ) 

1133 

1134 # Add each deblended source to the catalog 

1135 for k, scarletSource in enumerate(blend.sources): 

1136 # Skip any sources with no flux or that scarlet skipped because 

1137 # it could not initialize 

1138 if k in skippedSources or (self.config.version == "lite" and scarletSource.is_null): 

1139 # No need to propagate anything 

1140 continue 

1141 parent.set(self.deblendSkippedKey, False) 

1142 

1143 # Add all fields except the HeavyFootprint to the 

1144 # source record 

1145 sourceRecord = self._addChild( 

1146 parent=parent, 

1147 peak=scarletSource.detectedPeak, 

1148 catalog=catalog, 

1149 scarletSource=scarletSource, 

1150 ) 

1151 scarletSource.recordId = sourceRecord.getId() 

1152 scarletSource.peakId = scarletSource.detectedPeak.getId() 

1153 

1154 # Store the blend information so that it can be persisted 

1155 if self.config.version == "lite": 

1156 blendData = scarletLiteToData(blend, blend.psfCenter, bbox.getMin(), blend.observation.bands) 

1157 else: 

1158 blendData = scarletToData(blend, blend.psfCenter, bbox.getMin(), mExposure.filters) 

1159 dataModel.blends[parent.getId()] = blendData 

1160 

1161 # Log a message if it has been a while since the last log. 

1162 periodicLog.log("Deblended %d parent sources out of %d", parentIndex + 1, nParents) 

1163 

1164 # Clear the cached values in scarlet to clear out memory 

1165 scarlet.cache.Cache._cache = {} 

1166 

1167 # Update the mExposure mask with the footprint of skipped parents 

1168 if self.config.notDeblendedMask: 

1169 for mask in mExposure.mask: 

1170 for parentIndex in skippedParents: 

1171 fp = catalog[parentIndex].getFootprint() 

1172 fp.spans.setMask(mask, mask.getPlaneBitMask(self.config.notDeblendedMask)) 

1173 

1174 self.log.info("Deblender results: of %d parent sources, %d were deblended, " 

1175 "creating %d children, for a total of %d sources", 

1176 nParents, nDeblendedParents, len(catalog)-nParents, len(catalog)) 

1177 return catalog, dataModel 

1178 

1179 def _isLargeFootprint(self, footprint): 

1180 """Returns whether a Footprint is large 

1181 

1182 'Large' is defined by thresholds on the area, size and axis ratio, 

1183 and total area of the bounding box multiplied by 

1184 the number of children. 

1185 These may be disabled independently by configuring them to be 

1186 non-positive. 

1187 """ 

1188 if self.config.maxFootprintArea > 0 and footprint.getArea() > self.config.maxFootprintArea: 

1189 return True 

1190 if self.config.maxFootprintSize > 0: 

1191 bbox = footprint.getBBox() 

1192 if max(bbox.getWidth(), bbox.getHeight()) > self.config.maxFootprintSize: 

1193 return True 

1194 if self.config.minFootprintAxisRatio > 0: 

1195 axes = afwEll.Axes(footprint.getShape()) 

1196 if axes.getB() < self.config.minFootprintAxisRatio*axes.getA(): 

1197 return True 

1198 if self.config.maxAreaTimesPeaks > 0: 

1199 if footprint.getBBox().getArea() * len(footprint.peaks) > self.config.maxAreaTimesPeaks: 

1200 return True 

1201 return False 

1202 

1203 def _isMasked(self, footprint, mExposure): 

1204 """Returns whether the footprint violates the mask limits 

1205 

1206 Parameters 

1207 ---------- 

1208 footprint : `lsst.afw.detection.Footprint` 

1209 The footprint to check for masked pixels 

1210 mMask : `lsst.afw.image.MaskX` 

1211 The mask plane to check for masked pixels in the `footprint`. 

1212 

1213 Returns 

1214 ------- 

1215 isMasked : `bool` 

1216 `True` if `self.config.maskPlaneLimits` is less than the 

1217 fraction of pixels for a given mask in 

1218 `self.config.maskLimits`. 

1219 """ 

1220 bbox = footprint.getBBox() 

1221 mask = np.bitwise_or.reduce(mExposure.mask[:, bbox].array, axis=0) 

1222 size = float(footprint.getArea()) 

1223 for maskName, limit in self.config.maskLimits.items(): 

1224 maskVal = mExposure.mask.getPlaneBitMask(maskName) 

1225 _mask = afwImage.MaskX(mask & maskVal, xy0=bbox.getMin()) 

1226 # spanset of masked pixels 

1227 maskedSpan = footprint.spans.intersect(_mask, maskVal) 

1228 if (maskedSpan.getArea())/size > limit: 

1229 return True 

1230 return False 

1231 

1232 def _skipParent(self, parent, skipKey, logMessage): 

1233 """Update a parent record that is not being deblended. 

1234 

1235 This is a fairly trivial function but is implemented to ensure 

1236 that a skipped parent updates the appropriate columns 

1237 consistently, and always has a flag to mark the reason that 

1238 it is being skipped. 

1239 

1240 Parameters 

1241 ---------- 

1242 parent : `lsst.afw.table.source.source.SourceRecord` 

1243 The parent record to flag as skipped. 

1244 skipKey : `bool` 

1245 The name of the flag to mark the reason for skipping. 

1246 logMessage : `str` 

1247 The message to display in a log.trace when a source 

1248 is skipped. 

1249 """ 

1250 if logMessage is not None: 

1251 self.log.trace(logMessage) 

1252 self._updateParentRecord( 

1253 parent=parent, 

1254 nPeaks=len(parent.getFootprint().peaks), 

1255 nChild=0, 

1256 nComponents=0, 

1257 runtime=np.nan, 

1258 iterations=0, 

1259 logL=np.nan, 

1260 spectrumInit=False, 

1261 converged=False, 

1262 ) 

1263 

1264 # Mark the source as skipped by the deblender and 

1265 # flag the reason why. 

1266 parent.set(self.deblendSkippedKey, True) 

1267 parent.set(skipKey, True) 

1268 

1269 def _checkSkipped(self, parent, mExposure): 

1270 """Update a parent record that is not being deblended. 

1271 

1272 This is a fairly trivial function but is implemented to ensure 

1273 that a skipped parent updates the appropriate columns 

1274 consistently, and always has a flag to mark the reason that 

1275 it is being skipped. 

1276 

1277 Parameters 

1278 ---------- 

1279 parent : `lsst.afw.table.source.source.SourceRecord` 

1280 The parent record to flag as skipped. 

1281 mExposure : `MultibandExposure` 

1282 The exposures should be co-added images of the same 

1283 shape and region of the sky. 

1284 Returns 

1285 ------- 

1286 skip: `bool` 

1287 `True` if the deblender will skip the parent 

1288 """ 

1289 skipKey = None 

1290 skipMessage = None 

1291 footprint = parent.getFootprint() 

1292 if len(footprint.peaks) < 2 and not self.config.processSingles: 

1293 # Skip isolated sources unless processSingles is turned on. 

1294 # Note: this does not flag isolated sources as skipped or 

1295 # set the NOT_DEBLENDED mask in the exposure, 

1296 # since these aren't really any skipped blends. 

1297 skipKey = self.isolatedParentKey 

1298 elif isPseudoSource(parent, self.config.pseudoColumns): 

1299 # We also skip pseudo sources, like sky objects, which 

1300 # are intended to be skipped. 

1301 skipKey = self.pseudoKey 

1302 if self._isLargeFootprint(footprint): 

1303 # The footprint is above the maximum footprint size limit 

1304 skipKey = self.tooBigKey 

1305 skipMessage = f"Parent {parent.getId()}: skipping large footprint" 

1306 elif self._isMasked(footprint, mExposure): 

1307 # The footprint exceeds the maximum number of masked pixels 

1308 skipKey = self.maskedKey 

1309 skipMessage = f"Parent {parent.getId()}: skipping masked footprint" 

1310 elif self.config.maxNumberOfPeaks > 0 and len(footprint.peaks) > self.config.maxNumberOfPeaks: 

1311 # Unlike meas_deblender, in scarlet we skip the entire blend 

1312 # if the number of peaks exceeds max peaks, since neglecting 

1313 # to model any peaks often results in catastrophic failure 

1314 # of scarlet to generate models for the brighter sources. 

1315 skipKey = self.tooManyPeaksKey 

1316 skipMessage = f"Parent {parent.getId()}: skipping blend with too many peaks" 

1317 if skipKey is not None: 

1318 return (skipKey, skipMessage) 

1319 return None 

1320 

1321 def setSkipFlags(self, mExposure, catalog): 

1322 """Set the skip flags for all of the parent sources 

1323 

1324 This is mostly used for testing which parent sources will be deblended 

1325 and which will be skipped based on the current configuration options. 

1326 Skipped sources will have the appropriate flags set in place in the 

1327 catalog. 

1328 

1329 Parameters 

1330 ---------- 

1331 mExposure : `MultibandExposure` 

1332 The exposures should be co-added images of the same 

1333 shape and region of the sky. 

1334 catalog : `SourceCatalog` 

1335 The merged `SourceCatalog` that contains parent footprints 

1336 to (potentially) deblend. The new deblended sources are 

1337 appended to this catalog in place. 

1338 """ 

1339 for src in catalog: 

1340 if skipArgs := self._checkSkipped(src, mExposure) is not None: 

1341 self._skipParent(src, *skipArgs) 

1342 

1343 def _updateParentRecord(self, parent, nPeaks, nChild, nComponents, 

1344 runtime, iterations, logL, spectrumInit, converged): 

1345 """Update a parent record in all of the single band catalogs. 

1346 

1347 Ensure that all locations that update a parent record, 

1348 whether it is skipped or updated after deblending, 

1349 update all of the appropriate columns. 

1350 

1351 Parameters 

1352 ---------- 

1353 parent : `lsst.afw.table.source.source.SourceRecord` 

1354 The parent record to update. 

1355 nPeaks : `int` 

1356 Number of peaks in the parent footprint. 

1357 nChild : `int` 

1358 Number of children deblended from the parent. 

1359 This may differ from `nPeaks` if some of the peaks 

1360 were culled and have no deblended model. 

1361 nComponents : `int` 

1362 Total number of components in the parent. 

1363 This is usually different than the number of children, 

1364 since it is common for a single source to have multiple 

1365 components. 

1366 runtime : `float` 

1367 Total runtime for deblending. 

1368 iterations : `int` 

1369 Total number of iterations in scarlet before convergence. 

1370 logL : `float` 

1371 Final log likelihood of the blend. 

1372 spectrumInit : `bool` 

1373 True when scarlet used `set_spectra` to initialize all 

1374 sources with better initial intensities. 

1375 converged : `bool` 

1376 True when the optimizer reached convergence before 

1377 reaching the maximum number of iterations. 

1378 """ 

1379 parent.set(self.nPeaksKey, nPeaks) 

1380 parent.set(self.nChildKey, nChild) 

1381 parent.set(self.nComponentsKey, nComponents) 

1382 parent.set(self.runtimeKey, runtime) 

1383 parent.set(self.iterKey, iterations) 

1384 parent.set(self.scarletLogLKey, logL) 

1385 parent.set(self.scarletSpectrumInitKey, spectrumInit) 

1386 parent.set(self.blendConvergenceFailedFlagKey, converged) 

1387 

1388 def _addChild(self, parent, peak, catalog, scarletSource): 

1389 """Add a child to a catalog. 

1390 

1391 This creates a new child in the source catalog, 

1392 assigning it a parent id, and adding all columns 

1393 that are independent across all filter bands. 

1394 

1395 Parameters 

1396 ---------- 

1397 parent : `lsst.afw.table.source.source.SourceRecord` 

1398 The parent of the new child record. 

1399 peak : `lsst.afw.table.PeakRecord` 

1400 The peak record for the peak from the parent peak catalog. 

1401 catalog : `lsst.afw.table.source.source.SourceCatalog` 

1402 The merged `SourceCatalog` that contains parent footprints 

1403 to (potentially) deblend. 

1404 scarletSource : `scarlet.Component` 

1405 The scarlet model for the new source record. 

1406 """ 

1407 src = catalog.addNew() 

1408 for key in self.toCopyFromParent: 

1409 src.set(key, parent.get(key)) 

1410 # The peak catalog is the same for all bands, 

1411 # so we just use the first peak catalog 

1412 src.assign(peak, self.peakSchemaMapper) 

1413 src.setParent(parent.getId()) 

1414 src.set(self.nPeaksKey, 1) 

1415 # Set the psf key based on whether or not the source was 

1416 # deblended using the PointSource model. 

1417 # This key is not that useful anymore since we now keep track of 

1418 # `modelType`, but we continue to propagate it in case code downstream 

1419 # is expecting it. 

1420 src.set(self.psfKey, scarletSource.__class__.__name__ == "PointSource") 

1421 src.set(self.modelTypeKey, scarletSource.__class__.__name__) 

1422 # We set the runtime to zero so that summing up the 

1423 # runtime column will give the total time spent 

1424 # running the deblender for the catalog. 

1425 src.set(self.runtimeKey, 0) 

1426 

1427 # Set the position of the peak from the parent footprint 

1428 # This will make it easier to match the same source across 

1429 # deblenders and across observations, where the peak 

1430 # position is unlikely to change unless enough time passes 

1431 # for a source to move on the sky. 

1432 src.set(self.peakCenter, geom.Point2I(peak["i_x"], peak["i_y"])) 

1433 src.set(self.peakIdKey, peak["id"]) 

1434 

1435 # Store the number of components for the source 

1436 src.set(self.nComponentsKey, len(scarletSource.components)) 

1437 

1438 # Flag sources missing one or more bands 

1439 src.set(self.incompleteDataKey, parent.get(self.incompleteDataKey)) 

1440 

1441 # Propagate columns from the parent to the child 

1442 for parentColumn, childColumn in self.config.columnInheritance.items(): 

1443 src.set(childColumn, parent.get(parentColumn)) 

1444 

1445 return src