Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

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 

22import logging 

23import numpy as np 

24import scarlet 

25from scarlet.psf import ImagePSF, GaussianPSF 

26from scarlet import Blend, Frame, Observation 

27from scarlet.renderer import ConvolutionRenderer 

28from scarlet.initialization import init_all_sources 

29 

30import lsst.log 

31import lsst.pex.config as pexConfig 

32from lsst.pex.exceptions import InvalidParameterError 

33import lsst.pipe.base as pipeBase 

34from lsst.geom import Point2I, Box2I, Point2D 

35import lsst.afw.geom.ellipses as afwEll 

36import lsst.afw.image.utils 

37import lsst.afw.image as afwImage 

38import lsst.afw.detection as afwDet 

39import lsst.afw.table as afwTable 

40 

41from .source import modelToHeavy 

42 

43# scarlet initialization allows the user to specify the maximum number 

44# of components for a source but will fall back to fewer components or 

45# an initial PSF morphology depending on the S/N. If either of those happen 

46# then scarlet currently warnings that the type of source created by the 

47# user was modified. This is not ideal behavior, as it creates a lot of 

48# unnecessary warnings for expected behavior and the information is 

49# already persisted due to the change in source type. 

50# So we silence all of the initialization warnings here to prevent 

51# polluting the log files. 

52scarletInitLogger = logging.getLogger("scarlet.initialisation") 

53scarletSourceLogger = logging.getLogger("scarlet.source") 

54scarletInitLogger.setLevel(logging.ERROR) 

55scarletSourceLogger.setLevel(logging.ERROR) 

56 

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

58 

59logger = lsst.log.Log.getLogger("meas.deblender.deblend") 

60 

61 

62class IncompleteDataError(Exception): 

63 """The PSF could not be computed due to incomplete data 

64 """ 

65 pass 

66 

67 

68class ScarletGradientError(Exception): 

69 """An error occurred during optimization 

70 

71 This error occurs when the optimizer encounters 

72 a NaN value while calculating the gradient. 

73 """ 

74 def __init__(self, iterations, sources): 

75 self.iterations = iterations 

76 self.sources = sources 

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

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

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

80 

81 def __str__(self): 

82 return self.message 

83 

84 

85def _checkBlendConvergence(blend, f_rel): 

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

87 """ 

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

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

90 return deltaLoss < convergence 

91 

92 

93def _getPsfFwhm(psf): 

94 """Calculate the FWHM of the `psf` 

95 """ 

96 return psf.computeShape().getDeterminantRadius() * 2.35 

97 

98 

99def _computePsfImage(self, position=None): 

100 """Get a multiband PSF image 

101 The PSF Kernel Image is computed for each band 

102 and combined into a (filter, y, x) array and stored 

103 as `self._psfImage`. 

104 The result is not cached, so if the same PSF is expected 

105 to be used multiple times it is a good idea to store the 

106 result in another variable. 

107 Note: this is a temporary fix during the deblender sprint. 

108 In the future this function will replace the current method 

109 in `afw.MultibandExposure.computePsfImage` (DM-19789). 

110 Parameters 

111 ---------- 

112 position : `Point2D` or `tuple` 

113 Coordinates to evaluate the PSF. If `position` is `None` 

114 then `Psf.getAveragePosition()` is used. 

115 Returns 

116 ------- 

117 self._psfImage: array 

118 The multiband PSF image. 

119 """ 

120 psfs = [] 

121 # Make the coordinates into a Point2D (if necessary) 

122 if not isinstance(position, Point2D) and position is not None: 

123 position = Point2D(position[0], position[1]) 

124 

125 for bidx, single in enumerate(self.singles): 

126 try: 

127 if position is None: 

128 psf = single.getPsf().computeImage() 

129 psfs.append(psf) 

130 else: 

131 psf = single.getPsf().computeKernelImage(position) 

132 psfs.append(psf) 

133 except InvalidParameterError: 

134 # This band failed to compute the PSF due to incomplete data 

135 # at that location. This is unlikely to be a problem for Rubin, 

136 # however the edges of some HSC COSMOS fields contain incomplete 

137 # data in some bands, so we track this error to distinguish it 

138 # from unknown errors. 

139 msg = "Failed to compute PSF at {} in band {}" 

140 raise IncompleteDataError(msg.format(position, self.filters[bidx])) 

141 

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

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

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

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

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

147 psfs = [afwImage.utils.projectImage(psf, bbox) for psf in psfs] 

148 psfImage = afwImage.MultibandImage.fromImages(self.filters, psfs) 

149 return psfImage 

150 

151 

152def getFootprintMask(footprint, mExposure): 

153 """Mask pixels outside the footprint 

