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