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

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/>.
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
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
41from .source import modelToHeavy
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)
57__all__ = ["deblend", "ScarletDeblendConfig", "ScarletDeblendTask"]
59logger = lsst.log.Log.getLogger("meas.deblender.deblend")
62class IncompleteDataError(Exception):
63 """The PSF could not be computed due to incomplete data
64 """
65 pass
68class ScarletGradientError(Exception):
69 """An error occurred during optimization
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)
81 def __str__(self):
82 return self.message
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
93def _getPsfFwhm(psf):
94 """Calculate the FWHM of the `psf`
95 """
96 return psf.computeShape().getDeterminantRadius() * 2.35
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])
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]))
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
152def getFootprintMask(footprint, mExposure):
153 """Mask pixels outside the footprint
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
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
175def deblend(mExposure, footprint, config):
176 """Deblend a parent footprint
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()
191 # Create the data array from the masked images
192 images = mExposure.image[:, bbox].array
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
203 # Mask out the pixels outside the footprint
204 mask = getFootprintMask(footprint, mExposure)
205 weights *= ~mask
207 psfs = _computePsfImage(mExposure, footprint.getCentroid()).array.astype(np.float32)
208 psfs = ImagePSF(psfs)
209 model_psf = GaussianPSF(sigma=(config.modelPsfSigma,)*len(mExposure.filters))
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))
221 assert(config.sourceModel in ["single", "double", "compact", "fit"])
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")
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]
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
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 )
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
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)
292 return blend, skipped, spectrumInit
295class ScarletDeblendConfig(pexConfig.Config):
296 """MultibandDeblendConfig
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"))
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.")
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 )
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 )
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"))
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 )
430class ScarletDeblendTask(pipeBase.Task):
431 """ScarletDeblendTask
433 Split blended sources into individual sources.
435 This task has no return value; it only modifies the SourceCatalog in-place.
436 """
437 ConfigClass = ScarletDeblendConfig
438 _DefaultName = "scarletDeblend"
440 def __init__(self, schema, peakSchema=None, **kwargs):
441 """Create the task, adding necessary fields to the given schema.
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)
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")]
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')
489 self.iterKey = schema.addField('deblend_iterations', type=np.int32, doc='iterations to converge')
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.")
552 # self.log.trace('Added keys to schema: %s', ", ".join(str(x) for x in
553 # (self.nChildKey, self.tooManyPeaksKey, self.tooBigKey))
554 # )
556 @pipeBase.timeMethod
557 def run(self, mExposure, mergedSources):
558 """Get the psf from each exposure and then run deblend().
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.
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)
579 @pipeBase.timeMethod
580 def deblend(self, mExposure, sources):
581 """Deblend a data cube of multiband images
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.
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
602 filters = mExposure.filters
603 self.log.info("Deblending {0} sources in {1} exposure bands".format(len(sources), len(mExposure)))
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
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()
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)
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
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
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
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])
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)
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])
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)
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
740 def _isLargeFootprint(self, footprint):
741 """Returns whether a Footprint is large
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.
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
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
776 def _skipParent(self, source, masks):
777 """Indicate that the parent source is not being deblended
779 We set the appropriate flags and masks for each exposure.
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)
813 def _addChild(self, parent, sources, heavy, scarletSource, blend_converged, xy0, flux):
814 """Add a child to a catalog
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)
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"])
846 # The children have a single peak
847 src.set(self.nPeaksKey, 1)
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
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)))
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)
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