154 

155 Parameters 

156 ---------- 

157 mExposure : `lsst.image.MultibandExposure` 

158 - The multiband exposure containing the image, 

159 mask, and variance data 

160 footprint : `lsst.detection.Footprint` 

161 - The footprint of the parent to deblend 

162 

163 Returns 

164 ------- 

165 footprintMask : array 

166 Boolean array with pixels not in the footprint set to one. 

167 """ 

168 bbox = footprint.getBBox() 

169 fpMask = afwImage.Mask(bbox) 

170 footprint.spans.setMask(fpMask, 1) 

171 fpMask = ~fpMask.getArray().astype(bool) 

172 return fpMask 

173 

174 

175def isPseudoSource(source, pseudoColumns): 

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

177 

178 This is mostly for skipping sky objects, 

179 but any other column can also be added to disable 

180 deblending on a parent or individual source when 

181 set to `True`. 

182 

183 Parameters 

184 ---------- 

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

186 The source to check for the pseudo bit. 

187 pseudoColumns : `list` of `str` 

188 A list of columns to check for pseudo sources. 

189 """ 

190 isPseudo = False 

191 for col in pseudoColumns: 

192 try: 

193 isPseudo |= source[col] 

194 except KeyError: 

195 pass 

196 return isPseudo 

197 

198 

199def deblend(mExposure, footprint, config): 

200 """Deblend a parent footprint 

201 

202 Parameters 

203 ---------- 

204 mExposure : `lsst.image.MultibandExposure` 

205 - The multiband exposure containing the image, 

206 mask, and variance data 

207 footprint : `lsst.detection.Footprint` 

208 - The footprint of the parent to deblend 

209 config : `ScarletDeblendConfig` 

210 - Configuration of the deblending task 

211 """ 

212 # Extract coordinates from each MultiColorPeak 

213 bbox = footprint.getBBox() 

214 

215 # Create the data array from the masked images 

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

217 

218 # Use the inverse variance as the weights 

219 if config.useWeights: 

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

221 else: 

222 weights = np.ones_like(images) 

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

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

225 weights[mask > 0] = 0 

226 

227 # Mask out the pixels outside the footprint 

228 mask = getFootprintMask(footprint, mExposure) 

229 weights *= ~mask 

230 

231 psfs = _computePsfImage(mExposure, footprint.getCentroid()).array.astype(np.float32) 

232 psfs = ImagePSF(psfs) 

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

234 

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

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

237 if config.convolutionType == "fft": 

238 observation.match(frame) 

239 elif config.convolutionType == "real": 

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

241 observation.match(frame, renderer=renderer) 

242 else: 

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

244 

245 assert(config.sourceModel in ["single", "double", "compact", "fit"]) 

246 

247 # Set the appropriate number of components 

248 if config.sourceModel == "single": 

249 maxComponents = 1 

250 elif config.sourceModel == "double": 

251 maxComponents = 2 

252 elif config.sourceModel == "compact": 

253 maxComponents = 0 

254 elif config.sourceModel == "point": 

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

256 elif config.sourceModel == "fit": 

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

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

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

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

261 

262 # Convert the centers to pixel coordinates 

263 xmin = bbox.getMinX() 

264 ymin = bbox.getMinY() 

265 centers = [ 

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

267 for peak in footprint.peaks 

268 if not isPseudoSource(peak, config.pseudoColumns) 

269 ] 

270 

271 # Choose whether or not to use the improved spectral initialization 

272 if config.setSpectra: 

273 if config.maxSpectrumCutoff <= 0: 

274 spectrumInit = True 

275 else: 

276 spectrumInit = len(centers) * bbox.getArea() < config.maxSpectrumCutoff 

277 else: 

278 spectrumInit = False 

279 

280 # Only deblend sources that can be initialized 

