lsst.ip.diffim  14.0-6-gf4bc96c+17
dipoleMeasurement.py
Go to the documentation of this file.
1 #
2 # LSST Data Management System
3 # Copyright 2008-2016 AURA/LSST.
4 #
5 # This product includes software developed by the
6 # LSST Project (http://www.lsst.org/).
7 #
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
17 #
18 # You should have received a copy of the LSST License Statement and
19 # the GNU General Public License along with this program. If not,
20 # see <https://www.lsstcorp.org/LegalNotices/>.
21 #
22 from __future__ import absolute_import, division, print_function
23 
24 from builtins import zip
25 from builtins import object
26 import numpy as np
27 
28 import lsst.afw.geom as afwGeom
29 import lsst.afw.image as afwImage
30 import lsst.afw.detection as afwDetect
31 import lsst.pex.config as pexConfig
32 from lsst.log import Log
33 import lsst.meas.deblender.baseline as deblendBaseline
34 from lsst.meas.base.pluginRegistry import register
35 from lsst.meas.base import SingleFrameMeasurementTask, SingleFrameMeasurementConfig, \
36  SingleFramePluginConfig, SingleFramePlugin
37 import lsst.afw.display.ds9 as ds9
38 
39 __all__ = ("DipoleMeasurementConfig", "DipoleMeasurementTask", "DipoleAnalysis", "DipoleDeblender",
40  "SourceFlagChecker", "ClassificationDipoleConfig", "ClassificationDipolePlugin")
41 
42 
43 class ClassificationDipoleConfig(SingleFramePluginConfig):
44  """Configuration for classification of detected diaSources as dipole or not"""
45  minSn = pexConfig.Field(
46  doc="Minimum quadrature sum of positive+negative lobe S/N to be considered a dipole",
47  dtype=float, default=np.sqrt(2) * 5.0,
48  )
49  maxFluxRatio = pexConfig.Field(
50  doc="Maximum flux ratio in either lobe to be considered a dipole",
51  dtype=float, default=0.65
52  )
53 
54 
55 @register("ip_diffim_ClassificationDipole")
56 class ClassificationDipolePlugin(SingleFramePlugin):
57  """A plugin to classify whether a diaSource is a dipole.
58  """
59 
60  ConfigClass = ClassificationDipoleConfig
61 
62  @classmethod
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_flux"))
76  negFluxFlag = measRecord.get("ip_diffim_PsfDipoleFlux_neg_flag")
77  posFlux = np.abs(measRecord.get("ip_diffim_PsfDipoleFlux_pos_flux"))
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 
100 class 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.instFlux = None
118  self.slots.shape = None
119  self.slots.centroid = "ip_diffim_NaiveDipoleCentroid"
120  self.doReplaceWithNoise = False
121 
122 
128 
129 
130 class DipoleMeasurementTask(SingleFrameMeasurementTask):
131  """!
132 \anchor DipoleMeasurementTask_
133 
134 \brief Measurement of Sources, specifically ones from difference images, for characterization as dipoles
135 
136 \section ip_diffim_dipolemeas_Contents Contents
137 
138  - \ref ip_diffim_dipolemeas_Purpose
139  - \ref ip_diffim_dipolemeas_Initialize
140  - \ref ip_diffim_dipolemeas_IO
141  - \ref ip_diffim_dipolemeas_Config
142  - \ref ip_diffim_dipolemeas_Metadata
143  - \ref ip_diffim_dipolemeas_Debug
144  - \ref ip_diffim_dipolemeas_Example
145 
146 #-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
147 
148 \section ip_diffim_dipolemeas_Purpose Description
149 
150 This class provides a default configuration for running Source measurement on image differences.
151 
152 These default plugins include:
153 \dontinclude dipoleMeasurement.py
154 \skip class DipoleMeasurementConfig
155 @until self.doReplaceWithNoise
156 
157 These plugins enabled by default allow the user to test the hypothesis that the Source is a dipole.
158 This includes a set of measurements derived from intermediate base classes
159 DipoleCentroidAlgorithm and DipoleFluxAlgorithm. Their respective algorithm control classes are defined in
160 DipoleCentroidControl and DipoleFluxControl. Each centroid and flux measurement will have _neg (negative)
161 and _pos (positive lobe) fields.
162 
163 The first set of measurements uses a "naive" alrogithm for centroid and flux measurements, implemented in
164 NaiveDipoleCentroidControl and NaiveDipoleFluxControl. The algorithm uses a naive 3x3 weighted moment around
165 the nominal centroids of each peak in the Source Footprint. These algorithms fill the table fields
166 ip_diffim_NaiveDipoleCentroid* and ip_diffim_NaiveDipoleFlux*
167 
168 The second set of measurements undertakes a joint-Psf model on the negative and positive lobe simultaneously.
169 This fit simultaneously solves for the negative and positive lobe centroids and fluxes using non-linear
170 least squares minimization. The fields are stored in table elements ip_diffim_PsfDipoleFlux*.
171 
172 Because this Task is just a config for SourceMeasurementTask, the same result may be acheived by manually
173 editing the config and running SourceMeasurementTask. For example:
174 
175 \code
176 config = SingleFrameMeasurementConfig()
177 config.plugins.names = ["base_PsfFlux",
178  "ip_diffim_PsfDipoleFlux",
179  "ip_diffim_NaiveDipoleFlux",
180  "ip_diffim_NaiveDipoleCentroid",
181  "ip_diffim_ClassificationDipole",
182  "base_CircularApertureFlux",
183  "base_SkyCoord"]
184 
185 config.slots.calibFlux = None
186 config.slots.modelFlux = None
187 config.slots.instFlux = None
188 config.slots.shape = None
189 config.slots.centroid = "ip_diffim_NaiveDipoleCentroid"
190 config.doReplaceWithNoise = False
191 
192 schema = afwTable.SourceTable.makeMinimalSchema()
193 task = SingleFrameMeasurementTask(schema, config=config)
194 
195 task.run(sources, exposure)
196 \endcode
197 
198 
199 #-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
200 
201 \section ip_diffim_dipolemeas_Initialize Task initialization
202 
203 \copydoc \_\_init\_\_
204 
205 #-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
206 
207 \section ip_diffim_dipolemeas_IO Invoking the Task
208 
209 \copydoc run
210 
211 #-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
212 
213 \section ip_diffim_dipolemeas_Config Configuration parameters
214 
215 See \ref DipoleMeasurementConfig
216 
217 #-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
218 
219 \section ip_diffim_dipolemeas_Metadata Quantities set in Metadata
220 
221 No specific values are set in the Task metadata. However, the Source schema are modified to store the
222 results of the dipole-specific measurements.
223 
224 
225 #-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
226 
227 \section ip_diffim_dipolemeas_Debug Debug variables
228 
229 The \link lsst.pipe.base.cmdLineTask.CmdLineTask command line task\endlink interface supports a
230 flag \c -d/--debug to import \b debug.py from your \c PYTHONPATH. The relevant contents of debug.py
231 for this Task include:
232 
233 \code{.py}
234  import sys
235  import lsstDebug
236  def DebugInfo(name):
237  di = lsstDebug.getInfo(name)
238  if name == "lsst.ip.diffim.dipoleMeasurement":
239  di.display = True # enable debug output
240  di.maskTransparency = 90 # ds9 mask transparency
241  di.displayDiaSources = True # show exposure with dipole results
242  return di
243  lsstDebug.Info = DebugInfo
244  lsstDebug.frame = 1
245 \endcode
246 
247 #-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
248 
249 \section ip_diffim_dipolemeas_Example A complete example of using DipoleMeasurementTask
250 
251 This code is dipoleMeasTask.py in the examples directory, and can be run as \em e.g.
252 \code
253 examples/dipoleMeasTask.py
254 examples/dipoleMeasTask.py --debug
255 examples/dipoleMeasTask.py --debug --image /path/to/image.fits
256 \endcode
257 
258 \dontinclude dipoleMeasTask.py
259 Start the processing by parsing the command line, where the user has the option of enabling debugging output
260 and/or sending their own image for demonstration (in case they have not downloaded the afwdata package).
261 \skip main
262 @until run
263 
264 \dontinclude dipoleMeasTask.py
265 The processing occurs in the run function. We first extract an exposure from disk or afwdata, displaying
266 it if requested:
267 \skip args
268 @until mtv
269 
270 Create a default source schema that we will append fields to as we add more algorithms:
271 \skip makeMinimalSchema
272 @until makeMinimalSchema
273 
274 Create the detection and measurement Tasks, with some minor tweaking of their configs:
275 \skip Create
276 @until measureTask
277 
278 Having fully initialied the schema, we create a Source table from it:
279 \skip output
280 @until SourceTable
281 
282 Run detection:
283 \skip Process
284 @until detectionTask
285 
286 Because we are looking for dipoles, we need to merge the positive and negative detections:
287 \skip Merge
288 @until numNeg
289 
290 Finally, perform measurement (both standard and dipole-specialized) on the merged sources:
291 \skip measureTask
292 @until measureTask
293 
294 Optionally display debugging information:
295 \skip Display
296 @until displayDipoles
297 #-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
298 
299  """
300  ConfigClass = DipoleMeasurementConfig
301  _DefaultName = "dipoleMeasurement"
302 
303 
304 
307 
308 class SourceFlagChecker(object):
309  """!Functor class to check whether a diaSource has flags set that should cause it to be labeled bad."""
310 
311  def __init__(self, sources, badFlags=None):
312  """!Constructor
313 
314  @param sources Sources that will be measured
315  @param badFlags A list of flags that will be used to determine if there was a measurement problem
316 
317  The list of badFlags will be used to make a list of keys to check for measurement flags on. By
318  default the centroid keys are added to this list"""
319 
320  self.badFlags = ['base_PixelFlags_flag_edge', 'base_PixelFlags_flag_interpolatedCenter',
321  'base_PixelFlags_flag_saturatedCenter']
322  if badFlags is not None:
323  for flag in badFlags:
324  self.badFlags.append(flag)
325  self.keys = [sources.getSchema().find(name).key for name in self.badFlags]
326  self.keys.append(sources.table.getCentroidFlagKey())
327 
328  def __call__(self, source):
329  """!Call the source flag checker on a single Source
330 
331  @param source Source that will be examined"""
332  for k in self.keys:
333  if source.get(k):
334  return False
335  return True
336 
337 
338 class DipoleAnalysis(object):
339  """!Functor class that provides (S/N, position, orientation) of measured dipoles"""
340 
341  def __init__(self):
342  """!Constructor"""
343  pass
344 
345  def __call__(self, source):
346  """!Parse information returned from dipole measurement
347 
348  @param source The source that will be examined"""
349  return self.getSn(source), self.getCentroid(source), self.getOrientation(source)
350 
351  def getSn(self, source):
352  """!Get the total signal-to-noise of the dipole; total S/N is from positive and negative lobe
353 
354  @param source The source that will be examined"""
355 
356  posflux = source.get("ip_diffim_PsfDipoleFlux_pos_flux")
357  posfluxErr = source.get("ip_diffim_PsfDipoleFlux_pos_fluxSigma")
358  negflux = source.get("ip_diffim_PsfDipoleFlux_neg_flux")
359  negfluxErr = source.get("ip_diffim_PsfDipoleFlux_neg_fluxSigma")
360 
361  # Not a dipole!
362  if (posflux < 0) is (negflux < 0):
363  return 0
364 
365  return np.sqrt((posflux/posfluxErr)**2 + (negflux/negfluxErr)**2)
366 
367  def getCentroid(self, source):
368  """!Get the centroid of the dipole; average of positive and negative lobe
369 
370  @param source The source that will be examined"""
371 
372  negCenX = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_x")
373  negCenY = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_y")
374  posCenX = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_x")
375  posCenY = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_y")
376  if (np.isinf(negCenX) or np.isinf(negCenY) or np.isinf(posCenX) or np.isinf(posCenY)):
377  return None
378 
379  center = afwGeom.Point2D(0.5*(negCenX+posCenX),
380  0.5*(negCenY+posCenY))
381  return center
382 
383  def getOrientation(self, source):
384  """!Calculate the orientation of dipole; vector from negative to positive lobe
385 
386  @param source The source that will be examined"""
387 
388  negCenX = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_x")
389  negCenY = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_y")
390  posCenX = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_x")
391  posCenY = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_y")
392  if (np.isinf(negCenX) or np.isinf(negCenY) or np.isinf(posCenX) or np.isinf(posCenY)):
393  return None
394 
395  dx, dy = posCenX-negCenX, posCenY-negCenY
396  angle = afwGeom.Angle(np.arctan2(dx, dy), afwGeom.radians)
397  return angle
398 
399  def displayDipoles(self, exposure, sources):
400  """!Display debugging information on the detected dipoles
401 
402  @param exposure Image the dipoles were measured on
403  @param sources The set of diaSources that were measured"""
404 
405  import lsstDebug
406  display = lsstDebug.Info(__name__).display
407  displayDiaSources = lsstDebug.Info(__name__).displayDiaSources
408  maskTransparency = lsstDebug.Info(__name__).maskTransparency
409  if not maskTransparency:
410  maskTransparency = 90
411  ds9.setMaskTransparency(maskTransparency)
412  ds9.mtv(exposure, frame=lsstDebug.frame)
413 
414  if display and displayDiaSources:
415  with ds9.Buffering():
416  for source in sources:
417  cenX, cenY = source.get("ipdiffim_DipolePsfFlux_centroid")
418  if np.isinf(cenX) or np.isinf(cenY):
419  cenX, cenY = source.getCentroid()
420 
421  isdipole = source.get("classification.dipole")
422  if isdipole and np.isfinite(isdipole):
423  # Dipole
424  ctype = "green"
425  else:
426  # Not dipole
427  ctype = "red"
428 
429  ds9.dot("o", cenX, cenY, size=2, ctype=ctype, frame=lsstDebug.frame)
430 
431  negCenX = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_x")
432  negCenY = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_y")
433  posCenX = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_x")
434  posCenY = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_y")
435  if (np.isinf(negCenX) or np.isinf(negCenY) or np.isinf(posCenX) or np.isinf(posCenY)):
436  continue
437 
438  ds9.line([(negCenX, negCenY), (posCenX, posCenY)], ctype="yellow", frame=lsstDebug.frame)
439 
440  lsstDebug.frame += 1
441 
442 
443 class DipoleDeblender(object):
444  """!Functor to deblend a source as a dipole, and return a new source with deblended footprints.
445 
446  This necessarily overrides some of the functionality from
447  meas_algorithms/python/lsst/meas/algorithms/deblend.py since we
448  need a single source that contains the blended peaks, not
449  multiple children sources. This directly calls the core
450  deblending code deblendBaseline.deblend (optionally _fitPsf for
451  debugging).
452 
453  Not actively being used, but there is a unit test for it in
454  dipoleAlgorithm.py.
455  """
456 
457  def __init__(self):
458  # Set up defaults to send to deblender
459 
460  # Always deblend as Psf
461  self.psfChisqCut1 = self.psfChisqCut2 = self.psfChisqCut2b = np.inf
462  self.log = Log.getLogger('ip.diffim.DipoleDeblender')
463  self.sigma2fwhm = 2. * np.sqrt(2. * np.log(2.))
464 
465  def __call__(self, source, exposure):
466  fp = source.getFootprint()
467  peaks = fp.getPeaks()
468  peaksF = [pk.getF() for pk in peaks]
469  fbb = fp.getBBox()
470  fmask = afwImage.Mask(fbb)
471  fmask.setXY0(fbb.getMinX(), fbb.getMinY())
472  fp.spans.setMask(fmask, 1)
473 
474  psf = exposure.getPsf()
475  psfSigPix = psf.computeShape().getDeterminantRadius()
476  psfFwhmPix = psfSigPix * self.sigma2fwhm
477  subimage = afwImage.ExposureF(exposure, bbox=fbb, deep=True)
478  cpsf = deblendBaseline.CachingPsf(psf)
479 
480  # if fewer than 2 peaks, just return a copy of the source
481  if len(peaks) < 2:
482  return source.getTable().copyRecord(source)
483 
484  # make sure you only deblend 2 peaks; take the brighest and faintest
485  speaks = [(p.getPeakValue(), p) for p in peaks]
486  speaks.sort()
487  dpeaks = [speaks[0][1], speaks[-1][1]]
488 
489  # and only set these peaks in the footprint (peaks is mutable)
490  peaks.clear()
491  for peak in dpeaks:
492  peaks.append(peak)
493 
494  if True:
495  # Call top-level deblend task
496  fpres = deblendBaseline.deblend(fp, exposure.getMaskedImage(), psf, psfFwhmPix,
497  log=self.log,
498  psfChisqCut1=self.psfChisqCut1,
499  psfChisqCut2=self.psfChisqCut2,
500  psfChisqCut2b=self.psfChisqCut2b)
501  else:
502  # Call lower-level _fit_psf task
503 
504  # Prepare results structure
505  fpres = deblendBaseline.DeblenderResult(fp, exposure.getMaskedImage(), psf, psfFwhmPix, self.log)
506 
507  for pki, (pk, pkres, pkF) in enumerate(zip(dpeaks, fpres.deblendedParents[0].peaks, peaksF)):
508  self.log.debug('Peak %i', pki)
509  deblendBaseline._fitPsf(fp, fmask, pk, pkF, pkres, fbb, dpeaks, peaksF, self.log,
510  cpsf, psfFwhmPix,
511  subimage.getMaskedImage().getImage(),
512  subimage.getMaskedImage().getVariance(),
513  self.psfChisqCut1, self.psfChisqCut2, self.psfChisqCut2b)
514 
515  deblendedSource = source.getTable().copyRecord(source)
516  deblendedSource.setParent(source.getId())
517  peakList = deblendedSource.getFootprint().getPeaks()
518  peakList.clear()
519 
520  for i, peak in enumerate(fpres.deblendedParents[0].peaks):
521  if peak.psfFitFlux > 0:
522  suffix = "pos"
523  else:
524  suffix = "neg"
525  c = peak.psfFitCenter
526  self.log.info("deblended.centroid.dipole.psf.%s %f %f",
527  suffix, c[0], c[1])
528  self.log.info("deblended.chi2dof.dipole.%s %f",
529  suffix, peak.psfFitChisq / peak.psfFitDof)
530  self.log.info("deblended.flux.dipole.psf.%s %f",
531  suffix, peak.psfFitFlux * np.sum(peak.templateImage.getArray()))
532  peakList.append(peak.peak)
533  return deblendedSource
def displayDipoles(self, exposure, sources)
Display debugging information on the detected dipoles.
def getCentroid(self, source)
Get the centroid of the dipole; average of positive and negative lobe.
def __call__(self, source)
Call the source flag checker on a single Source.
def __init__(self, sources, badFlags=None)
Constructor.
Measurement of Sources, specifically ones from difference images, for characterization as dipoles...
Functor class that provides (S/N, position, orientation) of measured dipoles.
Functor to deblend a source as a dipole, and return a new source with deblended footprints.
Functor class to check whether a diaSource has flags set that should cause it to be labeled bad...
def __init__(self, config, name, schema, metadata)
Measurement of detected diaSources as dipoles.
def getOrientation(self, source)
Calculate the orientation of dipole; vector from negative to positive lobe.
def __call__(self, source)
Parse information returned from dipole measurement.
def getSn(self, source)
Get the total signal-to-noise of the dipole; total S/N is from positive and negative lobe...