lsst.ip.diffim g20c59dc0c7+acb0570d1a
Loading...
Searching...
No Matches
detectAndMeasure.py
Go to the documentation of this file.
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/>.
21
22import dataclasses
23import numpy as np
24import requests
25import os
26
27
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
50
51from . import DipoleFitTask
52
53__all__ = ["DetectAndMeasureConfig", "DetectAndMeasureTask",
54 "DetectAndMeasureScoreConfig", "DetectAndMeasureScoreTask"]
55
56
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
69
70 @property
71 def metadata(self):
72 return {"ratio": self.ratio,
73 "threshold": self.threshold
74 }
75
76
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)
83
84 @property
85 def metadata(self):
86 return {}
87
88
89class TooManyCosmicRays(pipeBase.AlgorithmError):
90 """Raised if the cosmic ray task fails with too many cosmics.
91
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
103
104 def __str__(self):
105 # Exception doesn't handle **kwargs, so we need a custom str.
106 return f"{self.msg}: {self.metadata}"
107
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
114
115
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 )
180
181 def __init__(self, *, config):
182 super().__init__(config=config)
183 if not (self.config.doCalculateResidualMetics):
184 self.inputs.remove("kernelSources")
185 if not (self.config.writeStreakInfo and self.config.doMaskStreaks):
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):
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")
197
198
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 )
426
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 = []
462
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
485
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
501
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"]
508
509 def validate(self):
510 super().validate()
511
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.")
517
518
519class DetectAndMeasureTask(lsst.pipe.base.PipelineTask):
520 """Detect and measure sources on a difference image.
521 """
522 ConfigClass = DetectAndMeasureConfig
523 _DefaultName = "detectAndMeasure"
524
525 def __init__(self, **kwargs):
526 super().__init__(**kwargs)
527 self.schema = afwTable.SourceTable.makeMinimalSchema()
528
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:
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)
586
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:
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")
599
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"])
603
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)
608
609 # initialize InitOutputs
610 self.outputSchema = afwTable.SourceCatalog(self.schema)
611 self.outputSchema.getTable().setMetadata(self.algMetadata)
612
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()
619
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()
635
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)
665
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.
669
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.
675
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.
683
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:
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.")
716
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.
721
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.
729
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.
749
750 Returns
751 -------
752 measurementResults : `lsst.pipe.base.Struct`
753
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()
765
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()
772
773 if self.config.doFindCosmicRays:
774 self.findAndMaskCosmicRays(difference)
775
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
788
789 if self.config.doDeblend:
790 sources, positives, negatives = self._deblend(difference,
791 results.positive,
792 results.negative)
793
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
800
801 self.processResults(science, matchedTemplate, difference,
802 sources, idFactory, kernelSources,
803 positives=positives,
804 negatives=negatives,
805 measurementResults=measurementResults)
806 return measurementResults
807
808 def _prepareInputs(self, difference):
809 """Ensure that we start with an empty detection and deblended mask.
810
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.
816
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)
832
833 def _fitAndSubtractBackground(self, differenceExposure, scoreExposure=None):
834 """Fit the final background to ``differenceExposure``.
835
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.
847
848 The first-pass detection's `Struct` is discarded; only the mask it
849 leaves on the clone is used downstream.
850
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.
859
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
902
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.
906
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.
931
932 Returns
933 -------
934 measurementResults : `lsst.pipe.base.Struct`
935
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:
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:
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
971
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)
979
980 self.metadata["nMergedDiaSources"] = len(initialDiaSources)
981
982 if self.config.doMaskStreaks:
983 streakInfo = self._runStreakMasking(difference)
984
985 if self.config.doSkySources:
986 self.addSkySources(initialDiaSources, difference.mask, difference.info.id)
987
988 if self.config.doRejectBadMaskPlaneDetections:
989 # Save time by rejecting peaks on bad mask planes prior to
990 # measurement.
991 initialDiaSources = self._rejectBadMaskedDetections(initialDiaSources, difference.mask)
992
993 if not initialDiaSources.isContiguous():
994 initialDiaSources = initialDiaSources.copy(deep=True)
995
996 self.measureDiaSources(initialDiaSources, science, difference, matchedTemplate)
997
998 # Remove unphysical diaSources per config.badSourceFlags
999 diaSources = self._removeBadSources(initialDiaSources)
1000
1001 if self.config.run_sattle:
1002 diaSources = self.filterSatellites(diaSources, science)
1003
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:
1007 measurementResults.mergeItems(trail_parameters, 'glintTrailInfo')
1008
1009 if self.config.doForcedMeasurement:
1010 self.measureForcedSources(diaSources, science, difference.getWcs())
1011 self.measureForcedSources(diaSources, matchedTemplate, difference.getWcs(),
1012 template=True)
1013
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
1019
1020 measurementResults.subtractedMeasuredExposure = difference
1021
1022 if self.config.doMaskStreaks and self.config.writeStreakInfo:
1023 measurementResults.maskedStreaks = streakInfo.maskedStreaks
1024
1025 if kernelSources is not None:
1026 self.calculateMetrics(science, difference, diaSources, kernelSources)
1027
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:
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
1041
1042 def _reorderPeaksBySignificance(self, diaSources):
1043 """Sort each merged source's footprint peaks by significance.
1044
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)
1059
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.
1063
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.
1071
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 """
1080
1081 def deblend(footprints, negative=False):
1082 """Deblend a positive or negative footprint set,
1083 and return the deblended children.
1084
1085 Parameters
1086 ----------
1087 footprints : `lsst.afw.detection.FootprintSet`
1088 negative : `bool`
1089 Set True if the footprints contain negative fluxes
1090
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)
1116
1117 positives = deblend(positiveFootprints)
1118 negatives = deblend(negativeFootprints, negative=True)
1119
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
1127
1128 def _rejectBadMaskedDetections(self, initialDiaSources, mask):
1129 """Remove detections whose footprint peak lies on a bad-masked pixel.
1130
1131 Detections are rejected if any peak of the source footprint falls on a
1132 pixel with any of the ``config.badMaskPlanes`` bits set.
1133
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.
1140
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:
1147 self.metadata["nRejectedBadMaskPlanes"] = 0
1148 return initialDiaSources
1149
1150 presentPlanes = [mp for mp in self.config.badMaskPlanes
1151 if mp in mask.getMaskPlaneDict()]
1152 if not presentPlanes:
1153 self.metadata["nRejectedBadMaskPlanes"] = 0
1154 return initialDiaSources
1155
1156 badBitMask = mask.getPlaneBitMask(presentPlanes)
1157 x0, y0 = mask.getXY0()
1158
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)
1175
1176 def _removeBadSources(self, diaSources):
1177 """Remove unphysical diaSources from the catalog.
1178
1179 Parameters
1180 ----------
1181 diaSources : `lsst.afw.table.SourceCatalog`
1182 The catalog of detected sources.
1183
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)
1201
1202 def _find_glint_trails(self, diaSources):
1203 """Define a new flag column for diaSources that are in a glint trail.
1204
1205 Parameters
1206 ----------
1207 diaSources : `lsst.afw.table.SourceCatalog`
1208 The catalog of detected sources.
1209
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.
1215
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):
1227 diaSources['glint_trail'] = np.array(glint_mask)
1228
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}
1236
1237 trail_parameters = pipeBase.Struct(glintTrailInfo=parameters)
1238
1239 return diaSources, trail_parameters
1240
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.
1245
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:
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)
1262
1263 def measureDiaSources(self, diaSources, science, difference, matchedTemplate):
1264 """Use (matched) template and science image to constrain dipole fitting.
1265
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:
1285 apCorrMap = difference.getInfo().getApCorrMap()
1286 if apCorrMap is None:
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 )
1293
1294 def measureForcedSources(self, diaSources, image, wcs, template=False):
1295 """Perform forced measurement of the diaSources on a direct image.
1296
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)
1312
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)
1332
1333 def findAndMaskCosmicRays(self, difference):
1334 """Detect and mask cosmic rays on the difference image.
1335
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:
1357 mask = difference.maskedImage.mask
1358 crBit = mask.getPlaneBitMask("CR")
1359 afwDetection.setMaskFromFootprintList(mask, crs, crBit)
1360 num = len(crs)
1361
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
1365
1366 def calculateMetrics(self, science, difference, diaSources, kernelSources):
1367 """Add difference image QA metrics to the Task metadata.
1368
1369 This may be used to produce corresponding metrics (see
1370 lsst.analysis.tools.tasks.diffimTaskDetectorVisitMetricAnalysis).
1371
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)
1395
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)
1403
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)
1409
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:
1420 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioMean,
1421 threshold=self.config.badSubtractionRatioThreshold)
1422 if metrics.differenceFootprintRatioStdev > self.config.badSubtractionVariationThreshold:
1423 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioStdev,
1424 threshold=self.config.badSubtractionVariationThreshold)
1425
1426 def getSattleDiaSourceAllowlist(self, diaSources, science):
1427 """Query the sattle service and determine which diaSources are allowed.
1428
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.
1435
1436 Returns
1437 ----------
1438 allow_list : `list` of `int`
1439 diaSourceIds of diaSources that can be made public.
1440
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')
1450
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})
1457
1458 payload = {"visit_id": visit_id, "detector_id": science.getDetector().getId(),
1459 "diasources": dia_sources_json, "historical": self.config.sattle_historical}
1460
1461 sattle_output = requests.put(f'{sattle_uri_base}/diasource_allow_list',
1462 json=payload)
1463
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)
1469
1470 sattle_output.raise_for_status()
1471
1472 return sattle_output.json()['allow_list']
1473
1474 @timeMethod
1475 def filterSatellites(self, diaSources, science):
1476 """Remove diaSources overlapping predicted satellite positions.
1477
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.
1484
1485 Returns
1486 ----------
1487 filterdDiaSources : `lsst.afw.table.SourceCatalog`
1488 Filtered catalog of diaSources
1489 """
1490
1491 allow_list = self.getSattleDiaSourceAllowlist(diaSources, science)
1492
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
1501
1502 def _runStreakMasking(self, difference):
1503 """Do streak masking and optionally save the resulting streak
1504 fit parameters in a catalog.
1505
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.
1511
1512 Parameters
1513 ----------
1514 difference: `lsst.afw.image.Exposure`
1515 The exposure in which to search for streaks. Must have a detection
1516 mask.
1517
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
1559
1560 if self.config.writeStreakInfo:
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)
1572
1573
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 )
1587
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")
1594
1595
1596class DetectAndMeasureScoreConfig(DetectAndMeasureConfig,
1597 pipelineConnections=DetectAndMeasureScoreConnections):
1598 pass
1599
1600
1601class DetectAndMeasureScoreTask(DetectAndMeasureTask):
1602 """Detect DIA sources using a score image,
1603 and measure the detections on the difference image.
1604
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"
1615
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.
1620
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.
1642
1643 Returns
1644 -------
1645 measurementResults : `lsst.pipe.base.Struct`
1646
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()
1660
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()
1670
1671 if self.config.doFindCosmicRays:
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
1676
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
1690
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
1704
1705 if self.config.doDeblend:
1706 sources, positives, negatives = self._deblend(difference,
1707 detectResults.positive,
1708 detectResults.negative)
1709
1710 self.processResults(science, matchedTemplate, difference,
1711 sources, idFactory, kernelSources,
1712 positives=positives,
1713 negatives=negatives,
1714 measurementResults=measurementResults)
1715
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
std::shared_ptr< ImageT > binImage(ImageT const &inImage, int const binX, int const binY, lsst::afw::math::Property const flags=lsst::afw::math::MEAN)