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 deblend(mExposure, footprint, config): 

176 """Deblend a parent footprint 

177 

178 Parameters 

179 ---------- 

180 mExposure : `lsst.image.MultibandExposure` 

181 - The multiband exposure containing the image, 

182 mask, and variance data 

183 footprint : `lsst.detection.Footprint` 

184 - The footprint of the parent to deblend 

185 config : `ScarletDeblendConfig` 

186 - Configuration of the deblending task 

187 """ 

188 # Extract coordinates from each MultiColorPeak 

189 bbox = footprint.getBBox() 

190 

191 # Create the data array from the masked images 

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

193 

194 # Use the inverse variance as the weights 

195 if config.useWeights: 

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

197 else: 

198 weights = np.ones_like(images) 

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

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

201 weights[mask > 0] = 0 

202 

203 # Mask out the pixels outside the footprint 

204 mask = getFootprintMask(footprint, mExposure) 

205 weights *= ~mask 

206 

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

208 psfs = ImagePSF(psfs) 

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

210 

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

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

213 if config.convolutionType == "fft": 

214 observation.match(frame) 

215 elif config.convolutionType == "real": 

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

217 observation.match(frame, renderer=renderer) 

218 else: 

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

220 

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

222 

223 # Set the appropriate number of components 

224 if config.sourceModel == "single": 

225 maxComponents = 1 

226 elif config.sourceModel == "double": 

227 maxComponents = 2 

228 elif config.sourceModel == "compact": 

229 maxComponents = 0 

230 elif config.sourceModel == "point": 

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

232 elif config.sourceModel == "fit": 

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

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

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

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

237 

238 # Convert the centers to pixel coordinates 

239 xmin = bbox.getMinX() 

240 ymin = bbox.getMinY() 

241 centers = [np.array([peak.getIy()-ymin, peak.getIx()-xmin], dtype=int) for peak in footprint.peaks] 

242 

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

244 if config.setSpectra: 

245 if config.maxSpectrumCutoff <= 0: 

246 spectrumInit = True 

247 else: 

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

249 else: 

250 spectrumInit = False 

251 

252 # Only deblend sources that can be initialized 

253 sources, skipped = init_all_sources( 

254 frame=frame, 

255 centers=centers, 

256 observations=observation, 

257 thresh=config.morphThresh, 

258 max_components=maxComponents, 

259 min_snr=config.minSNR, 

260 shifting=False, 

261 fallback=config.fallback, 

262 silent=config.catchFailures, 

263 set_spectra=spectrumInit, 

264 ) 

265 

266 # Attach the peak to all of the initialized sources 

267 srcIndex = 0 

268 for k, center in enumerate(centers): 

269 if k not in skipped: 

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

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

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

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

274 srcIndex += 1 

275 

276 # Create the blend and attempt to optimize it 

277 blend = Blend(sources, observation) 

278 try: 

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

280 except ArithmeticError: 

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

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

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

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

285 iterations = len(blend.loss) 

286 failedSources = [] 

287 for k, src in enumerate(sources): 

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

289 failedSources.append(k) 

290 raise ScarletGradientError(iterations, failedSources) 

291 

292 return blend, skipped, spectrumInit 

293 

294 

295class ScarletDeblendConfig(pexConfig.Config): 

296 """MultibandDeblendConfig 

297 

298 Configuration for the multiband deblender. 

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

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

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

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

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

304 """ 

305 # Stopping Criteria 

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

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

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

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

310 "iterations to exit fitter")) 

311 

312 # Constraints 

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

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

315 "to be included in the initial morphology") 

316 # Other scarlet paremeters 

