Coverage for python/lsst/meas/extensions/scarlet/scarletDeblendTask.py: 16%
463 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-25 00:40 -0700
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-25 00:40 -0700
1# This file is part of meas_extensions_scarlet.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22from dataclasses import dataclass
23from functools import partial
24import logging
25import numpy as np
26import scarlet
27from scarlet.psf import ImagePSF, GaussianPSF
28from scarlet import Blend, Frame, Observation
29from scarlet.renderer import ConvolutionRenderer
30from scarlet.detect import get_detect_wavelets
31from scarlet.initialization import init_all_sources
32from scarlet import lite
34import lsst.pex.config as pexConfig
35import lsst.pipe.base as pipeBase
36import lsst.geom as geom
37import lsst.afw.geom.ellipses as afwEll
38import lsst.afw.image as afwImage
39import lsst.afw.detection as afwDet
40import lsst.afw.table as afwTable
41from lsst.utils.logging import PeriodicLogger
42from lsst.utils.timer import timeMethod
43from lsst.afw.image.exposure._multiband import IncompleteDataError
45from .source import bboxToScarletBox
46from .io import ScarletModelData, scarletToData, scarletLiteToData
48# Scarlet and proxmin have a different definition of log levels than the stack,
49# so even "warnings" occur far more often than we would like.
50# So for now we only display scarlet and proxmin errors, as all other
51# scarlet outputs would be considered "TRACE" by our standards.
52scarletLogger = logging.getLogger("scarlet")
53scarletLogger.setLevel(logging.ERROR)
54proxminLogger = logging.getLogger("proxmin")
55proxminLogger.setLevel(logging.ERROR)
57__all__ = ["deblend", "deblend_lite", "ScarletDeblendConfig", "ScarletDeblendTask"]
59logger = logging.getLogger(__name__)
62class ScarletGradientError(Exception):
63 """An error occurred during optimization
65 This error occurs when the optimizer encounters
66 a NaN value while calculating the gradient.
67 """
68 def __init__(self, iterations, sources):
69 self.iterations = iterations
70 self.sources = sources
71 msg = ("ScalarGradientError in iteration {0}. "
72 "NaN values introduced in sources {1}")
73 self.message = msg.format(iterations, sources)
75 def __str__(self):
76 return self.message
79def _checkBlendConvergence(blend, f_rel):
80 """Check whether or not a blend has converged
81 """
82 deltaLoss = np.abs(blend.loss[-2] - blend.loss[-1])
83 convergence = f_rel * np.abs(blend.loss[-1])
84 return deltaLoss < convergence
87def isPseudoSource(source, pseudoColumns):
88 """Check if a source is a pseudo source.
90 This is mostly for skipping sky objects,
91 but any other column can also be added to disable
92 deblending on a parent or individual source when
93 set to `True`.
95 Parameters
96 ----------
97 source : `lsst.afw.table.source.source.SourceRecord`
98 The source to check for the pseudo bit.
99 pseudoColumns : `list` of `str`
100 A list of columns to check for pseudo sources.
101 """
102 isPseudo = False
103 for col in pseudoColumns:
104 try:
105 isPseudo |= source[col]
106 except KeyError:
107 pass
108 return isPseudo
111def computePsfKernelImage(mExposure, psfCenter):
112 """Compute the PSF kernel image and update the multiband exposure
113 if not all of the PSF images could be computed.
115 Parameters
116 ----------
117 psfCenter : `tuple` or `Point2I` or `Point2D`
118 The location `(x, y)` used as the center of the PSF.
120 Returns
121 -------
122 psfModels : `np.ndarray`
123 The multiband PSF image
124 mExposure : `MultibandExposure`
125 The exposure, updated to only use bands that
126 successfully generated a PSF image.
127 """
128 if not isinstance(psfCenter, geom.Point2D):
129 psfCenter = geom.Point2D(*psfCenter)
131 try:
132 psfModels = mExposure.computePsfKernelImage(psfCenter)
133 except IncompleteDataError as e:
134 psfModels = e.partialPsf
135 # Use only the bands that successfully generated a PSF image.
136 bands = psfModels.filters
137 mExposure = mExposure[bands, ]
138 if len(bands) == 1:
139 # Only a single band generated a PSF, so the MultibandExposure
140 # became a single band ExposureF.
141 # Convert the result back into a MultibandExposure.
142 mExposure = afwImage.MultibandExposure.fromExposures(bands, [mExposure])
143 return psfModels.array, mExposure
146def deblend(mExposure, footprint, config, spectrumInit):
147 """Deblend a parent footprint
149 Parameters
150 ----------
151 mExposure : `lsst.image.MultibandExposure`
152 The multiband exposure containing the image,
153 mask, and variance data.
154 footprint : `lsst.detection.Footprint`
155 The footprint of the parent to deblend.
156 config : `ScarletDeblendConfig`
157 Configuration of the deblending task.
158 spectrumInit : `bool`
159 Whether or not to initialize the spectrum.
161 Returns
162 -------
163 blendData : `lsst.meas.extensions.scarlet.io.ScarletBlendData`
164 The persistable representation of a `scarlet.Blend`.
165 skipped : `list` of `int`
166 The indices of any children that failed to initialize
167 and were skipped.
168 """
169 # Extract coordinates from each MultiColorPeak
170 bbox = footprint.getBBox()
172 # Create the data array from the masked images
173 images = mExposure.image[:, bbox].array
175 # Use the inverse variance as the weights
176 if config.useWeights:
177 weights = 1/mExposure.variance[:, bbox].array
178 else:
179 weights = np.ones_like(images)
180 badPixels = mExposure.mask.getPlaneBitMask(config.badMask)
181 mask = mExposure.mask[:, bbox].array & badPixels
182 weights[mask > 0] = 0
184 # Mask out the pixels outside the footprint
185 weights *= footprint.spans.asArray()
187 psfCenter = footprint.getCentroid()
188 psfs = mExposure.computePsfKernelImage(psfCenter).astype(np.float32)
189 psfs = ImagePSF(psfs)
190 model_psf = GaussianPSF(sigma=(config.modelPsfSigma,)*len(mExposure.filters))
192 frame = Frame(images.shape, psf=model_psf, channels=mExposure.filters)
193 observation = Observation(images, psf=psfs, weights=weights, channels=mExposure.filters)
194 if config.convolutionType == "fft":
195 observation.match(frame)
196 elif config.convolutionType == "real":
197 renderer = ConvolutionRenderer(observation, frame, convolution_type="real")
198 observation.match(frame, renderer=renderer)
199 else:
200 raise ValueError("Unrecognized convolution type {}".format(config.convolutionType))
202 assert config.sourceModel in ["single", "double", "compact", "fit"]
204 # Set the appropriate number of components
205 if config.sourceModel == "single":
206 maxComponents = 1
207 elif config.sourceModel == "double":
208 maxComponents = 2
209 elif config.sourceModel == "compact":
210 maxComponents = 0
211 elif config.sourceModel == "point":
212 raise NotImplementedError("Point source photometry is currently not implemented")
213 elif config.sourceModel == "fit":
214 # It is likely in the future that there will be some heuristic
215 # used to determine what type of model to use for each source,
216 # but that has not yet been implemented (see DM-22551)
217 raise NotImplementedError("sourceModel 'fit' has not been implemented yet")
219 # Convert the centers to pixel coordinates
220 xmin = bbox.getMinX()
221 ymin = bbox.getMinY()
222 centers = [
223 np.array([peak.getIy() - ymin, peak.getIx() - xmin], dtype=int)
224 for peak in footprint.peaks
225 if not isPseudoSource(peak, config.pseudoColumns)
226 ]
228 # Only deblend sources that can be initialized
229 sources, skipped = init_all_sources(
230 frame=frame,
231 centers=centers,
232 observations=observation,
233 thresh=config.morphThresh,
234 max_components=maxComponents,
235 min_snr=config.minSNR,
236 shifting=False,
237 fallback=config.fallback,
238 silent=config.catchFailures,
239 set_spectra=spectrumInit,
240 )
242 # Attach the peak to all of the initialized sources
243 srcIndex = 0
244 for k, center in enumerate(centers):
245 if k not in skipped:
246 # This is just to make sure that there isn't a coding bug
247 assert np.all(sources[srcIndex].center == center)
248 # Store the record for the peak with the appropriate source
249 sources[srcIndex].detectedPeak = footprint.peaks[k]
250 srcIndex += 1
252 # Create the blend and attempt to optimize it
253 blend = Blend(sources, observation)
254 try:
255 blend.fit(max_iter=config.maxIter, e_rel=config.relativeError)
256 except ArithmeticError:
257 # This occurs when a gradient update produces a NaN value
258 # This is usually due to a source initialized with a
259 # negative SED or no flux, often because the peak
260 # is a noise fluctuation in one band and not a real source.
261 iterations = len(blend.loss)
262 failedSources = []
263 for k, src in enumerate(sources):
264 if np.any(~np.isfinite(src.get_model())):
265 failedSources.append(k)
266 raise ScarletGradientError(iterations, failedSources)
268 # Store the location of the PSF center for storage
269 blend.psfCenter = (psfCenter.x, psfCenter.y)
271 return blend, skipped
274def buildLiteObservation(
275 modelPsf,
276 psfCenter,
277 mExposure,
278 footprint=None,
279 badPixelMasks=None,
280 useWeights=True,
281 convolutionType="real",
282):
283 """Generate a LiteObservation from a set of parameters.
285 Make the generation and reconstruction of a scarlet model consistent
286 by building a `LiteObservation` from a set of parameters.
288 Parameters
289 ----------
290 modelPsf : `numpy.ndarray`
291 The 2D model of the PSF in the partially deconvolved space.
292 psfCenter : `tuple` or `Point2I` or `Point2D`
293 The location `(x, y)` used as the center of the PSF.
294 mExposure : `lsst.afw.image.multiband.MultibandExposure`
295 The multi-band exposure that the model represents.
296 If `mExposure` is `None` then no image, variance, or weights are
297 attached to the observation.
298 footprint : `lsst.afw.detection.Footprint`
299 The footprint that is being fit.
300 If `Footprint` is `None` then the weights are not updated to mask
301 out pixels not contained in the footprint.
302 badPixelMasks : `list` of `str`
303 The keys from the bit mask plane used to mask out pixels
304 during the fit.
305 If `badPixelMasks` is `None` then the default values from
306 `ScarletDeblendConfig.badMask` is used.
307 useWeights : `bool`
308 Whether or not fitting should use inverse variance weights to
309 calculate the log-likelihood.
310 convolutionType : `str`
311 The type of convolution to use (either "real" or "fft").
312 When reconstructing an image it is advised to use "real" to avoid
313 polluting the footprint with
315 Returns
316 -------
317 observation : `scarlet.lite.LiteObservation`
318 The observation constructed from the input parameters.
319 """
320 # Initialize the observed PSFs
321 psfModels, mExposure = computePsfKernelImage(mExposure, psfCenter)
323 # Use the inverse variance as the weights
324 if useWeights:
325 weights = 1/mExposure.variance.array
326 else:
327 # Mask out bad pixels
328 weights = np.ones_like(mExposure.image.array)
329 if badPixelMasks is None:
330 badPixelMasks = ScarletDeblendConfig().badMask
331 badPixels = mExposure.mask.getPlaneBitMask(badPixelMasks)
332 mask = mExposure.mask.array & badPixels
333 weights[mask > 0] = 0
335 if footprint is not None:
336 # Mask out the pixels outside the footprint
337 weights *= footprint.spans.asArray()
339 observation = lite.LiteObservation(
340 images=mExposure.image.array,
341 variance=mExposure.variance.array,
342 weights=weights,
343 psfs=psfModels,
344 model_psf=modelPsf[None, :, :],
345 convolution_mode=convolutionType,
346 )
348 # Store the bands used to create the observation
349 observation.bands = mExposure.filters
350 return observation
353def deblend_lite(mExposure, modelPsf, footprint, config, spectrumInit, wavelets=None):
354 """Deblend a parent footprint
356 Parameters
357 ----------
358 mExposure : `lsst.image.MultibandExposure`
359 - The multiband exposure containing the image,
360 mask, and variance data
361 footprint : `lsst.detection.Footprint`
362 - The footprint of the parent to deblend
363 config : `ScarletDeblendConfig`
364 - Configuration of the deblending task
366 Returns
367 -------
368 blend : `scarlet.lite.Blend`
369 The blend this is to be deblended
370 skippedSources : `list[int]`
371 Indices of sources that were skipped due to no flux.
372 This usually means that a source was a spurrious detection in one
373 band that should not have been included in the merged catalog.
374 skippedBands : `list[str]`
375 Bands that were skipped because a PSF could not be generated for them.
376 """
377 # Extract coordinates from each MultiColorPeak
378 bbox = footprint.getBBox()
379 psfCenter = footprint.getCentroid()
381 observation = buildLiteObservation(
382 modelPsf=modelPsf,
383 psfCenter=psfCenter,
384 mExposure=mExposure[:, bbox],
385 footprint=footprint,
386 badPixelMasks=config.badMask,
387 useWeights=config.useWeights,
388 convolutionType=config.convolutionType,
389 )
391 # Convert the centers to pixel coordinates
392 xmin = bbox.getMinX()
393 ymin = bbox.getMinY()
394 centers = [
395 np.array([peak.getIy() - ymin, peak.getIx() - xmin], dtype=int)
396 for peak in footprint.peaks
397 if not isPseudoSource(peak, config.pseudoColumns)
398 ]
400 # Initialize the sources
401 if config.morphImage == "chi2":
402 sources = lite.init_all_sources_main(
403 observation,
404 centers,
405 min_snr=config.minSNR,
406 thresh=config.morphThresh,
407 )
408 elif config.morphImage == "wavelet":
409 _bbox = bboxToScarletBox(len(mExposure.filters), bbox, bbox.getMin())
410 _wavelets = wavelets[(slice(None), *_bbox[1:].slices)]
411 sources = lite.init_all_sources_wavelets(
412 observation,
413 centers,
414 use_psf=False,
415 wavelets=_wavelets,
416 min_snr=config.minSNR,
417 )
418 else:
419 raise ValueError("morphImage must be either 'chi2' or 'wavelet'.")
421 # Set the optimizer
422 if config.optimizer == "adaprox":
423 parameterization = partial(
424 lite.init_adaprox_component,
425 bg_thresh=config.backgroundThresh,
426 max_prox_iter=config.maxProxIter,
427 )
428 elif config.optimizer == "fista":
429 parameterization = partial(
430 lite.init_fista_component,
431 bg_thresh=config.backgroundThresh,
432 )
433 else:
434 raise ValueError("Unrecognized optimizer. Must be either 'adaprox' or 'fista'.")
435 sources = lite.parameterize_sources(sources, observation, parameterization)
437 # Attach the peak to all of the initialized sources
438 for k, center in enumerate(centers):
439 # This is just to make sure that there isn't a coding bug
440 if len(sources[k].components) > 0 and np.any(sources[k].center != center):
441 raise ValueError("Misaligned center, expected {center} but got {sources[k].center}")
442 # Store the record for the peak with the appropriate source
443 sources[k].detectedPeak = footprint.peaks[k]
445 blend = lite.LiteBlend(sources, observation)
447 # Initialize each source with its best fit spectrum
448 if spectrumInit:
449 blend.fit_spectra()
451 # Set the sources that could not be initialized and were skipped
452 skippedSources = [src for src in sources if src.is_null]
454 blend.fit(
455 max_iter=config.maxIter,
456 e_rel=config.relativeError,
457 min_iter=config.minIter,
458 reweight=False,
459 )
461 # Store the location of the PSF center for storage
462 blend.psfCenter = (psfCenter.x, psfCenter.y)
464 # Calculate the bands that were skipped
465 skippedBands = [band for band in mExposure.filters if band not in observation.bands]
467 return blend, skippedSources, skippedBands
470@dataclass
471class DeblenderMetrics:
472 """Metrics and measurements made on single sources.
474 Store deblender metrics to be added as attributes to a scarlet source
475 before it is converted into a `SourceRecord`.
476 When DM-34414 is finished this class will be eliminated and the metrics
477 will be added to the schema using a pipeline task that calculates them
478 from the stored deconvolved models.
480 All of the parameters are one dimensional numpy arrays,
481 with an element for each band in the observed images.
483 `maxOverlap` is useful as a metric for determining how blended a source
484 is because if it only overlaps with other sources at or below
485 the noise level, it is likely to be a mostly isolated source
486 in the deconvolved model frame.
488 `fluxOverlapFraction` is potentially more useful than the canonical
489 "blendedness" (or purity) metric because it accounts for potential
490 biases created during deblending by not weighting the overlapping
491 flux with the flux of this sources model.
493 Attributes
494 ----------
495 maxOverlap : `numpy.ndarray`
496 The maximum overlap that the source has with its neighbors in
497 a single pixel.
498 fluxOverlap : `numpy.ndarray`
499 The total flux from neighbors overlapping with the current source.
500 fluxOverlapFraction : `numpy.ndarray`
501 The fraction of `flux from neighbors/source flux` for a
502 given source within the source's footprint.
503 blendedness : `numpy.ndarray`
504 The metric for determining how blended a source is using the
505 Bosch et al. 2018 metric for "blendedness." Note that some
506 surveys use the term "purity," which is `1-blendedness`.
507 """
508 maxOverlap: np.array
509 fluxOverlap: np.array
510 fluxOverlapFraction: np.array
511 blendedness: np.array
514def setDeblenderMetrics(blend):
515 """Set metrics that can be used to evalute the deblender accuracy
517 This function calculates the `DeblenderMetrics` for each source in the
518 blend, and assigns it to that sources `metrics` property in place.
520 Parameters
521 ----------
522 blend : `scarlet.lite.Blend`
523 The blend containing the sources to measure.
524 """
525 # Store the full model of the scene for comparison
526 blendModel = blend.get_model()
527 for k, src in enumerate(blend.sources):
528 # Extract the source model in the full bounding box
529 model = src.get_model(bbox=blend.bbox)
530 # The footprint is the 2D array of non-zero pixels in each band
531 footprint = np.bitwise_or.reduce(model > 0, axis=0)
532 # Calculate the metrics.
533 # See `DeblenderMetrics` for a description of each metric.
534 neighborOverlap = (blendModel-model) * footprint[None, :, :]
535 maxOverlap = np.max(neighborOverlap, axis=(1, 2))
536 fluxOverlap = np.sum(neighborOverlap, axis=(1, 2))
537 fluxModel = np.sum(model, axis=(1, 2))
538 fluxOverlapFraction = np.zeros((len(model), ), dtype=float)
539 isFinite = fluxModel > 0
540 fluxOverlapFraction[isFinite] = fluxOverlap[isFinite]/fluxModel[isFinite]
541 blendedness = 1 - np.sum(model*model, axis=(1, 2))/np.sum(blendModel*model, axis=(1, 2))
542 src.metrics = DeblenderMetrics(maxOverlap, fluxOverlap, fluxOverlapFraction, blendedness)
545class ScarletDeblendConfig(pexConfig.Config):
546 """MultibandDeblendConfig
548 Configuration for the multiband deblender.
549 The parameters are organized by the parameter types, which are
550 - Stopping Criteria: Used to determine if the fit has converged
551 - Position Fitting Criteria: Used to fit the positions of the peaks
552 - Constraints: Used to apply constraints to the peaks and their components
553 - Other: Parameters that don't fit into the above categories
554 """
555 # Stopping Criteria
556 minIter = pexConfig.Field(dtype=int, default=1,
557 doc="Minimum number of iterations before the optimizer is allowed to stop.")
558 maxIter = pexConfig.Field(dtype=int, default=300,
559 doc=("Maximum number of iterations to deblend a single parent"))
560 relativeError = pexConfig.Field(dtype=float, default=1e-2,
561 doc=("Change in the loss function between iterations to exit fitter. "
562 "Typically this is `1e-2` if measurements will be made on the "
563 "flux re-distributed models and `1e-4` when making measurements "
564 "on the models themselves."))
566 # Constraints
567 morphThresh = pexConfig.Field(dtype=float, default=1,
568 doc="Fraction of background RMS a pixel must have"
569 "to be included in the initial morphology")
570 # Lite Parameters
571 # All of these parameters (except version) are only valid if version='lite'
572 version = pexConfig.ChoiceField(
573 dtype=str,
574 default="lite",
575 allowed={
576 "scarlet": "main scarlet version (likely to be deprecated soon)",
577 "lite": "Optimized version of scarlet for survey data from a single instrument",
578 },
579 doc="The version of scarlet to use.",
580 )
581 optimizer = pexConfig.ChoiceField(
582 dtype=str,
583 default="adaprox",
584 allowed={
585 "adaprox": "Proximal ADAM optimization",
586 "fista": "Accelerated proximal gradient method",
587 },
588 doc="The optimizer to use for fitting parameters and is only used when version='lite'",
589 )
590 morphImage = pexConfig.ChoiceField(
591 dtype=str,
592 default="chi2",
593 allowed={
594 "chi2": "Initialize sources on a chi^2 image made from all available bands",
595 "wavelet": "Initialize sources using a wavelet decomposition of the chi^2 image",
596 },
597 doc="The type of image to use for initializing the morphology. "
598 "Must be either 'chi2' or 'wavelet'. "
599 )
600 backgroundThresh = pexConfig.Field(
601 dtype=float,
602 default=0.25,
603 doc="Fraction of background to use for a sparsity threshold. "
604 "This prevents sources from growing unrealistically outside "
605 "the parent footprint while still modeling flux correctly "
606 "for bright sources."
607 )
608 maxProxIter = pexConfig.Field(
609 dtype=int,
610 default=1,
611 doc="Maximum number of proximal operator iterations inside of each "
612 "iteration of the optimizer. "
613 "This config field is only used if version='lite' and optimizer='adaprox'."
614 )
615 waveletScales = pexConfig.Field(
616 dtype=int,
617 default=5,
618 doc="Number of wavelet scales to use for wavelet initialization. "
619 "This field is only used when `version`='lite' and `morphImage`='wavelet'."
620 )
622 # Other scarlet paremeters
623 useWeights = pexConfig.Field(
624 dtype=bool, default=True,
625 doc=("Whether or not use use inverse variance weighting."
626 "If `useWeights` is `False` then flat weights are used"))
627 modelPsfSize = pexConfig.Field(
628 dtype=int, default=11,
629 doc="Model PSF side length in pixels")
630 modelPsfSigma = pexConfig.Field(
631 dtype=float, default=0.8,
632 doc="Define sigma for the model frame PSF")
633 minSNR = pexConfig.Field(
634 dtype=float, default=50,
635 doc="Minimum Signal to noise to accept the source."
636 "Sources with lower flux will be initialized with the PSF but updated "
637 "like an ordinary ExtendedSource (known in scarlet as a `CompactSource`).")
638 saveTemplates = pexConfig.Field(
639 dtype=bool, default=True,
640 doc="Whether or not to save the SEDs and templates")
641 processSingles = pexConfig.Field(
642 dtype=bool, default=True,
643 doc="Whether or not to process isolated sources in the deblender")
644 convolutionType = pexConfig.Field(
645 dtype=str, default="fft",
646 doc="Type of convolution to render the model to the observations.\n"
647 "- 'fft': perform convolutions in Fourier space\n"
648 "- 'real': peform convolutions in real space.")
649 sourceModel = pexConfig.Field(
650 dtype=str, default="double",
651 doc=("How to determine which model to use for sources, from\n"
652 "- 'single': use a single component for all sources\n"
653 "- 'double': use a bulge disk model for all sources\n"
654 "- 'compact': use a single component model, initialzed with a point source morphology, "
655 " for all sources\n"
656 "- 'point': use a point-source model for all sources\n"
657 "- 'fit: use a PSF fitting model to determine the number of components (not yet implemented)"),
658 deprecated="This field will be deprecated when the default for `version` is changed to `lite`.",
659 )
660 setSpectra = pexConfig.Field(
661 dtype=bool, default=True,
662 doc="Whether or not to solve for the best-fit spectra during initialization. "
663 "This makes initialization slightly longer, as it requires a convolution "
664 "to set the optimal spectra, but results in a much better initial log-likelihood "
665 "and reduced total runtime, with convergence in fewer iterations."
666 "This option is only used when "
667 "peaks*area < `maxSpectrumCutoff` will use the improved initialization.")
669 # Mask-plane restrictions
670 badMask = pexConfig.ListField(
671 dtype=str, default=["BAD", "CR", "NO_DATA", "SAT", "SUSPECT", "EDGE"],
672 doc="Whether or not to process isolated sources in the deblender")
673 statsMask = pexConfig.ListField(dtype=str, default=["SAT", "INTRP", "NO_DATA"],
674 doc="Mask planes to ignore when performing statistics")
675 maskLimits = pexConfig.DictField(
676 keytype=str,
677 itemtype=float,
678 default={},
679 doc=("Mask planes with the corresponding limit on the fraction of masked pixels. "
680 "Sources violating this limit will not be deblended. "
681 "If the fraction is `0` then the limit is a single pixel."),
682 )
684 # Size restrictions
685 maxNumberOfPeaks = pexConfig.Field(
686 dtype=int, default=200,
687 doc=("Only deblend the brightest maxNumberOfPeaks peaks in the parent"
688 " (<= 0: unlimited)"))
689 maxFootprintArea = pexConfig.Field(
690 dtype=int, default=100_000,
691 doc=("Maximum area for footprints before they are ignored as large; "
692 "non-positive means no threshold applied"))
693 maxAreaTimesPeaks = pexConfig.Field(
694 dtype=int, default=10_000_000,
695 doc=("Maximum rectangular footprint area * nPeaks in the footprint. "
696 "This was introduced in DM-33690 to prevent fields that are crowded or have a "
697 "LSB galaxy that causes memory intensive initialization in scarlet from dominating "
698 "the overall runtime and/or causing the task to run out of memory. "
699 "(<= 0: unlimited)")
700 )
701 maxFootprintSize = pexConfig.Field(
702 dtype=int, default=0,
703 doc=("Maximum linear dimension for footprints before they are ignored "
704 "as large; non-positive means no threshold applied"))
705 minFootprintAxisRatio = pexConfig.Field(
706 dtype=float, default=0.0,
707 doc=("Minimum axis ratio for footprints before they are ignored "
708 "as large; non-positive means no threshold applied"))
709 maxSpectrumCutoff = pexConfig.Field(
710 dtype=int, default=1_000_000,
711 doc=("Maximum number of pixels * number of sources in a blend. "
712 "This is different than `maxFootprintArea` because this isn't "
713 "the footprint area but the area of the bounding box that "
714 "contains the footprint, and is also multiplied by the number of"
715 "sources in the footprint. This prevents large skinny blends with "
716 "a high density of sources from running out of memory. "
717 "If `maxSpectrumCutoff == -1` then there is no cutoff.")
718 )
719 # Failure modes
720 fallback = pexConfig.Field(
721 dtype=bool, default=True,
722 doc="Whether or not to fallback to a smaller number of components if a source does not initialize"
723 )
724 notDeblendedMask = pexConfig.Field(
725 dtype=str, default="NOT_DEBLENDED", optional=True,
726 doc="Mask name for footprints not deblended, or None")
727 catchFailures = pexConfig.Field(
728 dtype=bool, default=True,
729 doc=("If True, catch exceptions thrown by the deblender, log them, "
730 "and set a flag on the parent, instead of letting them propagate up"))
732 # Other options
733 columnInheritance = pexConfig.DictField(
734 keytype=str, itemtype=str, default={
735 "deblend_nChild": "deblend_parentNChild",
736 "deblend_nPeaks": "deblend_parentNPeaks",
737 "deblend_spectrumInitFlag": "deblend_spectrumInitFlag",
738 "deblend_blendConvergenceFailedFlag": "deblend_blendConvergenceFailedFlag",
739 },
740 doc="Columns to pass from the parent to the child. "
741 "The key is the name of the column for the parent record, "
742 "the value is the name of the column to use for the child."
743 )
744 pseudoColumns = pexConfig.ListField(
745 dtype=str, default=['merge_peak_sky', 'sky_source'],
746 doc="Names of flags which should never be deblended."
747 )
749 # Logging option(s)
750 loggingInterval = pexConfig.Field(
751 dtype=int, default=600,
752 doc="Interval (in seconds) to log messages (at VERBOSE level) while deblending sources.",
753 deprecated="This field is no longer used and will be removed in v25.",
754 )
755 # Testing options
756 # Some obs packages and ci packages run the full pipeline on a small
757 # subset of data to test that the pipeline is functioning properly.
758 # This is not meant as scientific validation, so it can be useful
759 # to only run on a small subset of the data that is large enough to
760 # test the desired pipeline features but not so long that the deblender
761 # is the tall pole in terms of execution times.
762 useCiLimits = pexConfig.Field(
763 dtype=bool, default=False,
764 doc="Limit the number of sources deblended for CI to prevent long build times")
765 ciDeblendChildRange = pexConfig.ListField(
766 dtype=int, default=[5, 10],
767 doc="Only deblend parent Footprints with a number of peaks in the (inclusive) range indicated."
768 "If `useCiLimits==False` then this parameter is ignored.")
769 ciNumParentsToDeblend = pexConfig.Field(
770 dtype=int, default=10,
771 doc="Only use the first `ciNumParentsToDeblend` parent footprints with a total peak count "
772 "within `ciDebledChildRange`. "
773 "If `useCiLimits==False` then this parameter is ignored.")
776class ScarletDeblendTask(pipeBase.Task):
777 """ScarletDeblendTask
779 Split blended sources into individual sources.
781 This task has no return value; it only modifies the SourceCatalog in-place.
782 """
783 ConfigClass = ScarletDeblendConfig
784 _DefaultName = "scarletDeblend"
786 def __init__(self, schema, peakSchema=None, **kwargs):
787 """Create the task, adding necessary fields to the given schema.
789 Parameters
790 ----------
791 schema : `lsst.afw.table.schema.schema.Schema`
792 Schema object for measurement fields; will be modified in-place.
793 peakSchema : `lsst.afw.table.schema.schema.Schema`
794 Schema of Footprint Peaks that will be passed to the deblender.
795 Any fields beyond the PeakTable minimal schema will be transferred
796 to the main source Schema. If None, no fields will be transferred
797 from the Peaks.
798 filters : list of str
799 Names of the filters used for the eposures. This is needed to store
800 the SED as a field
801 **kwargs
802 Passed to Task.__init__.
803 """
804 pipeBase.Task.__init__(self, **kwargs)
806 peakMinimalSchema = afwDet.PeakTable.makeMinimalSchema()
807 if peakSchema is None:
808 # In this case, the peakSchemaMapper will transfer nothing, but
809 # we'll still have one
810 # to simplify downstream code
811 self.peakSchemaMapper = afwTable.SchemaMapper(peakMinimalSchema, schema)
812 else:
813 self.peakSchemaMapper = afwTable.SchemaMapper(peakSchema, schema)
814 for item in peakSchema:
815 if item.key not in peakMinimalSchema:
816 self.peakSchemaMapper.addMapping(item.key, item.field)
817 # Because SchemaMapper makes a copy of the output schema
818 # you give its ctor, it isn't updating this Schema in
819 # place. That's probably a design flaw, but in the
820 # meantime, we'll keep that schema in sync with the
821 # peakSchemaMapper.getOutputSchema() manually, by adding
822 # the same fields to both.
823 schema.addField(item.field)
824 assert schema == self.peakSchemaMapper.getOutputSchema(), "Logic bug mapping schemas"
825 self._addSchemaKeys(schema)
826 self.schema = schema
827 self.toCopyFromParent = [item.key for item in self.schema
828 if item.field.getName().startswith("merge_footprint")]
830 def _addSchemaKeys(self, schema):
831 """Add deblender specific keys to the schema
832 """
833 # Parent (blend) fields
834 self.runtimeKey = schema.addField('deblend_runtime', type=np.float32, doc='runtime in ms')
835 self.iterKey = schema.addField('deblend_iterations', type=np.int32, doc='iterations to converge')
836 self.nChildKey = schema.addField('deblend_nChild', type=np.int32,
837 doc='Number of children this object has (defaults to 0)')
838 self.nPeaksKey = schema.addField("deblend_nPeaks", type=np.int32,
839 doc="Number of initial peaks in the blend. "
840 "This includes peaks that may have been culled "
841 "during deblending or failed to deblend")
842 # Skipped flags
843 self.deblendSkippedKey = schema.addField('deblend_skipped', type='Flag',
844 doc="Deblender skipped this source")
845 self.isolatedParentKey = schema.addField('deblend_isolatedParent', type='Flag',
846 doc='The source has only a single peak '
847 'and was not deblended')
848 self.pseudoKey = schema.addField('deblend_isPseudo', type='Flag',
849 doc='The source is identified as a "pseudo" source and '
850 'was not deblended')
851 self.tooManyPeaksKey = schema.addField('deblend_tooManyPeaks', type='Flag',
852 doc='Source had too many peaks; '
853 'only the brightest were included')
854 self.tooBigKey = schema.addField('deblend_parentTooBig', type='Flag',
855 doc='Parent footprint covered too many pixels')
856 self.maskedKey = schema.addField('deblend_masked', type='Flag',
857 doc='Parent footprint had too many masked pixels')
858 # Convergence flags
859 self.sedNotConvergedKey = schema.addField('deblend_sedConvergenceFailed', type='Flag',
860 doc='scarlet sed optimization did not converge before'
861 'config.maxIter')
862 self.morphNotConvergedKey = schema.addField('deblend_morphConvergenceFailed', type='Flag',
863 doc='scarlet morph optimization did not converge before'
864 'config.maxIter')
865 self.blendConvergenceFailedFlagKey = schema.addField('deblend_blendConvergenceFailedFlag',
866 type='Flag',
867 doc='at least one source in the blend'
868 'failed to converge')
869 # Error flags
870 self.deblendFailedKey = schema.addField('deblend_failed', type='Flag',
871 doc="Deblending failed on source")
872 self.deblendErrorKey = schema.addField('deblend_error', type="String", size=25,
873 doc='Name of error if the blend failed')
874 self.incompleteDataKey = schema.addField('deblend_incompleteData', type='Flag',
875 doc='True when a blend has at least one band '
876 'that could not generate a PSF and was '
877 'not included in the model.')
878 # Deblended source fields
879 self.peakCenter = afwTable.Point2IKey.addFields(schema, name="deblend_peak_center",
880 doc="Center used to apply constraints in scarlet",
881 unit="pixel")
882 self.peakIdKey = schema.addField("deblend_peakId", type=np.int32,
883 doc="ID of the peak in the parent footprint. "
884 "This is not unique, but the combination of 'parent'"
885 "and 'peakId' should be for all child sources. "
886 "Top level blends with no parents have 'peakId=0'")
887 self.modelCenterFlux = schema.addField('deblend_peak_instFlux', type=float, units='count',
888 doc="The instFlux at the peak position of deblended mode")
889 self.modelTypeKey = schema.addField("deblend_modelType", type="String", size=25,
890 doc="The type of model used, for example "
891 "MultiExtendedSource, SingleExtendedSource, PointSource")
892 self.parentNPeaksKey = schema.addField("deblend_parentNPeaks", type=np.int32,
893 doc="deblend_nPeaks from this records parent.")
894 self.parentNChildKey = schema.addField("deblend_parentNChild", type=np.int32,
895 doc="deblend_nChild from this records parent.")
896 self.scarletFluxKey = schema.addField("deblend_scarletFlux", type=np.float32,
897 doc="Flux measurement from scarlet")
898 self.scarletLogLKey = schema.addField("deblend_logL", type=np.float32,
899 doc="Final logL, used to identify regressions in scarlet.")
900 self.edgePixelsKey = schema.addField('deblend_edgePixels', type='Flag',
901 doc='Source had flux on the edge of the parent footprint')
902 self.scarletSpectrumInitKey = schema.addField("deblend_spectrumInitFlag", type='Flag',
903 doc="True when scarlet initializes sources "
904 "in the blend with a more accurate spectrum. "
905 "The algorithm uses a lot of memory, "
906 "so large dense blends will use "
907 "a less accurate initialization.")
908 self.nComponentsKey = schema.addField("deblend_nComponents", type=np.int32,
909 doc="Number of components in a ScarletLiteSource. "
910 "If `config.version != 'lite'`then "
911 "this column is set to zero.")
912 self.psfKey = schema.addField('deblend_deblendedAsPsf', type='Flag',
913 doc='Deblender thought this source looked like a PSF')
914 self.coverageKey = schema.addField('deblend_dataCoverage', type=np.float32,
915 doc='Fraction of pixels with data. '
916 'In other words, 1 - fraction of pixels with NO_DATA set.')
917 # Blendedness/classification metrics
918 self.maxOverlapKey = schema.addField("deblend_maxOverlap", type=np.float32,
919 doc="Maximum overlap with all of the other neighbors flux "
920 "combined."
921 "This is useful as a metric for determining how blended a "
922 "source is because if it only overlaps with other sources "
923 "at or below the noise level, it is likely to be a mostly "
924 "isolated source in the deconvolved model frame.")
925 self.fluxOverlapKey = schema.addField("deblend_fluxOverlap", type=np.float32,
926 doc="This is the total flux from neighboring objects that "
927 "overlaps with this source.")
928 self.fluxOverlapFractionKey = schema.addField("deblend_fluxOverlapFraction", type=np.float32,
929 doc="This is the fraction of "
930 "`flux from neighbors/source flux` "
931 "for a given source within the source's"
932 "footprint.")
933 self.blendednessKey = schema.addField("deblend_blendedness", type=np.float32,
934 doc="The Bosch et al. 2018 metric for 'blendedness.' ")
936 @timeMethod
937 def run(self, mExposure, mergedSources):
938 """Get the psf from each exposure and then run deblend().
940 Parameters
941 ----------
942 mExposure : `MultibandExposure`
943 The exposures should be co-added images of the same
944 shape and region of the sky.
945 mergedSources : `SourceCatalog`
946 The merged `SourceCatalog` that contains parent footprints
947 to (potentially) deblend.
949 Returns
950 -------
951 templateCatalogs: dict
952 Keys are the names of the filters and the values are
953 `lsst.afw.table.source.source.SourceCatalog`'s.
954 These are catalogs with heavy footprints that are the templates
955 created by the multiband templates.
956 """
957 return self.deblend(mExposure, mergedSources)
959 @timeMethod
960 def deblend(self, mExposure, catalog):
961 """Deblend a data cube of multiband images
963 Parameters
964 ----------
965 mExposure : `MultibandExposure`
966 The exposures should be co-added images of the same
967 shape and region of the sky.
968 catalog : `SourceCatalog`
969 The merged `SourceCatalog` that contains parent footprints
970 to (potentially) deblend. The new deblended sources are
971 appended to this catalog in place.
973 Returns
974 -------
975 catalogs : `dict` or `None`
976 Keys are the names of the filters and the values are
977 `lsst.afw.table.source.source.SourceCatalog`'s.
978 These are catalogs with heavy footprints that are the templates
979 created by the multiband templates.
980 """
981 import time
983 # Cull footprints if required by ci
984 if self.config.useCiLimits:
985 self.log.info("Using CI catalog limits, the original number of sources to deblend was %d.",
986 len(catalog))
987 # Select parents with a number of children in the range
988 # config.ciDeblendChildRange
989 minChildren, maxChildren = self.config.ciDeblendChildRange
990 nPeaks = np.array([len(src.getFootprint().peaks) for src in catalog])
991 childrenInRange = np.where((nPeaks >= minChildren) & (nPeaks <= maxChildren))[0]
992 if len(childrenInRange) < self.config.ciNumParentsToDeblend:
993 raise ValueError("Fewer than ciNumParentsToDeblend children were contained in the range "
994 "indicated by ciDeblendChildRange. Adjust this range to include more "
995 "parents.")
996 # Keep all of the isolated parents and the first
997 # `ciNumParentsToDeblend` children
998 parents = nPeaks == 1
999 children = np.zeros((len(catalog),), dtype=bool)
1000 children[childrenInRange[:self.config.ciNumParentsToDeblend]] = True
1001 catalog = catalog[parents | children]
1002 # We need to update the IdFactory, otherwise the the source ids
1003 # will not be sequential
1004 idFactory = catalog.getIdFactory()
1005 maxId = np.max(catalog["id"])
1006 idFactory.notify(maxId)
1008 self.log.info("Deblending %d sources in %d exposure bands", len(catalog), len(mExposure))
1009 periodicLog = PeriodicLogger(self.log)
1011 # Create a set of wavelet coefficients if using wavelet initialization
1012 if self.config.version == "lite" and self.config.morphImage == "wavelet":
1013 images = mExposure.image.array
1014 variance = mExposure.variance.array
1015 wavelets = get_detect_wavelets(images, variance, scales=self.config.waveletScales)
1016 else:
1017 wavelets = None
1019 # Add the NOT_DEBLENDED mask to the mask plane in each band
1020 if self.config.notDeblendedMask:
1021 for mask in mExposure.mask:
1022 mask.addMaskPlane(self.config.notDeblendedMask)
1024 # Initialize the persistable data model
1025 modelPsf = lite.integrated_circular_gaussian(sigma=self.config.modelPsfSigma)
1026 dataModel = ScarletModelData(modelPsf)
1028 nParents = len(catalog)
1029 nDeblendedParents = 0
1030 skippedParents = []
1031 for parentIndex in range(nParents):
1032 parent = catalog[parentIndex]
1033 foot = parent.getFootprint()
1034 bbox = foot.getBBox()
1035 peaks = foot.getPeaks()
1037 # Since we use the first peak for the parent object, we should
1038 # propagate its flags to the parent source.
1039 parent.assign(peaks[0], self.peakSchemaMapper)
1041 # Block of conditions for skipping a parent with multiple children
1042 if (skipArgs := self._checkSkipped(parent, mExposure)) is not None:
1043 self._skipParent(parent, *skipArgs)
1044 skippedParents.append(parentIndex)
1045 continue
1047 nDeblendedParents += 1
1048 self.log.trace("Parent %d: deblending %d peaks", parent.getId(), len(peaks))
1049 # Run the deblender
1050 blendError = None
1052 # Choose whether or not to use improved spectral initialization.
1053 # This significantly cuts down on the number of iterations
1054 # that the optimizer needs and usually results in a better
1055 # fit.
1056 # But using least squares on a very large blend causes memory
1057 # issues, so it is not done for large blends
1058 if self.config.setSpectra:
1059 if self.config.maxSpectrumCutoff <= 0:
1060 spectrumInit = True
1061 else:
1062 spectrumInit = len(foot.peaks) * bbox.getArea() < self.config.maxSpectrumCutoff
1063 else:
1064 spectrumInit = False
1066 try:
1067 t0 = time.monotonic()
1068 # Build the parameter lists with the same ordering
1069 if self.config.version == "scarlet":
1070 blend, skippedSources = deblend(mExposure, foot, self.config, spectrumInit)
1071 skippedBands = []
1072 elif self.config.version == "lite":
1073 blend, skippedSources, skippedBands = deblend_lite(
1074 mExposure=mExposure,
1075 modelPsf=modelPsf,
1076 footprint=foot,
1077 config=self.config,
1078 spectrumInit=spectrumInit,
1079 wavelets=wavelets,
1080 )
1081 tf = time.monotonic()
1082 runtime = (tf-t0)*1000
1083 converged = _checkBlendConvergence(blend, self.config.relativeError)
1084 # Store the number of components in the blend
1085 if self.config.version == "lite":
1086 nComponents = len(blend.components)
1087 else:
1088 nComponents = 0
1089 nChild = len(blend.sources)
1090 parent.set(self.incompleteDataKey, len(skippedBands) > 0)
1091 # Catch all errors and filter out the ones that we know about
1092 except Exception as e:
1093 print("deblend failed")
1094 print(e)
1095 blendError = type(e).__name__
1096 if isinstance(e, ScarletGradientError):
1097 parent.set(self.iterKey, e.iterations)
1098 else:
1099 blendError = "UnknownError"
1100 if self.config.catchFailures:
1101 # Make it easy to find UnknownErrors in the log file
1102 self.log.warn("UnknownError")
1103 import traceback
1104 traceback.print_exc()
1105 else:
1106 raise
1108 self._skipParent(
1109 parent=parent,
1110 skipKey=self.deblendFailedKey,
1111 logMessage=f"Unable to deblend source {parent.getId}: {blendError}",
1112 )
1113 parent.set(self.deblendErrorKey, blendError)
1114 skippedParents.append(parentIndex)
1115 continue
1117 # Update the parent record with the deblending results
1118 if self.config.version == "scarlet":
1119 logL = -blend.loss[-1] + blend.observations[0].log_norm
1120 elif self.config.version == "lite":
1121 logL = blend.loss[-1]
1122 self._updateParentRecord(
1123 parent=parent,
1124 nPeaks=len(peaks),
1125 nChild=nChild,
1126 nComponents=nComponents,
1127 runtime=runtime,
1128 iterations=len(blend.loss),
1129 logL=logL,
1130 spectrumInit=spectrumInit,
1131 converged=converged,
1132 )
1134 # Add each deblended source to the catalog
1135 for k, scarletSource in enumerate(blend.sources):
1136 # Skip any sources with no flux or that scarlet skipped because
1137 # it could not initialize
1138 if k in skippedSources or (self.config.version == "lite" and scarletSource.is_null):
1139 # No need to propagate anything
1140 continue
1141 parent.set(self.deblendSkippedKey, False)
1143 # Add all fields except the HeavyFootprint to the
1144 # source record
1145 sourceRecord = self._addChild(
1146 parent=parent,
1147 peak=scarletSource.detectedPeak,
1148 catalog=catalog,
1149 scarletSource=scarletSource,
1150 )
1151 scarletSource.recordId = sourceRecord.getId()
1152 scarletSource.peakId = scarletSource.detectedPeak.getId()
1154 # Store the blend information so that it can be persisted
1155 if self.config.version == "lite":
1156 blendData = scarletLiteToData(blend, blend.psfCenter, bbox.getMin(), blend.observation.bands)
1157 else:
1158 blendData = scarletToData(blend, blend.psfCenter, bbox.getMin(), mExposure.filters)
1159 dataModel.blends[parent.getId()] = blendData
1161 # Log a message if it has been a while since the last log.
1162 periodicLog.log("Deblended %d parent sources out of %d", parentIndex + 1, nParents)
1164 # Clear the cached values in scarlet to clear out memory
1165 scarlet.cache.Cache._cache = {}
1167 # Update the mExposure mask with the footprint of skipped parents
1168 if self.config.notDeblendedMask:
1169 for mask in mExposure.mask:
1170 for parentIndex in skippedParents:
1171 fp = catalog[parentIndex].getFootprint()
1172 fp.spans.setMask(mask, mask.getPlaneBitMask(self.config.notDeblendedMask))
1174 self.log.info("Deblender results: of %d parent sources, %d were deblended, "
1175 "creating %d children, for a total of %d sources",
1176 nParents, nDeblendedParents, len(catalog)-nParents, len(catalog))
1177 return catalog, dataModel
1179 def _isLargeFootprint(self, footprint):
1180 """Returns whether a Footprint is large
1182 'Large' is defined by thresholds on the area, size and axis ratio,
1183 and total area of the bounding box multiplied by
1184 the number of children.
1185 These may be disabled independently by configuring them to be
1186 non-positive.
1187 """
1188 if self.config.maxFootprintArea > 0 and footprint.getArea() > self.config.maxFootprintArea:
1189 return True
1190 if self.config.maxFootprintSize > 0:
1191 bbox = footprint.getBBox()
1192 if max(bbox.getWidth(), bbox.getHeight()) > self.config.maxFootprintSize:
1193 return True
1194 if self.config.minFootprintAxisRatio > 0:
1195 axes = afwEll.Axes(footprint.getShape())
1196 if axes.getB() < self.config.minFootprintAxisRatio*axes.getA():
1197 return True
1198 if self.config.maxAreaTimesPeaks > 0:
1199 if footprint.getBBox().getArea() * len(footprint.peaks) > self.config.maxAreaTimesPeaks:
1200 return True
1201 return False
1203 def _isMasked(self, footprint, mExposure):
1204 """Returns whether the footprint violates the mask limits
1206 Parameters
1207 ----------
1208 footprint : `lsst.afw.detection.Footprint`
1209 The footprint to check for masked pixels
1210 mMask : `lsst.afw.image.MaskX`
1211 The mask plane to check for masked pixels in the `footprint`.
1213 Returns
1214 -------
1215 isMasked : `bool`
1216 `True` if `self.config.maskPlaneLimits` is less than the
1217 fraction of pixels for a given mask in
1218 `self.config.maskLimits`.
1219 """
1220 bbox = footprint.getBBox()
1221 mask = np.bitwise_or.reduce(mExposure.mask[:, bbox].array, axis=0)
1222 size = float(footprint.getArea())
1223 for maskName, limit in self.config.maskLimits.items():
1224 maskVal = mExposure.mask.getPlaneBitMask(maskName)
1225 _mask = afwImage.MaskX(mask & maskVal, xy0=bbox.getMin())
1226 # spanset of masked pixels
1227 maskedSpan = footprint.spans.intersect(_mask, maskVal)
1228 if (maskedSpan.getArea())/size > limit:
1229 return True
1230 return False
1232 def _skipParent(self, parent, skipKey, logMessage):
1233 """Update a parent record that is not being deblended.
1235 This is a fairly trivial function but is implemented to ensure
1236 that a skipped parent updates the appropriate columns
1237 consistently, and always has a flag to mark the reason that
1238 it is being skipped.
1240 Parameters
1241 ----------
1242 parent : `lsst.afw.table.source.source.SourceRecord`
1243 The parent record to flag as skipped.
1244 skipKey : `bool`
1245 The name of the flag to mark the reason for skipping.
1246 logMessage : `str`
1247 The message to display in a log.trace when a source
1248 is skipped.
1249 """
1250 if logMessage is not None:
1251 self.log.trace(logMessage)
1252 self._updateParentRecord(
1253 parent=parent,
1254 nPeaks=len(parent.getFootprint().peaks),
1255 nChild=0,
1256 nComponents=0,
1257 runtime=np.nan,
1258 iterations=0,
1259 logL=np.nan,
1260 spectrumInit=False,
1261 converged=False,
1262 )
1264 # Mark the source as skipped by the deblender and
1265 # flag the reason why.
1266 parent.set(self.deblendSkippedKey, True)
1267 parent.set(skipKey, True)
1269 def _checkSkipped(self, parent, mExposure):
1270 """Update a parent record that is not being deblended.
1272 This is a fairly trivial function but is implemented to ensure
1273 that a skipped parent updates the appropriate columns
1274 consistently, and always has a flag to mark the reason that
1275 it is being skipped.
1277 Parameters
1278 ----------
1279 parent : `lsst.afw.table.source.source.SourceRecord`
1280 The parent record to flag as skipped.
1281 mExposure : `MultibandExposure`
1282 The exposures should be co-added images of the same
1283 shape and region of the sky.
1284 Returns
1285 -------
1286 skip: `bool`
1287 `True` if the deblender will skip the parent
1288 """
1289 skipKey = None
1290 skipMessage = None
1291 footprint = parent.getFootprint()
1292 if len(footprint.peaks) < 2 and not self.config.processSingles:
1293 # Skip isolated sources unless processSingles is turned on.
1294 # Note: this does not flag isolated sources as skipped or
1295 # set the NOT_DEBLENDED mask in the exposure,
1296 # since these aren't really any skipped blends.
1297 skipKey = self.isolatedParentKey
1298 elif isPseudoSource(parent, self.config.pseudoColumns):
1299 # We also skip pseudo sources, like sky objects, which
1300 # are intended to be skipped.
1301 skipKey = self.pseudoKey
1302 if self._isLargeFootprint(footprint):
1303 # The footprint is above the maximum footprint size limit
1304 skipKey = self.tooBigKey
1305 skipMessage = f"Parent {parent.getId()}: skipping large footprint"
1306 elif self._isMasked(footprint, mExposure):
1307 # The footprint exceeds the maximum number of masked pixels
1308 skipKey = self.maskedKey
1309 skipMessage = f"Parent {parent.getId()}: skipping masked footprint"
1310 elif self.config.maxNumberOfPeaks > 0 and len(footprint.peaks) > self.config.maxNumberOfPeaks:
1311 # Unlike meas_deblender, in scarlet we skip the entire blend
1312 # if the number of peaks exceeds max peaks, since neglecting
1313 # to model any peaks often results in catastrophic failure
1314 # of scarlet to generate models for the brighter sources.
1315 skipKey = self.tooManyPeaksKey
1316 skipMessage = f"Parent {parent.getId()}: skipping blend with too many peaks"
1317 if skipKey is not None:
1318 return (skipKey, skipMessage)
1319 return None
1321 def setSkipFlags(self, mExposure, catalog):
1322 """Set the skip flags for all of the parent sources
1324 This is mostly used for testing which parent sources will be deblended
1325 and which will be skipped based on the current configuration options.
1326 Skipped sources will have the appropriate flags set in place in the
1327 catalog.
1329 Parameters
1330 ----------
1331 mExposure : `MultibandExposure`
1332 The exposures should be co-added images of the same
1333 shape and region of the sky.
1334 catalog : `SourceCatalog`
1335 The merged `SourceCatalog` that contains parent footprints
1336 to (potentially) deblend. The new deblended sources are
1337 appended to this catalog in place.
1338 """
1339 for src in catalog:
1340 if skipArgs := self._checkSkipped(src, mExposure) is not None:
1341 self._skipParent(src, *skipArgs)
1343 def _updateParentRecord(self, parent, nPeaks, nChild, nComponents,
1344 runtime, iterations, logL, spectrumInit, converged):
1345 """Update a parent record in all of the single band catalogs.
1347 Ensure that all locations that update a parent record,
1348 whether it is skipped or updated after deblending,
1349 update all of the appropriate columns.
1351 Parameters
1352 ----------
1353 parent : `lsst.afw.table.source.source.SourceRecord`
1354 The parent record to update.
1355 nPeaks : `int`
1356 Number of peaks in the parent footprint.
1357 nChild : `int`
1358 Number of children deblended from the parent.
1359 This may differ from `nPeaks` if some of the peaks
1360 were culled and have no deblended model.
1361 nComponents : `int`
1362 Total number of components in the parent.
1363 This is usually different than the number of children,
1364 since it is common for a single source to have multiple
1365 components.
1366 runtime : `float`
1367 Total runtime for deblending.
1368 iterations : `int`
1369 Total number of iterations in scarlet before convergence.
1370 logL : `float`
1371 Final log likelihood of the blend.
1372 spectrumInit : `bool`
1373 True when scarlet used `set_spectra` to initialize all
1374 sources with better initial intensities.
1375 converged : `bool`
1376 True when the optimizer reached convergence before
1377 reaching the maximum number of iterations.
1378 """
1379 parent.set(self.nPeaksKey, nPeaks)
1380 parent.set(self.nChildKey, nChild)
1381 parent.set(self.nComponentsKey, nComponents)
1382 parent.set(self.runtimeKey, runtime)
1383 parent.set(self.iterKey, iterations)
1384 parent.set(self.scarletLogLKey, logL)
1385 parent.set(self.scarletSpectrumInitKey, spectrumInit)
1386 parent.set(self.blendConvergenceFailedFlagKey, converged)
1388 def _addChild(self, parent, peak, catalog, scarletSource):
1389 """Add a child to a catalog.
1391 This creates a new child in the source catalog,
1392 assigning it a parent id, and adding all columns
1393 that are independent across all filter bands.
1395 Parameters
1396 ----------
1397 parent : `lsst.afw.table.source.source.SourceRecord`
1398 The parent of the new child record.
1399 peak : `lsst.afw.table.PeakRecord`
1400 The peak record for the peak from the parent peak catalog.
1401 catalog : `lsst.afw.table.source.source.SourceCatalog`
1402 The merged `SourceCatalog` that contains parent footprints
1403 to (potentially) deblend.
1404 scarletSource : `scarlet.Component`
1405 The scarlet model for the new source record.
1406 """
1407 src = catalog.addNew()
1408 for key in self.toCopyFromParent:
1409 src.set(key, parent.get(key))
1410 # The peak catalog is the same for all bands,
1411 # so we just use the first peak catalog
1412 src.assign(peak, self.peakSchemaMapper)
1413 src.setParent(parent.getId())
1414 src.set(self.nPeaksKey, 1)
1415 # Set the psf key based on whether or not the source was
1416 # deblended using the PointSource model.
1417 # This key is not that useful anymore since we now keep track of
1418 # `modelType`, but we continue to propagate it in case code downstream
1419 # is expecting it.
1420 src.set(self.psfKey, scarletSource.__class__.__name__ == "PointSource")
1421 src.set(self.modelTypeKey, scarletSource.__class__.__name__)
1422 # We set the runtime to zero so that summing up the
1423 # runtime column will give the total time spent
1424 # running the deblender for the catalog.
1425 src.set(self.runtimeKey, 0)
1427 # Set the position of the peak from the parent footprint
1428 # This will make it easier to match the same source across
1429 # deblenders and across observations, where the peak
1430 # position is unlikely to change unless enough time passes
1431 # for a source to move on the sky.
1432 src.set(self.peakCenter, geom.Point2I(peak["i_x"], peak["i_y"]))
1433 src.set(self.peakIdKey, peak["id"])
1435 # Store the number of components for the source
1436 src.set(self.nComponentsKey, len(scarletSource.components))
1438 # Flag sources missing one or more bands
1439 src.set(self.incompleteDataKey, parent.get(self.incompleteDataKey))
1441 # Propagate columns from the parent to the child
1442 for parentColumn, childColumn in self.config.columnInheritance.items():
1443 src.set(childColumn, parent.get(parentColumn))
1445 return src