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

521 statements  

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

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 __future__ import annotations 

23 

24import logging 

25from dataclasses import dataclass 

26from functools import partial 

27import time 

28import traceback 

29from typing import cast 

30 

31import lsst.afw.detection as afwDet 

32import lsst.afw.geom.ellipses as afwEll 

33import lsst.afw.image as afwImage 

34import lsst.afw.table as afwTable 

35import lsst.pex.config as pexConfig 

36import lsst.pipe.base as pipeBase 

37import lsst.scarlet.lite as scl 

38import numpy as np 

39from lsst.scarlet.lite.detect_pybind11 import get_footprints 

40from scipy import ndimage 

41from lsst.utils.logging import PeriodicLogger 

42from lsst.utils.timer import timeMethod 

43 

44from . import io, utils 

45from .footprint import scarletFootprintToAfw 

46from .source import IsolatedSource 

47 

48__all__ = ["deblend", "ScarletDeblendContext", "ScarletDeblendConfig", "ScarletDeblendTask"] 

49 

50logger = logging.getLogger(__name__) 

51 

52 

53class DeblenderError(Exception): 

54 """Exception raised when the deblender fails. 

55 

56 This is used to catch errors in the deblender and set the appropriate flags 

57 on the parent source. 

58 """ 

59 

60 def __init__(self, message: str, parentId: int, errorName: str): 

61 super().__init__(message) 

62 self.message = message 

63 self.parentId = parentId 

64 self.errorName = errorName 

65 

66 def __str__(self) -> str: 

67 return f"DeblenderError: {self.args[0]} (parentId: {self.parentId})" 

68 

69 

70class DeblenderSkippedError(Exception): 

71 """Exception raised when a blend is skipped. 

72 

73 This is used to catch cases where the deblender does not process 

74 a deconvolved parent because it is skipped for some reason. 

75 """ 

76 def __init__(self, message: str, parentId: int, skipKey): 

77 super().__init__(message) 

78 self.message = message 

79 self.parentId = parentId 

80 self.skipKey = skipKey 

81 

82 def __str__(self) -> str: 

83 return f"DeblenderSkippedError: {self.args[0]} (parentId: {self.parentId}, skipKey: {self.skipKey})" 

84 

85 

86def _checkBlendConvergence(blend: scl.Blend, f_rel: float) -> bool: 

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

88 if len(blend.loss) < 2: 

89 # No fitting ran (or only one iteration): there is no delta 

90 # to test against, so report vacuous convergence. The 

91 # ``convergenceFailed`` flag is reserved for a fitted blend 

92 # that did not converge. 

93 return True 

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

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

96 return deltaLoss < convergence 

97 

98 

99def isPseudoSource(source: afwTable.source.SourceRecord, pseudoColumns: list[str]) -> bool: 

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

101 

102 This is mostly for skipping sky objects, 

103 but any other column can also be added to disable 

104 deblending on a parent or individual source when 

105 set to `True`. 

106 

107 Parameters 

108 ---------- 

109 source : 

110 The source to check for the pseudo bit. 

111 pseudoColumns : 

112 A list of columns to check for pseudo sources. 

113 """ 

114 isPseudo = False 

115 for col in pseudoColumns: 

116 try: 

117 isPseudo |= source[col] 

118 except KeyError: 

119 pass 

120 return isPseudo 

121 

122 

123def _getDeconvolvedFootprints( 

124 mDeconvolved: afwImage.MultibandExposure, 

125 sources: afwTable.SourceCatalog, 

126 config: ScarletDeblendConfig, 

127 sigma: np.ndarray, 

128) -> tuple[list[scl.detect.Footprint], scl.Image]: 

129 """Detect footprints in the deconvolved image 

130 

131 Parameters 

132 ---------- 

133 mDeconvolved : 

134 The deconvolved multiband exposure to detect footprints in. 

135 sources : 

136 The source catalog for the entire coadd. 

137 config : 

138 The configuration for the deblender. 

139 sigma : 

140 Per-band noise scale used to normalize the deconvolved image 

141 before stacking. Must be sourced from the input coadd's 

142 variance plane; the deconvolved exposure deliberately carries 

143 an invalidated (zero-filled) variance. 

144 

145 Returns 

146 ------- 

147 footprints : 

148 The detected footprints in the deconvolved image. 

149 footprintImage : 

150 A footprint image as returned by `scarlet.detect.footprints_to_image`. 

151 """ 

152 bbox = mDeconvolved.getBBox() 

153 xmin, ymin = bbox.getMin() 

154 detect = np.nansum(mDeconvolved.image.array/sigma[:, None, None], axis=0) 

155 

156 # We don't use the variance here because testing in DM-47738 

157 # has shown that we get better results without it 

158 # (we're detecting footprints without peaks in the deconvolved, 

159 # noise reduced image). 

160 footprints = scl.detect.detect_footprints( 

161 images=np.array([detect]), 

162 variance=np.ones((1, detect.shape[0], detect.shape[1]), dtype=detect.dtype), 

163 scales=1, 

164 min_area=config.minDeconvolvedArea, 

165 footprint_thresh=config.footprintSNRThresh, 

166 find_peaks=False, 

167 origin=(ymin, xmin) 

168 ) 

169 

170 # Create an indexed image of the footprints so that the value of a pixel 

171 # gives the index + 1 of the footprints that contain that pixel. 

172 scarletBox = utils.bboxToScarletBox(bbox) 

173 footprintImage = scl.detect.footprints_to_image(footprints, scarletBox) 

174 

175 # Ensure that there is a minimal footprint for all peaks 

176 # in the detection catalog. 

177 footprintArray = footprintImage.data > 0 

178 for source in sources: 

179 for peak in source.getFootprint().peaks: 

180 x, y = peak.getI() 

181 footprintArray[y - ymin, x - xmin] = True 

182 

183 # Grow the footprints 

184 psfMinimalSize = config.growSize * 2 + 1 

185 detectionArray = ndimage.binary_dilation( 

186 footprintArray, 

187 scl.utils.get_circle_mask(psfMinimalSize, bool) 

188 ) 

189 

190 # Ensure that all of the deconvolved footprints are contained within 

191 # a single footprint from the input catalog. 

192 # This should be unecessary, however creating entries in the output 

193 # catalog will produce unexpected results if a deconvolved footprint 

194 # is in more than one footprint from the source catalog or has 

195 # flux outside of its parent footprint. 

196 sourceImage = afwDet.footprintsToNumpy(sources, shape=detect.shape, xy0=(xmin, ymin)) 

197 detectionArray = detectionArray * sourceImage 

198 

199 footprints = get_footprints( 

200 image=detectionArray.astype(np.float32), 

201 min_separation=0, 

202 min_area=1, 

203 peak_thresh=0, 

204 footprint_thresh=0, 

205 find_peaks=False, 

206 y0=ymin, 

207 x0=xmin, 

208 ) 

209 

210 footprintImage = scl.detect.footprints_to_image(footprints, scarletBox) 

211 

212 return footprints, footprintImage 

213 

214 

215@dataclass(kw_only=True) 

216class ScarletDeblendContext: 

217 """Context with parameters and config options for deblending 

218 

219 Attributes 

220 ---------- 

221 monotonicity : 

222 The monotonicity operator. 

223 observation : 

224 The observation for the entire coadd. 

225 deconvolved : 

226 The deconvolved image. 

227 residual : 

228 The residual image 

229 (observation - deconvolved mode convolved with difference kernel). 

230 footprints : 

231 The footprints in the deconvolved image. 

232 footprintImage : 

233 An indexed image of the scarlet footprints so that the value 

234 of a pixel gives the index + 1 of the footprints that 

235 contain that pixel. 

236 config : 

237 The configuration for the deblender. 

238 """ 

239 monotonicity: scl.operators.Monotonicity 

240 observation: scl.Observation 

241 deconvolved: scl.Image 

242 footprints: list[scl.Footprint] 

243 footprintImage: scl.Image 

244 config: ScarletDeblendConfig 

245 

246 @staticmethod 

247 def build( 

248 mExposure: afwImage.MultibandExposure, 

249 mDeconvolved: afwImage.MultibandExposure, 

250 catalog: afwTable.SourceCatalog, 

251 config: ScarletDeblendConfig, 

252 ) -> ScarletDeblendContext: 

253 """Build the context from a minimal set of inputs 

254 

255 Parameters 

256 ---------- 

257 mExposure : 

258 The multiband exposure for the entire coadd. 

259 mDeconvolved : 

260 The deconvolved multiband exposure for the entire coadd. 

261 catalog : 

262 The parent catalog for the entire coadd. 

263 config : 

264 The configuration for the deblender. 

