Loading [MathJax]/extensions/tex2jax.js
LSST Applications g0fba68d861+05816baf74,g1ec0fe41b4+f536777771,g1fd858c14a+a9301854fb,g35bb328faa+fcb1d3bbc8,g4af146b050+a5c07d5b1d,g4d2262a081+6e5fcc2a4e,g53246c7159+fcb1d3bbc8,g56a49b3a55+9c12191793,g5a012ec0e7+3632fc3ff3,g60b5630c4e+ded28b650d,g67b6fd64d1+ed4b5058f4,g78460c75b0+2f9a1b4bcd,g786e29fd12+cf7ec2a62a,g8352419a5c+fcb1d3bbc8,g87b7deb4dc+7b42cf88bf,g8852436030+e5453db6e6,g89139ef638+ed4b5058f4,g8e3bb8577d+d38d73bdbd,g9125e01d80+fcb1d3bbc8,g94187f82dc+ded28b650d,g989de1cb63+ed4b5058f4,g9d31334357+ded28b650d,g9f33ca652e+50a8019d8c,gabe3b4be73+1e0a283bba,gabf8522325+fa80ff7197,gb1101e3267+d9fb1f8026,gb58c049af0+f03b321e39,gb665e3612d+2a0c9e9e84,gb89ab40317+ed4b5058f4,gcf25f946ba+e5453db6e6,gd6cbbdb0b4+bb83cc51f8,gdd1046aedd+ded28b650d,gde0f65d7ad+941d412827,ge278dab8ac+d65b3c2b70,ge410e46f29+ed4b5058f4,gf23fb2af72+b7cae620c0,gf5e32f922b+fcb1d3bbc8,gf67bdafdda+ed4b5058f4,w.2025.16
LSST Data Management Base Package
All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Properties Friends Macros Modules Pages
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 numpy as np
23
24import lsst.afw.detection as afwDetection
25import lsst.afw.image as afwImage
26import lsst.afw.math as afwMath
27import lsst.afw.table as afwTable
28import lsst.daf.base as dafBase
29import lsst.geom
30from lsst.ip.diffim.utils import evaluateMaskFraction
31from lsst.meas.algorithms import SkyObjectsTask, SourceDetectionTask, SetPrimaryFlagsTask, MaskStreaksTask
32from lsst.meas.base import ForcedMeasurementTask, ApplyApCorrTask, DetectorVisitIdGeneratorConfig
35import lsst.meas.extensions.shapeHSM
36import lsst.pex.config as pexConfig
37from lsst.pex.exceptions import InvalidParameterError
38import lsst.pipe.base as pipeBase
39import lsst.utils
40from lsst.utils.timer import timeMethod
41
42from . import DipoleFitTask
43
44__all__ = ["DetectAndMeasureConfig", "DetectAndMeasureTask",
45 "DetectAndMeasureScoreConfig", "DetectAndMeasureScoreTask"]
46
47
48class DetectAndMeasureConnections(pipeBase.PipelineTaskConnections,
49 dimensions=("instrument", "visit", "detector"),
50 defaultTemplates={"coaddName": "deep",
51 "warpTypeSuffix": "",
52 "fakesType": ""}):
53 science = pipeBase.connectionTypes.Input(
54 doc="Input science exposure.",
55 dimensions=("instrument", "visit", "detector"),
56 storageClass="ExposureF",
57 name="{fakesType}calexp"
58 )
59 matchedTemplate = pipeBase.connectionTypes.Input(
60 doc="Warped and PSF-matched template used to create the difference image.",
61 dimensions=("instrument", "visit", "detector"),
62 storageClass="ExposureF",
63 name="{fakesType}{coaddName}Diff_matchedExp",
64 )
65 difference = pipeBase.connectionTypes.Input(
66 doc="Result of subtracting template from science.",
67 dimensions=("instrument", "visit", "detector"),
68 storageClass="ExposureF",
69 name="{fakesType}{coaddName}Diff_differenceTempExp",
70 )
71 outputSchema = pipeBase.connectionTypes.InitOutput(
72 doc="Schema (as an example catalog) for output DIASource catalog.",
73 storageClass="SourceCatalog",
74 name="{fakesType}{coaddName}Diff_diaSrc_schema",
75 )
76 diaSources = pipeBase.connectionTypes.Output(
77 doc="Detected diaSources on the difference image.",
78 dimensions=("instrument", "visit", "detector"),
79 storageClass="SourceCatalog",
80 name="{fakesType}{coaddName}Diff_diaSrc",
81 )
82 subtractedMeasuredExposure = pipeBase.connectionTypes.Output(
83 doc="Difference image with detection mask plane filled in.",
84 dimensions=("instrument", "visit", "detector"),
85 storageClass="ExposureF",
86 name="{fakesType}{coaddName}Diff_differenceExp",
87 )
88 maskedStreaks = pipeBase.connectionTypes.Output(
89 doc='Catalog of streak fit parameters for the difference image.',
90 storageClass="ArrowNumpyDict",
91 dimensions=("instrument", "visit", "detector"),
92 name="{fakesType}{coaddName}Diff_streaks",
93 )
94
95 def __init__(self, *, config):
96 super().__init__(config=config)
97 if not (self.config.writeStreakInfo and self.config.doMaskStreaks):
98 self.outputs.remove("maskedStreaks")
99
100
101class DetectAndMeasureConfig(pipeBase.PipelineTaskConfig,
102 pipelineConnections=DetectAndMeasureConnections):
103 """Config for DetectAndMeasureTask
104 """
105 doMerge = pexConfig.Field(
106 dtype=bool,
107 default=True,
108 doc="Merge positive and negative diaSources with grow radius "
109 "set by growFootprint"
110 )
111 doForcedMeasurement = pexConfig.Field(
112 dtype=bool,
113 default=True,
114 doc="Force photometer diaSource locations on PVI?")
115 doAddMetrics = pexConfig.Field(
116 dtype=bool,
117 default=False,
118 doc="Add columns to the source table to hold analysis metrics?"
119 )
120 detection = pexConfig.ConfigurableField(
121 target=SourceDetectionTask,
122 doc="Final source detection for diaSource measurement",
123 )
124 streakDetection = pexConfig.ConfigurableField(
125 target=SourceDetectionTask,
126 doc="Separate source detection used only for streak masking",
127 )
128 doDeblend = pexConfig.Field(
129 dtype=bool,
130 default=False,
131 doc="Deblend DIASources after detection?"
132 )
133 deblend = pexConfig.ConfigurableField(
135 doc="Task to split blended sources into their components."
136 )
137 measurement = pexConfig.ConfigurableField(
138 target=DipoleFitTask,
139 doc="Task to measure sources on the difference image.",
140 )
141 doApCorr = lsst.pex.config.Field(
142 dtype=bool,
143 default=True,
144 doc="Run subtask to apply aperture corrections"
145 )
147 target=ApplyApCorrTask,
148 doc="Task to apply aperture corrections"
149 )
150 forcedMeasurement = pexConfig.ConfigurableField(
151 target=ForcedMeasurementTask,
152 doc="Task to force photometer science image at diaSource locations.",
153 )
154 growFootprint = pexConfig.Field(
155 dtype=int,
156 default=2,
157 doc="Grow positive and negative footprints by this many pixels before merging"
158 )
159 diaSourceMatchRadius = pexConfig.Field(
160 dtype=float,
161 default=0.5,
162 doc="Match radius (in arcseconds) for DiaSource to Source association"
163 )
164 doSkySources = pexConfig.Field(
165 dtype=bool,
166 default=False,
167 doc="Generate sky sources?",
168 )
169 skySources = pexConfig.ConfigurableField(
170 target=SkyObjectsTask,
171 doc="Generate sky sources",
172 )
173 doMaskStreaks = pexConfig.Field(
174 dtype=bool,
175 default=True,
176 doc="Turn on streak masking",
177 )
178 maskStreaks = pexConfig.ConfigurableField(
179 target=MaskStreaksTask,
180 doc="Subtask for masking streaks. Only used if doMaskStreaks is True. "
181 "Adds a mask plane to an exposure, with the mask plane name set by streakMaskName.",
182 )
183 streakBinFactor = pexConfig.Field(
184 dtype=int,
185 default=4,
186 doc="Bin scale factor to use when rerunning detection for masking streaks. "
187 "Only used if doMaskStreaks is True.",
188 )
189 writeStreakInfo = pexConfig.Field(
190 dtype=bool,
191 default=False,
192 doc="Record the parameters of any detected streaks. For LSST, this should be turned off except for "
193 "development work."
194 )
195 setPrimaryFlags = pexConfig.ConfigurableField(
196 target=SetPrimaryFlagsTask,
197 doc="Task to add isPrimary and deblending-related flags to the catalog."
198 )
199 badSourceFlags = lsst.pex.config.ListField(
200 dtype=str,
201 doc="Sources with any of these flags set are removed before writing the output catalog.",
202 default=("base_PixelFlags_flag_offimage",
203 "base_PixelFlags_flag_interpolatedCenterAll",
204 "base_PixelFlags_flag_badCenterAll",
205 "base_PixelFlags_flag_edgeCenterAll",
206 "base_PixelFlags_flag_nodataCenterAll",
207 "base_PixelFlags_flag_saturatedCenterAll",
208 ),
209 )
210 clearMaskPlanes = lsst.pex.config.ListField(
211 dtype=str,
212 doc="Mask planes to clear before running detection.",
213 default=("DETECTED", "DETECTED_NEGATIVE", "NOT_DEBLENDED", "STREAK"),
214 )
215 idGenerator = DetectorVisitIdGeneratorConfig.make_field()
216
217 def setDefaults(self):
218 # DiaSource Detection
219 self.detection.thresholdPolarity = "both"
220 self.detection.thresholdValue = 5.0
221 self.detection.reEstimateBackground = False
222 self.detection.thresholdType = "pixel_stdev"
223 self.detection.excludeMaskPlanes = ["EDGE",
224 "BAD",
225 ]
226
227 # Copy configs for binned streak detection from the base detection task
228 self.streakDetection.thresholdType = self.detection.thresholdType
229 self.streakDetection.reEstimateBackground = self.detection.reEstimateBackground
230 self.streakDetection.excludeMaskPlanes = self.detection.excludeMaskPlanes
231 self.streakDetection.thresholdValue = self.detection.thresholdValue
232 # Only detect positive streaks
233 self.streakDetection.thresholdPolarity = "positive"
234 # Do not grow detected mask for streaks
235 self.streakDetection.nSigmaToGrow = 0
236 # Set the streak mask along the entire fit line, not only where the
237 # detected mask is set.
238 self.maskStreaks.onlyMaskDetected = False
239 # Restrict streak masking from growing too large
240 self.maskStreaks.maxStreakWidth = 100
241 # Restrict the number of iterations allowed for fitting streaks
242 # When the fit is good it should solve quickly, and exit a bad fit quickly
243 self.maskStreaks.maxFitIter = 10
244 # Only mask to 2 sigma in width
245 self.maskStreaks.nSigmaMask = 2
246 # Threshold for including streaks after the Hough Transform.
247 # A lower value will detect more features that are less linear.
248 self.maskStreaks.absMinimumKernelHeight = 2
249
250 self.measurement.plugins.names |= ["ext_trailedSources_Naive",
251 "base_LocalPhotoCalib",
252 "base_LocalWcs",
253 "ext_shapeHSM_HsmSourceMoments",
254 "ext_shapeHSM_HsmPsfMoments",
255 "base_ClassificationSizeExtendedness",
256 ]
257 self.measurement.slots.psfShape = "ext_shapeHSM_HsmPsfMoments"
258 self.measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments"
259 self.measurement.plugins["base_SdssCentroid"].maxDistToPeak = 5.0
260 self.forcedMeasurement.plugins = ["base_TransformedCentroid", "base_PsfFlux"]
261 self.forcedMeasurement.copyColumns = {
262 "id": "objectId", "parent": "parentObjectId", "coord_ra": "coord_ra", "coord_dec": "coord_dec"}
263 self.forcedMeasurement.slots.centroid = "base_TransformedCentroid"
264 self.forcedMeasurement.slots.shape = None
265
266 # Keep track of which footprints contain streaks
267 self.measurement.plugins["base_PixelFlags"].masksFpAnywhere = [
268 "STREAK", "INJECTED", "INJECTED_TEMPLATE"]
269 self.measurement.plugins["base_PixelFlags"].masksFpCenter = [
270 "STREAK", "INJECTED", "INJECTED_TEMPLATE"]
271 self.skySources.avoidMask = ["DETECTED", "DETECTED_NEGATIVE", "BAD", "NO_DATA", "EDGE"]
272
273
274class DetectAndMeasureTask(lsst.pipe.base.PipelineTask):
275 """Detect and measure sources on a difference image.
276 """
277 ConfigClass = DetectAndMeasureConfig
278 _DefaultName = "detectAndMeasure"
279
280 def __init__(self, **kwargs):
281 super().__init__(**kwargs)
282 self.schema = afwTable.SourceTable.makeMinimalSchema()
283 # Add coordinate error fields:
284 afwTable.CoordKey.addErrorFields(self.schema)
285
286 self.algMetadata = dafBase.PropertyList()
287 self.makeSubtask("detection", schema=self.schema)
288 if self.config.doDeblend:
289 self.makeSubtask("deblend", schema=self.schema)
290 self.makeSubtask("setPrimaryFlags", schema=self.schema, isSingleFrame=True)
291 self.makeSubtask("measurement", schema=self.schema,
292 algMetadata=self.algMetadata)
293 if self.config.doApCorr:
294 self.makeSubtask("applyApCorr", schema=self.measurement.schema)
295 if self.config.doForcedMeasurement:
296 self.schema.addField(
297 "ip_diffim_forced_PsfFlux_instFlux", "D",
298 "Forced PSF flux measured on the direct image.",
299 units="count")
300 self.schema.addField(
301 "ip_diffim_forced_PsfFlux_instFluxErr", "D",
302 "Forced PSF flux error measured on the direct image.",
303 units="count")
304 self.schema.addField(
305 "ip_diffim_forced_PsfFlux_area", "F",
306 "Forced PSF flux effective area of PSF.",
307 units="pixel")
308 self.schema.addField(
309 "ip_diffim_forced_PsfFlux_flag", "Flag",
310 "Forced PSF flux general failure flag.")
311 self.schema.addField(
312 "ip_diffim_forced_PsfFlux_flag_noGoodPixels", "Flag",
313 "Forced PSF flux not enough non-rejected pixels in data to attempt the fit.")
314 self.schema.addField(
315 "ip_diffim_forced_PsfFlux_flag_edge", "Flag",
316 "Forced PSF flux object was too close to the edge of the image to use the full PSF model.")
317 self.makeSubtask("forcedMeasurement", refSchema=self.schema)
318
319 self.schema.addField("refMatchId", "L", "unique id of reference catalog match")
320 self.schema.addField("srcMatchId", "L", "unique id of source match")
321 if self.config.doSkySources:
322 self.makeSubtask("skySources", schema=self.schema)
323 if self.config.doMaskStreaks:
324 self.makeSubtask("maskStreaks")
325 self.makeSubtask("streakDetection")
326
327 # To get the "merge_*" fields in the schema; have to re-initialize
328 # this later, once we have a peak schema post-detection.
329 lsst.afw.detection.FootprintMergeList(self.schema, ["positive", "negative"])
330
331 # Check that the schema and config are consistent
332 for flag in self.config.badSourceFlags:
333 if flag not in self.schema:
334 raise pipeBase.InvalidQuantumError("Field %s not in schema" % flag)
335
336 # initialize InitOutputs
337 self.outputSchema = afwTable.SourceCatalog(self.schema)
338 self.outputSchema.getTable().setMetadata(self.algMetadata)
339
340 def runQuantum(self, butlerQC: pipeBase.QuantumContext,
341 inputRefs: pipeBase.InputQuantizedConnection,
342 outputRefs: pipeBase.OutputQuantizedConnection):
343 inputs = butlerQC.get(inputRefs)
344 idGenerator = self.config.idGenerator.apply(butlerQC.quantum.dataId)
345 idFactory = idGenerator.make_table_id_factory()
346 outputs = self.run(**inputs, idFactory=idFactory)
347 butlerQC.put(outputs, outputRefs)
348
349 @timeMethod
350 def run(self, science, matchedTemplate, difference,
351 idFactory=None):
352 """Detect and measure sources on a difference image.
353
354 The difference image will be convolved with a gaussian approximation of
355 the PSF to form a maximum likelihood image for detection.
356 Close positive and negative detections will optionally be merged into
357 dipole diaSources.
358 Sky sources, or forced detections in background regions, will optionally
359 be added, and the configured measurement algorithm will be run on all
360 detections.
361
362 Parameters
363 ----------
364 science : `lsst.afw.image.ExposureF`
365 Science exposure that the template was subtracted from.
366 matchedTemplate : `lsst.afw.image.ExposureF`
367 Warped and PSF-matched template that was used produce the
368 difference image.
369 difference : `lsst.afw.image.ExposureF`
370 Result of subtracting template from the science image.
371 idFactory : `lsst.afw.table.IdFactory`, optional
372 Generator object used to assign ids to detected sources in the
373 difference image. Ids from this generator are not set until after
374 deblending and merging positive/negative peaks.
375
376 Returns
377 -------
378 measurementResults : `lsst.pipe.base.Struct`
379
380 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
381 Subtracted exposure with detection mask applied.
382 ``diaSources`` : `lsst.afw.table.SourceCatalog`
383 The catalog of detected sources.
384 """
385 if idFactory is None:
386 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory()
387
388 self._prepareInputs(difference)
389
390 # Don't use the idFactory until after deblend+merge, so that we aren't
391 # generating ids that just get thrown away (footprint merge doesn't
392 # know about past ids).
393 table = afwTable.SourceTable.make(self.schema)
394 results = self.detection.run(
395 table=table,
396 exposure=difference,
397 doSmooth=True,
398 )
399
400 if self.config.doDeblend:
401 sources, positives, negatives = self._deblend(difference,
402 results.positive,
403 results.negative)
404
405 return self.processResults(science, matchedTemplate, difference,
406 sources, idFactory,
407 positives=positives,
408 negatives=negatives)
409
410 else:
411 positives = afwTable.SourceCatalog(self.schema)
412 results.positive.makeSources(positives)
413 negatives = afwTable.SourceCatalog(self.schema)
414 results.negative.makeSources(negatives)
415 return self.processResults(science, matchedTemplate, difference,
416 results.sources, idFactory,
417 positives=positives,
418 negatives=negatives)
419
420 def _prepareInputs(self, difference):
421 """Ensure that we start with an empty detection and deblended mask.
422
423 Parameters
424 ----------
425 difference : `lsst.afw.image.ExposureF`
426 The difference image that will be used for detecting diaSources.
427 The mask plane will be modified in place.
428
429 Raises
430 ------
431 lsst.pipe.base.UpstreamFailureNoWorkFound
432 If the PSF is not usable for measurement.
433 """
434 # Check that we have a valid PSF now before we do more work
435 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
436 if np.isnan(sigma):
437 raise pipeBase.UpstreamFailureNoWorkFound("Invalid PSF detected! PSF width evaluates to NaN.")
438 # Ensure that we start with an empty detection and deblended mask.
439 mask = difference.mask
440 for mp in self.config.clearMaskPlanes:
441 if mp not in mask.getMaskPlaneDict():
442 mask.addMaskPlane(mp)
443 mask &= ~mask.getPlaneBitMask(self.config.clearMaskPlanes)
444
445 def processResults(self, science, matchedTemplate, difference, sources, idFactory,
446 positives=None, negatives=None,):
447 """Measure and process the results of source detection.
448
449 Parameters
450 ----------
451 science : `lsst.afw.image.ExposureF`
452 Science exposure that the template was subtracted from.
453 matchedTemplate : `lsst.afw.image.ExposureF`
454 Warped and PSF-matched template that was used produce the
455 difference image.
456 difference : `lsst.afw.image.ExposureF`
457 Result of subtracting template from the science image.
458 sources : `lsst.afw.table.SourceCatalog`
459 Detected sources on the difference exposure.
460 idFactory : `lsst.afw.table.IdFactory`
461 Generator object used to assign ids to detected sources in the
462 difference image.
463 positives : `lsst.afw.table.SourceCatalog`, optional
464 Positive polarity footprints.
465 negatives : `lsst.afw.table.SourceCatalog`, optional
466 Negative polarity footprints.
467
468 Returns
469 -------
470 measurementResults : `lsst.pipe.base.Struct`
471
472 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
473 Subtracted exposure with detection mask applied.
474 ``diaSources`` : `lsst.afw.table.SourceCatalog`
475 The catalog of detected sources.
476 """
477 self.metadata["nUnmergedDiaSources"] = len(sources)
478 if self.config.doMerge:
479 # preserve peak schema, if there are any footprints
480 if len(positives) > 0:
481 peakSchema = positives[0].getFootprint().peaks.schema
482 elif len(negatives) > 0:
483 peakSchema = negatives[0].getFootprint().peaks.schema
484 else:
485 peakSchema = afwDetection.PeakTable.makeMinimalSchema()
486 mergeList = afwDetection.FootprintMergeList(self.schema,
487 ["positive", "negative"], peakSchema)
488 initialDiaSources = afwTable.SourceCatalog(self.schema)
489 # Start with positive, as FootprintMergeList will self-merge the
490 # subsequent added catalogs, and we want to try to preserve
491 # deblended positive sources.
492 mergeList.addCatalog(initialDiaSources.table, positives, "positive", minNewPeakDist=0)
493 mergeList.addCatalog(initialDiaSources.table, negatives, "negative", minNewPeakDist=0)
494 mergeList.getFinalSources(initialDiaSources)
495 # Flag as negative those sources that *only* came from the negative
496 # footprint set.
497 initialDiaSources["is_negative"] = initialDiaSources["merge_footprint_negative"] & \
498 ~initialDiaSources["merge_footprint_positive"]
499 self.log.info("Merging detections into %d sources", len(initialDiaSources))
500 else:
501 initialDiaSources = sources
502
503 # Assign source ids at the end: deblend/merge mean that we don't keep
504 # track of parents and children, we only care about the final ids.
505 for source in initialDiaSources:
506 source.setId(idFactory())
507 # Ensure sources added after this get correct ids.
508 initialDiaSources.getTable().setIdFactory(idFactory)
509 initialDiaSources.setMetadata(self.algMetadata)
510
511 self.metadata["nMergedDiaSources"] = len(initialDiaSources)
512
513 if self.config.doMaskStreaks:
514 streakInfo = self._runStreakMasking(difference)
515
516 if self.config.doSkySources:
517 self.addSkySources(initialDiaSources, difference.mask, difference.info.id)
518
519 if not initialDiaSources.isContiguous():
520 initialDiaSources = initialDiaSources.copy(deep=True)
521
522 self.measureDiaSources(initialDiaSources, science, difference, matchedTemplate)
523 diaSources = self._removeBadSources(initialDiaSources)
524
525 if self.config.doForcedMeasurement:
526 self.measureForcedSources(diaSources, science, difference.getWcs())
527
528 self.calculateMetrics(difference)
529
530 measurementResults = pipeBase.Struct(
531 subtractedMeasuredExposure=difference,
532 )
533
534 if len(diaSources) > 0:
535 measurementResults.diaSources = diaSources
536
537 if self.config.doMaskStreaks and self.config.writeStreakInfo:
538 measurementResults.mergeItems(streakInfo, 'maskedStreaks')
539
540 return measurementResults
541
542 def _deblend(self, difference, positiveFootprints, negativeFootprints):
543 """Deblend the positive and negative footprints and return a catalog
544 containing just the children, and the deblended footprints.
545
546 Parameters
547 ----------
548 difference : `lsst.afw.image.Exposure`
549 Result of subtracting template from the science image.
550 positiveFootprints, negativeFootprints : `lsst.afw.detection.FootprintSet`
551 Positive and negative polarity footprints measured on
552 ``difference`` to be deblended separately.
553
554 Returns
555 -------
556 sources : `lsst.afw.table.SourceCatalog`
557 Positive and negative deblended children.
558 positives, negatives : `lsst.afw.table.SourceCatalog`
559 Deblended positive and negative polarity sources with footprints
560 detected on ``difference``.
561 """
562
563 def deblend(footprints, negative=False):
564 """Deblend a positive or negative footprint set,
565 and return the deblended children.
566
567 Parameters
568 ----------
569 footprints : `lsst.afw.detection.FootprintSet`
570 negative : `bool`
571 Set True if the footprints contain negative fluxes
572
573 Returns
574 -------
575 sources : `lsst.afw.table.SourceCatalog`
576 """
577 sources = afwTable.SourceCatalog(self.schema)
578 footprints.makeSources(sources)
579 if negative:
580 # Invert the image so the deblender can run on positive peaks
581 difference_inverted = difference.clone()
582 difference_inverted.image *= -1
583 self.deblend.run(exposure=difference_inverted, sources=sources)
584 children = sources[sources["parent"] != 0]
585 # Set the heavy footprint pixel values back to reality
586 for child in children:
587 footprint = child.getFootprint()
588 array = footprint.getImageArray()
589 array *= -1
590 else:
591 self.deblend.run(exposure=difference, sources=sources)
592 self.setPrimaryFlags.run(sources)
593 children = sources["detect_isDeblendedSource"] == 1
594 sources = sources[children].copy(deep=True)
595 # Clear parents, so that measurement plugins behave correctly.
596 sources['parent'] = 0
597 return sources.copy(deep=True)
598
599 positives = deblend(positiveFootprints)
600 negatives = deblend(negativeFootprints, negative=True)
601
602 sources = afwTable.SourceCatalog(self.schema)
603 sources.reserve(len(positives) + len(negatives))
604 sources.extend(positives, deep=True)
605 sources.extend(negatives, deep=True)
606 if len(negatives) > 0:
607 sources[-len(negatives):]["is_negative"] = True
608 return sources, positives, negatives
609
610 def _removeBadSources(self, diaSources):
611 """Remove unphysical diaSources from the catalog.
612
613 Parameters
614 ----------
615 diaSources : `lsst.afw.table.SourceCatalog`
616 The catalog of detected sources.
617
618 Returns
619 -------
620 diaSources : `lsst.afw.table.SourceCatalog`
621 The updated catalog of detected sources, with any source that has a
622 flag in ``config.badSourceFlags`` set removed.
623 """
624 selector = np.ones(len(diaSources), dtype=bool)
625 for flag in self.config.badSourceFlags:
626 flags = diaSources[flag]
627 nBad = np.count_nonzero(flags)
628 if nBad > 0:
629 self.log.debug("Found %d unphysical sources with flag %s.", nBad, flag)
630 selector &= ~flags
631 nBadTotal = np.count_nonzero(~selector)
632 self.metadata["nRemovedBadFlaggedSources"] = nBadTotal
633 self.log.info("Removed %d unphysical sources.", nBadTotal)
634 return diaSources[selector].copy(deep=True)
635
636 def addSkySources(self, diaSources, mask, seed,
637 subtask=None):
638 """Add sources in empty regions of the difference image
639 for measuring the background.
640
641 Parameters
642 ----------
643 diaSources : `lsst.afw.table.SourceCatalog`
644 The catalog of detected sources.
645 mask : `lsst.afw.image.Mask`
646 Mask plane for determining regions where Sky sources can be added.
647 seed : `int`
648 Seed value to initialize the random number generator.
649 """
650 if subtask is None:
651 subtask = self.skySources
652 skySourceFootprints = subtask.run(mask=mask, seed=seed, catalog=diaSources)
653 self.metadata[f"n_{subtask.getName()}"] = len(skySourceFootprints)
654
655 def measureDiaSources(self, diaSources, science, difference, matchedTemplate):
656 """Use (matched) template and science image to constrain dipole fitting.
657
658 Parameters
659 ----------
660 diaSources : `lsst.afw.table.SourceCatalog`
661 The catalog of detected sources.
662 science : `lsst.afw.image.ExposureF`
663 Science exposure that the template was subtracted from.
664 difference : `lsst.afw.image.ExposureF`
665 Result of subtracting template from the science image.
666 matchedTemplate : `lsst.afw.image.ExposureF`
667 Warped and PSF-matched template that was used produce the
668 difference image.
669 """
670 # Ensure that the required mask planes are present
671 for mp in self.config.measurement.plugins["base_PixelFlags"].masksFpAnywhere:
672 difference.mask.addMaskPlane(mp)
673 # Note that this may not be correct if we convolved the science image.
674 # In the future we may wish to persist the matchedScience image.
675 self.measurement.run(diaSources, difference, science, matchedTemplate)
676 if self.config.doApCorr:
677 apCorrMap = difference.getInfo().getApCorrMap()
678 if apCorrMap is None:
679 self.log.warning("Difference image does not have valid aperture correction; skipping.")
680 else:
681 self.applyApCorr.run(
682 catalog=diaSources,
683 apCorrMap=apCorrMap,
684 )
685
686 def measureForcedSources(self, diaSources, science, wcs):
687 """Perform forced measurement of the diaSources on the science image.
688
689 Parameters
690 ----------
691 diaSources : `lsst.afw.table.SourceCatalog`
692 The catalog of detected sources.
693 science : `lsst.afw.image.ExposureF`
694 Science exposure that the template was subtracted from.
695 wcs : `lsst.afw.geom.SkyWcs`
696 Coordinate system definition (wcs) for the exposure.
697 """
698 # Run forced psf photometry on the PVI at the diaSource locations.
699 # Copy the measured flux and error into the diaSource.
700 forcedSources = self.forcedMeasurement.generateMeasCat(science, diaSources, wcs)
701 self.forcedMeasurement.run(forcedSources, science, diaSources, wcs)
702 mapper = afwTable.SchemaMapper(forcedSources.schema, diaSources.schema)
703 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFlux")[0],
704 "ip_diffim_forced_PsfFlux_instFlux", True)
705 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFluxErr")[0],
706 "ip_diffim_forced_PsfFlux_instFluxErr", True)
707 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_area")[0],
708 "ip_diffim_forced_PsfFlux_area", True)
709 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag")[0],
710 "ip_diffim_forced_PsfFlux_flag", True)
711 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_noGoodPixels")[0],
712 "ip_diffim_forced_PsfFlux_flag_noGoodPixels", True)
713 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_edge")[0],
714 "ip_diffim_forced_PsfFlux_flag_edge", True)
715 for diaSource, forcedSource in zip(diaSources, forcedSources):
716 diaSource.assign(forcedSource, mapper)
717
718 def calculateMetrics(self, difference):
719 """Add difference image QA metrics to the Task metadata.
720
721 This may be used to produce corresponding metrics (see
722 lsst.analysis.tools.tasks.diffimTaskDetectorVisitMetricAnalysis).
723
724 Parameters
725 ----------
726 difference : `lsst.afw.image.Exposure`
727 The target difference image to calculate metrics for.
728 """
729 mask = difference.mask
730 badPix = (mask.array & mask.getPlaneBitMask(self.config.detection.excludeMaskPlanes)) > 0
731 self.metadata["nGoodPixels"] = np.sum(~badPix)
732 self.metadata["nBadPixels"] = np.sum(badPix)
733 detPosPix = (mask.array & mask.getPlaneBitMask("DETECTED")) > 0
734 detNegPix = (mask.array & mask.getPlaneBitMask("DETECTED_NEGATIVE")) > 0
735 self.metadata["nPixelsDetectedPositive"] = np.sum(detPosPix)
736 self.metadata["nPixelsDetectedNegative"] = np.sum(detNegPix)
737 detPosPix &= badPix
738 detNegPix &= badPix
739 self.metadata["nBadPixelsDetectedPositive"] = np.sum(detPosPix)
740 self.metadata["nBadPixelsDetectedNegative"] = np.sum(detNegPix)
741
742 metricsMaskPlanes = list(mask.getMaskPlaneDict().keys())
743 for maskPlane in metricsMaskPlanes:
744 try:
745 self.metadata["%s_mask_fraction"%maskPlane.lower()] = evaluateMaskFraction(mask, maskPlane)
746 except InvalidParameterError:
747 self.metadata["%s_mask_fraction"%maskPlane.lower()] = -1
748 self.log.info("Unable to calculate metrics for mask plane %s: not in image"%maskPlane)
749
750 def _runStreakMasking(self, difference):
751 """Do streak masking and optionally save the resulting streak
752 fit parameters in a catalog.
753
754 Only returns non-empty streakInfo if self.config.writeStreakInfo
755 is set. The difference image is binned by self.config.streakBinFactor
756 (and detection is run a second time) so that regions with lower
757 surface brightness streaks are more likely to fall above the
758 detection threshold.
759
760 Parameters
761 ----------
762 difference: `lsst.afw.image.Exposure`
763 The exposure in which to search for streaks. Must have a detection
764 mask.
765
766 Returns
767 -------
768 streakInfo: `lsst.pipe.base.Struct`
769 ``rho`` : `np.ndarray`
770 Angle of detected streak.
771 ``theta`` : `np.ndarray`
772 Distance from center of detected streak.
773 ``sigma`` : `np.ndarray`
774 Width of streak profile.
775 ``reducedChi2`` : `np.ndarray`
776 Reduced chi2 of the best-fit streak profile.
777 ``modelMaximum`` : `np.ndarray`
778 Peak value of the fit line profile.
779 """
780 maskedImage = difference.maskedImage
781 # Bin the diffim to enhance low surface brightness streaks
782 binnedMaskedImage = afwMath.binImage(maskedImage,
783 self.config.streakBinFactor,
784 self.config.streakBinFactor)
785 binnedExposure = afwImage.ExposureF(binnedMaskedImage.getBBox())
786 binnedExposure.setMaskedImage(binnedMaskedImage)
787 # Clear the DETECTED mask plane before streak detection
788 binnedExposure.mask &= ~binnedExposure.mask.getPlaneBitMask('DETECTED')
789 # Rerun detection to set the DETECTED mask plane on binnedExposure
790 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
791 _table = afwTable.SourceTable.make(afwTable.SourceTable.makeMinimalSchema())
792 self.streakDetection.run(table=_table, exposure=binnedExposure, doSmooth=True,
793 sigma=sigma/self.config.streakBinFactor)
794 binnedDetectedMaskPlane = binnedExposure.mask.array & binnedExposure.mask.getPlaneBitMask('DETECTED')
795 rescaledDetectedMaskPlane = binnedDetectedMaskPlane.repeat(self.config.streakBinFactor,
796 axis=0).repeat(self.config.streakBinFactor,
797 axis=1)
798 # Create new version of a diffim with DETECTED based on binnedExposure
799 streakMaskedImage = maskedImage.clone()
800 ysize, xsize = rescaledDetectedMaskPlane.shape
801 streakMaskedImage.mask.array[:ysize, :xsize] |= rescaledDetectedMaskPlane
802 # Detect streaks on this new version of the diffim
803 streaks = self.maskStreaks.run(streakMaskedImage)
804 streakMaskPlane = streakMaskedImage.mask.array & streakMaskedImage.mask.getPlaneBitMask('STREAK')
805 # Apply the new STREAK mask to the original diffim
806 maskedImage.mask.array |= streakMaskPlane
807
808 if self.config.writeStreakInfo:
809 rhos = np.array([line.rho for line in streaks.lines])
810 thetas = np.array([line.theta for line in streaks.lines])
811 sigmas = np.array([line.sigma for line in streaks.lines])
812 chi2s = np.array([line.reducedChi2 for line in streaks.lines])
813 modelMaximums = np.array([line.modelMaximum for line in streaks.lines])
814 streakInfo = {'rho': rhos, 'theta': thetas, 'sigma': sigmas, 'reducedChi2': chi2s,
815 'modelMaximum': modelMaximums}
816 else:
817 streakInfo = {'rho': np.array([]), 'theta': np.array([]), 'sigma': np.array([]),
818 'reducedChi2': np.array([]), 'modelMaximum': np.array([])}
819 return pipeBase.Struct(maskedStreaks=streakInfo)
820
821
822class DetectAndMeasureScoreConnections(DetectAndMeasureConnections):
823 scoreExposure = pipeBase.connectionTypes.Input(
824 doc="Maximum likelihood image for detection.",
825 dimensions=("instrument", "visit", "detector"),
826 storageClass="ExposureF",
827 name="{fakesType}{coaddName}Diff_scoreExp",
828 )
829
830
831class DetectAndMeasureScoreConfig(DetectAndMeasureConfig,
832 pipelineConnections=DetectAndMeasureScoreConnections):
833 pass
834
835
836class DetectAndMeasureScoreTask(DetectAndMeasureTask):
837 """Detect DIA sources using a score image,
838 and measure the detections on the difference image.
839
840 Source detection is run on the supplied score, or maximum likelihood,
841 image. Note that no additional convolution will be done in this case.
842 Close positive and negative detections will optionally be merged into
843 dipole diaSources.
844 Sky sources, or forced detections in background regions, will optionally
845 be added, and the configured measurement algorithm will be run on all
846 detections.
847 """
848 ConfigClass = DetectAndMeasureScoreConfig
849 _DefaultName = "detectAndMeasureScore"
850
851 @timeMethod
852 def run(self, science, matchedTemplate, difference, scoreExposure,
853 idFactory=None):
854 """Detect and measure sources on a score image.
855
856 Parameters
857 ----------
858 science : `lsst.afw.image.ExposureF`
859 Science exposure that the template was subtracted from.
860 matchedTemplate : `lsst.afw.image.ExposureF`
861 Warped and PSF-matched template that was used produce the
862 difference image.
863 difference : `lsst.afw.image.ExposureF`
864 Result of subtracting template from the science image.
865 scoreExposure : `lsst.afw.image.ExposureF`
866 Score or maximum likelihood difference image
867 idFactory : `lsst.afw.table.IdFactory`, optional
868 Generator object used to assign ids to detected sources in the
869 difference image. Ids from this generator are not set until after
870 deblending and merging positive/negative peaks.
871
872 Returns
873 -------
874 measurementResults : `lsst.pipe.base.Struct`
875
876 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
877 Subtracted exposure with detection mask applied.
878 ``diaSources`` : `lsst.afw.table.SourceCatalog`
879 The catalog of detected sources.
880 """
881 if idFactory is None:
882 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory()
883
884 self._prepareInputs(scoreExposure)
885
886 # Don't use the idFactory until after deblend+merge, so that we aren't
887 # generating ids that just get thrown away (footprint merge doesn't
888 # know about past ids).
889 table = afwTable.SourceTable.make(self.schema)
890 results = self.detection.run(
891 table=table,
892 exposure=scoreExposure,
893 doSmooth=False,
894 )
895 # Copy the detection mask from the Score image to the difference image
896 difference.mask.assign(scoreExposure.mask, scoreExposure.getBBox())
897
898 if self.config.doDeblend:
899 sources, positives, negatives = self._deblend(difference,
900 results.positive,
901 results.negative)
902
903 return self.processResults(science, matchedTemplate, difference,
904 sources, idFactory,
905 positives=positives,
906 negatives=negatives)
907
908 else:
909 positives = afwTable.SourceCatalog(self.schema)
910 results.positive.makeSources(positives)
911 negatives = afwTable.SourceCatalog(self.schema)
912 results.negative.makeSources(negatives)
913 return self.processResults(science, matchedTemplate, difference,
914 results.sources, idFactory,
915 positives=positives,
916 negatives=negatives)
A mapping between the keys of two Schemas, used to copy data between them.
Class for storing ordered metadata with comments.
std::shared_ptr< ImageT > binImage(ImageT const &inImage, int const binX, int const binY, lsst::afw::math::Property const flags=lsst::afw::math::MEAN)
Definition binImage.cc:44
run(self, *, coaddExposureHandles, bbox, wcs, dataIds, physical_filter)