Coverage for python/lsst/ip/diffim/detectAndMeasure.py: 90%
643 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 09:47 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 09:47 +0000
1# This file is part of ip_diffim.
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 dataclasses
23import numpy as np
24import requests
25import os
28import lsst.afw.detection as afwDetection
29import lsst.afw.image as afwImage
30import lsst.afw.math as afwMath
31import lsst.afw.table as afwTable
32import lsst.daf.base as dafBase
33import lsst.geom
34from lsst.images import Mask, get_legacy_difference_image_mask_planes
35from lsst.images.fields import field_from_legacy_background
36from lsst.ip.diffim.utils import (evaluateMaskFraction, computeDifferenceImageMetrics,
37 populate_sattle_visit_cache,
38 get_difference_image_provenance)
39from lsst.meas.algorithms import SkyObjectsTask, SourceDetectionTask, SetPrimaryFlagsTask, MaskStreaksTask
40from lsst.meas.algorithms import FindGlintTrailsTask, FindCosmicRaysConfig, findCosmicRays
41from lsst.meas.base import ForcedMeasurementTask, ApplyApCorrTask, DetectorVisitIdGeneratorConfig
42import lsst.meas.deblender
43import lsst.meas.extensions.trailedSources # noqa: F401
44import lsst.meas.extensions.shapeHSM
45import lsst.pex.config as pexConfig
46from lsst.pex.exceptions import InvalidParameterError, LengthError
47import lsst.pipe.base as pipeBase
48import lsst.utils
49from lsst.utils.timer import timeMethod
51from . import DipoleFitTask
53__all__ = ["DetectAndMeasureConfig", "DetectAndMeasureTask",
54 "DetectAndMeasureScoreConfig", "DetectAndMeasureScoreTask"]
57class BadSubtractionError(pipeBase.AlgorithmError):
58 """Raised when the residuals in footprints of stars used to compute the
59 psf-matching kernel exceeds the configured maximum.
60 """
61 def __init__(self, *, ratio, threshold):
62 msg = ("The ratio of residual power in source footprints on the"
63 " difference image to the power in the footprints on the"
64 f" science image was {ratio}, which exceeds the maximum"
65 f" threshold of {threshold}")
66 super().__init__(msg)
67 self.ratio = ratio
68 self.threshold = threshold
70 @property
71 def metadata(self):
72 return {"ratio": self.ratio,
73 "threshold": self.threshold
74 }
77class NoDiaSourcesError(pipeBase.AlgorithmError):
78 """Raised when there are no diaSources detected on an image difference.
79 """
80 def __init__(self):
81 msg = ("No diaSources detected!")
82 super().__init__(msg)
84 @property
85 def metadata(self):
86 return {}
89class TooManyCosmicRays(pipeBase.AlgorithmError):
90 """Raised if the cosmic ray task fails with too many cosmics.
92 Parameters
93 ----------
94 maxCosmicRays : `int`
95 Maximum number of cosmic rays allowed.
96 """
97 def __init__(self, maxCosmicRays, **kwargs):
98 msg = f"Cosmic ray task found more than {maxCosmicRays} cosmics."
99 self.msg = msg
100 self._metadata = kwargs
101 super().__init__(msg, **kwargs)
102 self._metadata["maxCosmicRays"] = maxCosmicRays
104 def __str__(self):
105 # Exception doesn't handle **kwargs, so we need a custom str.
106 return f"{self.msg}: {self.metadata}"
108 @property
109 def metadata(self):
110 for key, value in self._metadata.items():
111 if not (isinstance(value, int) or isinstance(value, float) or isinstance(value, str)):
112 raise TypeError(f"{key} is of type {type(value)}, but only (int, float, str) are allowed.")
113 return self._metadata
116class DetectAndMeasureConnections(pipeBase.PipelineTaskConnections,
117 dimensions=("instrument", "visit", "detector"),
118 defaultTemplates={"coaddName": "deep",
119 "warpTypeSuffix": "",
120 "fakesType": ""}):
121 science = pipeBase.connectionTypes.Input(
122 doc="Input science exposure.",
123 dimensions=("instrument", "visit", "detector"),
124 storageClass="ExposureF",
125 name="{fakesType}calexp"
126 )
127 matchedTemplate = pipeBase.connectionTypes.Input(
128 doc="Warped and PSF-matched template used to create the difference image.",
129 dimensions=("instrument", "visit", "detector"),
130 storageClass="ExposureF",
131 name="{fakesType}{coaddName}Diff_matchedExp",
132 )
133 difference = pipeBase.connectionTypes.Input(
134 doc="Result of subtracting template from science.",
135 dimensions=("instrument", "visit", "detector"),
136 storageClass="ExposureF",
137 name="{fakesType}{coaddName}Diff_differenceTempExp",
138 )
139 kernelSources = pipeBase.connectionTypes.Input(
140 doc="Final selection of sources used for psf matching.",
141 dimensions=("instrument", "visit", "detector"),
142 storageClass="SourceCatalog",
143 name="{fakesType}{coaddName}Diff_psfMatchSources"
144 )
145 outputSchema = pipeBase.connectionTypes.InitOutput(
146 doc="Schema (as an example catalog) for output DIASource catalog.",
147 storageClass="SourceCatalog",
148 name="{fakesType}{coaddName}Diff_diaSrc_schema",
149 )
150 diaSources = pipeBase.connectionTypes.Output(
151 doc="Detected diaSources on the difference image.",
152 dimensions=("instrument", "visit", "detector"),
153 storageClass="SourceCatalog",
154 name="{fakesType}{coaddName}Diff_diaSrc",
155 )
156 subtractedMeasuredExposure = pipeBase.connectionTypes.Output(
157 doc="Difference image with detection mask plane filled in.",
158 dimensions=("instrument", "visit", "detector"),
159 storageClass="ExposureF",
160 name="{fakesType}{coaddName}Diff_differenceExp",
161 )
162 differenceBackground = pipeBase.connectionTypes.Output(
163 doc="Background model that was subtracted from the difference image.",
164 dimensions=("instrument", "visit", "detector"),
165 storageClass="Background",
166 name="difference_background",
167 )
168 maskedStreaks = pipeBase.connectionTypes.Output(
169 doc='Catalog of streak fit parameters for the difference image.',
170 storageClass="ArrowNumpyDict",
171 dimensions=("instrument", "visit", "detector"),
172 name="{fakesType}{coaddName}Diff_streaks",
173 )
174 glintTrailInfo = pipeBase.connectionTypes.Output(
175 doc='Dict of fit parameters for glint trails in the catalog.',
176 storageClass="ArrowNumpyDict",
177 dimensions=("instrument", "visit", "detector"),
178 name="trailed_glints",
179 )
181 def __init__(self, *, config):
182 super().__init__(config=config)
183 if not (self.config.doCalculateResidualMetics): 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true
184 self.inputs.remove("kernelSources")
185 if not (self.config.writeStreakInfo and self.config.doMaskStreaks): 185 ↛ 188line 185 didn't jump to line 188 because the condition on line 185 was always true
186 self.outputs.remove("maskedStreaks")
187 # In future mode the background is recorded on the output images.
188 if (self.config.image_type == "future"
189 or not (self.config.doSubtractBackground and self.config.doWriteBackground)):
190 self.outputs.remove("differenceBackground")
191 if not (self.config.writeGlintInfo): 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true
192 self.outputs.remove("glintTrailInfo")
193 if self.config.image_type == "future":
194 self.difference = dataclasses.replace(self.difference, storageClass="DifferenceImage")
195 self.subtractedMeasuredExposure = dataclasses.replace(self.subtractedMeasuredExposure,
196 storageClass="DifferenceImage")
199class DetectAndMeasureConfig(pipeBase.PipelineTaskConfig,
200 pipelineConnections=DetectAndMeasureConnections):
201 """Config for DetectAndMeasureTask
202 """
203 doMerge = pexConfig.Field(
204 dtype=bool,
205 default=True,
206 doc="Merge positive and negative diaSources with grow radius "
207 "set by growFootprint"
208 )
209 doForcedMeasurement = pexConfig.Field(
210 dtype=bool,
211 default=True,
212 doc="Force photometer diaSource locations on PVI?"
213 )
214 doAddMetrics = pexConfig.Field(
215 dtype=bool,
216 default=False,
217 doc="Add columns to the source table to hold analysis metrics?"
218 )
219 doSubtractBackground = pexConfig.Field(
220 dtype=bool,
221 doc="Subtract a background model from the image before detection?",
222 default=True,
223 )
224 doWriteBackground = pexConfig.Field(
225 dtype=bool,
226 doc="Persist the fitted background model? Ignored when"
227 " image_type='future', which records it on the output images.",
228 default=False,
229 deprecated="Use image_type='future', which records the background on the output images."
230 " Will be removed after v31.",
231 )
232 doCalculateResidualMetics = pexConfig.Field(
233 dtype=bool,
234 doc="Calculate metrics to assess image subtraction quality for the task"
235 "metadata?",
236 default=True,
237 )
238 subtractInitialBackground = pexConfig.ConfigurableField(
239 target=lsst.meas.algorithms.SubtractBackgroundTask,
240 doc="Task to perform intial background subtraction, before first detection pass.",
241 )
242 subtractFinalBackground = pexConfig.ConfigurableField(
243 target=lsst.meas.algorithms.SubtractBackgroundTask,
244 doc="Task to perform final background subtraction, after first detection pass.",
245 )
246 doFindCosmicRays = pexConfig.Field(
247 dtype=bool,
248 doc="Detect and mask cosmic rays on the difference image?"
249 "CRs can be interpolated over by setting cosmicray.keepCRs=False",
250 default=True,
251 )
252 cosmicray = pexConfig.ConfigField(
253 dtype=FindCosmicRaysConfig,
254 doc="Options for finding and masking cosmic rays",
255 )
256 detection = pexConfig.ConfigurableField(
257 target=SourceDetectionTask,
258 doc="Final source detection for diaSource measurement",
259 )
260 streakDetection = pexConfig.ConfigurableField(
261 target=SourceDetectionTask,
262 doc="Separate source detection used only for streak masking",
263 )
264 doDeblend = pexConfig.Field(
265 dtype=bool,
266 default=False,
267 doc="Deblend DIASources after detection?"
268 )
269 deblend = pexConfig.ConfigurableField(
270 target=lsst.meas.deblender.SourceDeblendTask,
271 doc="Task to split blended sources into their components."
272 )
273 measurement = pexConfig.ConfigurableField(
274 target=DipoleFitTask,
275 doc="Task to measure sources on the difference image.",
276 )
277 doApCorr = lsst.pex.config.Field(
278 dtype=bool,
279 default=True,
280 doc="Run subtask to apply aperture corrections"
281 )
282 applyApCorr = lsst.pex.config.ConfigurableField(
283 target=ApplyApCorrTask,
284 doc="Task to apply aperture corrections"
285 )
286 forcedMeasurement = pexConfig.ConfigurableField(
287 target=ForcedMeasurementTask,
288 doc="Task to force photometer science image at diaSource locations.",
289 )
290 growFootprint = pexConfig.Field(
291 dtype=int,
292 default=2,
293 doc="Grow positive and negative footprints by this many pixels before merging"
294 )
295 diaSourceMatchRadius = pexConfig.Field(
296 dtype=float,
297 default=0.5,
298 doc="Match radius (in arcseconds) for DiaSource to Source association"
299 )
300 doSkySources = pexConfig.Field(
301 dtype=bool,
302 default=False,
303 doc="Generate sky sources?",
304 )
305 skySources = pexConfig.ConfigurableField(
306 target=SkyObjectsTask,
307 doc="Generate sky sources",
308 )
309 doMaskStreaks = pexConfig.Field(
310 dtype=bool,
311 default=True,
312 doc="Turn on streak masking",
313 )
314 maskStreaks = pexConfig.ConfigurableField(
315 target=MaskStreaksTask,
316 doc="Subtask for masking streaks. Only used if doMaskStreaks is True. "
317 "Adds a mask plane to an exposure, with the mask plane name set by streakMaskName.",
318 )
319 streakBinFactor = pexConfig.Field(
320 dtype=int,
321 default=4,
322 doc="Bin scale factor to use when rerunning detection for masking streaks. "
323 "Only used if doMaskStreaks is True.",
324 )
325 writeStreakInfo = pexConfig.Field(
326 dtype=bool,
327 default=False,
328 doc="Record the parameters of any detected streaks. For LSST, this should be turned off except for "
329 "development work."
330 )
331 findGlints = pexConfig.ConfigurableField(
332 target=FindGlintTrailsTask,
333 doc="Subtask for finding glint trails, usually caused by satellites or debris."
334 )
335 writeGlintInfo = pexConfig.Field(
336 dtype=bool,
337 default=True,
338 doc="Record the parameters of any detected glint trails."
339 )
340 setPrimaryFlags = pexConfig.ConfigurableField(
341 target=SetPrimaryFlagsTask,
342 doc="Task to add isPrimary and deblending-related flags to the catalog."
343 )
344 badSourceFlags = lsst.pex.config.ListField(
345 dtype=str,
346 doc="Sources with any of these flags set are removed before writing the output catalog.",
347 default=("base_PixelFlags_flag_offimage",
348 "base_PixelFlags_flag_interpolatedCenterAll",
349 "base_PixelFlags_flag_badCenter",
350 "base_PixelFlags_flag_edgeCenter",
351 "base_PixelFlags_flag_nodataCenter",
352 "base_PixelFlags_flag_saturatedCenter",
353 "base_PixelFlags_flag_sat_templateCenter",
354 "base_PixelFlags_flag_spikeCenter",
355 ),
356 )
357 clearMaskPlanes = lsst.pex.config.ListField(
358 dtype=str,
359 doc="Mask planes to clear before running detection.",
360 default=("DETECTED", "DETECTED_NEGATIVE", "NOT_DEBLENDED", "STREAK"),
361 )
362 doRejectBadMaskPlaneDetections = pexConfig.Field(
363 dtype=bool,
364 default=True,
365 doc="Reject any peaks detected on ``badMaskPlanes`` before measurement."
366 "These should all be rejected downstream by ``badSourceFlags``, "
367 "but filtering earlier can save time."
368 )
369 badMaskPlanes = lsst.pex.config.ListField(
370 dtype=str,
371 doc="Detections whose footprint peak lies on a pixel with any of these"
372 " mask planes set will be rejected before measurement."
373 " Any missing mask planes will be silently ignored.",
374 default=("NO_DATA", "BAD", "SAT", "EDGE"),
375 )
376 raiseOnBadSubtractionRatio = pexConfig.Field(
377 dtype=bool,
378 default=True,
379 doc="Raise an error if the ratio of power in detected footprints"
380 " on the difference image to the power in footprints on the science"
381 " image exceeds ``badSubtractionRatioThreshold``",
382 )
383 badSubtractionRatioThreshold = pexConfig.Field(
384 dtype=float,
385 default=0.2,
386 doc="Maximum ratio of power in footprints on the difference image to"
387 " the same footprints on the science image."
388 "Only used if ``raiseOnBadSubtractionRatio`` is set",
389 )
390 badSubtractionVariationThreshold = pexConfig.Field(
391 dtype=float,
392 default=0.4,
393 doc="Maximum standard deviation of the ratio of power in footprints on"
394 " the difference image to the same footprints on the science image."
395 "Only used if ``raiseOnBadSubtractionRatio`` is set",
396 )
397 raiseOnNoDiaSources = pexConfig.Field(
398 dtype=bool,
399 default=True,
400 doc="Raise an algorithm error if no diaSources are detected.",
401 )
402 run_sattle = pexConfig.Field(
403 dtype=bool,
404 default=False,
405 doc="If true, dia source bounding boxes will be sent for verification"
406 "to the sattle service."
407 )
408 sattle_historical = pexConfig.Field(
409 dtype=bool,
410 default=False,
411 doc="If re-running a pipeline that requires sattle, this should be set "
412 "to True. This will populate sattle's cache with the historic data "
413 "closest in time to the exposure."
414 )
415 idGenerator = DetectorVisitIdGeneratorConfig.make_field()
416 image_type = pexConfig.ChoiceField[str](
417 "Image type of the input and measured difference images (and, for"
418 " DetectAndMeasureScoreTask, the input and measured score images).",
419 allowed={
420 "legacy": "Read and write lsst.afw.image.ExposureF.",
421 "future": "Read and write lsst.images.DifferenceImage.",
422 },
423 optional=False,
424 default="legacy",
425 )
427 def setDefaults(self):
428 # Background subtraction
429 # Use a small binsize for the first pass to reduce detections on glints
430 # and extended structures. Should not affect the detectability of
431 # faint diaSources
432 self.subtractInitialBackground.binSize = 8
433 self.subtractInitialBackground.useApprox = False
434 self.subtractInitialBackground.statisticsProperty = "MEDIAN"
435 self.subtractInitialBackground.doFilterSuperPixels = True
436 self.subtractInitialBackground.ignoredPixelMask = ["BAD",
437 "EDGE",
438 "DETECTED",
439 "DETECTED_NEGATIVE",
440 "NO_DATA",
441 ]
442 # Use a larger binsize for the final background subtraction, to reduce
443 # over-subtraction of bright objects.
444 self.subtractFinalBackground.binSize = 40
445 self.subtractFinalBackground.useApprox = False
446 self.subtractFinalBackground.statisticsProperty = "MEDIAN"
447 self.subtractFinalBackground.doFilterSuperPixels = True
448 self.subtractFinalBackground.ignoredPixelMask = ["BAD",
449 "EDGE",
450 "DETECTED",
451 "DETECTED_NEGATIVE",
452 "NO_DATA",
453 ]
454 # Cosmic ray detection
455 self.cosmicray.keepCRs = True # do not interpolate over detected CRs
456 # DiaSource Detection
457 self.detection.thresholdPolarity = "both"
458 self.detection.thresholdValue = 5.0
459 self.detection.reEstimateBackground = False
460 self.detection.thresholdType = "pixel_stdev"
461 self.detection.excludeMaskPlanes = []
463 # Copy configs for binned streak detection from the base detection task
464 self.streakDetection.thresholdType = self.detection.thresholdType
465 self.streakDetection.reEstimateBackground = False
466 self.streakDetection.excludeMaskPlanes = self.detection.excludeMaskPlanes
467 self.streakDetection.thresholdValue = self.detection.thresholdValue
468 # Only detect positive streaks
469 self.streakDetection.thresholdPolarity = "positive"
470 # Do not grow detected mask for streaks
471 self.streakDetection.nSigmaToGrow = 0
472 # Set the streak mask along the entire fit line, not only where the
473 # detected mask is set.
474 self.maskStreaks.onlyMaskDetected = False
475 # Restrict streak masking from growing too large
476 self.maskStreaks.maxStreakWidth = 100
477 # Restrict the number of iterations allowed for fitting streaks
478 # When the fit is good it should solve quickly, and exit a bad fit quickly
479 self.maskStreaks.maxFitIter = 10
480 # Only mask to 2 sigma in width
481 self.maskStreaks.nSigmaMask = 2
482 # Threshold for including streaks after the Hough Transform.
483 # A lower value will detect more features that are less linear.
484 self.maskStreaks.absMinimumKernelHeight = 2
486 self.measurement.plugins.names |= ["ext_trailedSources_Naive",
487 "base_LocalPhotoCalib",
488 "base_LocalWcs",
489 "ext_shapeHSM_HsmSourceMoments",
490 "ext_shapeHSM_HsmPsfMoments",
491 "base_ClassificationSizeExtendedness",
492 ]
493 self.measurement.slots.psfShape = "ext_shapeHSM_HsmPsfMoments"
494 self.measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments"
495 self.measurement.plugins["base_SdssCentroid"].maxDistToPeak = 5.0
496 self.forcedMeasurement.plugins = ["base_TransformedCentroid", "base_PsfFlux"]
497 self.forcedMeasurement.copyColumns = {
498 "id": "objectId", "parent": "parentObjectId", "coord_ra": "coord_ra", "coord_dec": "coord_dec"}
499 self.forcedMeasurement.slots.centroid = "base_TransformedCentroid"
500 self.forcedMeasurement.slots.shape = None
502 # Keep track of which footprints contain streaks
503 self.measurement.plugins["base_PixelFlags"].masksFpAnywhere = [
504 "STREAK", "INJECTED", "INJECTED_TEMPLATE", "HIGH_VARIANCE", "SAT_TEMPLATE", "SPIKE"]
505 self.measurement.plugins["base_PixelFlags"].masksFpCenter = [
506 "STREAK", "INJECTED", "INJECTED_TEMPLATE", "HIGH_VARIANCE", "SAT_TEMPLATE", "SPIKE"]
507 self.skySources.avoidMask = ["DETECTED", "DETECTED_NEGATIVE", "BAD", "NO_DATA", "EDGE"]
509 def validate(self):
510 super().validate()
512 if self.run_sattle:
513 if not os.getenv("SATTLE_URI_BASE"):
514 raise pexConfig.FieldValidationError(DetectAndMeasureConfig.run_sattle, self,
515 "Sattle requested but SATTLE_URI_BASE "
516 "environment variable not set.")
519class DetectAndMeasureTask(lsst.pipe.base.PipelineTask):
520 """Detect and measure sources on a difference image.
521 """
522 ConfigClass = DetectAndMeasureConfig
523 _DefaultName = "detectAndMeasure"
525 def __init__(self, **kwargs):
526 super().__init__(**kwargs)
527 self.schema = afwTable.SourceTable.makeMinimalSchema()
529 self.algMetadata = dafBase.PropertyList()
530 if self.config.doSubtractBackground:
531 self.makeSubtask("subtractInitialBackground")
532 self.makeSubtask("subtractFinalBackground")
533 self.makeSubtask("detection", schema=self.schema)
534 if self.config.doDeblend:
535 self.makeSubtask("deblend", schema=self.schema)
536 self.makeSubtask("setPrimaryFlags", schema=self.schema, isSingleFrame=True)
537 self.makeSubtask("measurement", schema=self.schema,
538 algMetadata=self.algMetadata)
539 if self.config.doApCorr: 539 ↛ 541line 539 didn't jump to line 541 because the condition on line 539 was always true
540 self.makeSubtask("applyApCorr", schema=self.measurement.schema)
541 if self.config.doForcedMeasurement:
542 self.schema.addField(
543 "ip_diffim_forced_PsfFlux_instFlux", "D",
544 "Forced PSF flux measured on the direct image.",
545 units="count")
546 self.schema.addField(
547 "ip_diffim_forced_PsfFlux_instFluxErr", "D",
548 "Forced PSF flux error measured on the direct image.",
549 units="count")
550 self.schema.addField(
551 "ip_diffim_forced_PsfFlux_area", "F",
552 "Forced PSF flux effective area of PSF.",
553 units="pixel")
554 self.schema.addField(
555 "ip_diffim_forced_PsfFlux_flag", "Flag",
556 "Forced PSF flux general failure flag.")
557 self.schema.addField(
558 "ip_diffim_forced_PsfFlux_flag_noGoodPixels", "Flag",
559 "Forced PSF flux not enough non-rejected pixels in data to attempt the fit.")
560 self.schema.addField(
561 "ip_diffim_forced_PsfFlux_flag_edge", "Flag",
562 "Forced PSF flux object was too close to the edge of the image to use the full PSF model.")
563 self.schema.addField(
564 "ip_diffim_forced_template_PsfFlux_instFlux", "D",
565 "Forced PSF flux measured on the template image.",
566 units="count")
567 self.schema.addField(
568 "ip_diffim_forced_template_PsfFlux_instFluxErr", "D",
569 "Forced PSF flux error measured on the template image.",
570 units="count")
571 self.schema.addField(
572 "ip_diffim_forced_template_PsfFlux_area", "F",
573 "Forced template PSF flux effective area of PSF.",
574 units="pixel")
575 self.schema.addField(
576 "ip_diffim_forced_template_PsfFlux_flag", "Flag",
577 "Forced template PSF flux general failure flag.")
578 self.schema.addField(
579 "ip_diffim_forced_template_PsfFlux_flag_noGoodPixels", "Flag",
580 "Forced template PSF flux not enough non-rejected pixels in data to attempt the fit.")
581 self.schema.addField(
582 "ip_diffim_forced_template_PsfFlux_flag_edge", "Flag",
583 """Forced template PSF flux object was too close to the edge of the image """
584 """to use the full PSF model.""")
585 self.makeSubtask("forcedMeasurement", refSchema=self.schema)
587 self.schema.addField("refMatchId", "L", "unique id of reference catalog match")
588 self.schema.addField("srcMatchId", "L", "unique id of source match")
589 # Create the sky source task for use by metrics,
590 # even if sky sources are not added to the diaSource catalog
591 self.makeSubtask("skySources", schema=self.schema)
592 if self.config.doMaskStreaks: 592 ↛ 595line 592 didn't jump to line 595 because the condition on line 592 was always true
593 self.makeSubtask("maskStreaks")
594 self.makeSubtask("streakDetection")
595 self.makeSubtask("findGlints")
596 self.schema.addField("glint_trail", "Flag", "DiaSource is part of a glint trail.")
597 self.schema.addField("reliability", type="F", doc="Reliability score of the DiaSource")
598 self.schema.addField("reliabilityVersion", type=str, size=7, doc="Version of the reliability model")
600 # To get the "merge_*" fields in the schema; have to re-initialize
601 # this later, once we have a peak schema post-detection.
602 lsst.afw.detection.FootprintMergeList(self.schema, ["positive", "negative"])
604 # Check that the schema and config are consistent
605 for flag in self.config.badSourceFlags:
606 if flag not in self.schema:
607 raise pipeBase.InvalidQuantumError("Field %s not in schema" % flag)
609 # initialize InitOutputs
610 self.outputSchema = afwTable.SourceCatalog(self.schema)
611 self.outputSchema.getTable().setMetadata(self.algMetadata)
613 def runQuantum(self, butlerQC: pipeBase.QuantumContext,
614 inputRefs: pipeBase.InputQuantizedConnection,
615 outputRefs: pipeBase.OutputQuantizedConnection):
616 inputs = butlerQC.get(inputRefs)
617 idGenerator = self.config.idGenerator.apply(butlerQC.quantum.dataId)
618 idFactory = idGenerator.make_table_id_factory()
620 # Input image that each output is measured from, keyed by output name.
621 inputImages = {}
622 if self.config.image_type == "future":
623 dataId = butlerQC.quantum.dataId
624 for output, connection in (("subtractedMeasuredExposure", "difference"),
625 ("scoreMeasuredExposure", "scoreExposure")):
626 if connection not in inputs:
627 continue
628 image = inputs[connection]
629 # The output is written back into this image, so it must
630 # already carry the kernel and the template records.
631 get_difference_image_provenance(image, "kernel", connection, dataId)
632 get_difference_image_provenance(image, "templates", connection, dataId)
633 inputImages[output] = image
634 inputs[connection] = image.to_legacy()
636 # Specify the fields that `annotate` and `convert_outputs_to_future`
637 # need below, to ensure they exist, even as None.
638 measurementResults = pipeBase.Struct(
639 subtractedMeasuredExposure=None,
640 scoreMeasuredExposure=None,
641 diaSources=None,
642 maskedStreaks=None,
643 differenceBackground=None,
644 )
645 try:
646 self.run(**inputs, idFactory=idFactory, measurementResults=measurementResults)
647 except pipeBase.AlgorithmError as e:
648 # Annotate before converting
649 error = pipeBase.AnnotatedPartialOutputsError.annotate(
650 e,
651 self,
652 measurementResults.subtractedMeasuredExposure,
653 measurementResults.scoreMeasuredExposure,
654 measurementResults.diaSources,
655 measurementResults.maskedStreaks,
656 log=self.log
657 )
658 if self.config.image_type == "future":
659 self.convert_outputs_to_future(measurementResults, inputImages)
660 butlerQC.put(measurementResults, outputRefs)
661 raise error from e
662 if self.config.image_type == "future":
663 self.convert_outputs_to_future(measurementResults, inputImages)
664 butlerQC.put(measurementResults, outputRefs)
666 def convert_outputs_to_future(self, results, inputImages):
667 """Write the output images in a result struct back into the
668 `lsst.images` images they were measured from.
670 The image, variance, and mask planes of each input image are replaced
671 by those of the corresponding legacy output, and the background
672 subtracted by this task is attached. The kernel, template records,
673 and observation metadata of the input are kept. Outputs that are
674 absent or `None` are skipped.
676 Parameters
677 ----------
678 results : `lsst.pipe.base.Struct`
679 Output struct to read and modify in place.
680 inputImages : `dict` [`str`, `lsst.images.DifferenceImage`]
681 Input image each output was measured from, keyed by the name of
682 the output. Each is modified in place.
684 Notes
685 -----
686 This uses the default legacy mask plane map of
687 `lsst.images.get_legacy_difference_image_mask_planes` and adds any
688 planes from `~lsst.images.get_legacy_optional_mask_planes` that are set
689 for any pixels in the image.
690 """
691 background = getattr(results, "differenceBackground", None)
692 for name, image in inputImages.items():
693 exposure = getattr(results, name, None)
694 if exposure is None: 694 ↛ 695line 694 didn't jump to line 695 because the condition on line 694 was never true
695 continue
696 # These are no-ops when the legacy output is a view of the input,
697 # as returned by `~lsst.images.DifferenceImage.to_legacy`.
698 image.image.array[...] = exposure.image.array
699 image.variance.array[...] = exposure.variance.array
700 image.mask = Mask.from_legacy(exposure.mask, get_legacy_difference_image_mask_planes(),
701 sky_projection=image.sky_projection)
702 if background is not None and len(background) > 0:
703 # The same background is subtracted from the difference image
704 # and the score image, so it applies to both.
705 image.backgrounds.add(
706 "subtracted",
707 field_from_legacy_background(background, bounds=image.bbox, unit=image.unit),
708 description="Background subtracted from this image before detection.",
709 is_subtracted=True,
710 )
711 setattr(results, name, image)
712 if background is not None and len(background) == 0:
713 # An empty BackgroundList cannot be converted to a field, and means
714 # that no background was subtracted.
715 self.log.debug("No background model to attach to the output images.")
717 @timeMethod
718 def run(self, science, matchedTemplate, difference, kernelSources=None,
719 idFactory=None, measurementResults=None):
720 """Detect and measure sources on a difference image.
722 The difference image will be convolved with a gaussian approximation of
723 the PSF to form a maximum likelihood image for detection.
724 Close positive and negative detections will optionally be merged into
725 dipole diaSources.
726 Sky sources, or forced detections in background regions, will optionally
727 be added, and the configured measurement algorithm will be run on all
728 detections.
730 Parameters
731 ----------
732 science : `lsst.afw.image.ExposureF`
733 Science exposure that the template was subtracted from.
734 matchedTemplate : `lsst.afw.image.ExposureF`
735 Warped and PSF-matched template that was used produce the
736 difference image.
737 difference : `lsst.afw.image.ExposureF`
738 Result of subtracting template from the science image.
739 kernelSources : `lsst.afw.table.SourceCatalog`, optional
740 Final selection of sources that was used for psf matching.
741 idFactory : `lsst.afw.table.IdFactory`, optional
742 Generator object used to assign ids to detected sources in the
743 difference image. Ids from this generator are not set until after
744 deblending and merging positive/negative peaks.
745 measurementResults : `lsst.pipe.base.Struct`, optional
746 Result struct that is modified to allow saving of partial outputs
747 for some failure conditions. If the task completes successfully,
748 this is also returned.
750 Returns
751 -------
752 measurementResults : `lsst.pipe.base.Struct`
754 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
755 Subtracted exposure with detection mask applied.
756 ``diaSources`` : `lsst.afw.table.SourceCatalog`
757 The catalog of detected sources.
758 ``differenceBackground`` : `lsst.afw.math.BackgroundList`
759 Background that was subtracted from the difference image.
760 """
761 if measurementResults is None:
762 measurementResults = pipeBase.Struct()
763 if idFactory is None:
764 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory()
766 # Check image properties and clear detection mask planes.
767 self._prepareInputs(difference)
768 if self.config.doSubtractBackground:
769 background = self._fitAndSubtractBackground(differenceExposure=difference)
770 else:
771 background = afwMath.BackgroundList()
773 if self.config.doFindCosmicRays: 773 ↛ 779line 773 didn't jump to line 779 because the condition on line 773 was always true
774 self.findAndMaskCosmicRays(difference)
776 # Don't use the idFactory until after deblend+merge, so that we aren't
777 # generating ids that just get thrown away (footprint merge doesn't
778 # know about past ids).
779 table = afwTable.SourceTable.make(self.schema)
780 results = self.detection.run(
781 table=table,
782 exposure=difference,
783 doSmooth=True,
784 background=background,
785 clearMask=True,
786 )
787 measurementResults.differenceBackground = background
789 if self.config.doDeblend:
790 sources, positives, negatives = self._deblend(difference,
791 results.positive,
792 results.negative)
794 else:
795 positives = afwTable.SourceCatalog(self.schema)
796 results.positive.makeSources(positives)
797 negatives = afwTable.SourceCatalog(self.schema)
798 results.negative.makeSources(negatives)
799 sources = results.sources
801 self.processResults(science, matchedTemplate, difference,
802 sources, idFactory, kernelSources,
803 positives=positives,
804 negatives=negatives,
805 measurementResults=measurementResults)
806 return measurementResults
808 def _prepareInputs(self, difference):
809 """Ensure that we start with an empty detection and deblended mask.
811 Parameters
812 ----------
813 difference : `lsst.afw.image.ExposureF`
814 The difference image that will be used for detecting diaSources.
815 The mask plane will be modified in place.
817 Raises
818 ------
819 lsst.pipe.base.UpstreamFailureNoWorkFound
820 If the PSF is not usable for measurement.
821 """
822 # Check that we have a valid PSF now before we do more work
823 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
824 if np.isnan(sigma):
825 raise pipeBase.UpstreamFailureNoWorkFound("Invalid PSF detected! PSF width evaluates to NaN.")
826 # Ensure that we start with an empty detection and deblended mask.
827 mask = difference.mask
828 for mp in self.config.clearMaskPlanes:
829 if mp not in mask.getMaskPlaneDict():
830 mask.addMaskPlane(mp)
831 mask &= ~mask.getPlaneBitMask(self.config.clearMaskPlanes)
833 def _fitAndSubtractBackground(self, differenceExposure, scoreExposure=None):
834 """Fit the final background to ``differenceExposure``.
836 A rough background is first fit on the difference and an internal
837 first-pass detection is run on the (background-subtracted) clone; the
838 resulting detection mask is then transferred to ``differenceExposure``
839 so the final background fit can mask out real sources. This two-pass
840 scheme is what suppresses spurious detections from glints and
841 extended structures: the small-binsize initial fit aggressively
842 over-subtracts those features, and the resulting peak mask lets the
843 large-binsize final fit produce a clean background. When
844 ``scoreExposure`` is supplied (score-image variant), the final
845 background is also subtracted from its image so the caller's final
846 detection runs on a properly background-subtracted score.
848 The first-pass detection's `Struct` is discarded; only the mask it
849 leaves on the clone is used downstream.
851 Parameters
852 ----------
853 differenceExposure : `lsst.afw.image.ExposureF`
854 Difference image. The final background is subtracted from its
855 image in place but its mask is not modified.
856 scoreExposure : `lsst.afw.image.ExposureF`, optional
857 Maximum-likelihood score image. When supplied, the final
858 background is also subtracted from its image.
860 Returns
861 -------
862 background : `lsst.afw.math.BackgroundList`
863 The fit final background.
864 """
865 if scoreExposure is None:
866 detectionExposure = differenceExposure.clone()
867 background = self.subtractInitialBackground.run(detectionExposure).background
868 doSmooth = True
869 else:
870 # Use a clone of differenceExposure because the background is
871 # subtracted in place.
872 background = self.subtractInitialBackground.run(differenceExposure.clone()).background
873 detectionExposure = scoreExposure.clone()
874 detectionExposure.image -= background.getImage()
875 doSmooth = False
876 table = afwTable.SourceTable.make(self.schema)
877 self.detection.run(
878 table=table,
879 exposure=detectionExposure,
880 doSmooth=doSmooth,
881 background=background,
882 clearMask=True,
883 )
884 # Use the temporary detection mask for the final background subtraction,
885 # then restore the original mask. The score image variant never
886 # clears the detection planes of the difference image.
887 mask = differenceExposure.mask
888 originalMask = mask.array.copy()
889 detectedBit = mask.getPlaneBitMask(["DETECTED"])
890 detectedPix = detectionExposure.mask.array & detectedBit > 0
891 detectedNegativeBit = mask.getPlaneBitMask(["DETECTED_NEGATIVE"])
892 detectedNegativePix = detectionExposure.mask.array & detectedNegativeBit > 0
893 mask.array[detectedPix] |= detectedBit
894 mask.array[detectedNegativePix] |= detectedNegativeBit
895 background = self.subtractFinalBackground.run(differenceExposure).background
896 mask.array[...] = originalMask
897 if scoreExposure is not None:
898 # The preconvolution kernel is normalized to 1, so the same
899 # background level applies to the difference and score images.
900 scoreExposure.image -= background.getImage()
901 return background
903 def processResults(self, science, matchedTemplate, difference, sources, idFactory,
904 kernelSources=None, positives=None, negatives=None, measurementResults=None):
905 """Measure and process the results of source detection.
907 Parameters
908 ----------
909 science : `lsst.afw.image.ExposureF`
910 Science exposure that the template was subtracted from.
911 matchedTemplate : `lsst.afw.image.ExposureF`
912 Warped and PSF-matched template that was used produce the
913 difference image.
914 difference : `lsst.afw.image.ExposureF`
915 Result of subtracting template from the science image.
916 sources : `lsst.afw.table.SourceCatalog`
917 Detected sources on the difference exposure.
918 idFactory : `lsst.afw.table.IdFactory`
919 Generator object used to assign ids to detected sources in the
920 difference image.
921 kernelSources : `lsst.afw.table.SourceCatalog`, optional
922 Final selection of sources that was used for psf matching.
923 positives : `lsst.afw.table.SourceCatalog`, optional
924 Positive polarity footprints.
925 negatives : `lsst.afw.table.SourceCatalog`, optional
926 Negative polarity footprints.
927 measurementResults : `lsst.pipe.base.Struct`, optional
928 Result struct that is modified to allow saving of partial outputs
929 for some failure conditions. If the task completes successfully,
930 this is also returned.
932 Returns
933 -------
934 measurementResults : `lsst.pipe.base.Struct`
936 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
937 Subtracted exposure with detection mask applied.
938 ``diaSources`` : `lsst.afw.table.SourceCatalog`
939 The catalog of detected sources.
940 """
941 if measurementResults is None: 941 ↛ 942line 941 didn't jump to line 942 because the condition on line 941 was never true
942 measurementResults = pipeBase.Struct()
943 self.metadata["nUnmergedDiaSources"] = len(sources)
944 if self.config.doMerge:
945 # preserve peak schema, if there are any footprints
946 if len(positives) > 0:
947 peakSchema = positives[0].getFootprint().peaks.schema
948 elif len(negatives) > 0: 948 ↛ 951line 948 didn't jump to line 951 because the condition on line 948 was always true
949 peakSchema = negatives[0].getFootprint().peaks.schema
950 else:
951 peakSchema = afwDetection.PeakTable.makeMinimalSchema()
952 mergeList = afwDetection.FootprintMergeList(self.schema,
953 ["positive", "negative"], peakSchema)
954 initialDiaSources = afwTable.SourceCatalog(self.schema)
955 # Start with positive, as FootprintMergeList will self-merge the
956 # subsequent added catalogs, and we want to try to preserve
957 # deblended positive sources.
958 mergeList.addCatalog(initialDiaSources.table, positives, "positive", minNewPeakDist=0)
959 mergeList.addCatalog(initialDiaSources.table, negatives, "negative", minNewPeakDist=0)
960 mergeList.getFinalSources(initialDiaSources)
961 # Flag as negative those sources that *only* came from the negative
962 # footprint set.
963 initialDiaSources["is_negative"] = initialDiaSources["merge_footprint_negative"] & \
964 ~initialDiaSources["merge_footprint_positive"]
965 self.log.info("Merging detections into %d sources", len(initialDiaSources))
966 # All positive peaks were added before any negative peaks, so
967 # re-order the peaks in each footprint by significance.
968 self._reorderPeaksBySignificance(initialDiaSources)
969 else:
970 initialDiaSources = sources
972 # Assign source ids at the end: deblend/merge mean that we don't keep
973 # track of parents and children, we only care about the final ids.
974 for source in initialDiaSources:
975 source.setId(idFactory())
976 # Ensure sources added after this get correct ids.
977 initialDiaSources.getTable().setIdFactory(idFactory)
978 initialDiaSources.setMetadata(self.algMetadata)
980 self.metadata["nMergedDiaSources"] = len(initialDiaSources)
982 if self.config.doMaskStreaks: 982 ↛ 985line 982 didn't jump to line 985 because the condition on line 982 was always true
983 streakInfo = self._runStreakMasking(difference)
985 if self.config.doSkySources:
986 self.addSkySources(initialDiaSources, difference.mask, difference.info.id)
988 if self.config.doRejectBadMaskPlaneDetections: 988 ↛ 993line 988 didn't jump to line 993 because the condition on line 988 was always true
989 # Save time by rejecting peaks on bad mask planes prior to
990 # measurement.
991 initialDiaSources = self._rejectBadMaskedDetections(initialDiaSources, difference.mask)
993 if not initialDiaSources.isContiguous(): 993 ↛ 994line 993 didn't jump to line 994 because the condition on line 993 was never true
994 initialDiaSources = initialDiaSources.copy(deep=True)
996 self.measureDiaSources(initialDiaSources, science, difference, matchedTemplate)
998 # Remove unphysical diaSources per config.badSourceFlags
999 diaSources = self._removeBadSources(initialDiaSources)
1001 if self.config.run_sattle:
1002 diaSources = self.filterSatellites(diaSources, science)
1004 # Flag diaSources in glint trails, but do not remove them
1005 diaSources, trail_parameters = self._find_glint_trails(diaSources)
1006 if self.config.writeGlintInfo: 1006 ↛ 1009line 1006 didn't jump to line 1009 because the condition on line 1006 was always true
1007 measurementResults.mergeItems(trail_parameters, 'glintTrailInfo')
1009 if self.config.doForcedMeasurement:
1010 self.measureForcedSources(diaSources, science, difference.getWcs())
1011 self.measureForcedSources(diaSources, matchedTemplate, difference.getWcs(),
1012 template=True)
1014 # Clear the image plane for regions with NO_DATA.
1015 # These regions are most often caused by insufficient template coverage.
1016 # Do this for the final difference image after detection and measurement
1017 # since the subtasks should all be configured to handle NO_DATA properly
1018 difference.image.array[difference.mask.array & difference.mask.getPlaneBitMask('NO_DATA') > 0] = 0
1020 measurementResults.subtractedMeasuredExposure = difference
1022 if self.config.doMaskStreaks and self.config.writeStreakInfo: 1022 ↛ 1023line 1022 didn't jump to line 1023 because the condition on line 1022 was never true
1023 measurementResults.maskedStreaks = streakInfo.maskedStreaks
1025 if kernelSources is not None:
1026 self.calculateMetrics(science, difference, diaSources, kernelSources)
1028 if np.count_nonzero(~diaSources["sky_source"]) > 0:
1029 measurementResults.diaSources = diaSources
1030 elif self.config.raiseOnNoDiaSources:
1031 raise NoDiaSourcesError()
1032 elif len(diaSources) > 0: 1032 ↛ 1036line 1032 didn't jump to line 1036 because the condition on line 1032 was always true
1033 # This option allows returning sky sources,
1034 # even if there are no diaSources
1035 measurementResults.diaSources = diaSources
1036 self.log.info("Measured %d diaSources and %d sky sources",
1037 np.count_nonzero(~diaSources["sky_source"]),
1038 np.count_nonzero(diaSources["sky_source"])
1039 )
1040 return measurementResults
1042 def _reorderPeaksBySignificance(self, diaSources):
1043 """Sort each merged source's footprint peaks by significance.
1045 Parameters
1046 ----------
1047 diaSources : `lsst.afw.table.SourceCatalog`
1048 Merged sources whose footprint peaks are re-ordered in place.
1049 """
1050 for source in diaSources:
1051 peaks = source.getFootprint().getPeaks()
1052 if len(peaks) < 2 or "significance" not in peaks.schema.getNames():
1053 continue
1054 ordered = sorted(peaks, key=lambda peak: peak["significance"], reverse=True)
1055 newPeaks = afwDetection.PeakCatalog(peaks.table)
1056 newPeaks.extend(ordered)
1057 peaks.clear()
1058 source.getFootprint().setPeakCatalog(newPeaks)
1060 def _deblend(self, difference, positiveFootprints, negativeFootprints):
1061 """Deblend the positive and negative footprints and return a catalog
1062 containing just the children, and the deblended footprints.
1064 Parameters
1065 ----------
1066 difference : `lsst.afw.image.Exposure`
1067 Result of subtracting template from the science image.
1068 positiveFootprints, negativeFootprints : `lsst.afw.detection.FootprintSet`
1069 Positive and negative polarity footprints measured on
1070 ``difference`` to be deblended separately.
1072 Returns
1073 -------
1074 sources : `lsst.afw.table.SourceCatalog`
1075 Positive and negative deblended children.
1076 positives, negatives : `lsst.afw.table.SourceCatalog`
1077 Deblended positive and negative polarity sources with footprints
1078 detected on ``difference``.
1079 """
1081 def deblend(footprints, negative=False):
1082 """Deblend a positive or negative footprint set,
1083 and return the deblended children.
1085 Parameters
1086 ----------
1087 footprints : `lsst.afw.detection.FootprintSet`
1088 negative : `bool`
1089 Set True if the footprints contain negative fluxes
1091 Returns
1092 -------
1093 sources : `lsst.afw.table.SourceCatalog`
1094 """
1095 sources = afwTable.SourceCatalog(self.schema)
1096 footprints.makeSources(sources)
1097 if negative:
1098 # Invert the image so the deblender can run on positive peaks
1099 difference_inverted = difference.clone()
1100 difference_inverted.image *= -1
1101 self.deblend.run(exposure=difference_inverted, sources=sources)
1102 children = sources[sources["parent"] != 0]
1103 # Set the heavy footprint pixel values back to reality
1104 for child in children:
1105 footprint = child.getFootprint()
1106 array = footprint.getImageArray()
1107 array *= -1
1108 else:
1109 self.deblend.run(exposure=difference, sources=sources)
1110 self.setPrimaryFlags.run(sources)
1111 children = sources["detect_isDeblendedSource"] == 1
1112 sources = sources[children].copy(deep=True)
1113 # Clear parents, so that measurement plugins behave correctly.
1114 sources['parent'] = 0
1115 return sources.copy(deep=True)
1117 positives = deblend(positiveFootprints)
1118 negatives = deblend(negativeFootprints, negative=True)
1120 sources = afwTable.SourceCatalog(self.schema)
1121 sources.reserve(len(positives) + len(negatives))
1122 sources.extend(positives, deep=True)
1123 sources.extend(negatives, deep=True)
1124 if len(negatives) > 0:
1125 sources[-len(negatives):]["is_negative"] = True
1126 return sources, positives, negatives
1128 def _rejectBadMaskedDetections(self, initialDiaSources, mask):
1129 """Remove detections whose footprint peak lies on a bad-masked pixel.
1131 Detections are rejected if any peak of the source footprint falls on a
1132 pixel with any of the ``config.badMaskPlanes`` bits set.
1134 Parameters
1135 ----------
1136 initialDiaSources : `lsst.afw.table.SourceCatalog`
1137 The catalog of detected peaks.
1138 mask : `lsst.afw.image.Mask`
1139 Mask of the image used for detection.
1141 Returns
1142 -------
1143 initialDiaSources : `lsst.afw.table.SourceCatalog`
1144 The catalog with bad-masked detections removed.
1145 """
1146 if not self.config.badMaskPlanes or len(initialDiaSources) == 0: 1146 ↛ 1147line 1146 didn't jump to line 1147 because the condition on line 1146 was never true
1147 self.metadata["nRejectedBadMaskPlanes"] = 0
1148 return initialDiaSources
1150 presentPlanes = [mp for mp in self.config.badMaskPlanes
1151 if mp in mask.getMaskPlaneDict()]
1152 if not presentPlanes: 1152 ↛ 1153line 1152 didn't jump to line 1153 because the condition on line 1152 was never true
1153 self.metadata["nRejectedBadMaskPlanes"] = 0
1154 return initialDiaSources
1156 badBitMask = mask.getPlaneBitMask(presentPlanes)
1157 x0, y0 = mask.getXY0()
1159 selector = np.ones(len(initialDiaSources), dtype=bool)
1160 for i, source in enumerate(initialDiaSources):
1161 peaks = source.getFootprint().getPeaks()
1162 for peak in peaks:
1163 ix = peak.getIx() - x0
1164 iy = peak.getIy() - y0
1165 if mask.array[iy, ix] & badBitMask:
1166 selector[i] = False
1167 break
1168 nRejected = np.count_nonzero(~selector)
1169 self.metadata["nRejectedBadMaskPlanes"] = nRejected
1170 if nRejected > 0:
1171 self.log.info("Rejected %d detections on pixels with bad mask "
1172 "planes %s before measurement.",
1173 nRejected, presentPlanes)
1174 return initialDiaSources[selector].copy(deep=True)
1176 def _removeBadSources(self, diaSources):
1177 """Remove unphysical diaSources from the catalog.
1179 Parameters
1180 ----------
1181 diaSources : `lsst.afw.table.SourceCatalog`
1182 The catalog of detected sources.
1184 Returns
1185 -------
1186 diaSources : `lsst.afw.table.SourceCatalog`
1187 The updated catalog of detected sources, with any source that has a
1188 flag in ``config.badSourceFlags`` set removed.
1189 """
1190 selector = np.ones(len(diaSources), dtype=bool)
1191 for flag in self.config.badSourceFlags:
1192 flags = diaSources[flag]
1193 nBad = np.count_nonzero(flags)
1194 if nBad > 0:
1195 self.log.debug("Found %d unphysical sources with flag %s.", nBad, flag)
1196 selector &= ~flags
1197 nBadTotal = np.count_nonzero(~selector)
1198 self.metadata["nRemovedBadFlaggedSources"] = nBadTotal
1199 self.log.info("Removed %d unphysical sources.", nBadTotal)
1200 return diaSources[selector].copy(deep=True)
1202 def _find_glint_trails(self, diaSources):
1203 """Define a new flag column for diaSources that are in a glint trail.
1205 Parameters
1206 ----------
1207 diaSources : `lsst.afw.table.SourceCatalog`
1208 The catalog of detected sources.
1210 Returns
1211 -------
1212 diaSources : `lsst.afw.table.SourceCatalog`
1213 The updated catalog of detected sources, with a new bool column
1214 called 'glint_trail' added.
1216 trail_parameters : `dict`
1217 Parameters of all the trails that were found.
1218 """
1219 if self.config.doSkySources:
1220 # Do not include sky sources in glint detection
1221 candidateDiaSources = diaSources[~diaSources["sky_source"]].copy(deep=True)
1222 else:
1223 candidateDiaSources = diaSources
1224 trailed_glints = self.findGlints.run(candidateDiaSources)
1225 glint_mask = [True if id in trailed_glints.trailed_ids else False for id in diaSources['id']]
1226 if np.any(glint_mask): 1226 ↛ 1227line 1226 didn't jump to line 1227 because the condition on line 1226 was never true
1227 diaSources['glint_trail'] = np.array(glint_mask)
1229 slopes = np.array([trail.slope for trail in trailed_glints.parameters])
1230 intercepts = np.array([trail.intercept for trail in trailed_glints.parameters])
1231 stderrs = np.array([trail.stderr for trail in trailed_glints.parameters])
1232 lengths = np.array([trail.length for trail in trailed_glints.parameters])
1233 angles = np.array([trail.angle for trail in trailed_glints.parameters])
1234 parameters = {'slopes': slopes, 'intercepts': intercepts, 'stderrs': stderrs, 'lengths': lengths,
1235 'angles': angles}
1237 trail_parameters = pipeBase.Struct(glintTrailInfo=parameters)
1239 return diaSources, trail_parameters
1241 def addSkySources(self, diaSources, mask, seed,
1242 subtask=None):
1243 """Add sources in empty regions of the difference image
1244 for measuring the background.
1246 Parameters
1247 ----------
1248 diaSources : `lsst.afw.table.SourceCatalog`
1249 The catalog of detected sources.
1250 mask : `lsst.afw.image.Mask`
1251 Mask plane for determining regions where Sky sources can be added.
1252 seed : `int`
1253 Seed value to initialize the random number generator.
1254 """
1255 if subtask is None: 1255 ↛ 1257line 1255 didn't jump to line 1257 because the condition on line 1255 was always true
1256 subtask = self.skySources
1257 if subtask.config.nSources <= 0:
1258 self.metadata[f"n_{subtask.getName()}"] = 0
1259 return
1260 skySourceFootprints = subtask.run(mask=mask, seed=seed, catalog=diaSources)
1261 self.metadata[f"n_{subtask.getName()}"] = len(skySourceFootprints)
1263 def measureDiaSources(self, diaSources, science, difference, matchedTemplate):
1264 """Use (matched) template and science image to constrain dipole fitting.
1266 Parameters
1267 ----------
1268 diaSources : `lsst.afw.table.SourceCatalog`
1269 The catalog of detected sources.
1270 science : `lsst.afw.image.ExposureF`
1271 Science exposure that the template was subtracted from.
1272 difference : `lsst.afw.image.ExposureF`
1273 Result of subtracting template from the science image.
1274 matchedTemplate : `lsst.afw.image.ExposureF`
1275 Warped and PSF-matched template that was used produce the
1276 difference image.
1277 """
1278 # Ensure that the required mask planes are present
1279 for mp in self.config.measurement.plugins["base_PixelFlags"].masksFpAnywhere:
1280 difference.mask.addMaskPlane(mp)
1281 # Note that this may not be correct if we convolved the science image.
1282 # In the future we may wish to persist the matchedScience image.
1283 self.measurement.run(diaSources, difference, science, matchedTemplate)
1284 if self.config.doApCorr: 1284 ↛ exitline 1284 didn't return from function 'measureDiaSources' because the condition on line 1284 was always true
1285 apCorrMap = difference.getInfo().getApCorrMap()
1286 if apCorrMap is None: 1286 ↛ 1289line 1286 didn't jump to line 1289 because the condition on line 1286 was always true
1287 self.log.warning("Difference image does not have valid aperture correction; skipping.")
1288 else:
1289 self.applyApCorr.run(
1290 catalog=diaSources,
1291 apCorrMap=apCorrMap,
1292 )
1294 def measureForcedSources(self, diaSources, image, wcs, template=False):
1295 """Perform forced measurement of the diaSources on a direct image.
1297 Parameters
1298 ----------
1299 diaSources : `lsst.afw.table.SourceCatalog`
1300 The catalog of detected sources.
1301 image: `lsst.afw.image.ExposureF`
1302 Exposure that the forced measurement is being performed on
1303 wcs : `lsst.afw.geom.SkyWcs`
1304 Coordinate system definition (wcs) for the exposure.
1305 template : `bool`
1306 Is the forced measurement being performed on the template?
1307 """
1308 # Run forced psf photometry on the image at the diaSource locations.
1309 # Copy the measured flux and error into the diaSource.
1310 forcedSources = self.forcedMeasurement.generateMeasCat(image, diaSources, wcs)
1311 self.forcedMeasurement.run(forcedSources, image, diaSources, wcs)
1313 if template:
1314 base_key = 'ip_diffim_forced_template_PsfFlux'
1315 else:
1316 base_key = 'ip_diffim_forced_PsfFlux'
1317 mapper = afwTable.SchemaMapper(forcedSources.schema, diaSources.schema)
1318 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFlux")[0],
1319 f"{base_key}_instFlux", True)
1320 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFluxErr")[0],
1321 f"{base_key}_instFluxErr", True)
1322 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_area")[0],
1323 f"{base_key}_area", True)
1324 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag")[0],
1325 f"{base_key}_flag", True)
1326 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_noGoodPixels")[0],
1327 f"{base_key}_flag_noGoodPixels", True)
1328 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_edge")[0],
1329 f"{base_key}_flag_edge", True)
1330 for diaSource, forcedSource in zip(diaSources, forcedSources):
1331 diaSource.assign(forcedSource, mapper)
1333 def findAndMaskCosmicRays(self, difference):
1334 """Detect and mask cosmic rays on the difference image.
1336 Parameters
1337 ----------
1338 difference : `lsst.afw.image.Exposure`
1339 The background-subtracted difference image.
1340 The mask plane will be modified in place.
1341 """
1342 # This is run on a background-subtracted difference image, so the
1343 # remaining background should be ~0.
1344 medianBg = 0.
1345 try:
1346 crs = findCosmicRays(
1347 difference.maskedImage,
1348 difference.psf,
1349 medianBg,
1350 pexConfig.makePropertySet(self.config.cosmicray),
1351 self.config.cosmicray.keepCRs,
1352 )
1353 except LengthError:
1354 raise TooManyCosmicRays(self.config.cosmicray.nCrPixelMax) from None
1355 num = 0
1356 if crs is not None: 1356 ↛ 1362line 1356 didn't jump to line 1362 because the condition on line 1356 was always true
1357 mask = difference.maskedImage.mask
1358 crBit = mask.getPlaneBitMask("CR")
1359 afwDetection.setMaskFromFootprintList(mask, crs, crBit)
1360 num = len(crs)
1362 text = "masked" if self.config.cosmicray.keepCRs else "interpolated over"
1363 self.log.info("Identified and %s %s cosmic rays.", text, num)
1364 self.metadata["cosmic_ray_count"] = num
1366 def calculateMetrics(self, science, difference, diaSources, kernelSources):
1367 """Add difference image QA metrics to the Task metadata.
1369 This may be used to produce corresponding metrics (see
1370 lsst.analysis.tools.tasks.diffimTaskDetectorVisitMetricAnalysis).
1372 Parameters
1373 ----------
1374 science : `lsst.afw.image.ExposureF`
1375 Science exposure that was subtracted.
1376 difference : `lsst.afw.image.Exposure`
1377 The target difference image to calculate metrics for.
1378 diaSources : `lsst.afw.table.SourceCatalog`
1379 The catalog of detected sources.
1380 kernelSources : `lsst.afw.table.SourceCatalog`
1381 Final selection of sources that was used for psf matching.
1382 """
1383 mask = difference.mask
1384 badPix = (mask.array & mask.getPlaneBitMask(self.config.detection.excludeMaskPlanes)) > 0
1385 self.metadata["nGoodPixels"] = np.sum(~badPix)
1386 self.metadata["nBadPixels"] = np.sum(badPix)
1387 detPosPix = (mask.array & mask.getPlaneBitMask("DETECTED")) > 0
1388 detNegPix = (mask.array & mask.getPlaneBitMask("DETECTED_NEGATIVE")) > 0
1389 self.metadata["nPixelsDetectedPositive"] = np.sum(detPosPix)
1390 self.metadata["nPixelsDetectedNegative"] = np.sum(detNegPix)
1391 detPosPix &= badPix
1392 detNegPix &= badPix
1393 self.metadata["nBadPixelsDetectedPositive"] = np.sum(detPosPix)
1394 self.metadata["nBadPixelsDetectedNegative"] = np.sum(detNegPix)
1396 metricsMaskPlanes = list(mask.getMaskPlaneDict().keys())
1397 for maskPlane in metricsMaskPlanes:
1398 try:
1399 self.metadata["%s_mask_fraction"%maskPlane.lower()] = evaluateMaskFraction(mask, maskPlane)
1400 except InvalidParameterError:
1401 self.metadata["%s_mask_fraction"%maskPlane.lower()] = -1
1402 self.log.info("Unable to calculate metrics for mask plane %s: not in image"%maskPlane)
1404 if self.config.doSkySources:
1405 skySources = diaSources[diaSources["sky_source"]]
1406 else:
1407 skySources = None
1408 metrics = computeDifferenceImageMetrics(science, difference, kernelSources, sky_sources=skySources)
1410 self.metadata["residualFootprintRatioMean"] = metrics.differenceFootprintRatioMean
1411 self.metadata["residualFootprintRatioStdev"] = metrics.differenceFootprintRatioStdev
1412 self.metadata["differenceFootprintSkyRatioMean"] = metrics.differenceFootprintSkyRatioMean
1413 self.metadata["differenceFootprintSkyRatioStdev"] = metrics.differenceFootprintSkyRatioStdev
1414 self.log.info("Mean, stdev of ratio of difference to science "
1415 "pixels in star footprints: %5.4f, %5.4f",
1416 self.metadata["residualFootprintRatioMean"],
1417 self.metadata["residualFootprintRatioStdev"])
1418 if self.config.raiseOnBadSubtractionRatio:
1419 if metrics.differenceFootprintRatioMean > self.config.badSubtractionRatioThreshold: 1419 ↛ 1420line 1419 didn't jump to line 1420 because the condition on line 1419 was never true
1420 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioMean,
1421 threshold=self.config.badSubtractionRatioThreshold)
1422 if metrics.differenceFootprintRatioStdev > self.config.badSubtractionVariationThreshold: 1422 ↛ 1423line 1422 didn't jump to line 1423 because the condition on line 1422 was never true
1423 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioStdev,
1424 threshold=self.config.badSubtractionVariationThreshold)
1426 def getSattleDiaSourceAllowlist(self, diaSources, science):
1427 """Query the sattle service and determine which diaSources are allowed.
1429 Parameters
1430 ----------
1431 diaSources : `lsst.afw.table.SourceCatalog`
1432 The catalog of detected sources.
1433 science : `lsst.afw.image.ExposureF`
1434 Science exposure that was subtracted.
1436 Returns
1437 ----------
1438 allow_list : `list` of `int`
1439 diaSourceIds of diaSources that can be made public.
1441 Raises
1442 ------
1443 requests.HTTPError
1444 Raised if sattle call does not return success.
1445 """
1446 wcs = science.getWcs()
1447 visit_info = science.getInfo().getVisitInfo()
1448 visit_id = visit_info.getId()
1449 sattle_uri_base = os.getenv('SATTLE_URI_BASE')
1451 dia_sources_json = []
1452 for source in diaSources:
1453 source_bbox = source.getFootprint().getBBox()
1454 corners = wcs.pixelToSky([lsst.geom.Point2D(c) for c in source_bbox.getCorners()])
1455 bbox_radec = [[pt.getRa().asDegrees(), pt.getDec().asDegrees()] for pt in corners]
1456 dia_sources_json.append({"diasource_id": source["id"], "bbox": bbox_radec})
1458 payload = {"visit_id": visit_id, "detector_id": science.getDetector().getId(),
1459 "diasources": dia_sources_json, "historical": self.config.sattle_historical}
1461 sattle_output = requests.put(f'{sattle_uri_base}/diasource_allow_list',
1462 json=payload)
1464 # retry once if visit cache is not populated
1465 if sattle_output.status_code == 404:
1466 self.log.warning(f'Visit {visit_id} not found in sattle cache, re-sending')
1467 populate_sattle_visit_cache(visit_info, historical=self.config.sattle_historical)
1468 sattle_output = requests.put(f'{sattle_uri_base}/diasource_allow_list', json=payload)
1470 sattle_output.raise_for_status()
1472 return sattle_output.json()['allow_list']
1474 @timeMethod
1475 def filterSatellites(self, diaSources, science):
1476 """Remove diaSources overlapping predicted satellite positions.
1478 Parameters
1479 ----------
1480 diaSources : `lsst.afw.table.SourceCatalog`
1481 The catalog of detected sources.
1482 science : `lsst.afw.image.ExposureF`
1483 Science exposure that was subtracted.
1485 Returns
1486 ----------
1487 filterdDiaSources : `lsst.afw.table.SourceCatalog`
1488 Filtered catalog of diaSources
1489 """
1491 allow_list = self.getSattleDiaSourceAllowlist(diaSources, science)
1493 if allow_list:
1494 allow_set = set(allow_list)
1495 allowed_ids = [source['id'] in allow_set for source in diaSources]
1496 diaSources = diaSources[np.array(allowed_ids)].copy(deep=True)
1497 else:
1498 self.log.warning('Sattle allowlist is empty, all diaSources removed')
1499 diaSources = diaSources[0:0].copy(deep=True)
1500 return diaSources
1502 def _runStreakMasking(self, difference):
1503 """Do streak masking and optionally save the resulting streak
1504 fit parameters in a catalog.
1506 Only returns non-empty streakInfo if self.config.writeStreakInfo
1507 is set. The difference image is binned by self.config.streakBinFactor
1508 (and detection is run a second time) so that regions with lower
1509 surface brightness streaks are more likely to fall above the
1510 detection threshold.
1512 Parameters
1513 ----------
1514 difference: `lsst.afw.image.Exposure`
1515 The exposure in which to search for streaks. Must have a detection
1516 mask.
1518 Returns
1519 -------
1520 streakInfo: `lsst.pipe.base.Struct`
1521 ``rho`` : `np.ndarray`
1522 Angle of detected streak.
1523 ``theta`` : `np.ndarray`
1524 Distance from center of detected streak.
1525 ``sigma`` : `np.ndarray`
1526 Width of streak profile.
1527 ``reducedChi2`` : `np.ndarray`
1528 Reduced chi2 of the best-fit streak profile.
1529 ``modelMaximum`` : `np.ndarray`
1530 Peak value of the fit line profile.
1531 """
1532 maskedImage = difference.maskedImage
1533 # Bin the diffim to enhance low surface brightness streaks
1534 binnedMaskedImage = afwMath.binImage(maskedImage,
1535 self.config.streakBinFactor,
1536 self.config.streakBinFactor)
1537 binnedExposure = afwImage.ExposureF(binnedMaskedImage.getBBox())
1538 binnedExposure.setMaskedImage(binnedMaskedImage)
1539 # Clear the DETECTED mask plane before streak detection
1540 binnedExposure.mask &= ~binnedExposure.mask.getPlaneBitMask('DETECTED')
1541 # Rerun detection to set the DETECTED mask plane on binnedExposure
1542 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
1543 _table = afwTable.SourceTable.make(afwTable.SourceTable.makeMinimalSchema())
1544 self.streakDetection.run(table=_table, exposure=binnedExposure, doSmooth=True,
1545 sigma=sigma/self.config.streakBinFactor)
1546 binnedDetectedMaskPlane = binnedExposure.mask.array & binnedExposure.mask.getPlaneBitMask('DETECTED')
1547 rescaledDetectedMaskPlane = binnedDetectedMaskPlane.repeat(self.config.streakBinFactor,
1548 axis=0).repeat(self.config.streakBinFactor,
1549 axis=1)
1550 # Create new version of a diffim with DETECTED based on binnedExposure
1551 streakMaskedImage = maskedImage.clone()
1552 ysize, xsize = rescaledDetectedMaskPlane.shape
1553 streakMaskedImage.mask.array[:ysize, :xsize] |= rescaledDetectedMaskPlane
1554 # Detect streaks on this new version of the diffim
1555 streaks = self.maskStreaks.run(streakMaskedImage)
1556 streakMaskPlane = streakMaskedImage.mask.array & streakMaskedImage.mask.getPlaneBitMask('STREAK')
1557 # Apply the new STREAK mask to the original diffim
1558 maskedImage.mask.array |= streakMaskPlane
1560 if self.config.writeStreakInfo: 1560 ↛ 1561line 1560 didn't jump to line 1561 because the condition on line 1560 was never true
1561 rhos = np.array([line.rho for line in streaks.lines])
1562 thetas = np.array([line.theta for line in streaks.lines])
1563 sigmas = np.array([line.sigma for line in streaks.lines])
1564 chi2s = np.array([line.reducedChi2 for line in streaks.lines])
1565 modelMaximums = np.array([line.modelMaximum for line in streaks.lines])
1566 streakInfo = {'rho': rhos, 'theta': thetas, 'sigma': sigmas, 'reducedChi2': chi2s,
1567 'modelMaximum': modelMaximums}
1568 else:
1569 streakInfo = {'rho': np.array([]), 'theta': np.array([]), 'sigma': np.array([]),
1570 'reducedChi2': np.array([]), 'modelMaximum': np.array([])}
1571 return pipeBase.Struct(maskedStreaks=streakInfo)
1574class DetectAndMeasureScoreConnections(DetectAndMeasureConnections):
1575 scoreExposure = pipeBase.connectionTypes.Input(
1576 doc="Maximum likelihood image for detection.",
1577 dimensions=("instrument", "visit", "detector"),
1578 storageClass="ExposureF",
1579 name="{fakesType}{coaddName}Diff_scoreTempExp",
1580 )
1581 scoreMeasuredExposure = pipeBase.connectionTypes.Output(
1582 doc="Score image after backgrond subtraction and detection.",
1583 dimensions=("instrument", "visit", "detector"),
1584 storageClass="ExposureF",
1585 name="{fakesType}{coaddName}Diff_scoreExp",
1586 )
1588 def __init__(self, *, config):
1589 super().__init__(config=config)
1590 if self.config.image_type == "future":
1591 self.scoreExposure = dataclasses.replace(self.scoreExposure, storageClass="DifferenceImage")
1592 self.scoreMeasuredExposure = dataclasses.replace(self.scoreMeasuredExposure,
1593 storageClass="DifferenceImage")
1596class DetectAndMeasureScoreConfig(DetectAndMeasureConfig,
1597 pipelineConnections=DetectAndMeasureScoreConnections):
1598 pass
1601class DetectAndMeasureScoreTask(DetectAndMeasureTask):
1602 """Detect DIA sources using a score image,
1603 and measure the detections on the difference image.
1605 Source detection is run on the supplied score, or maximum likelihood,
1606 image. Note that no additional convolution will be done in this case.
1607 Close positive and negative detections will optionally be merged into
1608 dipole diaSources.
1609 Sky sources, or forced detections in background regions, will optionally
1610 be added, and the configured measurement algorithm will be run on all
1611 detections.
1612 """
1613 ConfigClass = DetectAndMeasureScoreConfig
1614 _DefaultName = "detectAndMeasureScore"
1616 @timeMethod
1617 def run(self, science, matchedTemplate, difference, scoreExposure, kernelSources,
1618 idFactory=None, measurementResults=None):
1619 """Detect and measure sources on a score image.
1621 Parameters
1622 ----------
1623 science : `lsst.afw.image.ExposureF`
1624 Science exposure that the template was subtracted from.
1625 matchedTemplate : `lsst.afw.image.ExposureF`
1626 Warped and PSF-matched template that was used produce the
1627 difference image.
1628 difference : `lsst.afw.image.ExposureF`
1629 Result of subtracting template from the science image.
1630 scoreExposure : `lsst.afw.image.ExposureF`
1631 Score or maximum likelihood difference image
1632 kernelSources : `lsst.afw.table.SourceCatalog`
1633 Final selection of sources that was used for psf matching.
1634 idFactory : `lsst.afw.table.IdFactory`, optional
1635 Generator object used to assign ids to detected sources in the
1636 difference image. Ids from this generator are not set until after
1637 deblending and merging positive/negative peaks.
1638 measurementResults : `lsst.pipe.base.Struct`, optional
1639 Result struct that is modified to allow saving of partial outputs
1640 for some failure conditions. If the task completes successfully,
1641 this is also returned.
1643 Returns
1644 -------
1645 measurementResults : `lsst.pipe.base.Struct`
1647 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
1648 Subtracted exposure with detection mask applied.
1649 ``scoreMeasuredExposure`` : `lsst.afw.image.ExposureF`
1650 Score exposure with detection mask applied.
1651 ``diaSources`` : `lsst.afw.table.SourceCatalog`
1652 The catalog of detected sources.
1653 ``differenceBackground`` : `lsst.afw.math.BackgroundList`
1654 Background that was subtracted from the difference image.
1655 """
1656 if measurementResults is None:
1657 measurementResults = pipeBase.Struct()
1658 if idFactory is None:
1659 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory()
1661 # Check image properties and clear detection mask planes.
1662 self._prepareInputs(difference)
1663 self._prepareInputs(scoreExposure)
1664 if self.config.doSubtractBackground:
1665 background = self._fitAndSubtractBackground(
1666 differenceExposure=difference, scoreExposure=scoreExposure,
1667 )
1668 else:
1669 background = afwMath.BackgroundList()
1671 if self.config.doFindCosmicRays: 1671 ↛ 1680line 1671 didn't jump to line 1680 because the condition on line 1671 was always true
1672 # Cosmic rays are detected on the difference and the resulting
1673 # mask is propagated to the score image used for detection.
1674 self.findAndMaskCosmicRays(difference)
1675 scoreExposure.mask.array |= difference.mask.array
1677 # Don't use the idFactory until after deblend+merge, so that we aren't
1678 # generating ids that just get thrown away (footprint merge doesn't
1679 # know about past ids).
1680 table = afwTable.SourceTable.make(self.schema)
1681 detectResults = self.detection.run(
1682 table=table,
1683 exposure=scoreExposure,
1684 doSmooth=False,
1685 background=background,
1686 clearMask=True,
1687 )
1688 measurementResults.differenceBackground = background
1689 measurementResults.scoreMeasuredExposure = scoreExposure
1691 # Copy the final detection masks from the Score image to the difference.
1692 # Do not copy all mask planes, since the convolution will grow them by
1693 # the size of the convolution kernel.
1694 detectedBit = difference.mask.getPlaneBitMask(["DETECTED"])
1695 detectedPix = scoreExposure.mask.array & detectedBit > 0
1696 detectedNegativeBit = difference.mask.getPlaneBitMask(["DETECTED_NEGATIVE"])
1697 detectedNegativePix = scoreExposure.mask.array & detectedNegativeBit > 0
1698 difference.mask.array[detectedPix] |= detectedBit
1699 difference.mask.array[detectedNegativePix] |= detectedNegativeBit
1700 # Also set the EDGE mask, since the science image was convolved
1701 edgeBit = difference.mask.getPlaneBitMask(["EDGE"])
1702 edgePix = scoreExposure.mask.array & edgeBit > 0
1703 difference.mask.array[edgePix] |= edgeBit
1705 if self.config.doDeblend:
1706 sources, positives, negatives = self._deblend(difference,
1707 detectResults.positive,
1708 detectResults.negative)
1710 self.processResults(science, matchedTemplate, difference,
1711 sources, idFactory, kernelSources,
1712 positives=positives,
1713 negatives=negatives,
1714 measurementResults=measurementResults)
1716 else:
1717 positives = afwTable.SourceCatalog(self.schema)
1718 detectResults.positive.makeSources(positives)
1719 negatives = afwTable.SourceCatalog(self.schema)
1720 detectResults.negative.makeSources(negatives)
1721 self.processResults(science, matchedTemplate, difference,
1722 detectResults.sources, idFactory, kernelSources,
1723 positives=positives,
1724 negatives=negatives,
1725 measurementResults=measurementResults)
1726 return measurementResults