265 """ 

266 # The PSF of the model in the deconvolved space 

267 modelPsf = scl.utils.integrated_circular_gaussian( 

268 sigma=config.modelPsfSigma 

269 ) 

270 # Initialize the monotonicity operator with a size of 101 x 101 pixels. 

271 # Note: If a component is > 101x101 in either axis then the 

272 # monotonicity operator will resize itself. 

273 monotonicity = scl.operators.Monotonicity((101, 101)) 

274 # Build the observation for the entire coadd 

275 observation = utils.buildObservation( 

276 modelPsf=modelPsf, 

277 psfCenter=mExposure.getBBox().getCenter(), 

278 mExposure=mExposure, 

279 badPixelMasks=config.badMask, 

280 useWeights=config.useWeights, 

281 convolutionType=config.convolutionType, 

282 catalog=catalog, 

283 ) 

284 

285 # Create the deconvolved image 

286 yx0 = observation.images.yx0 

287 bands = observation.images.bands 

288 deconvolved = scl.Image(mDeconvolved.image.array, bands=mDeconvolved.bands, yx0=yx0) 

289 if len(bands) == 1: 289 ↛ 290line 289 didn't jump to line 290 because the condition on line 289 was never true

290 deconvolved = deconvolved[bands[0]:] 

291 else: 

292 deconvolved = deconvolved[bands] 

293 

294 # Detect footprints in the deconvolved image. The deconvolved 

295 # exposure carries an invalidated (zero-filled) variance plane, 

296 # so the per-band noise scale must come from the input coadd. 

297 sigma = np.nanmedian(np.sqrt(mExposure.variance.array), axis=(1, 2)) 

298 

299 footprints, footprintImage = _getDeconvolvedFootprints( 

300 mDeconvolved=mDeconvolved, 

301 sources=catalog, 

302 config=config, 

303 sigma=sigma, 

304 ) 

305 

306 return ScarletDeblendContext( 

307 monotonicity=monotonicity, 

308 observation=observation, 

309 deconvolved=deconvolved, 

310 footprints=footprints, 

311 footprintImage=footprintImage, 

312 config=config, 

313 ) 

314 

315 

316def deblend( 

317 context: ScarletDeblendContext, 

318 footprint: afwDet.Footprint, 

319 config: ScarletDeblendConfig, 

320 spectrumInit: bool = True, 

321) -> scl.Blend: 

322 """Deblend a parent footprint 

323 

324 Parameters 

325 ---------- 

326 context : 

327 Context with parameters and config options for deblending 

328 footprint : 

329 The parent footprint to deblend 

330 config : 

331 The configuration for the deblender 

332 spectrumInit : 

333 Whether or not to initialize the sources with their best-fit spectra 

334 

335 Returns 

336 ------- 

337 blend : `scarlet.lite.Blend` 

338 The blend this is to be deblended 

339 skippedSources : `list[int]` 

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

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

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

343 skippedBands : `list[str]` 

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

345 """ 

346 # Define the bounding boxes in lsst.geom.Box2I and lsst.scarlet.lite.Box 

347 footBox = footprint.getBBox() 

348 bbox = utils.bboxToScarletBox(footBox) 

349 

350 # Extract the observation that covers the footprint and make 

351 # a copy so that the changes don't affect the original observation. 

352 observation = context.observation[:, bbox].copy() 

353 footprintData = footprint.spans.asArray() 

354 

355 # Mask the pixels outside of the footprint 

356 observation.weights.data[:] *= footprintData 

357 

358 # Drop pseudo peaks (e.g. sky objects) so that sources are 

359 # initialized only for real detections. Keep the surviving peak 

360 # records so the source-to-peak back-pointer below is indexed in 

361 # the same filtered list that drives initialization. 

362 non_pseudo_peaks = [ 

363 peak 

364 for peak in footprint.peaks 

365 if not isPseudoSource(peak, config.pseudoColumns) 

366 ] 

367 # Convert the peaks into an array 

368 peaks = [ 

369 np.array([peak.getIy(), peak.getIx()], dtype=int) 

370 for peak in non_pseudo_peaks 

371 ] 

372 

373 detect_image = np.sum(context.deconvolved[:, bbox].data, axis=0) 

374 

375 # Initialize the sources 

376 sources = scl.initialization.FactorizedInitialization( 

377 observation=observation, 

378 centers=peaks, 

379 detect=detect_image, 

380 min_snr=config.minSNR, 

381 monotonicity=context.monotonicity, 

382 bg_thresh=config.backgroundThresh, 

383 initial_bg_thresh=config.initialBackgroundThresh, 

384 ).sources 

385 

386 blend = scl.Blend(sources, observation) 

387 

388 # Initialize each source with its best fit spectrum 

389 if spectrumInit: 

390 try: 

391 blend.fit_spectra() 

392 except Exception as e: 

393 # If the spectrum initialization fails, we will just skip it 

394 # and use the default spectrum. 

395 logger.warning( 

396 "Spectrum initialization failed with error: %s", e, exc_info=True 

397 ) 

398 

399 # Set the optimizer 

400 if config.optimizer == "adaprox": 400 ↛ 407line 400 didn't jump to line 407 because the condition on line 400 was always true

401 blend.parameterize( 

402 partial( 

403 scl.component.default_adaprox_parameterization, 

404 noise_rms=observation.noise_rms / 10, 

405 ) 

406 ) 

407 elif config.optimizer == "fista": 

408 blend.parameterize(scl.component.default_fista_parameterization) 

409 else: 

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

411 

412 if config.maxIter > 0: 

413 blend.fit( 

414 max_iter=config.maxIter, 

415 e_rel=config.relativeError, 

416 min_iter=config.minIter, 

417 resize=config.resizeFrequency, 

418 ) 

419 

420 # Attach the peak to all of the initialized sources 

421 for k, center in enumerate(peaks): 

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

423 if len(sources[k].components) > 0 and np.any(sources[k].center != center): 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true

424 raise ValueError( 

425 f"Misaligned center, expected {center} but got {sources[k].center}" 

426 ) 

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

428 sources[k].detectedPeak = non_pseudo_peaks[k] 

429 

430 return blend 

431 

432 

433class ScarletDeblendConfig(pexConfig.Config): 

434 """MultibandDeblendConfig 

435 

436 Configuration for the multiband deblender. 

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

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

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

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

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

