22 from __future__
import absolute_import, division, print_function
27 from builtins
import str
28 from builtins
import range
29 from builtins
import object
31 import lsst.afw.geom
as afwGeom
32 import lsst.afw.image
as afwImage
33 import lsst.meas.base
as measBase
34 import lsst.afw.table
as afwTable
35 import lsst.afw.detection
as afwDet
36 from lsst.log
import Log
37 import lsst.pex.exceptions
as pexExcept
38 import lsst.pex.config
as pexConfig
39 from lsst.pipe.base
import Struct
41 __all__ = (
"DipoleFitTask",
"DipoleFitPlugin",
"DipoleFitTaskConfig",
"DipoleFitPluginConfig")
49 """!Configuration for DipoleFitPlugin 52 fitAllDiaSources = pexConfig.Field(
53 dtype=float, default=
False,
54 doc=
"""Attempte dipole fit of all diaSources (otherwise just the ones consisting of overlapping 55 positive and negative footprints)""")
57 maxSeparation = pexConfig.Field(
58 dtype=float, default=5.,
59 doc=
"Assume dipole is not separated by more than maxSeparation * psfSigma")
61 relWeight = pexConfig.Field(
62 dtype=float, default=0.5,
63 doc=
"""Relative weighting of pre-subtraction images (higher -> greater influence of pre-sub. 66 tolerance = pexConfig.Field(
67 dtype=float, default=1e-7,
70 fitBackground = pexConfig.Field(
72 doc=
"""Set whether and how to fit for linear gradient in pre-sub. images. Possible values: 73 0: do not fit background at all 74 1 (default): pre-fit the background using linear least squares and then do not fit it as part 75 of the dipole fitting optimization 76 2: pre-fit the background using linear least squares (as in 1), and use the parameter 77 estimates from that fit as starting parameters for an integrated "re-fit" of the background 80 fitSeparateNegParams = pexConfig.Field(
81 dtype=bool, default=
False,
82 doc=
"Include parameters to fit for negative values (flux, gradient) separately from pos.")
85 minSn = pexConfig.Field(
86 dtype=float, default=np.sqrt(2) * 5.0,
87 doc=
"Minimum quadrature sum of positive+negative lobe S/N to be considered a dipole")
89 maxFluxRatio = pexConfig.Field(
90 dtype=float, default=0.65,
91 doc=
"Maximum flux ratio in either lobe to be considered a dipole")
93 maxChi2DoF = pexConfig.Field(
94 dtype=float, default=0.05,
95 doc=
"""Maximum Chi2/DoF significance of fit to be considered a dipole. 96 Default value means \"Choose a chi2DoF corresponding to a significance level of at most 0.05\" 97 (note this is actually a significance, not a chi2 value).""")
101 """!Measurement of detected diaSources as dipoles 103 Currently we keep the "old" DipoleMeasurement algorithms turned on. 107 measBase.SingleFrameMeasurementConfig.setDefaults(self)
109 self.plugins.names = [
"base_CircularApertureFlux",
115 "base_GaussianCentroid",
117 "base_PeakLikelihoodFlux",
119 "base_NaiveCentroid",
120 "ip_diffim_NaiveDipoleCentroid",
121 "ip_diffim_NaiveDipoleFlux",
122 "ip_diffim_PsfDipoleFlux",
123 "ip_diffim_ClassificationDipole",
126 self.slots.calibFlux =
None 127 self.slots.modelFlux =
None 128 self.slots.instFlux =
None 129 self.slots.shape =
"base_SdssShape" 130 self.slots.centroid =
"ip_diffim_NaiveDipoleCentroid" 135 """!Subclass of SingleFrameMeasurementTask which accepts up to three input images in its run() method. 137 Because it subclasses SingleFrameMeasurementTask, and calls 138 SingleFrameMeasurementTask.run() from its run() method, it still 139 can be used identically to a standard SingleFrameMeasurementTask. 142 ConfigClass = DipoleFitTaskConfig
143 _DefaultName =
"ip_diffim_DipoleFit" 145 def __init__(self, schema, algMetadata=None, **kwds):
147 measBase.SingleFrameMeasurementTask.__init__(self, schema, algMetadata, **kwds)
149 dpFitPluginConfig = self.config.plugins[
'ip_diffim_DipoleFit']
152 schema=schema, metadata=algMetadata)
154 def run(self, sources, exposure, posExp=None, negExp=None, **kwds):
155 """!Run dipole measurement and classification 157 @param sources diaSources that will be measured using dipole measurement 158 @param exposure Difference exposure on which the diaSources were detected; exposure = posExp - negExp 159 @param posExp "Positive" exposure, typically a science exposure, or None if unavailable 160 @param negExp "Negative" exposure, typically a template exposure, or None if unavailable 161 @param **kwds Sent to SingleFrameMeasurementTask 163 @note When `posExp` is `None`, will compute `posImage = exposure + negExp`. 164 Likewise, when `negExp` is `None`, will compute `negImage = posExp - exposure`. 165 If both `posExp` and `negExp` are `None`, will attempt to fit the dipole to just the `exposure` 169 measBase.SingleFrameMeasurementTask.run(self, sources, exposure, **kwds)
174 for source
in sources:
175 self.
dipoleFitter.measure(source, exposure, posExp, negExp)
179 """!Lightweight class containing methods for generating a dipole model for fitting 180 to sources in diffims, used by DipoleFitAlgorithm. 182 This code is documented in DMTN-007 (http://dmtn-007.lsst.io). 187 self.
debug = lsstDebug.Info(__name__).debug
188 self.
log = Log.getLogger(__name__)
191 """!Generate gradient model (2-d array) with up to 2nd-order polynomial 193 @param in_x (2, w, h)-dimensional `numpy.array`, containing the 194 input x,y meshgrid providing the coordinates upon which to 195 compute the gradient. This will typically be generated via 196 `_generateXYGrid()`. `w` and `h` correspond to the width and 197 height of the desired grid. 198 @param pars Up to 6 floats for up 199 to 6 2nd-order 2-d polynomial gradient parameters, in the 200 following order: (intercept, x, y, xy, x**2, y**2). If `pars` 201 is emtpy or `None`, do nothing and return `None` (for speed). 203 @return None, or 2-d numpy.array of width/height matching 204 input bbox, containing computed gradient values. 208 if (pars
is None)
or (len(pars) <= 0)
or (pars[0]
is None):
211 y, x = in_x[0, :], in_x[1, :]
212 gradient = np.full_like(x, pars[0], dtype=
'float64')
213 if len(pars) > 1
and pars[1]
is not None:
214 gradient += pars[1] * x
215 if len(pars) > 2
and pars[2]
is not None:
216 gradient += pars[2] * y
217 if len(pars) > 3
and pars[3]
is not None:
218 gradient += pars[3] * (x * y)
219 if len(pars) > 4
and pars[4]
is not None:
220 gradient += pars[4] * (x * x)
221 if len(pars) > 5
and pars[5]
is not None:
222 gradient += pars[5] * (y * y)
227 """!Generate a meshgrid covering the x,y coordinates bounded by bbox 229 @param bbox input BoundingBox defining the coordinate limits 230 @return in_x (2, w, h)-dimensional numpy array containing the grid indexing over x- and 233 @see makeBackgroundModel 236 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
237 in_x = np.array([y, x]).astype(np.float64)
238 in_x[0, :] -= np.mean(in_x[0, :])
239 in_x[1, :] -= np.mean(in_x[1, :])
243 """!Extract the image from a `HeavyFootprint` as an `afwImage.ImageF`. 245 @param fp HeavyFootprint to use to generate the subimage 246 @param badfill Value to fill in pixels in extracted image that are outside the footprint 247 @param grow Optionally grow the footprint by this amount before extraction 249 @return an `afwImage.ImageF` containing the subimage 255 subim2 = afwImage.ImageF(bbox, badfill)
256 fp.getSpans().unflatten(subim2.getArray(), fp.getImageArray(), bbox.getCorners()[0])
260 """!Fit a linear (polynomial) model of given order (max 2) to the background of a footprint. 262 Only fit the pixels OUTSIDE of the footprint, but within its bounding box. 264 @param source SourceRecord, the footprint of which is to be fit 265 @param posImage The exposure from which to extract the footprint subimage 266 @param order Polynomial order of background gradient to fit. 268 @return pars `tuple` of length (1 if order==0; 3 if order==1; 6 if order == 2), 269 containing the resulting fit parameters 271 @todo look into whether to use afwMath background methods -- see 272 http://lsst-web.ncsa.illinois.edu/doxygen/x_masterDoxyDoc/_background_example.html 275 fp = source.getFootprint()
278 posImg = afwImage.ImageF(posImage.getMaskedImage().getImage(), bbox, afwImage.PARENT)
283 posHfp = afwDet.HeavyFootprintF(fp, posImage.getMaskedImage())
286 isBg = np.isnan(posFpImg.getArray()).ravel()
288 data = posImg.getArray().ravel()
292 x, y = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
293 x = x.astype(np.float64).ravel()
296 y = y.astype(np.float64).ravel()
299 b = np.ones_like(x, dtype=np.float64)
303 M = np.vstack([b, x, y]).T
305 M = np.vstack([b, x, y, x**2., y**2., x*y]).T
307 pars = np.linalg.lstsq(M, B)[0]
311 """!Generate model (2-d Image) of a 'star' (single PSF) centered at given coordinates 313 @param bbox Bounding box marking pixel coordinates for generated model 314 @param psf Psf model used to generate the 'star' 315 @param xcen Desired x-centroid of the 'star' 316 @param ycen Desired y-centroid of the 'star' 317 @param flux Desired flux of the 'star' 319 @return 2-d stellar `afwImage.Image` of width/height matching input `bbox`, 320 containing PSF with given centroid and flux 324 psf_img = psf.computeImage(afwGeom.Point2D(xcen, ycen)).convertF()
325 psf_img_sum = np.nansum(psf_img.getArray())
326 psf_img *= (flux/psf_img_sum)
329 psf_box = psf_img.getBBox()
331 psf_img = afwImage.ImageF(psf_img, psf_box, afwImage.PARENT)
337 p_Im = afwImage.ImageF(bbox)
338 tmpSubim = afwImage.ImageF(p_Im, psf_box, afwImage.PARENT)
343 def makeModel(self, x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
344 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
345 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
347 """!Generate dipole model with given parameters. 349 This is the function whose sum-of-squared difference from data 350 is minimized by `lmfit`. 352 @param x Input independent variable. Used here as the grid on 353 which to compute the background gradient model. 354 @param flux Desired flux of the positive lobe of the dipole 355 @param xcenPos Desired x-centroid of the positive lobe of the dipole 356 @param ycenPos Desired y-centroid of the positive lobe of the dipole 357 @param xcenNeg Desired x-centroid of the negative lobe of the dipole 358 @param ycenNeg Desired y-centroid of the negative lobe of the dipole 359 @param fluxNeg Desired flux of the negative lobe of the dipole, set to 'flux' if None 360 @param b, x1, y1, xy, x2, y2 Gradient parameters for positive lobe. 361 @param bNeg, x1Neg, y1Neg, xyNeg, x2Neg, y2Neg Gradient parameters for negative lobe. 362 They are set to the corresponding positive values if None. 364 @param **kwargs Keyword arguments passed through `lmfit` and 365 used by this function. These must include: 366 - `psf` Psf model used to generate the 'star' 367 - `rel_weight` Used to signify least-squares weighting of posImage/negImage 368 relative to diffim. If `rel_weight == 0` then posImage/negImage are ignored. 369 - `bbox` Bounding box containing region to be modelled 371 @see `makeBackgroundModel` for further parameter descriptions. 373 @return `numpy.array` of width/height matching input bbox, 374 containing dipole model with given centroids and flux(es). If 375 `rel_weight` = 0, this is a 2-d array with dimensions matching 376 those of bbox; otherwise a stack of three such arrays, 377 representing the dipole (diffim), positive and negative images 381 psf = kwargs.get(
'psf')
382 rel_weight = kwargs.get(
'rel_weight')
383 fp = kwargs.get(
'footprint')
390 self.
log.
debug(
'%.2f %.2f %.2f %.2f %.2f %.2f',
391 flux, fluxNeg, xcenPos, ycenPos, xcenNeg, ycenNeg)
393 self.
log.
debug(
' %.2f %.2f %.2f', b, x1, y1)
395 self.
log.
debug(
' %.2f %.2f %.2f', xy, x2, y2)
397 posIm = self.
makeStarModel(bbox, psf, xcenPos, ycenPos, flux)
398 negIm = self.
makeStarModel(bbox, psf, xcenNeg, ycenNeg, fluxNeg)
402 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
403 in_x = np.array([x, y]) * 1.
404 in_x[0, :] -= in_x[0, :].mean()
405 in_x[1, :] -= in_x[1, :].mean()
414 gradientNeg = gradient
416 posIm.getArray()[:, :] += gradient
417 negIm.getArray()[:, :] += gradientNeg
420 diffIm = afwImage.ImageF(bbox)
424 zout = diffIm.getArray()
426 zout = np.append([zout], [posIm.getArray(), negIm.getArray()], axis=0)
432 """!Lightweight class containing methods for fitting a dipole model in 433 a diffim, used by DipoleFitPlugin. 435 This code is documented in DMTN-007 (http://dmtn-007.lsst.io). 437 Below is a (somewhat incomplete) list of improvements 438 that would be worth investigating, given the time: 440 @todo 1. evaluate necessity for separate parameters for pos- and neg- images 441 @todo 2. only fit background OUTSIDE footprint (DONE) and dipole params INSIDE footprint (NOT DONE)? 442 @todo 3. correct normalization of least-squares weights based on variance planes 443 @todo 4. account for PSFs that vary across the exposures (should be happening by default?) 444 @todo 5. correctly account for NA/masks (i.e., ignore!) 445 @todo 6. better exception handling in the plugin 446 @todo 7. better classification of dipoles (e.g. by comparing chi2 fit vs. monopole?) 447 @todo 8. (DONE) Initial fast estimate of background gradient(s) params -- perhaps using numpy.lstsq 448 @todo 9. (NOT NEEDED - see (2)) Initial fast test whether a background gradient needs to be fit 449 @todo 10. (DONE) better initial estimate for flux when there's a strong gradient 450 @todo 11. (DONE) requires a new package `lmfit` -- investiate others? (astropy/scipy/iminuit?) 455 _private_version_ =
'0.0.5' 457 def __init__(self, diffim, posImage=None, negImage=None):
458 """!Algorithm to run dipole measurement on a diaSource 460 @param diffim Exposure on which the diaSources were detected 461 @param posImage "Positive" exposure from which the template was subtracted 462 @param negImage "Negative" exposure which was subtracted from the posImage 469 if diffim
is not None:
470 self.
psfSigma = diffim.getPsf().computeShape().getDeterminantRadius()
472 self.
log = Log.getLogger(__name__)
475 self.
debug = lsstDebug.Info(__name__).debug
478 fitBackground=1, bgGradientOrder=1, maxSepInSigma=5.,
479 separateNegParams=True, verbose=False):
480 """!Fit a dipole model to an input difference image. 482 Actually, fits the subimage bounded by the input source's 483 footprint) and optionally constrain the fit using the 484 pre-subtraction images posImage and negImage. 486 @return `lmfit.MinimizerResult` object containing the fit 487 parameters and other information. 495 fp = source.getFootprint()
497 subim = afwImage.MaskedImageF(self.
diffim.getMaskedImage(), bbox=bbox, origin=afwImage.PARENT)
499 z = diArr = subim.getArrays()[0]
500 weights = 1. / subim.getArrays()[2]
502 if rel_weight > 0.
and ((self.
posImage is not None)
or (self.
negImage is not None)):
504 negSubim = afwImage.MaskedImageF(self.
negImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
506 posSubim = afwImage.MaskedImageF(self.
posImage.getMaskedImage(), bbox, origin=afwImage.PARENT)
508 posSubim = subim.clone()
511 negSubim = posSubim.clone()
514 z = np.append([z], [posSubim.getArrays()[0],
515 negSubim.getArrays()[0]], axis=0)
517 weights = np.append([weights], [1. / posSubim.getArrays()[2] * rel_weight,
518 1. / negSubim.getArrays()[2] * rel_weight], axis=0)
525 def dipoleModelFunctor(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None,
526 b=None, x1=None, y1=None, xy=None, x2=None, y2=None,
527 bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None,
529 """!Generate dipole model with given parameters. 531 It simply defers to `modelObj.makeModel()`, where `modelObj` comes 532 out of `kwargs['modelObj']`. 534 @see DipoleModel.makeModel 536 modelObj = kwargs.pop(
'modelObj')
537 return modelObj.makeModel(x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=fluxNeg,
538 b=b, x1=x1, y1=y1, xy=xy, x2=x2, y2=y2,
539 bNeg=bNeg, x1Neg=x1Neg, y1Neg=y1Neg, xyNeg=xyNeg,
540 x2Neg=x2Neg, y2Neg=y2Neg, **kwargs)
544 modelFunctor = dipoleModelFunctor
547 gmod = lmfit.Model(modelFunctor, verbose=verbose, missing=
'drop')
552 fpCentroid = np.array([fp.getCentroid().getX(), fp.getCentroid().getY()])
553 cenNeg = cenPos = fpCentroid
558 cenPos = pks[0].getF()
560 cenNeg = pks[-1].getF()
564 maxSep = self.
psfSigma * maxSepInSigma
567 if np.sum(np.sqrt((np.array(cenPos) - fpCentroid)**2.)) > maxSep:
569 if np.sum(np.sqrt((np.array(cenNeg) - fpCentroid)**2.)) > maxSep:
575 gmod.set_param_hint(
'xcenPos', value=cenPos[0],
576 min=cenPos[0]-maxSep, max=cenPos[0]+maxSep)
577 gmod.set_param_hint(
'ycenPos', value=cenPos[1],
578 min=cenPos[1]-maxSep, max=cenPos[1]+maxSep)
579 gmod.set_param_hint(
'xcenNeg', value=cenNeg[0],
580 min=cenNeg[0]-maxSep, max=cenNeg[0]+maxSep)
581 gmod.set_param_hint(
'ycenNeg', value=cenNeg[1],
582 min=cenNeg[1]-maxSep, max=cenNeg[1]+maxSep)
586 startingFlux = np.nansum(np.abs(diArr) - np.nanmedian(np.abs(diArr))) * 5.
587 posFlux = negFlux = startingFlux
590 gmod.set_param_hint(
'flux', value=posFlux, min=0.1)
592 if separateNegParams:
594 gmod.set_param_hint(
'fluxNeg', value=np.abs(negFlux), min=0.1)
602 bgParsPos = bgParsNeg = (0., 0., 0.)
603 if ((rel_weight > 0.)
and (fitBackground != 0)
and (bgGradientOrder >= 0)):
607 bgParsPos = bgParsNeg = dipoleModel.fitFootprintBackground(source, bgFitImage,
608 order=bgGradientOrder)
611 if fitBackground == 1:
612 in_x = dipoleModel._generateXYGrid(bbox)
613 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsPos))
615 z[1, :] -= np.nanmedian(z[1, :])
616 posFlux = np.nansum(z[1, :])
617 gmod.set_param_hint(
'flux', value=posFlux*1.5, min=0.1)
619 if separateNegParams
and self.
negImage is not None:
620 bgParsNeg = dipoleModel.fitFootprintBackground(source, self.
negImage,
621 order=bgGradientOrder)
622 pbg = dipoleModel.makeBackgroundModel(in_x, tuple(bgParsNeg))
624 z[2, :] -= np.nanmedian(z[2, :])
625 if separateNegParams:
626 negFlux = np.nansum(z[2, :])
627 gmod.set_param_hint(
'fluxNeg', value=negFlux*1.5, min=0.1)
630 if fitBackground == 2:
631 if bgGradientOrder >= 0:
632 gmod.set_param_hint(
'b', value=bgParsPos[0])
633 if separateNegParams:
634 gmod.set_param_hint(
'bNeg', value=bgParsNeg[0])
635 if bgGradientOrder >= 1:
636 gmod.set_param_hint(
'x1', value=bgParsPos[1])
637 gmod.set_param_hint(
'y1', value=bgParsPos[2])
638 if separateNegParams:
639 gmod.set_param_hint(
'x1Neg', value=bgParsNeg[1])
640 gmod.set_param_hint(
'y1Neg', value=bgParsNeg[2])
641 if bgGradientOrder >= 2:
642 gmod.set_param_hint(
'xy', value=bgParsPos[3])
643 gmod.set_param_hint(
'x2', value=bgParsPos[4])
644 gmod.set_param_hint(
'y2', value=bgParsPos[5])
645 if separateNegParams:
646 gmod.set_param_hint(
'xyNeg', value=bgParsNeg[3])
647 gmod.set_param_hint(
'x2Neg', value=bgParsNeg[4])
648 gmod.set_param_hint(
'y2Neg', value=bgParsNeg[5])
650 y, x = np.mgrid[bbox.getBeginY():bbox.getEndY(), bbox.getBeginX():bbox.getEndX()]
651 in_x = np.array([x, y]).astype(np.float)
652 in_x[0, :] -= in_x[0, :].mean()
653 in_x[1, :] -= in_x[1, :].mean()
657 mask = np.ones_like(z, dtype=bool)
662 weights = mask.astype(np.float64)
663 if self.
posImage is not None and rel_weight > 0.:
664 weights = np.array([np.ones_like(diArr), np.ones_like(diArr)*rel_weight,
665 np.ones_like(diArr)*rel_weight])
674 with warnings.catch_warnings():
675 warnings.simplefilter(
"ignore")
676 result = gmod.fit(z, weights=weights, x=in_x,
678 fit_kws={
'ftol': tol,
'xtol': tol,
'gtol': tol,
681 rel_weight=rel_weight,
683 modelObj=dipoleModel)
690 print(result.fit_report(show_correl=
False))
691 if separateNegParams:
692 print(result.ci_report())
696 def fitDipole(self, source, tol=1e-7, rel_weight=0.1,
697 fitBackground=1, maxSepInSigma=5., separateNegParams=True,
698 bgGradientOrder=1, verbose=False, display=False):
699 """!Wrapper around `fitDipoleImpl()` which performs the fit of a dipole 700 model to an input diaSource. 702 Actually, fits the subimage bounded by the input source's 703 footprint) and optionally constrain the fit using the 704 pre-subtraction images self.posImage (science) and 705 self.negImage (template). Wraps the output into a 706 `pipeBase.Struct` named tuple after computing additional 707 statistics such as orientation and SNR. 709 @param source Record containing the (merged) dipole source footprint detected on the diffim 710 @param tol Tolerance parameter for scipy.leastsq() optimization 711 @param rel_weight Weighting of posImage/negImage relative to the diffim in the fit 712 @param fitBackground How to fit linear background gradient in posImage/negImage (see notes) 713 @param bgGradientOrder Desired polynomial order of background gradient (allowed are [0,1,2]) 714 @param maxSepInSigma Allowed window of centroid parameters relative to peak in input source footprint 715 @param separateNegParams Fit separate parameters to the flux and background gradient in 716 the negative images? If true, this adds a separate parameter for the negative flux, and [1, 3, or 6] 717 additional parameters to fit for the background gradient in the negImage. Otherwise, the flux and 718 gradient parameters are constrained to be exactly equal in the fit. 719 @param verbose Be verbose 720 @param display Display input data, best fit model(s) and residuals in a matplotlib window. 722 @return `pipeBase.Struct` object containing the fit parameters and other information. 723 @return `lmfit.MinimizerResult` object for debugging and error estimation, etc. 725 @note Parameter `fitBackground` has three options, thus it is an integer: 726 - 0: do not fit background at all 727 - 1 (default): pre-fit the background using linear least squares and then do not fit it as part 728 of the dipole fitting optimization 729 - 2: pre-fit the background using linear least squares (as in 1), and use the parameter 730 estimates from that fit as starting parameters for an integrated "re-fit" of the background 731 as part of the overall dipole fitting optimization. 735 source, tol=tol, rel_weight=rel_weight, fitBackground=fitBackground,
736 maxSepInSigma=maxSepInSigma, separateNegParams=separateNegParams,
737 bgGradientOrder=bgGradientOrder, verbose=verbose)
741 fp = source.getFootprint()
744 fitParams = fitResult.best_values
745 if fitParams[
'flux'] <= 1.:
746 out = Struct(posCentroidX=np.nan, posCentroidY=np.nan,
747 negCentroidX=np.nan, negCentroidY=np.nan,
748 posFlux=np.nan, negFlux=np.nan, posFluxSigma=np.nan, negFluxSigma=np.nan,
749 centroidX=np.nan, centroidY=np.nan, orientation=np.nan,
750 signalToNoise=np.nan, chi2=np.nan, redChi2=np.nan)
751 return out, fitResult
753 centroid = ((fitParams[
'xcenPos'] + fitParams[
'xcenNeg']) / 2.,
754 (fitParams[
'ycenPos'] + fitParams[
'ycenNeg']) / 2.)
755 dx, dy = fitParams[
'xcenPos'] - fitParams[
'xcenNeg'], fitParams[
'ycenPos'] - fitParams[
'ycenNeg']
756 angle = np.arctan2(dy, dx) / np.pi * 180.
760 def computeSumVariance(exposure, footprint):
761 box = footprint.getBBox()
762 subim = afwImage.MaskedImageF(exposure.getMaskedImage(), box, origin=afwImage.PARENT)
763 return np.sqrt(np.nansum(subim.getArrays()[1][:, :]))
765 fluxVal = fluxVar = fitParams[
'flux']
766 fluxErr = fluxErrNeg = fitResult.params[
'flux'].stderr
768 fluxVar = computeSumVariance(self.
posImage, source.getFootprint())
770 fluxVar = computeSumVariance(self.
diffim, source.getFootprint())
772 fluxValNeg, fluxVarNeg = fluxVal, fluxVar
773 if separateNegParams:
774 fluxValNeg = fitParams[
'fluxNeg']
775 fluxErrNeg = fitResult.params[
'fluxNeg'].stderr
777 fluxVarNeg = computeSumVariance(self.
negImage, source.getFootprint())
780 signalToNoise = np.sqrt((fluxVal/fluxVar)**2 + (fluxValNeg/fluxVarNeg)**2)
782 signalToNoise = np.nan
784 out = Struct(posCentroidX=fitParams[
'xcenPos'], posCentroidY=fitParams[
'ycenPos'],
785 negCentroidX=fitParams[
'xcenNeg'], negCentroidY=fitParams[
'ycenNeg'],
786 posFlux=fluxVal, negFlux=-fluxValNeg, posFluxSigma=fluxErr, negFluxSigma=fluxErrNeg,
787 centroidX=centroid[0], centroidY=centroid[1], orientation=angle,
788 signalToNoise=signalToNoise, chi2=fitResult.chisqr, redChi2=fitResult.redchi)
791 return out, fitResult
794 """!Display data, model fits and residuals (currently uses matplotlib display functions). 796 @param footprint Footprint containing the dipole that was fit 797 @param result `lmfit.MinimizerResult` object returned by `lmfit` optimizer 800 import matplotlib.pyplot
as plt
801 except ImportError
as err:
802 self.
log.warn(
'Unable to import matplotlib: %s', err)
805 def display2dArray(arr, title='Data', extent=None):
806 """!Use `matplotlib.pyplot.imshow` to display a 2-D array with a given coordinate range. 808 fig = plt.imshow(arr, origin=
'lower', interpolation=
'none', cmap=
'gray', extent=extent)
810 plt.colorbar(fig, cmap=
'gray')
814 fit = result.best_fit
815 bbox = footprint.getBBox()
816 extent = (bbox.getBeginX(), bbox.getEndX(), bbox.getBeginY(), bbox.getEndY())
818 fig = plt.figure(figsize=(8, 8))
820 plt.subplot(3, 3, i*3+1)
821 display2dArray(z[i, :],
'Data', extent=extent)
822 plt.subplot(3, 3, i*3+2)
823 display2dArray(fit[i, :],
'Model', extent=extent)
824 plt.subplot(3, 3, i*3+3)
825 display2dArray(z[i, :] - fit[i, :],
'Residual', extent=extent)
828 fig = plt.figure(figsize=(8, 2.5))
830 display2dArray(z,
'Data', extent=extent)
832 display2dArray(fit,
'Model', extent=extent)
834 display2dArray(z - fit,
'Residual', extent=extent)
840 @measBase.register(
"ip_diffim_DipoleFit")
842 """!Subclass of SingleFramePlugin which fits dipoles to all merged (two-peak) diaSources 844 Accepts up to three input images in its `measure` method. If these are 845 provided, it includes data from the pre-subtraction posImage 846 (science image) and optionally negImage (template image) to 847 constrain the fit. The meat of the fitting routines are in the 848 class DipoleFitAlgorithm. 850 The motivation behind this plugin and the necessity for including more than 851 one exposure are documented in DMTN-007 (http://dmtn-007.lsst.io). 853 This class is named `ip_diffim_DipoleFit` so that it may be used alongside 854 the existing `ip_diffim_DipoleMeasurement` classes until such a time as those 855 are deemed to be replaceable by this. 858 ConfigClass = DipoleFitPluginConfig
859 DipoleFitAlgorithmClass = DipoleFitAlgorithm
863 FAILURE_NOT_DIPOLE = 4
867 """!Set execution order to `FLUX_ORDER`. 869 This includes algorithms that require both `getShape()` and `getCentroid()`, 870 in addition to a Footprint and its Peaks. 872 return cls.FLUX_ORDER
874 def __init__(self, config, name, schema, metadata):
875 measBase.SingleFramePlugin.__init__(self, config, name, schema, metadata)
877 self.
log = Log.getLogger(name)
889 for pos_neg
in [
'pos',
'neg']:
891 key = schema.addField(
892 schema.join(name, pos_neg,
"flux"), type=float, units=
"count",
893 doc=
"Dipole {0} lobe flux".format(pos_neg))
894 setattr(self,
''.join((pos_neg,
'FluxKey')), key)
896 key = schema.addField(
897 schema.join(name, pos_neg,
"fluxSigma"), type=float, units=
"pixel",
898 doc=
"1-sigma uncertainty for {0} dipole flux".format(pos_neg))
899 setattr(self,
''.join((pos_neg,
'FluxSigmaKey')), key)
901 for x_y
in [
'x',
'y']:
902 key = schema.addField(
903 schema.join(name, pos_neg,
"centroid", x_y), type=float, units=
"pixel",
904 doc=
"Dipole {0} lobe centroid".format(pos_neg))
905 setattr(self,
''.join((pos_neg,
'CentroidKey', x_y.upper())), key)
907 for x_y
in [
'x',
'y']:
908 key = schema.addField(
909 schema.join(name,
"centroid", x_y), type=float, units=
"pixel",
910 doc=
"Dipole centroid")
911 setattr(self,
''.join((
'centroidKey', x_y.upper())), key)
914 schema.join(name,
"flux"), type=float, units=
"count",
915 doc=
"Dipole overall flux")
918 schema.join(name,
"orientation"), type=float, units=
"deg",
919 doc=
"Dipole orientation")
922 schema.join(name,
"separation"), type=float, units=
"pixel",
923 doc=
"Pixel separation between positive and negative lobes of dipole")
926 schema.join(name,
"chi2dof"), type=float,
927 doc=
"Chi2 per degree of freedom of dipole fit")
930 schema.join(name,
"signalToNoise"), type=float,
931 doc=
"Estimated signal-to-noise of dipole fit")
934 schema.join(name,
"flag",
"classification"), type=
"Flag",
935 doc=
"Flag indicating diaSource is classified as a dipole")
938 schema.join(name,
"flag",
"classificationAttempted"), type=
"Flag",
939 doc=
"Flag indicating diaSource was attempted to be classified as a dipole")
942 schema.join(name,
"flag"), type=
"Flag",
943 doc=
"General failure flag for dipole fit")
946 schema.join(name,
"flag",
"edge"), type=
"Flag",
947 doc=
"Flag set when dipole is too close to edge of image")
949 def measure(self, measRecord, exposure, posExp=None, negExp=None):
950 """!Perform the non-linear least squares minimization on the putative dipole source. 952 @param measRecord diaSources that will be measured using dipole measurement 953 @param exposure Difference exposure on which the diaSources were detected; `exposure = posExp-negExp` 954 @param posExp "Positive" exposure, typically a science exposure, or None if unavailable 955 @param negExp "Negative" exposure, typically a template exposure, or None if unavailable 957 @note When `posExp` is `None`, will compute `posImage = exposure + negExp`. 958 Likewise, when `negExp` is `None`, will compute `negImage = posExp - exposure`. 959 If both `posExp` and `negExp` are `None`, will attempt to fit the dipole to just the `exposure` 962 The main functionality of this routine was placed outside of 963 this plugin (into `DipoleFitAlgorithm.fitDipole()`) so that 964 `DipoleFitAlgorithm.fitDipole()` can be called separately for 965 testing (@see `tests/testDipoleFitter.py`) 969 pks = measRecord.getFootprint().getPeaks()
974 (len(pks) > 1
and (np.sign(pks[0].getPeakValue()) ==
975 np.sign(pks[-1].getPeakValue())))
980 if not self.config.fitAllDiaSources:
985 result, _ = alg.fitDipole(
986 measRecord, rel_weight=self.config.relWeight,
987 tol=self.config.tolerance,
988 maxSepInSigma=self.config.maxSeparation,
989 fitBackground=self.config.fitBackground,
990 separateNegParams=self.config.fitSeparateNegParams,
991 verbose=
False, display=
False)
992 except pexExcept.LengthError:
993 self.
fail(measRecord, measBase.MeasurementError(
'edge failure', self.
FAILURE_EDGE))
995 self.
fail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FIT))
1002 self.
log.debug(
"Dipole fit result: %d %s", measRecord.getId(), str(result))
1004 if result.posFlux <= 1.:
1005 self.
fail(measRecord, measBase.MeasurementError(
'dipole fit failure', self.
FAILURE_FIT))
1009 measRecord[self.posFluxKey] = result.posFlux
1010 measRecord[self.posFluxSigmaKey] = result.signalToNoise
1011 measRecord[self.posCentroidKeyX] = result.posCentroidX
1012 measRecord[self.posCentroidKeyY] = result.posCentroidY
1014 measRecord[self.negFluxKey] = result.negFlux
1015 measRecord[self.negFluxSigmaKey] = result.signalToNoise
1016 measRecord[self.negCentroidKeyX] = result.negCentroidX
1017 measRecord[self.negCentroidKeyY] = result.negCentroidY
1020 measRecord[self.
fluxKey] = (abs(result.posFlux) + abs(result.negFlux))/2.
1022 measRecord[self.
separationKey] = np.sqrt((result.posCentroidX - result.negCentroidX)**2. +
1023 (result.posCentroidY - result.negCentroidY)**2.)
1024 measRecord[self.centroidKeyX] = result.centroidX
1025 measRecord[self.centroidKeyY] = result.centroidY
1033 """!Determine if source is classified as dipole via three criteria: 1034 - does the total signal-to-noise surpass the minSn? 1035 - are the pos/neg fluxes greater than 1.0 and no more than 0.65 (param `maxFluxRatio`) 1036 of the total flux? By default this will never happen since `posFlux == negFlux`. 1037 - is it a good fit (`chi2dof` < 1)? (Currently not used.) 1045 passesFluxPos = (abs(measRecord[self.posFluxKey]) /
1046 (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1047 passesFluxPos &= (abs(measRecord[self.posFluxKey]) >= 1.0)
1048 passesFluxNeg = (abs(measRecord[self.negFluxKey]) /
1049 (measRecord[self.
fluxKey]*2.)) < self.config.maxFluxRatio
1050 passesFluxNeg &= (abs(measRecord[self.negFluxKey]) >= 1.0)
1051 allPass = (passesSn
and passesFluxPos
and passesFluxNeg)
1059 from scipy.stats
import chi2
1061 significance = chi2.cdf(chi2val, ndof)
1062 passesChi2 = significance < self.config.maxChi2DoF
1063 allPass = allPass
and passesChi2
1072 def fail(self, measRecord, error=None):
1073 """!Catch failures and set the correct flags. 1076 measRecord.set(self.
flagKey,
True)
1077 if error
is not None:
1079 self.
log.warn(
'DipoleFitPlugin not run on record %d: %s', measRecord.getId(), str(error))
1082 self.
log.warn(
'DipoleFitPlugin failed on record %d: %s', measRecord.getId(), str(error))
1083 measRecord.set(self.
flagKey,
True)
1085 self.
log.debug(
'DipoleFitPlugin not run on record %d: %s',
1086 measRecord.getId(), str(error))
1088 measRecord.set(self.
flagKey,
True)
1090 self.
log.warn(
'DipoleFitPlugin failed on record %d', measRecord.getId())
def _getHeavyFootprintSubimage(self, fp, badfill=np.nan, grow=0)
Extract the image from a HeavyFootprint as an afwImage.ImageF.
Subclass of SingleFramePlugin which fits dipoles to all merged (two-peak) diaSources.
Lightweight class containing methods for generating a dipole model for fitting to sources in diffims...
def makeModel(self, x, flux, xcenPos, ycenPos, xcenNeg, ycenNeg, fluxNeg=None, b=None, x1=None, y1=None, xy=None, x2=None, y2=None, bNeg=None, x1Neg=None, y1Neg=None, xyNeg=None, x2Neg=None, y2Neg=None, kwargs)
Generate dipole model with given parameters.
def makeStarModel(self, bbox, psf, xcen, ycen, flux)
Generate model (2-d Image) of a 'star' (single PSF) centered at given coordinates.
def fitDipole(self, source, tol=1e-7, rel_weight=0.1, fitBackground=1, maxSepInSigma=5., separateNegParams=True, bgGradientOrder=1, verbose=False, display=False)
Wrapper around fitDipoleImpl() which performs the fit of a dipole model to an input diaSource...
Lightweight class containing methods for fitting a dipole model in a diffim, used by DipoleFitPlugin...
def __init__(self, diffim, posImage=None, negImage=None)
Algorithm to run dipole measurement on a diaSource.
def displayFitResults(self, footprint, result)
Display data, model fits and residuals (currently uses matplotlib display functions).
def _generateXYGrid(self, bbox)
Generate a meshgrid covering the x,y coordinates bounded by bbox.
Configuration for DipoleFitPlugin.
def fitFootprintBackground(self, source, posImage, order=1)
Fit a linear (polynomial) model of given order (max 2) to the background of a footprint.
def __init__(self, config, name, schema, metadata)
classificationAttemptedFlagKey
def __init__(self, schema, algMetadata=None, kwds)
def makeBackgroundModel(self, in_x, pars=None)
Generate gradient model (2-d array) with up to 2nd-order polynomial.
Measurement of detected diaSources as dipoles.
def _setupSchema(self, config, name, schema, metadata)
Subclass of SingleFrameMeasurementTask which accepts up to three input images in its run() method...
def getExecutionOrder(cls)
Set execution order to FLUX_ORDER.
def fitDipoleImpl(self, source, tol=1e-7, rel_weight=0.5, fitBackground=1, bgGradientOrder=1, maxSepInSigma=5., separateNegParams=True, verbose=False)
Fit a dipole model to an input difference image.
def run(self, sources, exposure, posExp=None, negExp=None, kwds)
Run dipole measurement and classification.
def doClassify(self, measRecord, chi2val)
Determine if source is classified as dipole via three criteria:
def fail(self, measRecord, error=None)
Catch failures and set the correct flags.
def measure(self, measRecord, exposure, posExp=None, negExp=None)
Perform the non-linear least squares minimization on the putative dipole source.