lsst.ip.diffim g6c579da96f+67e576a142
Loading...
Searching...
No Matches
dipoleMeasurement.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.image as afwImage
25import lsst.geom as geom
26import lsst.pex.config as pexConfig
27import lsst.meas.deblender.baseline as deblendBaseline
28from lsst.meas.base.pluginRegistry import register
29from lsst.meas.base import SingleFrameMeasurementTask, SingleFrameMeasurementConfig, \
30 SingleFramePluginConfig, SingleFramePlugin
31import lsst.afw.display as afwDisplay
32from lsst.utils.logging import getLogger
33
34__all__ = ("DipoleMeasurementConfig", "DipoleMeasurementTask", "DipoleAnalysis", "DipoleDeblender",
35 "SourceFlagChecker", "ClassificationDipoleConfig", "ClassificationDipolePlugin")
36
37
38class ClassificationDipoleConfig(SingleFramePluginConfig):
39 """Configuration for classification of detected diaSources as dipole or not"""
40 minSn = pexConfig.Field(
41 doc="Minimum quadrature sum of positive+negative lobe S/N to be considered a dipole",
42 dtype=float, default=np.sqrt(2) * 5.0,
43 )
44 maxFluxRatio = pexConfig.Field(
45 doc="Maximum flux ratio in either lobe to be considered a dipole",
46 dtype=float, default=0.65
47 )
48
49
50@register("ip_diffim_ClassificationDipole")
51class ClassificationDipolePlugin(SingleFramePlugin):
52 """A plugin to classify whether a diaSource is a dipole.
53 """
54
55 ConfigClass = ClassificationDipoleConfig
56
57 @classmethod
59 """
60 Returns
61 -------
62 result : `callable`
63 """
64 return cls.APCORR_ORDER
65
66 def __init__(self, config, name, schema, metadata):
67 SingleFramePlugin.__init__(self, config, name, schema, metadata)
69 self.keyProbability = schema.addField(name + "_value", type="D",
70 doc="Set to 1 for dipoles, else 0.")
71 self.keyFlag = schema.addField(name + "_flag", type="Flag", doc="Set to 1 for any fatal failure.")
72
73 def measure(self, measRecord, exposure):
74 passesSn = self.dipoleAnalysis.getSn(measRecord) > self.config.minSn
75 negFlux = np.abs(measRecord.get("ip_diffim_PsfDipoleFlux_neg_instFlux"))
76 negFluxFlag = measRecord.get("ip_diffim_PsfDipoleFlux_neg_flag")
77 posFlux = np.abs(measRecord.get("ip_diffim_PsfDipoleFlux_pos_instFlux"))
78 posFluxFlag = measRecord.get("ip_diffim_PsfDipoleFlux_pos_flag")
79
80 if negFluxFlag or posFluxFlag:
81 self.fail(measRecord)
82 # continue on to classify
83
84 totalFlux = negFlux + posFlux
85
86 # If negFlux or posFlux are NaN, these evaluate to False
87 passesFluxNeg = (negFlux / totalFlux) < self.config.maxFluxRatio
88 passesFluxPos = (posFlux / totalFlux) < self.config.maxFluxRatio
89 if (passesSn and passesFluxPos and passesFluxNeg):
90 val = 1.0
91 else:
92 val = 0.0
93
94 measRecord.set(self.keyProbability, val)
95
96 def fail(self, measRecord, error=None):
97 measRecord.set(self.keyFlag, True)
98
99
100class DipoleMeasurementConfig(SingleFrameMeasurementConfig):
101 """Measurement of detected diaSources as dipoles"""
102
103 def setDefaults(self):
104 SingleFrameMeasurementConfig.setDefaults(self)
105 self.plugins = ["base_CircularApertureFlux",
106 "base_PixelFlags",
107 "base_SkyCoord",
108 "base_PsfFlux",
109 "ip_diffim_NaiveDipoleCentroid",
110 "ip_diffim_NaiveDipoleFlux",
111 "ip_diffim_PsfDipoleFlux",
112 "ip_diffim_ClassificationDipole",
113 ]
114
115 self.slots.calibFlux = None
116 self.slots.modelFlux = None
117 self.slots.gaussianFlux = None
118 self.slots.shape = None
119 self.slots.centroid = "ip_diffim_NaiveDipoleCentroid"
121
122
123class DipoleMeasurementTask(SingleFrameMeasurementTask):
124 """Measurement of Sources, specifically ones from difference images, for characterization as dipoles
125
126 Parameters
127 ----------
128 sources : 'lsst.afw.table.SourceCatalog'
129 Sources that will be measured
130 badFlags : `list` of `dict`
131 A list of flags that will be used to determine if there was a measurement problem
132
133 Notes
134 -----
135 The list of badFlags will be used to make a list of keys to check for measurement flags on. By
136 default the centroid keys are added to this list
137
138 Description
139
140 This class provides a default configuration for running Source measurement on image differences.
141
142 .. code-block:: py
143
144 class DipoleMeasurementConfig(SingleFrameMeasurementConfig):
145 "Measurement of detected diaSources as dipoles"
146 def setDefaults(self):
147 SingleFrameMeasurementConfig.setDefaults(self)
148 self.plugins = ["base_CircularApertureFlux",
149 "base_PixelFlags",
150 "base_SkyCoord",
151 "base_PsfFlux",
152 "ip_diffim_NaiveDipoleCentroid",
153 "ip_diffim_NaiveDipoleFlux",
154 "ip_diffim_PsfDipoleFlux",
155 "ip_diffim_ClassificationDipole",
156 ]
157 self.slots.calibFlux = None
158 self.slots.modelFlux = None
159 self.slots.instFlux = None
160 self.slots.shape = None
161 self.slots.centroid = "ip_diffim_NaiveDipoleCentroid"
162 self.doReplaceWithNoise = False
163
164 These plugins enabled by default allow the user to test the hypothesis that the Source is a dipole.
165 This includes a set of measurements derived from intermediate base classes
166 DipoleCentroidAlgorithm and DipoleFluxAlgorithm.
167 Their respective algorithm control classes are defined in
168 DipoleCentroidControl and DipoleFluxControl.
169 Each centroid and flux measurement will have _neg (negative)
170 and _pos (positive lobe) fields.
171
172 The first set of measurements uses a "naive" alrogithm
173 for centroid and flux measurements, implemented in
174 NaiveDipoleCentroidControl and NaiveDipoleFluxControl.
175 The algorithm uses a naive 3x3 weighted moment around
176 the nominal centroids of each peak in the Source Footprint. These algorithms fill the table fields
177 ip_diffim_NaiveDipoleCentroid* and ip_diffim_NaiveDipoleFlux*
178
179 The second set of measurements undertakes a joint-Psf model on the negative
180 and positive lobe simultaneously. This fit simultaneously solves for the negative and positive
181 lobe centroids and fluxes using non-linear least squares minimization.
182 The fields are stored in table elements ip_diffim_PsfDipoleFlux*.
183
184 Because this Task is just a config for SingleFrameMeasurementTask, the same result may be acheived by
185 manually editing the config and running SingleFrameMeasurementTask. For example:
186
187 .. code-block:: py
188
189 config = SingleFrameMeasurementConfig()
190 config.plugins.names = ["base_PsfFlux",
191 "ip_diffim_PsfDipoleFlux",
192 "ip_diffim_NaiveDipoleFlux",
193 "ip_diffim_NaiveDipoleCentroid",
194 "ip_diffim_ClassificationDipole",
195 "base_CircularApertureFlux",
196 "base_SkyCoord"]
197
198 config.slots.calibFlux = None
199 config.slots.modelFlux = None
200 config.slots.instFlux = None
201 config.slots.shape = None
202 config.slots.centroid = "ip_diffim_NaiveDipoleCentroid"
203 config.doReplaceWithNoise = False
204
205 schema = afwTable.SourceTable.makeMinimalSchema()
206 task = SingleFrameMeasurementTask(schema, config=config)-
207
208 Debug variables
209
210 The ``pipetask`` command line interface supports a
211 flag --debug to import @b debug.py from your PYTHONPATH. The relevant contents of debug.py
212 for this Task include:
213
214 .. code-block:: py
215
216 import sys
217 import lsstDebug
218 def DebugInfo(name):
219 di = lsstDebug.getInfo(name)
220 if name == "lsst.ip.diffim.dipoleMeasurement":
221 di.display = True # enable debug output
222 di.maskTransparency = 90 # display mask transparency
223 di.displayDiaSources = True # show exposure with dipole results
224 return di
225 lsstDebug.Info = DebugInfo
226 lsstDebug.frame = 1
227
228 config.slots.calibFlux = None
229 config.slots.modelFlux = None
230 config.slots.gaussianFlux = None
231 config.slots.shape = None
232 config.slots.centroid = "ip_diffim_NaiveDipoleCentroid"
233 config.doReplaceWithNoise = False
234
235 Start the processing by parsing the command line, where the user has the option of
236 enabling debugging output and/or sending their own image for demonstration
237 (in case they have not downloaded the afwdata package).
238
239 .. code-block:: py
240
241 if __name__ == "__main__":
242 import argparse
243 parser = argparse.ArgumentParser(
244 description="Demonstrate the use of SourceDetectionTask and DipoleMeasurementTask")
245 parser.add_argument('--debug', '-d', action="store_true", help="Load debug.py?", default=False)
246 parser.add_argument("--image", "-i", help="User defined image", default=None)
247 args = parser.parse_args()
248 if args.debug:
249 try:
250 import debug
251 debug.lsstDebug.frame = 2
252 except ImportError as e:
253 print(e, file=sys.stderr)
254 run(args)
255
256 The processing occurs in the run function. We first extract an exposure from disk or afwdata, displaying
257 it if requested:
258
259 .. code-block:: py
260
261 def run(args):
262 exposure = loadData(args.image)
263 if args.debug:
264 afwDisplay.Display(frame=1).mtv(exposure)
265
266 Create a default source schema that we will append fields to as we add more algorithms:
267
268 .. code-block:: py
269
270 schema = afwTable.SourceTable.makeMinimalSchema()
271
272 Create the detection and measurement Tasks, with some minor tweaking of their configs:
273
274 .. code-block:: py
275
276 # Create the detection task
277 config = SourceDetectionTask.ConfigClass()
278 config.thresholdPolarity = "both"
279 config.background.isNanSafe = True
280 config.thresholdValue = 3
281 detectionTask = SourceDetectionTask(config=config, schema=schema)
282 # And the measurement Task
283 config = DipoleMeasurementTask.ConfigClass()
284 config.plugins.names.remove('base_SkyCoord')
285 algMetadata = dafBase.PropertyList()
286 measureTask = DipoleMeasurementTask(schema, algMetadata, config=config)
287
288 Having fully initialied the schema, we create a Source table from it:
289
290 .. code-block:: py
291
292 # Create the output table
293 tab = afwTable.SourceTable.make(schema)
294
295 Run detection:
296
297 .. code-block:: py
298
299 # Process the data
300 results = detectionTask.run(tab, exposure)
301
302 Because we are looking for dipoles, we need to merge the positive and negative detections:
303
304 .. code-block:: py
305
306 # Merge the positve and negative sources
307 fpSet = results.fpSets.positive
308 growFootprint = 2
309 fpSet.merge(results.fpSets.negative, growFootprint, growFootprint, False)
310 diaSources = afwTable.SourceCatalog(tab)
311 fpSet.makeSources(diaSources)
312 print("Merged %s Sources into %d diaSources (from %d +ve, %d -ve)" % (len(results.sources),
313 len(diaSources),
314 results.fpSets.numPos,
315 results.fpSets.numNeg))
316
317 Finally, perform measurement (both standard and dipole-specialized) on the merged sources:
318
319 .. code-block:: py
320
321 measureTask.run(diaSources, exposure)
322
323 Optionally display debugging information:
324
325 .. code-block:: py
326
327 # Display dipoles if debug enabled
328 if args.debug:
329 dpa = DipoleAnalysis()
330 dpa.displayDipoles(exposure, diaSources)
331
332 """
333 ConfigClass = DipoleMeasurementConfig
334 _DefaultName = "dipoleMeasurement"
335
336
337
340
341class SourceFlagChecker(object):
342 """Functor class to check whether a diaSource has flags set that should cause it to be labeled bad."""
343
344 def __init__(self, sources, badFlags=None):
345 self.badFlags = ['base_PixelFlags_flag_edge', 'base_PixelFlags_flag_interpolatedCenter',
346 'base_PixelFlags_flag_saturatedCenter']
347 if badFlags is not None:
348 for flag in badFlags:
349 self.badFlags.append(flag)
350 self.keys = [sources.getSchema().find(name).key for name in self.badFlags]
351 self.keys.append(sources.table.getCentroidFlagKey())
352
353 def __call__(self, source):
354 """Call the source flag checker on a single Source
355
356 Parameters
357 ----------
358 source :
359 Source that will be examined
360 """
361 for k in self.keys:
362 if source.get(k):
363 return False
364 return True
365
366
367class DipoleAnalysis(object):
368 """Functor class that provides (S/N, position, orientation) of measured dipoles"""
369
370 def __init__(self):
371 pass
372
373 def __call__(self, source):
374 """Parse information returned from dipole measurement
375
376 Parameters
377 ----------
379 The source that will be examined"""
380 return self.getSn(source), self.getCentroid(source), self.getOrientation(source)
381
382 def getSn(self, source):
383 """Get the total signal-to-noise of the dipole; total S/N is from positive and negative lobe
384
385 Parameters
386 ----------
388 The source that will be examined"""
389
390 posflux = source.get("ip_diffim_PsfDipoleFlux_pos_instFlux")
391 posfluxErr = source.get("ip_diffim_PsfDipoleFlux_pos_instFluxErr")
392 negflux = source.get("ip_diffim_PsfDipoleFlux_neg_instFlux")
393 negfluxErr = source.get("ip_diffim_PsfDipoleFlux_neg_instFluxErr")
394
395 # Not a dipole!
396 if (posflux < 0) is (negflux < 0):
397 return 0
398
399 return np.sqrt((posflux/posfluxErr)**2 + (negflux/negfluxErr)**2)
400
401 def getCentroid(self, source):
402 """Get the centroid of the dipole; average of positive and negative lobe
403
404 Parameters
405 ----------
407 The source that will be examined"""
408
409 negCenX = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_x")
410 negCenY = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_y")
411 posCenX = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_x")
412 posCenY = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_y")
413 if (np.isinf(negCenX) or np.isinf(negCenY) or np.isinf(posCenX) or np.isinf(posCenY)):
414 return None
415
416 center = geom.Point2D(0.5*(negCenX+posCenX),
417 0.5*(negCenY+posCenY))
418 return center
419
420 def getOrientation(self, source):
421 """Calculate the orientation of dipole; vector from negative to positive lobe
422
423 Parameters
424 ----------
426 The source that will be examined"""
427
428 negCenX = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_x")
429 negCenY = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_y")
430 posCenX = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_x")
431 posCenY = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_y")
432 if (np.isinf(negCenX) or np.isinf(negCenY) or np.isinf(posCenX) or np.isinf(posCenY)):
433 return None
434
435 dx, dy = posCenX-negCenX, posCenY-negCenY
436 angle = geom.Angle(np.arctan2(dx, dy), geom.radians)
437 return angle
438
439 def displayDipoles(self, exposure, sources):
440 """Display debugging information on the detected dipoles
441
442 Parameters
443 ----------
444 exposure : `lsst.afw.image.Exposure`
445 Image the dipoles were measured on
447 The set of diaSources that were measured"""
448
449 import lsstDebug
450 display = lsstDebug.Info(__name__).display
451 displayDiaSources = lsstDebug.Info(__name__).displayDiaSources
452 maskTransparency = lsstDebug.Info(__name__).maskTransparency
453 if not maskTransparency:
454 maskTransparency = 90
455 disp = afwDisplay.Display(frame=lsstDebug.frame)
456 disp.setMaskTransparency(maskTransparency)
457 disp.mtv(exposure)
458
459 if display and displayDiaSources:
460 with disp.Buffering():
461 for source in sources:
462 cenX, cenY = source.get("ipdiffim_DipolePsfFlux_centroid")
463 if np.isinf(cenX) or np.isinf(cenY):
464 cenX, cenY = source.getCentroid()
465
466 isdipole = source.get("ip_diffim_ClassificationDipole_value")
467 if isdipole and np.isfinite(isdipole):
468 # Dipole
469 ctype = afwDisplay.GREEN
470 else:
471 # Not dipole
472 ctype = afwDisplay.RED
473
474 disp.dot("o", cenX, cenY, size=2, ctype=ctype)
475
476 negCenX = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_x")
477 negCenY = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_y")
478 posCenX = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_x")
479 posCenY = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_y")
480 if (np.isinf(negCenX) or np.isinf(negCenY) or np.isinf(posCenX) or np.isinf(posCenY)):
481 continue
482
483 disp.line([(negCenX, negCenY), (posCenX, posCenY)], ctype=afwDisplay.YELLOW)
484
485 lsstDebug.frame += 1
486
487
488class DipoleDeblender(object):
489 """Functor to deblend a source as a dipole, and return a new source with deblended footprints.
490
491 This necessarily overrides some of the functionality from
492 meas_algorithms/python/lsst/meas/algorithms/deblend.py since we
493 need a single source that contains the blended peaks, not
494 multiple children sources. This directly calls the core
495 deblending code deblendBaseline.deblend (optionally _fitPsf for
496 debugging).
497
498 Not actively being used, but there is a unit test for it in
499 dipoleAlgorithm.py.
500 """
501
502 def __init__(self):
503 # Set up defaults to send to deblender
504
505 # Always deblend as Psf
506 self.psfChisqCut1 = self.psfChisqCut2 = self.psfChisqCut2b = np.inf
507 self.log = getLogger('lsst.ip.diffim.DipoleDeblender')
508 self.sigma2fwhm = 2. * np.sqrt(2. * np.log(2.))
509
510 def __call__(self, source, exposure):
511 fp = source.getFootprint()
512 peaks = fp.getPeaks()
513 peaksF = [pk.getF() for pk in peaks]
514 fbb = fp.getBBox()
515 fmask = afwImage.Mask(fbb)
516 fmask.setXY0(fbb.getMinX(), fbb.getMinY())
517 fp.spans.setMask(fmask, 1)
518
519 psf = exposure.getPsf()
520 psfSigPix = psf.computeShape(psf.getAveragePosition()).getDeterminantRadius()
521 psfFwhmPix = psfSigPix * self.sigma2fwhm
522 subimage = afwImage.ExposureF(exposure, bbox=fbb, deep=True)
523 cpsf = deblendBaseline.CachingPsf(psf)
524
525 # if fewer than 2 peaks, just return a copy of the source
526 if len(peaks) < 2:
527 return source.getTable().copyRecord(source)
528
529 # make sure you only deblend 2 peaks; take the brighest and faintest
530 speaks = [(p.getPeakValue(), p) for p in peaks]
531 speaks.sort()
532 dpeaks = [speaks[0][1], speaks[-1][1]]
533
534 # and only set these peaks in the footprint (peaks is mutable)
535 peaks.clear()
536 for peak in dpeaks:
537 peaks.append(peak)
538
539 if True:
540 # Call top-level deblend task
541 fpres = deblendBaseline.deblend(fp, exposure.getMaskedImage(), psf, psfFwhmPix,
542 log=self.log,
543 psfChisqCut1=self.psfChisqCut1,
544 psfChisqCut2=self.psfChisqCut2,
545 psfChisqCut2b=self.psfChisqCut2b)
546 else:
547 # Call lower-level _fit_psf task
548
549 # Prepare results structure
550 fpres = deblendBaseline.DeblenderResult(fp, exposure.getMaskedImage(), psf, psfFwhmPix, self.log)
551
552 for pki, (pk, pkres, pkF) in enumerate(zip(dpeaks, fpres.deblendedParents[0].peaks, peaksF)):
553 self.log.debug('Peak %i', pki)
554 deblendBaseline._fitPsf(fp, fmask, pk, pkF, pkres, fbb, dpeaks, peaksF, self.log,
555 cpsf, psfFwhmPix,
556 subimage.getMaskedImage().getImage(),
557 subimage.getMaskedImage().getVariance(),
558 self.psfChisqCut1, self.psfChisqCut2, self.psfChisqCut2b)
559
560 deblendedSource = source.getTable().copyRecord(source)
561 deblendedSource.setParent(source.getId())
562 peakList = deblendedSource.getFootprint().getPeaks()
563 peakList.clear()
564
565 for i, peak in enumerate(fpres.deblendedParents[0].peaks):
566 if peak.psfFitFlux > 0:
567 suffix = "pos"
568 else:
569 suffix = "neg"
570 c = peak.psfFitCenter
571 self.log.info("deblended.centroid.dipole.psf.%s %f %f",
572 suffix, c[0], c[1])
573 self.log.info("deblended.chi2dof.dipole.%s %f",
574 suffix, peak.psfFitChisq / peak.psfFitDof)
575 self.log.info("deblended.flux.dipole.psf.%s %f",
576 suffix, peak.psfFitFlux * np.sum(peak.templateImage.getArray()))
577 peakList.append(peak.peak)
578 return deblendedSource
def __init__(self, config, name, schema, metadata)
def run(self, coaddExposures, bbox, wcs, dataIds, **kwargs)
Definition: getTemplate.py:494