442 """ 

443 

444 # Stopping Criteria 

445 minIter = pexConfig.Field[int]( 

446 default=5, 

447 doc="Minimum number of iterations before the optimizer is allowed to stop.", 

448 ) 

449 maxIter = pexConfig.Field[int]( 

450 default=300, 

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

452 ) 

453 relativeError = pexConfig.Field[float]( 

454 default=1e-3, 

455 doc=( 

456 "Change in the loss function between iterations to exit fitter. " 

457 "Typically this is `1e-3` if measurements will be made on the " 

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

459 "on the models themselves." 

460 ), 

461 ) 

462 resizeFrequency = pexConfig.Field[int]( 

463 default=3, 

464 doc="Number of iterations between resizing sources.", 

465 ) 

466 

467 # Constraints 

468 morphThresh = pexConfig.Field[float]( 

469 default=1, 

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

471 "to be included in the initial morphology", 

472 ) 

473 # Lite Parameters 

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

475 optimizer = pexConfig.ChoiceField[str]( 

476 default="adaprox", 

477 allowed={ 

478 "adaprox": "Proximal ADAM optimization", 

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

480 }, 

481 doc="The optimizer to use for fitting parameters.", 

482 ) 

483 backgroundThresh = pexConfig.Field[float]( 

484 default=1.0, 

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

486 "This prevents sources from growing unrealistically outside " 

487 "the parent footprint while still modeling flux correctly " 

488 "for bright sources.", 

489 ) 

490 initialBackgroundThresh = pexConfig.Field[float]( 

491 default=1.0, 

492 doc="Same as `backgroundThresh` but used only for source initialization.", 

493 ) 

494 maxProxIter = pexConfig.Field[int]( 

495 default=1, 

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

497 "iteration of the optimizer. " 

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

499 ) 

500 # Other scarlet paremeters 

501 useWeights = pexConfig.Field[bool]( 

502 default=True, 

503 doc=( 

504 "Whether or not use use inverse variance weighting." 

505 "If `useWeights` is `False` then flat weights are used" 

506 ), 

507 ) 

508 modelPsfSize = pexConfig.Field[int]( 

509 default=11, doc="Model PSF side length in pixels" 

510 ) 

511 modelPsfSigma = pexConfig.Field[float]( 

512 default=0.8, doc="Define sigma for the model frame PSF" 

513 ) 

514 minSNR = pexConfig.Field[float]( 

515 default=50, 

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

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

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

519 ) 

520 saveTemplates = pexConfig.Field[bool]( 

521 default=True, doc="Whether or not to save the SEDs and templates" 

522 ) 

523 processSingles = pexConfig.Field[bool]( 

524 default=False, 

525 doc="Whether or not to process isolated sources in the deblender", 

526 ) 

527 persistIsolated = pexConfig.Field[bool]( 

528 default=True, 

529 doc="Whether or not to persist isolated sources in the scarlet models", 

530 ) 

531 convolutionType = pexConfig.Field[str]( 

532 default="fft", 

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

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

535 "- 'real': peform convolutions in real space.", 

536 ) 

537 setSpectra = pexConfig.Field[bool]( 

538 default=True, 

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

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

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

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

543 "This option is only used when " 

544 "peaks*area < `maxSpectrumCutoff` will use the improved initialization.", 

545 ) 

546 footprintSNRThresh = pexConfig.Field[float]( 

547 default=5.0, 

548 doc="Minimum SNR for a pixel to be detected in a footprint.", 

549 ) 

550 growSize = pexConfig.Field[int]( 

551 default=2, 

552 doc="Number of pixels to grow the deconvolved footprints before final detection.", 

553 ) 

554 

555 # Mask-plane restrictions 

556 badMask = pexConfig.ListField[str]( 

557 default=utils.defaultBadPixelMasks, 

558 doc="Whether or not to process isolated sources in the deblender", 

559 ) 

560 statsMask = pexConfig.ListField[str]( 

561 default=["SAT", "INTRP", "NO_DATA"], 

562 doc="Mask planes to ignore when performing statistics", 

563 ) 

564 maskLimits = pexConfig.DictField( 

565 keytype=str, 

566 itemtype=float, 

567 default={}, 

568 doc=( 

569 "Mask planes with the corresponding limit on the fraction of masked pixels. " 

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

571 "If the fraction is `0` then the limit is a single pixel." 

572 ), 

573 ) 

574 minDeconvolvedArea = pexConfig.Field[int]( 

575 default=9, 

576 doc="Minimum area for a single footprint in the deconvolved image. " 

577 "Detected footprints smaller than this will not be created.", 

578 ) 

579 

580 # Size restrictions 

581 maxNumberOfPeaks = pexConfig.Field[int]( 

582 default=600, 

583 doc=( 

584 "Only deblend the brightest maxNumberOfPeaks peaks in the parent" 

585 " (<= 0: unlimited)" 

586 ), 

587 ) 

588 maxFootprintArea = pexConfig.Field[int]( 

589 default=2_000_000, 

590 doc=( 

591 "Maximum area for footprints before they are ignored as large; " 

592 "non-positive means no threshold applied" 

593 ), 

594 ) 

595 maxAreaTimesPeaks = pexConfig.Field[int]( 

596 default=1_000_000_000, 

597 doc=( 

598 "Maximum rectangular footprint area * nPeaks in the footprint. " 

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

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

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

602 "(<= 0: unlimited)" 

603 ), 

604 ) 

605 maxFootprintSize = pexConfig.Field[int]( 

606 default=0, 

607 doc=( 

608 "Maximum linear dimension for footprints before they are ignored " 

609 "as large; non-positive means no threshold applied" 

610 ), 

611 ) 

612 minFootprintAxisRatio = pexConfig.Field[float]( 

613 default=0.0, 

614 doc=( 

615 "Minimum axis ratio for footprints before they are ignored " 

616 "as large; non-positive means no threshold applied" 

617 ), 

618 ) 

619 maxSpectrumCutoff = pexConfig.Field[int]( 

620 default=1_000_000, 

621 doc=( 

622 "Maximum number of pixels * number of sources in a blend. " 

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

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

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

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

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

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

629 ), 

630 ) 

631 # Failure modes 

632 fallback = pexConfig.Field[bool]( 

633 default=True, 

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

635 ) 

636 notDeblendedMask = pexConfig.Field[str]( 

637 default="NOT_DEBLENDED", 

638 optional=True, 

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

640 ) 

641 catchFailures = pexConfig.Field[bool]( 

642 default=True, 

643 doc=( 

644 "If True, catch exceptions thrown by the deblender, log them, " 

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

646 ), 

647 ) 

648 

649 # Other options 

650 columnInheritance = pexConfig.DictField( 

651 keytype=str, 

652 itemtype=str, 

653 default={ 

654 "deblend_nChild": "deblend_parentNChild", 

655 "deblend_nPeaks": "deblend_parentNPeaks", 

656 }, 

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

658 "The child catalog records the number of peaks and children " 

659 "in the parent footprint so downstream measurement consumers " 

660 "can recover the parent context without a separate join.", 

661 ) 

662 pseudoColumns = pexConfig.ListField[str]( 

663 default=["merge_peak_sky", "sky_source"], 

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

665 ) 

666 measureParents = pexConfig.Field[bool]( 

667 default=False, 

668 doc="Whether to add parents to the object catalog for measurement in downstream tasks.", 

669 ) 

670 

671 # Testing options 

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

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

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

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

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

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

678 useCiLimits = pexConfig.Field[bool]( 

679 default=False, 

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

681 ) 

682 ciDeblendChildRange = pexConfig.ListField[int]( 

683 default=[5, 10], 

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

685 "If `useCiLimits==False` then this parameter is ignored.", 

686 ) 

687 ciNumParentsToDeblend = pexConfig.Field[int]( 

688 default=10, 

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

690 "within `ciDebledChildRange`. " 

691 "If `useCiLimits==False` then this parameter is ignored.", 

692 ) 

693 

694 

695class ScarletDeblendTask(pipeBase.Task): 

696 """ScarletDeblendTask 

697 

698 Split blended sources into individual sources. 

699 

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

701 """ 

702 

703 ConfigClass = ScarletDeblendConfig 

704 _DefaultName = "scarletDeblend" 

705 

706 def __init__( 

707 self, 

708 schema: afwTable.Schema, 

709 peakSchema: afwTable.Schema = None, 

710 **kwargs 

711 ): 

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

713 

714 Parameters 

715 ---------- 

716 schema : 

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

718 peakSchema : 

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

720 Any fields beyond the PeakTable minimal schema will be transferred 

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

722 from the Peaks. 

723 **kwargs 

724 Passed to Task.__init__. 

725 """ 

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

727 

728 peakMinimalSchema = afwDet.PeakTable.makeMinimalSchema() 

729 if peakSchema is None: 

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

731 # we'll still have one 

732 # to simplify downstream code 

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

734 else: 

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

736 for item in peakSchema: 

737 if item.key not in peakMinimalSchema: 

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

739 # Because SchemaMapper makes a copy of the output schema 

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

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

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

743 # peakSchemaMapper.getOutputSchema() manually, by adding 

744 # the same fields to both. 

745 schema.addField(item.field) 

746 assert ( 

747 schema == self.peakSchemaMapper.getOutputSchema() 

748 ), "Logic bug mapping schemas" 

749 

750 # Add the parent keys to the parent catalog schema 

751 self.parentSchemaMapper = afwTable.SchemaMapper(schema) 

752 self.parentSchemaMapper.addMinimalSchema(schema, doMap=True) 

753 parentOutSchema = self.parentSchemaMapper.editOutputSchema() 

754 self._addParentSchemaKeys(parentOutSchema) 

755 self.parentSchema = parentOutSchema 

756 # Mirror peakSchemaMapper's extra-field handling so any 

757 # merge_peak_* (or other non-minimal) peak fields are also 

758 # carried onto parent records. 

759 if peakSchema is None: 

760 self.parentPeakSchemaMapper = afwTable.SchemaMapper(peakMinimalSchema, self.parentSchema) 

761 else: 

762 self.parentPeakSchemaMapper = afwTable.SchemaMapper(peakSchema, self.parentSchema) 

763 for item in peakSchema: 

764 if item.key not in peakMinimalSchema: 

765 self.parentPeakSchemaMapper.addMapping(item.key, item.field) 

766 

767 # Add keys for isolated sources and deblended children to the schema. 

768 self._addChildSchemaKeys(schema) 

769 self.objectSchema = schema 

770 self.objectPeakSchemaMapper = afwTable.SchemaMapper(peakMinimalSchema, self.objectSchema) 

771 self.toCopyFromParent = [ 

772 name 

773 for item in self.objectSchema 

774 if ((name := item.field.getName()).startswith("merge_footprint") 

775 or (name := item.field.getName()).startswith("merge_peak")) 

776 ] 

777 

778 # Any pseudoColumn must be present on the deblender schema — 

779 # the science pipeline always adds these upstream (e.g. 

780 # ``SkyObjectsTask`` for ``sky_source``, the coadd merge step 

781 # for ``merge_peak_*``). A missing column means a typo in 

782 # config or a missing upstream task; warn so the user knows 

783 # the corresponding filter is silently inactive. 

784 schemaNames = set(self.objectSchema.getNames()) 

785 unknownPseudoColumns = [ 

786 col for col in self.config.pseudoColumns 

787 if col not in schemaNames 

788 ] 

789 if unknownPseudoColumns: 789 ↛ exitline 789 didn't return from function '__init__' because the condition on line 789 was always true

790 self.log.warning( 

791 "pseudoColumns %s not found on the deblender schema. " 

792 "Pseudo-source filtering for these columns will be " 

793 "silently skipped at runtime — check for config typos " 

794 "or missing upstream tasks (e.g. SkyObjectsTask " 

795 "provides 'sky_source', the coadd merge provides " 

796 "'merge_peak_sky').", 

797 unknownPseudoColumns, 

798 ) 

799 

800 def _addParentSchemaKeys(self, schema: afwTable.Schema): 

801 """Add parent specific keys to the schema""" 

802 # Parent (blend) fields 

803 schema.addField( 

804 "deblend_runtime", type=np.float32, doc="runtime in ms" 

805 ) 

806 schema.addField( 

807 "deblend_iterations", type=np.int32, doc="iterations to converge" 

808 ) 

809 schema.addField( 

810 "deblend_nChild", 

811 type=np.int32, 

812 doc="Number of children this object has (defaults to 0)", 

813 ) 

814 schema.addField( 

815 "deblend_nPeaks", 

816 type=np.int32, 

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

818 "This includes peaks that may have been culled " 

819 "during deblending or failed to deblend", 

820 ) 