281 sources, skipped = init_all_sources( 

282 frame=frame, 

283 centers=centers, 

284 observations=observation, 

285 thresh=config.morphThresh, 

286 max_components=maxComponents, 

287 min_snr=config.minSNR, 

288 shifting=False, 

289 fallback=config.fallback, 

290 silent=config.catchFailures, 

291 set_spectra=spectrumInit, 

292 ) 

293 

294 # Attach the peak to all of the initialized sources 

295 srcIndex = 0 

296 for k, center in enumerate(centers): 

297 if k not in skipped: 

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

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

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

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

302 srcIndex += 1 

303 

304 # Create the blend and attempt to optimize it 

305 blend = Blend(sources, observation) 

306 try: 

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

308 except ArithmeticError: 

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

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

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

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

313 iterations = len(blend.loss) 

314 failedSources = [] 

315 for k, src in enumerate(sources): 

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

317 failedSources.append(k) 

318 raise ScarletGradientError(iterations, failedSources) 

319 

320 return blend, skipped, spectrumInit 

321 

322 

323class ScarletDeblendConfig(pexConfig.Config): 

324 """MultibandDeblendConfig 

325 

326 Configuration for the multiband deblender. 

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

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

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

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

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

332 """ 

333 # Stopping Criteria 

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

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

336 relativeError = pexConfig.Field(dtype=float, default=1e-4, 

337 doc=("Change in the loss function between" 

338 "iterations to exit fitter")) 

339 

340 # Constraints 

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

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

343 "to be included in the initial morphology") 

344 # Other scarlet paremeters 