317 useWeights = pexConfig.Field( 

318 dtype=bool, default=True, 

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

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

321 modelPsfSize = pexConfig.Field( 

322 dtype=int, default=11, 

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

324 modelPsfSigma = pexConfig.Field( 

325 dtype=float, default=0.8, 

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

327 minSNR = pexConfig.Field( 

328 dtype=float, default=50, 

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

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

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

332 saveTemplates = pexConfig.Field( 

333 dtype=bool, default=True, 

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

335 processSingles = pexConfig.Field( 

336 dtype=bool, default=True, 

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

338 convolutionType = pexConfig.Field( 

339 dtype=str, default="fft", 

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

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

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

343 sourceModel = pexConfig.Field( 

344 dtype=str, default="double", 

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

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

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

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

349 " for all sources\n" 

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

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

352 ) 

353 setSpectra = pexConfig.Field( 

354 dtype=bool, default=True, 

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

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

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

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

359 "This option is only used when " 

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

361 

362 # Mask-plane restrictions 

363 badMask = pexConfig.ListField( 

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

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

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

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

368 maskLimits = pexConfig.DictField( 

369 keytype=str, 

370 itemtype=float, 

371 default={}, 

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

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

374 ) 

375 

376 # Size restrictions 

377 maxNumberOfPeaks = pexConfig.Field( 

378 dtype=int, default=0, 

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

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

381 maxFootprintArea = pexConfig.Field( 

382 dtype=int, default=1000000, 

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

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

385 maxFootprintSize = pexConfig.Field( 

386 dtype=int, default=0, 

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

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

389 minFootprintAxisRatio = pexConfig.Field( 

390 dtype=float, default=0.0, 

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

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

393 maxSpectrumCutoff = pexConfig.Field( 

394 dtype=int, default=1000000, 

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

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

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

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

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

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

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

402 ) 

403 

404 # Failure modes 

405 fallback = pexConfig.Field( 

406 dtype=bool, default=True, 

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

408 ) 

409 notDeblendedMask = pexConfig.Field( 

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

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

412 catchFailures = pexConfig.Field( 

413 dtype=bool, default=True, 

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

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

416 

417 # Other options 

418 columnInheritance = pexConfig.DictField( 

419 keytype=str, itemtype=str, default={ 

420 "deblend_nChild": "deblend_parentNChild", 

421 "deblend_nPeaks": "deblend_parentNPeaks", 

422 "deblend_spectrumInitFlag": "deblend_spectrumInitFlag", 

423 }, 

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

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

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

427 ) 

428 

429 

430class ScarletDeblendTask(pipeBase.Task): 

431 """ScarletDeblendTask 

432 

433 Split blended sources into individual sources. 

434 

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

436 """ 

437 ConfigClass = ScarletDeblendConfig 

438 _DefaultName = "scarletDeblend" 

439 

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

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

442 

443 Parameters 

444 ---------- 

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

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

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

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

449 Any fields beyond the PeakTable minimal schema will be transferred 

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

451 from the Peaks. 

452 filters : list of str 

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

454 the SED as a field 

455 **kwargs 

456 Passed to Task.__init__. 

457 """ 

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

459 

460 peakMinimalSchema = afwDet.PeakTable.makeMinimalSchema() 

461 if peakSchema is None: 

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

463 # we'll still have one 

464 # to simplify downstream code 

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

466 else: 

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

468 for item in peakSchema: 

469 if item.key not in peakMinimalSchema: 

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

471 # Because SchemaMapper makes a copy of the output schema 

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

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

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

475 # peakSchemaMapper.getOutputSchema() manually, by adding 

476 # the same fields to both. 

477 schema.addField(item.field) 

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

479 self._addSchemaKeys(schema) 

480 self.schema = schema 

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

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

483 

484 def _addSchemaKeys(self, schema): 

485 """Add deblender specific keys to the schema 

486 """ 

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

488 

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

490 

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

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

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

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

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

496 doc='Source had too many peaks; ' 

497 'only the brightest were included') 

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

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

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

501 doc='Parent footprint was predominantly masked') 

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

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

504 'config.maxIter') 

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

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

507 'config.maxIter') 

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

509 type='Flag', 

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

511 'failed to converge') 

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

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

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

515 doc="Deblending failed on source") 

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

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

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

519 doc="Deblender skipped this source") 

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

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

522 unit="pixel") 

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

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

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

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

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

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

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

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

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

532 "MultiExtendedSource, SingleExtendedSource, PointSource") 

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

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

535 "This includes peaks that may have been culled " 

536 "during deblending or failed to deblend") 

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

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

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

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

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

542 doc="Flux measurement from scarlet") 

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

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

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

546 doc="True when scarlet initializes sources " 

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

548 "The algorithm uses a lot of memory, " 

549 "so large dense blends will use " 

550 "a less accurate initialization.") 

551 

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

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

554 # ) 

555 

556 @pipeBase.timeMethod 

557 def run(self, mExposure, mergedSources): 

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

559 

560 Parameters 

561 ---------- 

562 mExposure : `MultibandExposure` 

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

564 shape and region of the sky. 

565 mergedSources : `SourceCatalog` 

566 The merged `SourceCatalog` that contains parent footprints 

567 to (potentially) deblend. 

568 

569 Returns 

570 ------- 

571 templateCatalogs: dict 

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

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

574 These are catalogs with heavy footprints that are the templates 

575 created by the multiband templates. 

576 """ 

577 return self.deblend(mExposure, mergedSources) 

578 

579 @pipeBase.timeMethod 

580 def deblend(self, mExposure, sources): 

581 """Deblend a data cube of multiband images 

582 

583 Parameters 

584 ---------- 

585 mExposure : `MultibandExposure` 

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

587 shape and region of the sky. 

588 sources : `SourceCatalog` 

589 The merged `SourceCatalog` that contains parent footprints 

590 to (potentially) deblend. 

591 

592 Returns 

593 ------- 

594 templateCatalogs : dict or None 

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

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

597 These are catalogs with heavy footprints that are the templates 

598 created by the multiband templates. 

599 """ 

600 import time 

601 

602 filters = mExposure.filters 

603 self.log.info("Deblending {0} sources in {1} exposure bands".format(len(sources), len(mExposure))) 

604 

605 # Create the output catalogs 

606 templateCatalogs = {} 

607 # This must be returned but is not calculated right now, setting it to 

608 # None to be consistent with doc string 

609 for f in filters: 

610 _catalog = afwTable.SourceCatalog(sources.table.clone()) 

611 _catalog.extend(sources) 

612 templateCatalogs[f] = _catalog 

613 

614 n0 = len(sources) 

615 nparents = 0 

616 for pk, src in enumerate(sources): 

617 foot = src.getFootprint() 

618 bbox = foot.getBBox() 

619 peaks = foot.getPeaks() 

620 

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

622 # propagate its flags to the parent source. 

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

624 

625 # Block of Skipping conditions 

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

627 for f in filters: 

628 templateCatalogs[f][pk].set(self.runtimeKey, 0) 

629 continue 

630 if self._isLargeFootprint(foot): 

631 src.set(self.tooBigKey, True) 

632 self._skipParent(src, mExposure.mask) 

633 self.log.trace('Parent %i: skipping large footprint', int(src.getId())) 

634 continue 

635 if self._isMasked(foot, mExposure): 

636 src.set(self.maskedKey, True) 

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

638 mask = afwImage.MaskX(mask, xy0=bbox.getMin()) 

639 self._skipParent(src, mask) 

640 self.log.trace('Parent %i: skipping masked footprint', int(src.getId())) 

641 continue 

642 if self.config.maxNumberOfPeaks > 0 and len(peaks) > self.config.maxNumberOfPeaks: 

643 src.set(self.tooManyPeaksKey, True) 

644 self._skipParent(src, mExposure.mask) 

645 msg = 'Parent {0}: Too many peaks, skipping blend' 

646 self.log.trace(msg.format(int(src.getId()))) 

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

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

649 # to model any peaks often results in catastrophic failure 

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

651 continue 

652 

653 nparents += 1 

654 self.log.trace('Parent %i: deblending %i peaks', int(src.getId()), len(peaks)) 

655 # Run the deblender 

656 blendError = None 

657 try: 

658 t0 = time.time() 

659 # Build the parameter lists with the same ordering 

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

661 tf = time.time() 

662 runtime = (tf-t0)*1000 

663 src.set(self.deblendFailedKey, False) 

664 src.set(self.runtimeKey, runtime) 

665 src.set(self.scarletSpectrumInitKey, spectrumInit) 

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

667 src.set(self.blendConvergenceFailedFlagKey, converged) 

668 sources = [src for src in blend.sources] 

669 # Re-insert place holders for skipped sources 

670 # to propagate them in the catalog so 

671 # that the peaks stay consistent 

672 for k in skipped: 

673 sources.insert(k, None) 

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

675 except Exception as e: 

676 blendError = type(e).__name__ 

677 if isinstance(e, ScarletGradientError): 

678 src.set(self.iterKey, e.iterations) 

679 elif not isinstance(e, IncompleteDataError): 

680 blendError = "UnknownError" 

681 self._skipParent(src, mExposure.mask) 

682 if self.config.catchFailures: 

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

684 self.log.warn("UnknownError") 

685 import traceback 

686 traceback.print_exc() 

687 else: 

688 raise 

689 

690 self.log.warn("Unable to deblend source %d: %s" % (src.getId(), blendError)) 

691 src.set(self.deblendFailedKey, True) 

692 src.set(self.deblendErrorKey, blendError) 

693 self._skipParent(src, mExposure.mask) 

694 continue 

695 

696 # Calculate the number of children deblended from the parent 

697 nChild = len([k for k in range(len(sources)) if k not in skipped]) 

698 

699 # Add the merged source as a parent in the catalog for each band 

700 templateParents = {} 

701 parentId = src.getId() 

702 for f in filters: 

703 templateParents[f] = templateCatalogs[f][pk] 

704 templateParents[f].set(self.nChildKey, nChild) 

705 templateParents[f].set(self.nPeaksKey, len(foot.peaks)) 

706 templateParents[f].set(self.runtimeKey, runtime) 

707 templateParents[f].set(self.iterKey, len(blend.loss)) 

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

709 templateParents[f].set(self.scarletLogLKey, logL) 

710 

711 # Add each source to the catalogs in each band 

712 for k, source in enumerate(sources): 

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

714 # it could not initialize 

715 if k in skipped: 

716 # No need to propagate anything 

717 continue 

718 else: 

719 src.set(self.deblendSkippedKey, False) 

720 models = modelToHeavy(source, filters, xy0=bbox.getMin(), 

721 observation=blend.observations[0]) 

722 

723 flux = scarlet.measure.flux(source) 

724 for fidx, f in enumerate(filters): 

725 if len(models[f].getPeaks()) != 1: 

726 err = "Heavy footprint should have a single peak, got {0}" 

727 raise ValueError(err.format(len(models[f].peaks))) 

728 cat = templateCatalogs[f] 

729 child = self._addChild(src, cat, models[f], source, converged, 

730 xy0=bbox.getMin(), flux=flux[fidx]) 

731 if parentId == 0: 

732 child.setId(src.getId()) 

733 child.set(self.runtimeKey, runtime) 

734 

735 K = len(list(templateCatalogs.values())[0]) 

736 self.log.info('Deblended: of %i sources, %i were deblended, creating %i children, total %i sources' 

737 % (n0, nparents, K-n0, K)) 

738 return templateCatalogs 

739 

740 def _isLargeFootprint(self, footprint): 

741 """Returns whether a Footprint is large 

742 

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

744 These may be disabled independently by configuring them to be 

745 non-positive. 

746 

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

748 deblender or other downstream processing can have trouble dealing with 

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

750 """ 

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

752 return True 

753 if self.config.maxFootprintSize > 0: 

754 bbox = footprint.getBBox() 

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

756 return True 

757 if self.config.minFootprintAxisRatio > 0: 

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

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

760 return True 

761 return False 

762 

763 def _isMasked(self, footprint, mExposure): 

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

765 bbox = footprint.getBBox() 

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

767 size = float(footprint.getArea()) 

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

769 maskVal = mExposure.mask.getPlaneBitMask(maskName) 

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

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

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

773 return True 

774 return False 

775 

776 def _skipParent(self, source, masks): 

777 """Indicate that the parent source is not being deblended 

778 

779 We set the appropriate flags and masks for each exposure. 

780 

781 Parameters 

782 ---------- 

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

784 The source to flag as skipped 

785 masks : list of `lsst.afw.image.MaskX` 

786 The mask in each band to update with the non-detection 

787 """ 

788 fp = source.getFootprint() 

789 source.set(self.deblendSkippedKey, True) 

790 if self.config.notDeblendedMask: 

791 for mask in masks: 

792 mask.addMaskPlane(self.config.notDeblendedMask) 

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

794 # The deblender didn't run on this source, so it has zero runtime 

795 source.set(self.runtimeKey, 0) 

796 # Set the center of the parent 

797 bbox = fp.getBBox() 

798 centerX = int(bbox.getMinX()+bbox.getWidth()/2) 

799 centerY = int(bbox.getMinY()+bbox.getHeight()/2) 

800 source.set(self.peakCenter, Point2I(centerX, centerY)) 

801 # There are no deblended children, so nChild = 0 

802 source.set(self.nChildKey, 0) 

803 # But we also want to know how many peaks that we would have 

804 # deblended if the parent wasn't skipped. 

805 source.set(self.nPeaksKey, len(fp.peaks)) 

806 # The blend was skipped, so it didn't take any iterations 

807 source.set(self.iterKey, 0) 

808 # Top level parents are not a detected peak, so they have no peakId 

809 source.set(self.peakIdKey, 0) 

810 # Top level parents also have no parentNPeaks 

811 source.set(self.parentNPeaksKey, 0) 

812 

813 def _addChild(self, parent, sources, heavy, scarletSource, blend_converged, xy0, flux): 

814 """Add a child to a catalog 

815 

816 This creates a new child in the source catalog, 

817 assigning it a parent id, adding a footprint, 

818 and setting all appropriate flags based on the 

819 deblender result. 

820 """ 

821 assert len(heavy.getPeaks()) == 1 

822 src = sources.addNew() 

823 for key in self.toCopyFromParent: 

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

825 src.assign(heavy.getPeaks()[0], self.peakSchemaMapper) 

826 src.setParent(parent.getId()) 

827 src.setFootprint(heavy) 

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

829 # deblended using the PointSource model. 

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

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

832 # is expecting it. 

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

834 src.set(self.runtimeKey, 0) 

835 src.set(self.blendConvergenceFailedFlagKey, not blend_converged) 

836 

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

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

839 # deblenders and across observations, where the peak 

840 # position is unlikely to change unless enough time passes 

841 # for a source to move on the sky. 

842 peak = scarletSource.detectedPeak 

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

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

845 

846 # The children have a single peak 

847 src.set(self.nPeaksKey, 1) 

848 

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

850 # scarlet flux measurement. 

851 morph = afwDet.multiband.heavyFootprintToImage(heavy).image.array 

852 

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

854 try: 

855 cy, cx = scarletSource.center 

856 cy = np.max([np.min([int(np.round(cy)), morph.shape[0]-1]), 0]) 

857 cx = np.max([np.min([int(np.round(cx)), morph.shape[1]-1]), 0]) 

858 src.set(self.modelCenterFlux, morph[cy, cx]) 

859 except AttributeError: 

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

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

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

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

864 

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

866 # Include the source flux in the model space in the catalog. 

867 # This uses the narrower model PSF, which ensures that all sources 

868 # not located on an edge have all of their flux included in the 

869 # measurement. 

870 src.set(self.scarletFluxKey, flux) 

871 

872 # Propagate columns from the parent to the child 

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

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

875 return src