821 schema.addField( 

822 "deblend_spectrumInitFlag", 

823 type="Flag", 

824 doc="True when scarlet initializes sources " 

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

826 "The algorithm uses a lot of memory, " 

827 "so large dense blends will use " 

828 "a less accurate initialization.", 

829 ) 

830 # Skipped flags 

831 schema.addField( 

832 "deblend_skipped", 

833 type="Flag", 

834 doc=( 

835 "The deblender skipped this source. On a deconvolved " 

836 "sub-blend the flag means that sub-blend itself was " 

837 "skipped. On a top-level parent the flag is the union " 

838 "over its deconvolved sub-blends: it fires whenever at " 

839 "least one sub-blend was skipped, even when the others " 

840 "succeeded. The per-reason ``deblend_skipped_*`` " 

841 "sub-flags follow the same union convention on a " 

842 "top-level parent, so a parent record can carry " 

843 "multiple sub-flags when different sub-blends were " 

844 "skipped for different reasons." 

845 ), 

846 ) 

847 schema.addField( 

848 "deblend_skipped_isolatedParent", 

849 type="Flag", 

850 doc="The source has only a single peak " "and was not deblended", 

851 ) 

852 schema.addField( 

853 "deblend_skipped_isPseudo", 

854 type="Flag", 

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

856 "was not deblended", 

857 ) 

858 schema.addField( 

859 "deblend_skipped_tooManyPeaks", 

860 type="Flag", 

861 doc="Source had too many peaks; " "only the brightest were included", 

862 ) 

863 schema.addField( 

864 "deblend_skipped_parentTooBig", 

865 type="Flag", 

866 doc="Parent footprint covered too many pixels", 

867 ) 

868 schema.addField( 

869 "deblend_skipped_masked", 

870 type="Flag", 

871 doc="Parent footprint had too many masked pixels", 

872 ) 

873 # Convergence flags 

874 schema.addField( 

875 "deblend_blendConvergenceFailedFlag", 

876 type="Flag", 

877 doc="at least one source in the blend" "failed to converge", 

878 ) 

879 # Error flags 

880 schema.addField( 

881 "deblend_failed", type="Flag", doc="Deblending failed on source" 

882 ) 

883 schema.addField( 

884 "deblend_childFailed", 

885 type="Flag", 

886 doc="Deblending failed on at least one child blend. " 

887 " This is set in a parent when at least one of its children " 

888 "is a blend that failed to deblend.", 

889 ) 

890 schema.addField( 

891 "deblend_error", 

892 type="String", 

893 size=25, 

894 doc="Name of error if the blend failed", 

895 ) 

896 schema.addField( 

897 "deblend_incompleteData", 

898 type="Flag", 

899 doc="True when a blend has at least one band " 

900 "that could not generate a PSF and was " 

901 "not included in the model.", 

902 ) 

903 schema.addField( 

904 "deblend_nComponents", 

905 type=np.int32, 

906 doc="Number of components in a ScarletLiteSource.", 

907 ) 

908 schema.addField( 

909 "deblend_chi2", 

910 type=np.float32, 

911 doc="Final reduced chi2 (per pixel), used to identify goodness of fit.", 

912 ) 

913 

914 def _addChildSchemaKeys(self, schema: afwTable.Schema): 

915 """Add deblender specific keys to the schema""" 

916 afwTable.Point2IKey.addFields( 

917 schema, 

918 name="deblend_peak_center", 

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

920 unit="pixel", 

921 ) 

922 schema.addField( 

923 "deblend_nChild", 

924 type=np.int32, 

925 doc="Number of children this object has (defaults to 0)", 

926 ) 

927 schema.addField( 

928 "deblend_peakId", 

929 type=np.int32, 

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

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

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

933 "Top level blends with no parents have 'peakId=0'", 

934 ) 

935 schema.addField( 

936 "deblend_peak_instFlux", 

937 type=float, 

938 units="count", 

939 doc="The instFlux at the peak position of deblended mode", 

940 ) 

941 schema.addField( 

942 "deblend_scarletFlux", type=np.float32, doc="Flux measurement from scarlet" 

943 ) 

944 schema.addField( 

945 "deblend_edgePixels", 

946 type="Flag", 

947 doc="Source had flux on the edge of the parent footprint", 

948 ) 

949 schema.addField( 

950 "deblend_dataCoverage", 

951 type=np.float32, 

952 doc="Fraction of pixels with data. " 

953 "In other words, 1 - fraction of pixels with NO_DATA set.", 

954 ) 

955 schema.addField( 

956 "deblend_zeroFlux", type="Flag", doc="Source has zero flux." 

957 ) 

958 # Blendedness/classification metrics 

959 schema.addField( 

960 "deblend_maxOverlap", 

961 type=np.float32, 

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

963 "combined." 

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

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

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

967 "isolated source in the deconvolved model frame.", 

968 ) 

969 schema.addField( 

970 "deblend_fluxOverlap", 

971 type=np.float32, 

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

973 "overlaps with this source.", 

974 ) 

975 schema.addField( 

976 "deblend_fluxOverlapFraction", 

977 type=np.float32, 

978 doc="This is the fraction of " 

979 "`flux from neighbors/source flux` " 

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

981 "footprint.", 

982 ) 

983 schema.addField( 

984 "deblend_blendedness", 

985 type=np.float32, 

986 doc="The Bosch et al. 2018 metric for 'blendedness.' ", 

987 ) 

988 schema.addField( 

989 "deblend_blendId", 

990 type=np.int64, 

991 doc="Parents in the catalog may be subdivided by deblending " 

992 "into multiple deconvolved blends that are each " 

993 "deblended separately. This is the ID of the " 

994 "deconvolved blend in the catalog." 

995 ) 

996 schema.addField( 

997 "deblend_blendNChild", 

998 type=np.int32, 

999 doc="The number of children in the deconvolved blend." 

1000 ) 

1001 schema.addField( 

1002 "deblend_parentNPeaks", 

1003 type=np.int32, 

1004 doc="deblend_nPeaks from this records parent.", 

1005 ) 

1006 schema.addField( 

1007 "deblend_parentNChild", 

1008 type=np.int32, 

1009 doc="deblend_nChild from this records parent.", 

1010 ) 

1011 schema.addField( 

1012 "deblend_nComponents", 

1013 type=np.int32, 

1014 doc="Number of components in a ScarletLiteSource.", 

1015 ) 

1016 schema.addField( 

1017 "deblend_chi2", 

1018 type=np.float32, 

1019 doc="Final reduced chi2 (per pixel), used to identify goodness of fit.", 

1020 ) 

1021 

1022 @timeMethod 

1023 def run( 

1024 self, 

1025 mExposure: afwImage.MultibandExposure, 

1026 mDeconvolved: afwImage.MultibandExposure, 

1027 mergedSources: afwTable.SourceCatalog, 

1028 ) -> pipeBase.Struct: 

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

1030 

1031 Parameters 

1032 ---------- 

1033 mExposure : 

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

1035 shape and region of the sky. 

1036 mDeconvolved : 

1037 The deconvolved images of the same shape and region of the sky. 

1038 mergedSources : 

1039 The merged `SourceCatalog` that contains parent footprints 

1040 to (potentially) deblend. 

1041 

1042 Returns 

1043 ------- 

1044 templateCatalogs: dict 

1045 Keys are the names of the bands and the values are 

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

1047 These are catalogs with heavy footprints that are the templates 

1048 created by the multiband templates. 

1049 """ 

1050 # Create a table to hold object records 

1051 table = afwTable.SourceTable.make(self.objectSchema) 

1052 objectCatalog = afwTable.SourceCatalog(table) 

1053 

1054 # Create a table to hold parent records 

1055 table = afwTable.SourceTable.make(self.parentSchema, mergedSources.getIdFactory()) 

1056 parentCatalog = afwTable.SourceCatalog(table) 

1057 parentCatalog.extend(mergedSources, self.parentSchemaMapper) 

1058 

1059 return self.deblend(mExposure, mDeconvolved, objectCatalog, parentCatalog) 

1060 

1061 @timeMethod 

1062 def deblend( 

1063 self, 

1064 mExposure: afwImage.MultibandExposure, 

1065 mDeconvolved: afwImage.MultibandExposure, 

1066 objectCatalog: afwTable.SourceCatalog, 

1067 parentCatalog: afwTable.SourceCatalog, 

1068 ) -> pipeBase.Struct: 

1069 """Deblend a data cube of multiband images 

1070 

1071 Deblending iterates over sources from the input catalog, 

1072 which are blends of peaks with overlapping PSFs (depth 0 parents). 

1073 In many cases those footprints can be subdived into multiple 

1074 deconvolved footprints, which have an intermediate 

1075 parent record added to the catalog and are be deblended separately. 

1076 All deblended peaks have a source record added to the catalog, 

1077 each of which has a depth one greater than the parent. 

1078 

1079 Parameters 

1080 ---------- 

1081 mExposure : 

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

1083 shape and region of the sky. 

1084 mDeconvolved : 

1085 The deconvolved images of the same shape and region of the sky. 

1086 objectCatalog : 