345 useWeights = pexConfig.Field( 

346 dtype=bool, default=True, 

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

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

349 modelPsfSize = pexConfig.Field( 

350 dtype=int, default=11, 

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

352 modelPsfSigma = pexConfig.Field( 

353 dtype=float, default=0.8, 

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

355 minSNR = pexConfig.Field( 

356 dtype=float, default=50, 

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

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

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

360 saveTemplates = pexConfig.Field( 

361 dtype=bool, default=True, 

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

363 processSingles = pexConfig.Field( 

364 dtype=bool, default=True, 

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

366 convolutionType = pexConfig.Field( 

367 dtype=str, default="fft", 

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

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

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

371 sourceModel = pexConfig.Field( 

372 dtype=str, default="double", 

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

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

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

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

377 " for all sources\n" 

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

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

380 ) 

381 setSpectra = pexConfig.Field( 

382 dtype=bool, default=True, 

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

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

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

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

387 "This option is only used when " 

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

389 

390 # Mask-plane restrictions 

391 badMask = pexConfig.ListField( 

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

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

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

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

396 maskLimits = pexConfig.DictField( 

397 keytype=str, 

398 itemtype=float, 

399 default={}, 

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

401 "Sources violating this limit will not be deblended."), 

402 ) 

403 

404 # Size restrictions 

405 maxNumberOfPeaks = pexConfig.Field( 

406 dtype=int, default=0, 

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

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

409 maxFootprintArea = pexConfig.Field( 

410 dtype=int, default=1000000, 

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

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

413 maxFootprintSize = pexConfig.Field( 

414 dtype=int, default=0, 

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

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

417 minFootprintAxisRatio = pexConfig.Field( 

418 dtype=float, default=0.0, 

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

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

421 maxSpectrumCutoff = pexConfig.Field( 

422 dtype=int, default=1000000, 

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

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

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

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

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

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

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

430 ) 

431 

432 # Failure modes 

433 fallback = pexConfig.Field( 

434 dtype=bool, default=True, 

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

436 ) 

437 notDeblendedMask = pexConfig.Field( 

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

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

440 catchFailures = pexConfig.Field( 

441 dtype=bool, default=True, 

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

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

444 

445 # Other options 

446 columnInheritance = pexConfig.DictField( 

447 keytype=str, itemtype=str, default={ 

448 "deblend_nChild": "deblend_parentNChild", 

449 "deblend_nPeaks": "deblend_parentNPeaks", 

450 "deblend_spectrumInitFlag": "deblend_spectrumInitFlag", 

451 "deblend_blendConvergenceFailedFlag": "deblend_blendConvergenceFailedFlag", 

452 }, 

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

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

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

456 ) 

457 pseudoColumns = pexConfig.ListField( 

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

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

460 ) 

461 

462 

463class ScarletDeblendTask(pipeBase.Task): 

464 """ScarletDeblendTask 

465 

466 Split blended sources into individual sources. 

467 

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

469 """ 

470 ConfigClass = ScarletDeblendConfig 

471 _DefaultName = "scarletDeblend" 

472 

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

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

475 

476 Parameters 

477 ---------- 

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

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

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

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

482 Any fields beyond the PeakTable minimal schema will be transferred 

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

484 from the Peaks. 

485 filters : list of str 

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

487 the SED as a field 

488 **kwargs 

489 Passed to Task.__init__. 

490 """ 

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

492 

493 peakMinimalSchema = afwDet.PeakTable.makeMinimalSchema() 

494 if peakSchema is None: 

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

496 # we'll still have one 

497 # to simplify downstream code 

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

499 else: 

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

501 for item in peakSchema: 

502 if item.key not in peakMinimalSchema: 

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

504 # Because SchemaMapper makes a copy of the output schema 

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

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

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

508 # peakSchemaMapper.getOutputSchema() manually, by adding 

509 # the same fields to both. 

510 schema.addField(item.field) 

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

512 self._addSchemaKeys(schema) 

513 self.schema = schema 

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

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

516 

517 def _addSchemaKeys(self, schema): 

518 """Add deblender specific keys to the schema 

519 """ 

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

521 

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

523 

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

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

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

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

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

529 doc='Source had too many peaks; ' 

530 'only the brightest were included') 

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

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

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

534 doc='Parent footprint was predominantly masked') 

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

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

537 'config.maxIter') 

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

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

540 'config.maxIter') 

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

542 type='Flag', 

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

544 'failed to converge') 

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

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

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

548 doc="Deblending failed on source") 

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

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

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

552 doc="Deblender skipped this source") 

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

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

555 unit="pixel") 

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

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

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

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

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

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

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

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

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

565 "MultiExtendedSource, SingleExtendedSource, PointSource") 

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

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

568 "This includes peaks that may have been culled " 

569 "during deblending or failed to deblend") 

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

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

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

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

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

575 doc="Flux measurement from scarlet") 

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

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

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

579 doc="True when scarlet initializes sources " 

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

581 "The algorithm uses a lot of memory, " 

582 "so large dense blends will use " 

583 "a less accurate initialization.") 

584 

585 # self.log.trace('Added keys to schema: %s', ", ".join(str(x) for x in 

586 # (self.nChildKey, self.tooManyPeaksKey, self.tooBigKey)) 

587 # ) 

588 

589 @pipeBase.timeMethod 

590 def run(self, mExposure, mergedSources): 

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

592 

593 Parameters 

594 ---------- 

595 mExposure : `MultibandExposure` 

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

597 shape and region of the sky. 

598 mergedSources : `SourceCatalog` 

599 The merged `SourceCatalog` that contains parent footprints 

600 to (potentially) deblend. 

601 

602 Returns 

603 ------- 

604 templateCatalogs: dict 

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

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

607 These are catalogs with heavy footprints that are the templates 

608 created by the multiband templates. 

609 """ 

610 return self.deblend(mExposure, mergedSources) 

611 

612 @pipeBase.timeMethod 

613 def deblend(self, mExposure, catalog): 

614 """Deblend a data cube of multiband images 

615 

616 Parameters 

617 ---------- 

618 mExposure : `MultibandExposure` 

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

620 shape and region of the sky. 

621 catalog : `SourceCatalog` 

622 The merged `SourceCatalog` that contains parent footprints 

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

624 appended to this catalog in place. 

625 

626 Returns 

627 ------- 

628 catalogs : `dict` or `None` 

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

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

631 These are catalogs with heavy footprints that are the templates 

632 created by the multiband templates. 

633 """ 

634 import time 

635 

636 filters = mExposure.filters 

637 self.log.info(f"Deblending {len(catalog)} sources in {len(mExposure)} exposure bands") 

638 

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

640 if self.config.notDeblendedMask: 

641 for mask in mExposure.mask: 

642 mask.addMaskPlane(self.config.notDeblendedMask) 

643 

644 nParents = len(catalog) 

645 nDeblendedParents = 0 

646 skippedParents = [] 

647 multibandColumns = { 

648 "heavies": [], 

649 "fluxes": [], 

650 "centerFluxes": [], 

651 } 

652 for parentIndex in range(nParents): 

653 parent = catalog[parentIndex] 

654 foot = parent.getFootprint() 

655 bbox = foot.getBBox() 

656 peaks = foot.getPeaks() 

657 

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

659 # propagate its flags to the parent source. 

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

661 

662 # Skip isolated sources unless processSingles is turned on. 

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

664 # set the NOT_DEBLENDED mask in the exposure, 

665 # since these aren't really a skipped blends. 

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

667 # are intended to be skipped 

668 if ((len(peaks) < 2 and not self.config.processSingles) 

669 or isPseudoSource(parent, self.config.pseudoColumns)): 

670 self._updateParentRecord( 

671 parent=parent, 

672 nPeaks=len(peaks), 

673 nChild=0, 

674 runtime=np.nan, 

675 iterations=0, 

676 logL=np.nan, 

677 spectrumInit=False, 

678 converged=False, 

679 ) 

680 continue 

681 

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

683 skipKey = None 

684 if self._isLargeFootprint(foot): 

685 # The footprint is above the maximum footprint size limit 

686 skipKey = self.tooBigKey 

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

688 elif self._isMasked(foot, mExposure): 

689 # The footprint exceeds the maximum number of masked pixels 

690 skipKey = self.maskedKey 

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

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

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

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

695 # to model any peaks often results in catastrophic failure 

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

697 skipKey = self.tooManyPeaksKey 

698 skipMessage = f"Parent {parent.getId()}: Too many peaks, skipping blend" 

699 if skipKey is not None: 

700 self._skipParent( 

701 parent=parent, 

702 skipKey=skipKey, 

703 logMessage=skipMessage, 

704 ) 

705 skippedParents.append(parentIndex) 

706 continue 

707 

708 print(f"deblending parent with area {foot.getArea()}") 

709 

710 nDeblendedParents += 1 

711 self.log.trace(f"Parent {parent.getId()}: deblending {len(peaks)} peaks") 

712 # Run the deblender 

713 blendError = None 

714 try: 

715 t0 = time.time() 

716 # Build the parameter lists with the same ordering 

717 blend, skipped, spectrumInit = deblend(mExposure, foot, self.config) 

718 tf = time.time() 

719 runtime = (tf-t0)*1000 

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

721 scarletSources = [src for src in blend.sources] 

722 nChild = len(scarletSources) 

723 # Re-insert place holders for skipped sources 

724 # to propagate them in the catalog so 

725 # that the peaks stay consistent 

726 for k in skipped: 

727 scarletSources.insert(k, None) 

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

729 except Exception as e: 

730 blendError = type(e).__name__ 

731 if isinstance(e, ScarletGradientError): 

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

733 elif not isinstance(e, IncompleteDataError): 

734 blendError = "UnknownError" 

735 if self.config.catchFailures: 

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

737 self.log.warn("UnknownError") 

738 import traceback 

739 traceback.print_exc() 

740 else: 

741 raise 

742 

743 self._skipParent( 

744 parent=parent, 

745 skipKey=self.deblendFailedKey, 

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

747 ) 

748 parent.set(self.deblendErrorKey, blendError) 

749 skippedParents.append(parentIndex) 

750 continue 

751 

752 # Update the parent record with the deblending results 

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

754 self._updateParentRecord( 

755 parent=parent, 

756 nPeaks=len(peaks), 

757 nChild=nChild, 

758 runtime=runtime, 

759 iterations=len(blend.loss), 

760 logL=logL, 

761 spectrumInit=spectrumInit, 

762 converged=converged, 

763 ) 

764 

765 # Add each deblended source to the catalog 

766 for k, scarletSource in enumerate(scarletSources): 

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

768 # it could not initialize 

769 if k in skipped: 

770 # No need to propagate anything 

771 continue 

772 parent.set(self.deblendSkippedKey, False) 

773 mHeavy = modelToHeavy(scarletSource, filters, xy0=bbox.getMin(), 

774 observation=blend.observations[0]) 

775 multibandColumns["heavies"].append(mHeavy) 

776 flux = scarlet.measure.flux(scarletSource) 

777 multibandColumns["fluxes"].append({ 

778 filters[fidx]: _flux 

779 for fidx, _flux in enumerate(flux) 

780 }) 

781 centerFlux = self._getCenterFlux(mHeavy, scarletSource, xy0=bbox.getMin()) 

782 multibandColumns["centerFluxes"].append(centerFlux) 

783 

784 # Add all fields except the HeavyFootprint to the 

785 # source record 

786 self._addChild( 

787 parent=parent, 

788 mHeavy=mHeavy, 

789 catalog=catalog, 

790 scarletSource=scarletSource, 

791 ) 

792 

793 # Make sure that the number of new sources matches the number of 

794 # entries in each of the band dependent columns. 

795 # This should never trigger and is just a sanity check. 

796 nChildren = len(catalog) - nParents 

797 if np.any([len(meas) != nChildren for meas in multibandColumns.values()]): 

798 msg = f"Added {len(catalog)-nParents} new sources, but have " 

799 msg += ", ".join([ 

800 f"{len(value)} {key}" 

801 for key, value in multibandColumns 

802 ]) 

803 raise RuntimeError(msg) 

804 # Make a copy of the catlog in each band and update the footprints 

805 catalogs = {} 

806 for f in filters: 

807 _catalog = afwTable.SourceCatalog(catalog.table.clone()) 

808 _catalog.extend(catalog, deep=True) 

809 # Update the footprints and columns that are different 

810 # for each filter 

811 for sourceIndex, source in enumerate(_catalog[nParents:]): 

812 source.setFootprint(multibandColumns["heavies"][sourceIndex][f]) 

813 source.set(self.scarletFluxKey, multibandColumns["fluxes"][sourceIndex][f]) 

814 source.set(self.modelCenterFlux, multibandColumns["centerFluxes"][sourceIndex][f]) 

815 catalogs[f] = _catalog 

816 

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

818 if self.config.notDeblendedMask: 

819 for mask in mExposure.mask: 

820 for parentIndex in skippedParents: 

821 fp = _catalog[parentIndex].getFootprint() 

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

823 

824 self.log.info(f"Deblender results: of {nParents} parent sources, {nDeblendedParents} " 

825 f"were deblended, creating {nChildren} children, " 

826 f"for a total of {len(catalog)} sources") 

827 return catalogs 

828 

829 def _isLargeFootprint(self, footprint): 

830 """Returns whether a Footprint is large 

831 

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

833 These may be disabled independently by configuring them to be 

834 non-positive. 

835 

836 This is principally intended to get rid of satellite streaks, which the 

837 deblender or other downstream processing can have trouble dealing with 

838 (e.g., multiple large HeavyFootprints can chew up memory). 

839 """ 

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

841 return True 

842 if self.config.maxFootprintSize > 0: 

843 bbox = footprint.getBBox() 

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

845 return True 

846 if self.config.minFootprintAxisRatio > 0: 

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

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

849 return True 

850 return False 

851 

852 def _isMasked(self, footprint, mExposure): 

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

854 bbox = footprint.getBBox() 

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

856 size = float(footprint.getArea()) 

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

858 maskVal = mExposure.mask.getPlaneBitMask(maskName) 

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

860 unmaskedSpan = footprint.spans.intersectNot(_mask) # spanset of unmasked pixels 

861 if (size - unmaskedSpan.getArea())/size > limit: 

862 return True 

863 return False 

864 

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

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

867 

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

869 that a skipped parent updates the appropriate columns 

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

871 it is being skipped. 

872 

873 Parameters 

874 ---------- 

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

876 The parent record to flag as skipped. 

877 skipKey : `bool` 

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

879 logMessage : `str` 

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

881 is skipped. 

882 """ 

883 if logMessage is not None: 

884 self.log.trace(logMessage) 

885 self._updateParentRecord( 

886 parent=parent, 

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

888 nChild=0, 

889 runtime=np.nan, 

890 iterations=0, 

891 logL=np.nan, 

892 spectrumInit=False, 

893 converged=False, 

894 ) 

895 

896 # Mark the source as skipped by the deblender and 

897 # flag the reason why. 

898 parent.set(self.deblendSkippedKey, True) 

899 parent.set(skipKey, True) 

900 

901 def _updateParentRecord(self, parent, nPeaks, nChild, 

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

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

904 

905 Ensure that all locations that update a parent record, 

906 whether it is skipped or updated after deblending, 

907 update all of the appropriate columns. 

908 

909 Parameters 

910 ---------- 

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

912 The parent record to update. 

913 nPeaks : `int` 

914 Number of peaks in the parent footprint. 

915 nChild : `int` 

916 Number of children deblended from the parent. 

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

918 were culled and have no deblended model. 

919 runtime : `float` 

920 Total runtime for deblending. 

921 iterations : `int` 

922 Total number of iterations in scarlet before convergence. 

923 logL : `float` 

924 Final log likelihood of the blend. 

925 spectrumInit : `bool` 

926 True when scarlet used `set_spectra` to initialize all 

927 sources with better initial intensities. 

928 converged : `bool` 

929 True when the optimizer reached convergence before 

930 reaching the maximum number of iterations. 

931 """ 

932 parent.set(self.nPeaksKey, nPeaks) 

933 parent.set(self.nChildKey, nChild) 

934 parent.set(self.runtimeKey, runtime) 

935 parent.set(self.iterKey, iterations) 

936 parent.set(self.scarletLogLKey, logL) 

937 parent.set(self.scarletSpectrumInitKey, spectrumInit) 

938 parent.set(self.blendConvergenceFailedFlagKey, converged) 

939 

940 def _addChild(self, parent, mHeavy, catalog, scarletSource): 

941 """Add a child to a catalog. 

942 

943 This creates a new child in the source catalog, 

944 assigning it a parent id, and adding all columns 

945 that are independent across all filter bands. 

946 

947 Parameters 

948 ---------- 

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

950 The parent of the new child record. 

951 mHeavy : `lsst.detection.MultibandFootprint` 

952 The multi-band footprint containing the model and 

953 peak catalog for the new child record. 

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

955 The merged `SourceCatalog` that contains parent footprints 

956 to (potentially) deblend. 

957 scarletSource : `scarlet.Component` 

958 The scarlet model for the new source record. 

959 """ 

960 src = catalog.addNew() 

961 for key in self.toCopyFromParent: 

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

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

964 # so we just use the first peak catalog 

965 peaks = mHeavy[mHeavy.filters[0]].peaks 

966 src.assign(peaks[0], self.peakSchemaMapper) 

967 src.setParent(parent.getId()) 

968 # Currently all children only have a single peak, 

969 # but it's possible in the future that there will be hierarchical 

970 # deblending, so we use the footprint to set the number of peaks 

971 # for each child. 

972 src.set(self.nPeaksKey, len(peaks)) 

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

974 # deblended using the PointSource model. 

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

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

977 # is expecting it. 

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

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

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

981 # runtime column will give the total time spent 

982 # running the deblender for the catalog. 

983 src.set(self.runtimeKey, 0) 

984 

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

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

987 # deblenders and across observations, where the peak 

988 # position is unlikely to change unless enough time passes 

989 # for a source to move on the sky. 

990 peak = scarletSource.detectedPeak 

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

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

993 

994 # Propagate columns from the parent to the child 

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

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

997 

998 def _getCenterFlux(self, mHeavy, scarletSource, xy0): 

999 """Get the flux at the center of a HeavyFootprint 

1000 

1001 Parameters 

1002 ---------- 

1003 mHeavy : `lsst.detection.MultibandFootprint` 

1004 The multi-band footprint containing the model for the source. 

1005 scarletSource : `scarlet.Component` 

1006 The scarlet model for the heavy footprint 

1007 """ 

1008 # Store the flux at the center of the model and the total 

1009 # scarlet flux measurement. 

1010 mImage = mHeavy.getImage(fill=0.0).image 

1011 

1012 # Set the flux at the center of the model (for SNR) 

1013 try: 

1014 cy, cx = scarletSource.center 

1015 cy += xy0.y 

1016 cx += xy0.x 

1017 return mImage[:, cx, cy] 

1018 except AttributeError: 

1019 msg = "Did not recognize coordinates for source type of `{0}`, " 

1020 msg += "could not write coordinates or center flux. " 

1021 msg += "Add `{0}` to meas_extensions_scarlet to properly persist this information." 

1022 logger.warning(msg.format(type(scarletSource))) 

1023 return {f: np.nan for f in mImage.filters}