1087 An empty `SourceCatalog` with the schema to hold isolated sources 

1088 and deblended children. This catalog is filled in place. 

1089 parentCatalog : 

1090 The merged `SourceCatalog` that contains parent footprints 

1091 to (potentially) deblend. If a parent is subdivided into 

1092 multiple deconvolved parents, the deconvolved parents are 

1093 added to this catalog in place. 

1094 

1095 Returns 

1096 ------- 

1097 deblendedCatalog : 

1098 The ``deblendedCatalog`` isolated and deblended child sources. 

1099 scarletModelData : 

1100 The persistable data model for the deblender. 

1101 objectParents : 

1102 The parent catalog with deconvolved parents added. 

1103 """ 

1104 

1105 # Cull footprints if required by ci 

1106 if self.config.useCiLimits: 1106 ↛ 1107line 1106 didn't jump to line 1107 because the condition on line 1106 was never true

1107 self.log.info( 

1108 "Using CI catalog limits, the original number of sources to deblend was %d.", 

1109 len(objectCatalog), 

1110 ) 

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

1112 # config.ciDeblendChildRange 

1113 minChildren, maxChildren = self.config.ciDeblendChildRange 

1114 nPeaks = np.array([len(src.getFootprint().peaks) for src in parentCatalog]) 

1115 childrenInRange = np.where( 

1116 (nPeaks >= minChildren) & (nPeaks <= maxChildren) 

1117 )[0] 

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

1119 raise ValueError( 

1120 "Fewer than ciNumParentsToDeblend children were contained in the range " 

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

1122 "parents." 

1123 ) 

1124 # Keep all of the isolated parents and the first 

1125 # `ciNumParentsToDeblend` children 

1126 parents = nPeaks == 1 

1127 children = np.zeros((len(parentCatalog),), dtype=bool) 

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

1129 parentCatalog = parentCatalog[parents | children] 

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

1131 # will not be sequential 

1132 idFactory = parentCatalog.getIdFactory() 

1133 maxId = np.max(parentCatalog["id"]) 

1134 idFactory.notify(maxId) 

1135 del children 

1136 

1137 self.log.info( 

1138 "Deblending %d sources in %d exposure bands", len(parentCatalog), len(mExposure), 

1139 ) 

1140 periodicLog = PeriodicLogger(self.log) 

1141 

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

1143 if self.config.notDeblendedMask: 1143 ↛ 1148line 1143 didn't jump to line 1148 because the condition on line 1143 was always true

1144 for mask in mExposure.mask: 

1145 mask.addMaskPlane(self.config.notDeblendedMask) 

1146 

1147 # Create the context for the entire coadd 

1148 context = ScarletDeblendContext.build( 

1149 mExposure=mExposure, 

1150 mDeconvolved=mDeconvolved, 

1151 config=self.config, 

1152 catalog=parentCatalog, 

1153 ) 

1154 nBands = len(context.observation.bands) 

1155 

1156 # Initialize the persistable ScarletModelData object 

1157 modelData = io.LsstScarletModelData(metadata={ 

1158 "model_psf": context.observation.model_psf[0], 

1159 "psf": context.observation.psfs, 

1160 "bands": context.observation.bands, 

1161 }) 

1162 

1163 if self.config.persistIsolated: 1163 ↛ 1179line 1163 didn't jump to line 1179 because the condition on line 1163 was always true

1164 # Add isolated sources to the model data 

1165 for source in parentCatalog: 

1166 if ( 

1167 len(source.getFootprint().peaks) == 1 

1168 and not isPseudoSource(source, self.config.pseudoColumns) 

1169 ): 

1170 isolated = IsolatedSource.from_footprint( 

1171 footprint=source.getFootprint(), 

1172 mCoadd=mExposure, 

1173 dtype=np.float32, 

1174 ) 

1175 modelData.isolated[source.getId()] = isolated.to_data() 

1176 

1177 # Attach full image objects to the task to simplify the API 

1178 # and use for debugging. 

1179 self.objectCatalog = objectCatalog 

1180 self.parentCatalog = parentCatalog 

1181 self.context = context 

1182 self.modelData = modelData 

1183 self.mExposure = mExposure 

1184 

1185 # Subdivide the psf blended parents into deconvolved parents 

1186 # using the deconvolved footprints stored in the context. 

1187 nParents = len(parentCatalog) 

1188 self._initializeCatalogs( 

1189 objectCatalog, 

1190 parentCatalog, 

1191 context, 

1192 ) 

1193 nBlends = len(parentCatalog) 

1194 

1195 self.log.info( 

1196 "Subdivided %d top level parents to create %d deconvolved parents.", 

1197 nParents, 

1198 nBlends-nParents, 

1199 ) 

1200 

1201 # Deblend sources 

1202 for parentIndex in range(nParents): 

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

1204 periodicLog.log( 

1205 "Deblended %d out of %d parents", 

1206 parentIndex, 

1207 nParents, 

1208 ) 

1209 

1210 parentRecord = parentCatalog[parentIndex] 

1211 blendRecords = parentCatalog[parentCatalog["parent"] == parentRecord.getId()] 

1212 

1213 if len(blendRecords) == 0: 

1214 # The source is isolated. 

1215 if self.config.processSingles: 1215 ↛ 1216line 1215 didn't jump to line 1216 because the condition on line 1215 was never true

1216 blendRecords = [parentRecord] 

1217 else: 

1218 continue 

1219 

1220 self.log.trace( 

1221 "Split parent %d into %d deconvolved parents", 

1222 parentRecord.getId(), 

1223 len(blendRecords), 

1224 ) 

1225 # Create an image to keep track of the cumulative model 

1226 # for all sub blends in the parent footprint. 

1227 parentFootprint = parentRecord.getFootprint() 

1228 bbox = parentFootprint.getBBox() 

1229 width, height = bbox.getDimensions() 

1230 x0, y0 = bbox.getMin() 

1231 emptyModel = np.zeros( 

1232 (nBands, height, width), 

1233 dtype=mExposure.image.array.dtype, 

1234 ) 

1235 parentModel = scl.Image( 

1236 emptyModel, 

1237 bands=context.observation.images.bands, 

1238 yx0=(y0, x0), 

1239 ) 

1240 

1241 sourceRecords = [] 

1242 parentBlends = {} 

1243 for subBlendIndex, blendRecord in enumerate(blendRecords): 

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

1245 periodicLog.log( 

1246 "Deblending sub-blend %d/%d of parent %d", 

1247 subBlendIndex + 1, 

1248 len(blendRecords), 

1249 parentRecord.getId(), 

1250 ) 

1251 try: 

1252 blend, blendModel, chi2 = self._deblendParent(blendRecord) 

1253 except DeblenderSkippedError as e: 

1254 self._skipBlend(blendRecord, e.skipKey, e.message) 

1255 parentRecord.set("deblend_skipped", True) 

1256 parentRecord.set(e.skipKey, True) 

1257 continue 

1258 except DeblenderError as e: 

1259 blendRecord.set("deblend_error", e.errorName) 

1260 blendRecord.set("deblend_failed", True) 

1261 self._skipBlend(blendRecord, "deblend_failed", e.message) 

1262 parentRecord.set("deblend_childFailed", True) 

1263 continue 

1264 

1265 # Update the parent model 

1266 parentModel.insert(blendModel) 

1267 

1268 # Add each deblended source to the catalog 

1269 for scarletSource in blend.sources: 

1270 # Add all fields except the HeavyFootprint to the 

1271 # source record 

1272 scarletSource.metadata = {} 

1273 scarletSource.metadata["peak_id"] = scarletSource.detectedPeak.getId() 

1274 sourceRecord = self._addDeblendedSource( 

1275 parentId=parentRecord.getId(), 

1276 blendRecord=blendRecord, 

1277 peak=scarletSource.detectedPeak, 

1278 objectCatalog=objectCatalog, 

1279 scarletSource=scarletSource, 

1280 chi2=chi2, 

1281 ) 

1282 scarletSource.metadata["id"] = sourceRecord.getId() 

1283 sourceRecords.append(sourceRecord) 

1284 

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

1286 blendData = blend.to_data() 

1287 parentBlends[blendRecord.getId()] = blendData 

1288 

1289 if len(parentBlends) == 0: 

1290 # All of the deconvolved blends failed to deblend 

1291 self._updateParentRecord( 

1292 parentRecord=parentRecord, 

1293 nPeaks=len(parentRecord.getFootprint().peaks), 

1294 nChild=0, 

1295 nComponents=0, 

1296 runtime=np.nan, 

1297 iterations=0, 

1298 logL=np.nan, 

1299 chi2=np.nan, 

1300 spectrumInit=False, 

1301 # No blend was fit, so convergence does not apply. 

1302 convergenceFailed=False, 

1303 ) 

1304 continue 

1305 

1306 # Calculate the reduced chi2 for the PSF parent 

1307 parentFootprintImage = parentModel.data > 0 

1308 chi2 = utils.calcChi2(parentModel, context.observation, parentFootprintImage) 

1309 # Defensive: a detected parent's aggregate model has 

1310 # positive flux somewhere, so the area should never be 0. 

1311 parentArea = np.sum(parentFootprintImage) 

1312 parentReducedChi2 = ( 

1313 np.sum(chi2.data) / parentArea if parentArea > 0 else np.nan 

1314 ) 

1315 

1316 # Update the parent record with the deblending results 

1317 self._updateParentRecord( 

1318 parentRecord=parentRecord, 

1319 nPeaks=len(parentFootprint.peaks), 

1320 nChild=np.sum([child["deblend_nChild"] for child in blendRecords]), 

1321 nComponents=np.sum([child["deblend_nComponents"] for child in blendRecords]), 

1322 runtime=np.sum([child["deblend_runtime"] for child in blendRecords]), 

1323 iterations=np.sum([child["deblend_iterations"] for child in blendRecords]), 

1324 logL=np.nan, 

1325 chi2=parentReducedChi2, 

1326 spectrumInit=np.all([ 

1327 child["deblend_spectrumInitFlag"] 

1328 for child in blendRecords 

1329 ]), # type: ignore 

1330 convergenceFailed=np.any([ 

1331 child["deblend_blendConvergenceFailedFlag"] 

1332 for child in blendRecords 

1333 ]), # type: ignore 

1334 ) 

1335 # Persist parent columns to the children 

1336 for child in sourceRecords: 

1337 for key in self.toCopyFromParent: 1337 ↛ 1338line 1337 didn't jump to line 1338 because the loop on line 1337 never started

1338 child.set(key, parentRecord.get(key)) 

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

1340 child.set(childColumn, parentRecord.get(parentColumn)) 

1341 

1342 # Persist the blend data 

1343 modelData.blends[parentRecord.getId()] = scl.io.HierarchicalBlendData( 

1344 children=parentBlends, 

1345 metadata={ 

1346 "spans": parentFootprint.getSpans().asArray(), 

1347 "origin": (y0, x0), 

1348 } 

1349 ) 

1350 

1351 nDeblendedSources = np.sum(objectCatalog["parent"] != 0) 

1352 self.log.info( 

1353 "Deblender results: %d parent sources were " 

1354 "split into %d deconvolved parents," 

1355 "resulting in %d deblended sources, " 

1356 "for a total catalog size of %d sources", 

1357 nParents, 

1358 nBlends, 

1359 nDeblendedSources, 

1360 len(objectCatalog), 

1361 ) 

1362 

1363 table = afwTable.SourceTable.make(self.objectSchema) 

1364 sortedCatalog = afwTable.SourceCatalog(table) 

1365 sortedCatalog.extend(objectCatalog, deep=True) 

1366 table = afwTable.SourceTable.make(self.parentSchema) 

1367 sortedBlendCatalog = afwTable.SourceCatalog(table) 

1368 sortedBlendCatalog.extend(parentCatalog, deep=True) 

1369 

1370 return pipeBase.Struct( 

1371 deblendedCatalog=sortedCatalog, 

1372 scarletModelData=modelData, 

1373 objectParents=sortedBlendCatalog, 

1374 ) 

1375 

1376 def _deblendParent( 

1377 self, 

1378 blendRecord: afwTable.SourceRecord, 

1379 ) -> tuple[scl.Blend, scl.Image, scl.Image]: 

1380 """Deblend a parent source record 

1381 

1382 Parameters 

1383 ---------- 

1384 blendRecord : 

1385 The parent source record to deblend. 

1386 

1387 Returns 

1388 ------- 

1389 blend : 

1390 The `scl.Blend` object that contains the deblended sources. 

1391 blendModel : 

1392 The `scl.Image` model of the blend. 

1393 chi2 : 

1394 The reduced chi2 of the blend model. 

1395 """ 

1396 footprint = blendRecord.getFootprint() 

1397 bbox = footprint.getBBox() 

1398 peaks = footprint.getPeaks() 

1399 

1400 # Skip the source if it meets the skipping criteria 

1401 isSkipped = self._checkSkipped(blendRecord, self.mExposure) 

1402 if isSkipped is not None: 

1403 skipKey, skipMessage = isSkipped 

1404 raise DeblenderSkippedError( 

1405 skipMessage, 

1406 blendRecord.getId(), 

1407 skipKey, 

1408 ) 

1409 

1410 self.log.trace( 

1411 "Blend %d: deblending {%d} peaks", 

1412 blendRecord.getId(), 

1413 len(peaks), 

1414 ) 

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

1416 # This significantly cuts down on the number of iterations 

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

1418 # fit. 

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

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

1421 if self.config.setSpectra: 1421 ↛ 1429line 1421 didn't jump to line 1429 because the condition on line 1421 was always true

1422 if self.config.maxSpectrumCutoff <= 0: 1422 ↛ 1423line 1422 didn't jump to line 1423 because the condition on line 1422 was never true

1423 spectrumInit = True 

1424 else: 

1425 spectrumInit = ( 

1426 len(footprint.peaks) * bbox.getArea() < self.config.maxSpectrumCutoff 

1427 ) 

1428 else: 

1429 spectrumInit = False 

1430 

1431 try: 

1432 t0 = time.monotonic() 

1433 # Build the parameter lists with the same ordering 

1434 blend = deblend(self.context, footprint, self.config, spectrumInit) 

1435 tf = time.monotonic() 

1436 runtime = (tf - t0) * 1000 

1437 convergenceFailed = not _checkBlendConvergence( 

1438 blend, self.config.relativeError 

1439 ) 

1440 # Store the number of components in the blend 

1441 nComponents = len(blend.components) 

1442 nChild = len(blend.sources) 

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

1444 except Exception as e: 

1445 blendError = type(e).__name__ 

1446 if self.config.catchFailures: 

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

1448 self.log.warning("UnknownError") 

1449 traceback.print_exc() 

1450 else: 

1451 raise 

1452 

1453 raise DeblenderError( 

1454 f"Unable to deblend parent {blendRecord.getId()}: {blendError}", 

1455 blendRecord.getId(), 

1456 blendError, 

1457 ) 

1458 

1459 # Calculate the reduced chi2 

1460 blendModel = blend.get_model(convolve=False) 

1461 blendFootprintImage = blendModel.data > 0 

1462 chi2 = utils.calcChi2(blendModel, self.context.observation, blendFootprintImage) 

1463 # Defensive: same unreachable-in-practice guard as the 

1464 # aggregate-parent branch above. 

1465 blendArea = np.sum(blendFootprintImage) 

1466 blendReducedChi2 = ( 

1467 np.sum(chi2.data) / blendArea if blendArea > 0 else np.nan 

1468 ) 

1469 

1470 # Update the blend record with the deblending results 

1471 self._updateParentRecord( 

1472 parentRecord=blendRecord, 

1473 nPeaks=len(peaks), 

1474 nChild=nChild, 

1475 nComponents=nComponents, 

1476 runtime=runtime, 

1477 iterations=len(blend.loss), 

1478 logL=blend.log_likelihood, 

1479 chi2=blendReducedChi2, 

1480 spectrumInit=spectrumInit, 

1481 convergenceFailed=convergenceFailed, 

1482 ) 

1483 

1484 return blend, blendModel, chi2 

1485 

1486 def _isLargeFootprint(self, footprint: afwDet.Footprint) -> bool: 

1487 """Returns whether a Footprint is large 

1488 

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

1490 and total area of the bounding box multiplied by 

1491 the number of children. 

1492 These may be disabled independently by configuring them to be 

1493 non-positive. 

1494 """ 

1495 if ( 

1496 self.config.maxFootprintArea > 0 

1497 and footprint.getBBox().getArea() > self.config.maxFootprintArea 

1498 ): 

1499 return True 

1500 if self.config.maxFootprintSize > 0: 1500 ↛ 1501line 1500 didn't jump to line 1501 because the condition on line 1500 was never true

1501 bbox = footprint.getBBox() 

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

1503 return True 

1504 if self.config.minFootprintAxisRatio > 0: 1504 ↛ 1505line 1504 didn't jump to line 1505 because the condition on line 1504 was never true

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

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

1507 return True 

1508 if self.config.maxAreaTimesPeaks > 0: 1508 ↛ 1514line 1508 didn't jump to line 1514 because the condition on line 1508 was always true

1509 if ( 1509 ↛ 1513line 1509 didn't jump to line 1513 because the condition on line 1509 was never true

1510 footprint.getBBox().getArea() * len(footprint.peaks) 

1511 > self.config.maxAreaTimesPeaks 

1512 ): 

1513 return True 

1514 return False 

1515 

1516 def _isMasked(self, footprint: afwDet.Footprint, mExposure: afwImage.MultibandExposure) -> bool: 

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

1518 

1519 Parameters 

1520 ---------- 

1521 footprint : 

1522 The footprint to check for masked pixels 

1523 mExposure : 

1524 The multiband exposure to check for masked pixels. 

1525 

1526 Returns 

1527 ------- 

1528 isMasked : `bool` 

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

1530 fraction of pixels for a given mask in 

1531 `self.config.maskLimits`. 

1532 """ 

1533 bbox = footprint.getBBox() 

1534 # AND across bands: a pixel counts as masked only when the bit 

1535 # is set in every band. Matches ``buildObservation``'s per-band 

1536 # weight zeroing — a pixel is truly unconstrained only when 

1537 # masked in all bands; otherwise the unmasked bands still 

1538 # contribute. 

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

1540 size = float(footprint.getArea()) 

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

1542 maskVal = mExposure.mask.getPlaneBitMask(maskName) 

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

1544 # spanset of masked pixels 

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

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

1547 return True 

1548 return False 

1549 

1550 def _skipBlend( 

1551 self, 

1552 blendRecord: afwTable.SourceRecord, 

1553 skipKey: str, 

1554 logMessage: str | None, 

1555 ): 

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

1557 

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

1559 that a skipped blend updates the appropriate columns 

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

1561 it is being skipped. 

1562 

1563 Parameters 

1564 ---------- 

1565 blendRecord : 

1566 The blend record to flag as skipped. 

1567 skipKey : 

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

1569 logMessage : 

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

1571 is skipped. 

1572 """ 

1573 if logMessage: 1573 ↛ 1575line 1573 didn't jump to line 1575 because the condition on line 1573 was always true

1574 self.log.trace(logMessage) 

1575 footprint = blendRecord.getFootprint() 

1576 self._updateParentRecord( 

1577 parentRecord=blendRecord, 

1578 nPeaks=len(footprint.peaks), 

1579 nChild=0, 

1580 nComponents=0, 

1581 runtime=np.nan, 

1582 iterations=0, 

1583 logL=np.nan, 

1584 chi2=np.nan, 

1585 spectrumInit=False, 

1586 # A skipped blend was never fit, so convergence does not 

1587 # apply; leave the convergence-failure flag unset. 

1588 convergenceFailed=False, 

1589 ) 

1590 

1591 # Mark the source as skipped by the deblender and 

1592 # flag the reason why. 

1593 blendRecord.set("deblend_skipped", True) 

1594 blendRecord.set(skipKey, True) 

1595 

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

1597 if self.config.notDeblendedMask: 1597 ↛ exitline 1597 didn't return from function '_skipBlend' because the condition on line 1597 was always true

1598 for mask in self.mExposure.mask: 

1599 footprint.spans.setMask( 

1600 mask, mask.getPlaneBitMask(self.config.notDeblendedMask) 

1601 ) 

1602 

1603 def _checkSkipped( 

1604 self, 

1605 parent: afwTable.SourceRecord, 

1606 mExposure: afwImage.MultibandExposure 

1607 ) -> tuple[afwTable.Key, str] | None: 

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

1609 

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

1611 that a skipped parent updates the appropriate columns 

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

1613 it is being skipped. 

1614 

1615 Parameters 

1616 ---------- 

1617 parent : 

1618 The parent record to flag as skipped. 

1619 mExposure : 

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

1621 shape and region of the sky. 

1622 

1623 Returns 

1624 ------- 

1625 skip : 

1626 `True` if the deblender will skip the parent 

1627 """ 

1628 skipKey = None 

1629 skipMessage = "" 

1630 footprint = parent.getFootprint() 

1631 if isPseudoSource(parent, self.config.pseudoColumns): 1631 ↛ 1634line 1631 didn't jump to line 1634 because the condition on line 1631 was never true

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

1633 # are intended to be skipped. 

1634 skipKey = "deblend_skipped_isPseudo" 

1635 elif self._isLargeFootprint(footprint): 

1636 # The footprint is above the maximum footprint size limit 

1637 skipKey = "deblend_skipped_parentTooBig" 

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

1639 elif self._isMasked(footprint, mExposure): 1639 ↛ 1641line 1639 didn't jump to line 1641 because the condition on line 1639 was never true

1640 # The footprint exceeds the maximum number of masked pixels 

1641 skipKey = "deblend_skipped_masked" 

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

1643 elif ( 

1644 self.config.maxNumberOfPeaks > 0 

1645 and len(footprint.peaks) > self.config.maxNumberOfPeaks 

1646 ): 

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

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

1649 # to model any peaks often results in catastrophic failure 

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

1651 skipKey = "deblend_skipped_tooManyPeaks" 

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

1653 if skipKey is not None: 

1654 return (cast(afwTable.Key, skipKey), skipMessage) 

1655 return None 

1656 

1657 def _updateParentRecord( 

1658 self, 

1659 parentRecord: afwTable.SourceRecord, 

1660 nPeaks: int, 

1661 nChild: int, 

1662 nComponents: int, 

1663 runtime: float, 

1664 iterations: int, 

1665 logL: float, 

1666 chi2: float, 

1667 spectrumInit: bool, 

1668 convergenceFailed: bool, 

1669 ): 

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

1671 

1672 Ensure that all locations that update a blend record, 

1673 whether it is skipped or updated after deblending, 

1674 update all of the appropriate columns. 

1675 

1676 Parameters 

1677 ---------- 

1678 blendRecord : 

1679 The catalog record to update. 

1680 nPeaks : 

1681 Number of peaks in the parent footprint. 

1682 nChild : 

1683 Number of children deblended from the parent. 

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

1685 were culled and have no deblended model. 

1686 nComponents : 

1687 Total number of components in the parent. 

1688 This is usually different than the number of children, 

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

1690 components. 

1691 runtime : 

1692 Total runtime for deblending. 

1693 iterations : 

1694 Total number of iterations in scarlet before convergence. 

1695 logL : 

1696 Final log likelihood of the blend. 

1697 chi2 : 

1698 Final reduced chi2 of the blend. 

1699 spectrumInit : 

1700 True when scarlet used `set_spectra` to initialize all 

1701 sources with better initial intensities. 

1702 convergenceFailed : 

1703 True when the blend was fit but the optimizer reached the 

1704 maximum number of iterations without converging. False both 

1705 when the blend converged and when no fit was attempted 

1706 (skipped or isolated parents), so the flag never fires for 

1707 blends where convergence does not apply. 

1708 """ 

1709 parentRecord.set("deblend_nPeaks", nPeaks) 

1710 parentRecord.set("deblend_nChild", nChild) 

1711 parentRecord.set("deblend_nComponents", nComponents) 

1712 parentRecord.set("deblend_runtime", runtime) 

1713 parentRecord.set("deblend_iterations", iterations) 

1714 parentRecord.set("deblend_spectrumInitFlag", spectrumInit) 

1715 parentRecord.set( 

1716 "deblend_blendConvergenceFailedFlag", convergenceFailed 

1717 ) 

1718 parentRecord.set("deblend_chi2", chi2) 

1719 

1720 def _initializeCatalogs( 

1721 self, 

1722 objectCatalog: afwTable.SourceCatalog, 

1723 parentCatalog: afwTable.SourceCatalog, 

1724 context: ScarletDeblendContext, 

1725 ) -> None: 

1726 """Create footprints for deconvolved parents 

1727 

1728 Each parent may be subdivided into multiple blends that are 

1729 isolated in deconvolved space but still blended in the image. 

1730 This method finds all of the deconvolved footprints that overlap 

1731 with a single parent footprint from the input catalog and 

1732 returns a dictionary to map the parent ids to a list of 

1733 deconvolved footprints. 

1734 

1735 Parameters 

1736 ---------- 

1737 objectCatalog : 

1738 The catalog of isolated and deblended sources. 

1739 parentCatalog : 

1740 The catalog of parents and deconvolved parents used for deblending. 

1741 context : 

1742 The context for the entire coadd. 

1743 """ 

1744 nParents = len(parentCatalog) 

1745 

1746 isolated = [] 

1747 for n in range(nParents): 

1748 parent = parentCatalog[n] 

1749 parentFoot = parent.getFootprint() 

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

1751 # propagate its flags to the parent source. 

1752 # For example, this propagates `merge_peak_sky` to the parent 

1753 parent.assign(parent.getFootprint().peaks[0], self.parentPeakSchemaMapper) 

1754 

1755 if isPseudoSource(parent, self.config.pseudoColumns): 1755 ↛ 1760line 1755 didn't jump to line 1760 because the condition on line 1755 was never true

1756 # Skip pseudo sources 

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

1758 # set the NOT_DEBLENDED mask in the exposure, 

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

1760 isolated.append(parent) 

1761 continue 

1762 

1763 if len(parentFoot.peaks) == 1: 

1764 # Isolated source and we are not processing singles 

1765 isolated.append(parent) 

1766 parent.set("deblend_skipped_isolatedParent", True) 

1767 continue 

1768 

1769 # Find deconvolved footprints that intersect with the parent 

1770 # and add them to the blend catalog. 

1771 parentId = parent.getId() 

1772 self._buildIntersectingFootprints( 

1773 parentId, 

1774 parentFoot, 

1775 parentCatalog, 

1776 context.footprints, 

1777 context.footprintImage 

1778 ) 

1779 

1780 # Update the IdFactory to account for all of the parents 

1781 # and deconvolved parents added to the catalog. 

1782 idFactory = objectCatalog.getIdFactory() 

1783 maxId = np.max(parentCatalog["id"]) 

1784 idFactory.notify(maxId) 

1785 

1786 # Add the isolated sources to the object catalog. 

1787 # This has to be done before adding the other deblended children 

1788 # because a SourceCatalog must be ordered by parentId and all isolated 

1789 # sources have parentId == 0. 

1790 for parent in isolated: 

1791 self._addIsolatedSource(parent, objectCatalog) 

1792 

1793 def _buildIntersectingFootprints( 

1794 self, 

1795 parentId: int, 

1796 afwFootprint: afwDet.Footprint, 

1797 parentCatalog: afwTable.SourceCatalog, 

1798 sclFootprints: list[scl.detect.Footprint], 

1799 footprintImage: scl.Image, 

1800 ) -> None: 

1801 """Get the intersection of two footprints 

1802 

1803 Parameters 

1804 ---------- 

1805 parentId : 

1806 The parent id containing the footprints. 

1807 afwFootprint : 

1808 The afw footprint 

1809 parentCatalog : 

1810 The catalog of parents and deconvolved parents. 

1811 sclFootprints : 

1812 List of scarlet lite Footprints. 

1813 footprintImage : 

1814 An indexed image of the scarlet footprints so that the value 

1815 of a pixel gives the index + 1 of the footprints that 

1816 contain that pixel. 

1817 

1818 Returns 

1819 ------- 

1820 intersection : 

1821 The intersection of the two footprints 

1822 """ 

1823 footprintIndices = set() 

1824 ymin, xmin = footprintImage.bbox.origin 

1825 

1826 # Get the index of the deconvolved footprint at the peak location 

1827 height, width = footprintImage.data.shape 

1828 for peak in afwFootprint.peaks: 

1829 x = peak["i_x"] - xmin 

1830 y = peak["i_y"] - ymin 

1831 # NumPy wraps negative indices, so a try/except on 

1832 # IndexError would silently accept peaks west/south of 

1833 # the bbox origin. Bounds-check both directions 

1834 # explicitly. 

1835 if x < 0 or y < 0 or x >= width or y >= height: 

1836 raise RuntimeError(f"no footprint at ({y}, {x})") 

1837 footprintIndex = footprintImage.data[y, x] - 1 

1838 if footprintIndex >= 0: 1838 ↛ 1828line 1838 didn't jump to line 1828 because the condition on line 1838 was always true

1839 footprintIndices.add(footprintIndex) 

1840 

1841 # Get the intersection of each deconvolved footprint with 

1842 # the parent footprint. 

1843 for index in footprintIndices: 

1844 _sclFootprint = scarletFootprintToAfw(sclFootprints[index]) 

1845 intersection = afwFootprint.intersect(_sclFootprint, copyPeaks=True) 

1846 if len(intersection.peaks) > 0: 1846 ↛ 1843line 1846 didn't jump to line 1843 because the condition on line 1846 was always true

1847 self._addBlendRecord( 

1848 parentId=parentId, 

1849 parentCatalog=parentCatalog, 

1850 footprint=intersection, 

1851 ) 

1852 

1853 def _addBlendRecord( 

1854 self, 

1855 parentId: int, 

1856 parentCatalog: afwTable.SourceCatalog, 

1857 footprint: afwDet.Footprint, 

1858 ) -> None: 

1859 """Add deconvolved parents to the parent catalog 

1860 

1861 Each parent may be subdivided into multiple blends that are 

1862 isolated in deconvolved space but still blended in the image. 

1863 This function adds the sub-parents to the catalog. 

1864 

1865 Parameters 

1866 ---------- 

1867 parentId : 

1868 The ID of the parent of the sub-parents. 

1869 parentCatalog : 

1870 The catalog of parents and deconvolved parents. 

1871 footprint : 

1872 The footprint of the deconvolved parent. 

1873 """ 

1874 blendRecord = parentCatalog.addNew() 

1875 blendRecord.setParent(parentId) 

1876 blendRecord.setFootprint(footprint) 

1877 # Propagate the first peak's schema fields onto the 

1878 # sub-blend record, mirroring what ``_initializeCatalogs`` 

1879 # does for top-level parents. 

1880 blendRecord.assign(footprint.peaks[0], self.parentPeakSchemaMapper) 

1881 

1882 def _addDeblendedSource( 

1883 self, 

1884 parentId: int, 

1885 blendRecord: afwTable.SourceRecord, 

1886 peak: afwDet.PeakRecord, 

1887 objectCatalog: afwTable.SourceCatalog, 

1888 scarletSource: scl.Source, 

1889 chi2: scl.Image, 

1890 ): 

1891 """Add a deblended source to a catalog. 

1892 

1893 This creates a new child in the source catalog, 

1894 assigning it a parent id, and adding all columns 

1895 that are independent across all filter bands and not 

1896 updated after deblending. 

1897 

1898 Parameters 

1899 ---------- 

1900 parent : 

1901 The parent of the new child record. 

1902 blendRecord : 

1903 The deconvolved parent of the new child record. 

1904 peak : 

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

1906 catalog : 

1907 The source catalog that the child is added to. 

1908 scarletSource : 

1909 The scarlet model for the new source record. 

1910 chi2 : 

1911 The chi2 for each pixel. 

1912 

1913 Returns 

1914 ------- 

1915 src : 

1916 The new child source record. 

1917 """ 

1918 src = objectCatalog.addNew() 

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

1920 # so we just use the first peak catalog 

1921 src.assign(peak, self.peakSchemaMapper) 

1922 src.setParent(parentId) 

1923 src.set("deblend_blendId", blendRecord.getId()) 

1924 src.set("deblend_blendNChild", len(blendRecord.getFootprint().peaks)) 

1925 src.set("deblend_nChild", 0) 

1926 

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

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

1929 # deblenders and across observations, where the peak 

1930 # position is unlikely to change unless enough time passes 

1931 # for a source to move on the sky. 

1932 src.set("deblend_peak_center_x", peak["i_x"]) 

1933 src.set("deblend_peak_center_y", peak["i_y"]) 

1934 src.set("deblend_peakId", peak["id"]) 

1935 

1936 # Store the number of components for the source 

1937 src.set("deblend_nComponents", len(scarletSource.components)) 

1938 

1939 # Calculate the reduced chi2 for the source. The ``chi2`` 

1940 # image is the blend's, so within this source's bbox it 

1941 # carries contributions wherever the *combined* blend model 

1942 # was positive — including pixels where neighboring sources' 

1943 # models extend into this bbox. Mask by this source's own 

1944 # positive-model footprint so the chi2 sum and the area 

1945 # normalization are over the same pixel set. 

1946 # Defensive: a detected peak's source model has positive flux, 

1947 # so ``area`` should never be 0. 

1948 sourceFootprint = scarletSource.get_model().data > 0 

1949 area = np.sum(sourceFootprint) 

1950 sourceReducedChi2 = ( 

1951 np.sum(chi2[:, scarletSource.bbox].data * sourceFootprint) / area 

1952 if area > 0 else np.nan 

1953 ) 

1954 src.set("deblend_chi2", sourceReducedChi2) 

1955 

1956 return src 

1957 

1958 def _addIsolatedSource( 

1959 self, 

1960 parentRecord: afwTable.SourceRecord, 

1961 objectCatalog: afwTable.SourceCatalog, 

1962 ) -> afwTable.SourceRecord: 

1963 """Add an isolated source to the catalog. 

1964 

1965 This creates a new record in the object catalog, 

1966 and assigns values for all fields not dependent on deblending. 

1967 

1968 Parameters 

1969 ---------- 

1970 parentRecord : 

1971 The parent record to copy values from. 

1972 objectCatalog : 

1973 The source catalog that the child is added to. 

1974 

1975 Returns 

1976 ------- 

1977 src : 

1978 The new isolated source record. 

1979 """ 

1980 # The isolated source has the same peak as the parent 

1981 footprint = parentRecord.getFootprint() 

1982 peak = footprint.peaks[0] 

1983 src = objectCatalog.addNew() 

1984 src.assign(peak, self.peakSchemaMapper) 

1985 src.setParent(0) 

1986 # The id of the isolated source is the same as the parent 

1987 src.setId(parentRecord.getId()) 

1988 src.setFootprint(footprint) 

1989 src.set("deblend_blendId", 0) 

1990 src.set("deblend_blendNChild", 0) 

1991 src.set("deblend_peak_center_x", peak["i_x"]) 

1992 src.set("deblend_peak_center_y", peak["i_y"]) 

1993 src.set("deblend_peakId", peak["id"]) 

1994 

1995 # Isolated sources are not modeled, so they don't have components 

1996 src.set("deblend_nComponents", 0) 

1997 

1998 # There are no children so we need to update the parent 

1999 # record and add the isolated source to the object catalog 

2000 self._updateParentRecord( 

2001 parentRecord=parentRecord, 

2002 nPeaks=len(parentRecord.getFootprint().peaks), 

2003 nChild=0, 

2004 nComponents=0, 

2005 runtime=0.0, 

2006 iterations=0, 

2007 logL=np.nan, 

2008 chi2=np.nan, 

2009 spectrumInit=False, 

2010 # An isolated source is not fit, so convergence does not apply. 

2011 convergenceFailed=False, 

2012 ) 

2013 

2014 # Persist parent columns to the isolated source 

2015 # This is usually detection flags, like merge_peak_sky 

2016 # or merge_footprint_r, etc. 

2017 for key in self.toCopyFromParent: 2017 ↛ 2018line 2017 didn't jump to line 2018 because the loop on line 2017 never started

2018 src.set(key, parentRecord.get(key)) 

2019 

2020 return src