Coverage for python/lsst/ip/diffim/utils.py: 5%
660 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-16 02:11 -0700
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-16 02:11 -0700
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/>.
22"""Support utilities for Measuring sources"""
24# Export DipoleTestImage to expose fake image generating funcs
25__all__ = ["DipoleTestImage", "evaluateMeanPsfFwhm", "getPsfFwhm"]
27import itertools
28import numpy as np
29import lsst.geom as geom
30import lsst.afw.detection as afwDet
31import lsst.afw.display as afwDisplay
32import lsst.afw.geom as afwGeom
33import lsst.afw.image as afwImage
34import lsst.afw.math as afwMath
35import lsst.afw.table as afwTable
36import lsst.meas.algorithms as measAlg
37import lsst.meas.base as measBase
38from lsst.meas.algorithms.testUtils import plantSources
39from lsst.pex.exceptions import InvalidParameterError
40from lsst.utils.logging import getLogger
41from .dipoleFitTask import DipoleFitAlgorithm
42from . import diffimLib
44afwDisplay.setDefaultMaskTransparency(75)
45keptPlots = False # Have we arranged to keep spatial plots open?
47_LOG = getLogger(__name__)
50def showSourceSet(sSet, xy0=(0, 0), frame=0, ctype=afwDisplay.GREEN, symb="+", size=2):
51 """Draw the (XAstrom, YAstrom) positions of a set of Sources.
53 Image has the given XY0.
54 """
55 disp = afwDisplay.afwDisplay(frame=frame)
56 with disp.Buffering():
57 for s in sSet:
58 xc, yc = s.getXAstrom() - xy0[0], s.getYAstrom() - xy0[1]
60 if symb == "id":
61 disp.dot(str(s.getId()), xc, yc, ctype=ctype, size=size)
62 else:
63 disp.dot(symb, xc, yc, ctype=ctype, size=size)
66# Kernel display utilities
67#
70def showKernelSpatialCells(maskedIm, kernelCellSet, showChi2=False, symb="o",
71 ctype=None, ctypeUnused=None, ctypeBad=None, size=3,
72 frame=None, title="Spatial Cells"):
73 """Show the SpatialCells.
75 If symb is something that display.dot understands (e.g. "o"), the top
76 nMaxPerCell candidates will be indicated with that symbol, using ctype
77 and size.
78 """
79 disp = afwDisplay.Display(frame=frame)
80 disp.mtv(maskedIm, title=title)
81 with disp.Buffering():
82 origin = [-maskedIm.getX0(), -maskedIm.getY0()]
83 for cell in kernelCellSet.getCellList():
84 afwDisplay.utils.drawBBox(cell.getBBox(), origin=origin, display=disp)
86 goodies = ctypeBad is None
87 for cand in cell.begin(goodies):
88 xc, yc = cand.getXCenter() + origin[0], cand.getYCenter() + origin[1]
89 if cand.getStatus() == afwMath.SpatialCellCandidate.BAD:
90 color = ctypeBad
91 elif cand.getStatus() == afwMath.SpatialCellCandidate.GOOD:
92 color = ctype
93 elif cand.getStatus() == afwMath.SpatialCellCandidate.UNKNOWN:
94 color = ctypeUnused
95 else:
96 continue
98 if color:
99 disp.dot(symb, xc, yc, ctype=color, size=size)
101 if showChi2:
102 rchi2 = cand.getChi2()
103 if rchi2 > 1e100:
104 rchi2 = np.nan
105 disp.dot("%d %.1f" % (cand.getId(), rchi2),
106 xc - size, yc - size - 4, ctype=color, size=size)
109def showDiaSources(sources, exposure, isFlagged, isDipole, frame=None):
110 """Display Dia Sources.
111 """
112 #
113 # Show us the ccandidates
114 #
115 # Too many mask planes in diffims
116 disp = afwDisplay.Display(frame=frame)
117 for plane in ("BAD", "CR", "EDGE", "INTERPOlATED", "INTRP", "SAT", "SATURATED"):
118 disp.setMaskPlaneColor(plane, color="ignore")
120 mos = afwDisplay.utils.Mosaic()
121 for i in range(len(sources)):
122 source = sources[i]
123 badFlag = isFlagged[i]
124 dipoleFlag = isDipole[i]
125 bbox = source.getFootprint().getBBox()
126 stamp = exposure.Factory(exposure, bbox, True)
127 im = afwDisplay.utils.Mosaic(gutter=1, background=0, mode="x")
128 im.append(stamp.getMaskedImage())
129 lab = "%.1f,%.1f:" % (source.getX(), source.getY())
130 if badFlag:
131 ctype = afwDisplay.RED
132 lab += "BAD"
133 if dipoleFlag:
134 ctype = afwDisplay.YELLOW
135 lab += "DIPOLE"
136 if not badFlag and not dipoleFlag:
137 ctype = afwDisplay.GREEN
138 lab += "OK"
139 mos.append(im.makeMosaic(), lab, ctype)
140 title = "Dia Sources"
141 mosaicImage = mos.makeMosaic(display=disp, title=title)
142 return mosaicImage
145def showKernelCandidates(kernelCellSet, kernel, background, frame=None, showBadCandidates=True,
146 resids=False, kernels=False):
147 """Display the Kernel candidates.
149 If kernel is provided include spatial model and residuals;
150 If chi is True, generate a plot of residuals/sqrt(variance), i.e. chi.
151 """
152 #
153 # Show us the ccandidates
154 #
155 if kernels:
156 mos = afwDisplay.utils.Mosaic(gutter=5, background=0)
157 else:
158 mos = afwDisplay.utils.Mosaic(gutter=5, background=-1)
159 #
160 candidateCenters = []
161 candidateCentersBad = []
162 candidateIndex = 0
163 for cell in kernelCellSet.getCellList():
164 for cand in cell.begin(False): # include bad candidates
165 # Original difference image; if does not exist, skip candidate
166 try:
167 resid = cand.getDifferenceImage(diffimLib.KernelCandidateF.ORIG)
168 except Exception:
169 continue
171 rchi2 = cand.getChi2()
172 if rchi2 > 1e100:
173 rchi2 = np.nan
175 if not showBadCandidates and cand.isBad():
176 continue
178 im_resid = afwDisplay.utils.Mosaic(gutter=1, background=-0.5, mode="x")
180 try:
181 im = cand.getScienceMaskedImage()
182 im = im.Factory(im, True)
183 im.setXY0(cand.getScienceMaskedImage().getXY0())
184 except Exception:
185 continue
186 if (not resids and not kernels):
187 im_resid.append(im.Factory(im, True))
188 try:
189 im = cand.getTemplateMaskedImage()
190 im = im.Factory(im, True)
191 im.setXY0(cand.getTemplateMaskedImage().getXY0())
192 except Exception:
193 continue
194 if (not resids and not kernels):
195 im_resid.append(im.Factory(im, True))
197 # Difference image with original basis
198 if resids:
199 var = resid.variance
200 var = var.Factory(var, True)
201 np.sqrt(var.array, var.array) # inplace sqrt
202 resid = resid.image
203 resid /= var
204 bbox = kernel.shrinkBBox(resid.getBBox())
205 resid = resid.Factory(resid, bbox, deep=True)
206 elif kernels:
207 kim = cand.getKernelImage(diffimLib.KernelCandidateF.ORIG).convertF()
208 resid = kim.Factory(kim, True)
209 im_resid.append(resid)
211 # residuals using spatial model
212 ski = afwImage.ImageD(kernel.getDimensions())
213 kernel.computeImage(ski, False, int(cand.getXCenter()), int(cand.getYCenter()))
214 sk = afwMath.FixedKernel(ski)
215 sbg = 0.0
216 if background:
217 sbg = background(int(cand.getXCenter()), int(cand.getYCenter()))
218 sresid = cand.getDifferenceImage(sk, sbg)
219 resid = sresid
220 if resids:
221 resid = sresid.image
222 resid /= var
223 bbox = kernel.shrinkBBox(resid.getBBox())
224 resid = resid.Factory(resid, bbox, deep=True)
225 elif kernels:
226 kim = ski.convertF()
227 resid = kim.Factory(kim, True)
228 im_resid.append(resid)
230 im = im_resid.makeMosaic()
232 lab = "%d chi^2 %.1f" % (cand.getId(), rchi2)
233 ctype = afwDisplay.RED if cand.isBad() else afwDisplay.GREEN
235 mos.append(im, lab, ctype)
237 if False and np.isnan(rchi2):
238 disp = afwDisplay.Display(frame=1)
239 disp.mtv(cand.getScienceMaskedImage.image, title="candidate")
240 print("rating", cand.getCandidateRating())
242 im = cand.getScienceMaskedImage()
243 center = (candidateIndex, cand.getXCenter() - im.getX0(), cand.getYCenter() - im.getY0())
244 candidateIndex += 1
245 if cand.isBad():
246 candidateCentersBad.append(center)
247 else:
248 candidateCenters.append(center)
250 if resids:
251 title = "chi Diffim"
252 elif kernels:
253 title = "Kernels"
254 else:
255 title = "Candidates & residuals"
257 disp = afwDisplay.Display(frame=frame)
258 mosaicImage = mos.makeMosaic(display=disp, title=title)
260 return mosaicImage
263def showKernelBasis(kernel, frame=None):
264 """Display a Kernel's basis images.
265 """
266 mos = afwDisplay.utils.Mosaic()
268 for k in kernel.getKernelList():
269 im = afwImage.ImageD(k.getDimensions())
270 k.computeImage(im, False)
271 mos.append(im)
273 disp = afwDisplay.Display(frame=frame)
274 mos.makeMosaic(display=disp, title="Kernel Basis Images")
276 return mos
278###############
281def plotKernelSpatialModel(kernel, kernelCellSet, showBadCandidates=True,
282 numSample=128, keepPlots=True, maxCoeff=10):
283 """Plot the Kernel spatial model.
284 """
285 try:
286 import matplotlib.pyplot as plt
287 import matplotlib.colors
288 except ImportError as e:
289 print("Unable to import numpy and matplotlib: %s" % e)
290 return
292 x0 = kernelCellSet.getBBox().getBeginX()
293 y0 = kernelCellSet.getBBox().getBeginY()
295 candPos = list()
296 candFits = list()
297 badPos = list()
298 badFits = list()
299 candAmps = list()
300 badAmps = list()
301 for cell in kernelCellSet.getCellList():
302 for cand in cell.begin(False):
303 if not showBadCandidates and cand.isBad():
304 continue
305 candCenter = geom.PointD(cand.getXCenter(), cand.getYCenter())
306 try:
307 im = cand.getTemplateMaskedImage()
308 except Exception:
309 continue
311 targetFits = badFits if cand.isBad() else candFits
312 targetPos = badPos if cand.isBad() else candPos
313 targetAmps = badAmps if cand.isBad() else candAmps
315 # compare original and spatial kernel coefficients
316 kp0 = np.array(cand.getKernel(diffimLib.KernelCandidateF.ORIG).getKernelParameters())
317 amp = cand.getCandidateRating()
319 targetFits = badFits if cand.isBad() else candFits
320 targetPos = badPos if cand.isBad() else candPos
321 targetAmps = badAmps if cand.isBad() else candAmps
323 targetFits.append(kp0)
324 targetPos.append(candCenter)
325 targetAmps.append(amp)
327 xGood = np.array([pos.getX() for pos in candPos]) - x0
328 yGood = np.array([pos.getY() for pos in candPos]) - y0
329 zGood = np.array(candFits)
331 xBad = np.array([pos.getX() for pos in badPos]) - x0
332 yBad = np.array([pos.getY() for pos in badPos]) - y0
333 zBad = np.array(badFits)
334 numBad = len(badPos)
336 xRange = np.linspace(0, kernelCellSet.getBBox().getWidth(), num=numSample)
337 yRange = np.linspace(0, kernelCellSet.getBBox().getHeight(), num=numSample)
339 if maxCoeff:
340 maxCoeff = min(maxCoeff, kernel.getNKernelParameters())
341 else:
342 maxCoeff = kernel.getNKernelParameters()
344 for k in range(maxCoeff):
345 func = kernel.getSpatialFunction(k)
346 dfGood = zGood[:, k] - np.array([func(pos.getX(), pos.getY()) for pos in candPos])
347 yMin = dfGood.min()
348 yMax = dfGood.max()
349 if numBad > 0:
350 dfBad = zBad[:, k] - np.array([func(pos.getX(), pos.getY()) for pos in badPos])
351 # Can really screw up the range...
352 yMin = min([yMin, dfBad.min()])
353 yMax = max([yMax, dfBad.max()])
354 yMin -= 0.05*(yMax - yMin)
355 yMax += 0.05*(yMax - yMin)
357 fRange = np.ndarray((len(xRange), len(yRange)))
358 for j, yVal in enumerate(yRange):
359 for i, xVal in enumerate(xRange):
360 fRange[j][i] = func(xVal, yVal)
362 fig = plt.figure(k)
364 fig.clf()
365 try:
366 fig.canvas._tkcanvas._root().lift() # == Tk's raise, but raise is a python reserved word
367 except Exception: # protect against API changes
368 pass
370 fig.suptitle('Kernel component %d' % k)
372 # LL
373 ax = fig.add_axes((0.1, 0.05, 0.35, 0.35))
374 vmin = fRange.min() # - 0.05*np.fabs(fRange.min())
375 vmax = fRange.max() # + 0.05*np.fabs(fRange.max())
376 norm = matplotlib.colors.Normalize(vmin=vmin, vmax=vmax)
377 im = ax.imshow(fRange, aspect='auto', norm=norm,
378 extent=[0, kernelCellSet.getBBox().getWidth() - 1,
379 0, kernelCellSet.getBBox().getHeight() - 1])
380 ax.set_title('Spatial polynomial')
381 plt.colorbar(im, orientation='horizontal', ticks=[vmin, vmax])
383 # UL
384 ax = fig.add_axes((0.1, 0.55, 0.35, 0.35))
385 ax.plot(-2.5*np.log10(candAmps), zGood[:, k], 'b+')
386 if numBad > 0:
387 ax.plot(-2.5*np.log10(badAmps), zBad[:, k], 'r+')
388 ax.set_title("Basis Coefficients")
389 ax.set_xlabel("Instr mag")
390 ax.set_ylabel("Coeff")
392 # LR
393 ax = fig.add_axes((0.55, 0.05, 0.35, 0.35))
394 ax.set_autoscale_on(False)
395 ax.set_xbound(lower=0, upper=kernelCellSet.getBBox().getHeight())
396 ax.set_ybound(lower=yMin, upper=yMax)
397 ax.plot(yGood, dfGood, 'b+')
398 if numBad > 0:
399 ax.plot(yBad, dfBad, 'r+')
400 ax.axhline(0.0)
401 ax.set_title('dCoeff (indiv-spatial) vs. y')
403 # UR
404 ax = fig.add_axes((0.55, 0.55, 0.35, 0.35))
405 ax.set_autoscale_on(False)
406 ax.set_xbound(lower=0, upper=kernelCellSet.getBBox().getWidth())
407 ax.set_ybound(lower=yMin, upper=yMax)
408 ax.plot(xGood, dfGood, 'b+')
409 if numBad > 0:
410 ax.plot(xBad, dfBad, 'r+')
411 ax.axhline(0.0)
412 ax.set_title('dCoeff (indiv-spatial) vs. x')
414 fig.show()
416 global keptPlots
417 if keepPlots and not keptPlots:
418 # Keep plots open when done
419 def show():
420 print("%s: Please close plots when done." % __name__)
421 try:
422 plt.show()
423 except Exception:
424 pass
425 print("Plots closed, exiting...")
426 import atexit
427 atexit.register(show)
428 keptPlots = True
431def plotKernelCoefficients(spatialKernel, kernelCellSet, showBadCandidates=False, keepPlots=True):
432 """Plot the individual kernel candidate and the spatial kernel solution coefficients.
434 Parameters
435 ----------
437 spatialKernel : `lsst.afw.math.LinearCombinationKernel`
438 The spatial spatialKernel solution model which is a spatially varying linear combination
439 of the spatialKernel basis functions.
440 Typically returned by `lsst.ip.diffim.SpatialKernelSolution.getSolutionPair()`.
442 kernelCellSet : `lsst.afw.math.SpatialCellSet`
443 The spatial cells that was used for solution for the spatialKernel. They contain the
444 local solutions of the AL kernel for the selected sources.
446 showBadCandidates : `bool`, optional
447 If True, plot the coefficient values for kernel candidates where the solution was marked
448 bad by the numerical algorithm. Defaults to False.
450 keepPlots: `bool`, optional
451 If True, sets ``plt.show()`` to be called before the task terminates, so that the plots
452 can be explored interactively. Defaults to True.
454 Notes
455 -----
456 This function produces 3 figures per image subtraction operation.
457 * A grid plot of the local solutions. Each grid cell corresponds to a proportional area in
458 the image. In each cell, local kernel solution coefficients are plotted of kernel candidates (color)
459 that fall into this area as a function of the kernel basis function number.
460 * A grid plot of the spatial solution. Each grid cell corresponds to a proportional area in
461 the image. In each cell, the spatial solution coefficients are evaluated for the center of the cell.
462 * Histogram of the local solution coefficients. Red line marks the spatial solution value at
463 center of the image.
465 This function is called if ``lsst.ip.diffim.psfMatch.plotKernelCoefficients==True`` in lsstDebug. This
466 function was implemented as part of DM-17825.
467 """
468 try:
469 import matplotlib.pyplot as plt
470 except ImportError as e:
471 print("Unable to import matplotlib: %s" % e)
472 return
474 # Image dimensions
475 imgBBox = kernelCellSet.getBBox()
476 x0 = imgBBox.getBeginX()
477 y0 = imgBBox.getBeginY()
478 wImage = imgBBox.getWidth()
479 hImage = imgBBox.getHeight()
480 imgCenterX = imgBBox.getCenterX()
481 imgCenterY = imgBBox.getCenterY()
483 # Plot the local solutions
484 # ----
486 # Grid size
487 nX = 8
488 nY = 8
489 wCell = wImage / nX
490 hCell = hImage / nY
492 fig = plt.figure()
493 fig.suptitle("Kernel candidate parameters on an image grid")
494 arrAx = fig.subplots(nrows=nY, ncols=nX, sharex=True, sharey=True, gridspec_kw=dict(
495 wspace=0, hspace=0))
497 # Bottom left panel is for bottom left part of the image
498 arrAx = arrAx[::-1, :]
500 allParams = []
501 for cell in kernelCellSet.getCellList():
502 cellBBox = geom.Box2D(cell.getBBox())
503 # Determine which panel this spatial cell belongs to
504 iX = int((cellBBox.getCenterX() - x0)//wCell)
505 iY = int((cellBBox.getCenterY() - y0)//hCell)
507 for cand in cell.begin(False):
508 try:
509 kernel = cand.getKernel(cand.ORIG)
510 except Exception:
511 continue
513 if not showBadCandidates and cand.isBad():
514 continue
516 nKernelParams = kernel.getNKernelParameters()
517 kernelParams = np.array(kernel.getKernelParameters())
518 allParams.append(kernelParams)
520 if cand.isBad():
521 color = 'red'
522 else:
523 color = None
524 arrAx[iY, iX].plot(np.arange(nKernelParams), kernelParams, '.-',
525 color=color, drawstyle='steps-mid', linewidth=0.1)
526 for ax in arrAx.ravel():
527 ax.grid(True, axis='y')
529 # Plot histogram of the local parameters and the global solution at the image center
530 # ----
532 spatialFuncs = spatialKernel.getSpatialFunctionList()
533 nKernelParams = spatialKernel.getNKernelParameters()
534 nX = 8
535 fig = plt.figure()
536 fig.suptitle("Hist. of parameters marked with spatial solution at img center")
537 arrAx = fig.subplots(nrows=int(nKernelParams//nX)+1, ncols=nX)
538 arrAx = arrAx[::-1, :]
539 allParams = np.array(allParams)
540 for k in range(nKernelParams):
541 ax = arrAx.ravel()[k]
542 ax.hist(allParams[:, k], bins=20, edgecolor='black')
543 ax.set_xlabel('P{}'.format(k))
544 valueParam = spatialFuncs[k](imgCenterX, imgCenterY)
545 ax.axvline(x=valueParam, color='red')
546 ax.text(0.1, 0.9, '{:.1f}'.format(valueParam),
547 transform=ax.transAxes, backgroundcolor='lightsteelblue')
549 # Plot grid of the spatial solution
550 # ----
552 nX = 8
553 nY = 8
554 wCell = wImage / nX
555 hCell = hImage / nY
556 x0 += wCell / 2
557 y0 += hCell / 2
559 fig = plt.figure()
560 fig.suptitle("Spatial solution of kernel parameters on an image grid")
561 arrAx = fig.subplots(nrows=nY, ncols=nX, sharex=True, sharey=True, gridspec_kw=dict(
562 wspace=0, hspace=0))
563 arrAx = arrAx[::-1, :]
564 kernelParams = np.zeros(nKernelParams, dtype=float)
566 for iX in range(nX):
567 for iY in range(nY):
568 x = x0 + iX * wCell
569 y = y0 + iY * hCell
570 # Evaluate the spatial solution functions for this x,y location
571 kernelParams = [f(x, y) for f in spatialFuncs]
572 arrAx[iY, iX].plot(np.arange(nKernelParams), kernelParams, '.-', drawstyle='steps-mid')
573 arrAx[iY, iX].grid(True, axis='y')
575 global keptPlots
576 if keepPlots and not keptPlots:
577 # Keep plots open when done
578 def show():
579 print("%s: Please close plots when done." % __name__)
580 try:
581 plt.show()
582 except Exception:
583 pass
584 print("Plots closed, exiting...")
585 import atexit
586 atexit.register(show)
587 keptPlots = True
590def showKernelMosaic(bbox, kernel, nx=7, ny=None, frame=None, title=None,
591 showCenter=True, showEllipticity=True):
592 """Show a mosaic of Kernel images.
593 """
594 mos = afwDisplay.utils.Mosaic()
596 x0 = bbox.getBeginX()
597 y0 = bbox.getBeginY()
598 width = bbox.getWidth()
599 height = bbox.getHeight()
601 if not ny:
602 ny = int(nx*float(height)/width + 0.5)
603 if not ny:
604 ny = 1
606 schema = afwTable.SourceTable.makeMinimalSchema()
607 centroidName = "base_SdssCentroid"
608 shapeName = "base_SdssShape"
609 control = measBase.SdssCentroidControl()
610 schema.getAliasMap().set("slot_Centroid", centroidName)
611 schema.getAliasMap().set("slot_Centroid_flag", centroidName + "_flag")
612 centroider = measBase.SdssCentroidAlgorithm(control, centroidName, schema)
613 sdssShape = measBase.SdssShapeControl()
614 shaper = measBase.SdssShapeAlgorithm(sdssShape, shapeName, schema)
615 table = afwTable.SourceTable.make(schema)
616 table.defineCentroid(centroidName)
617 table.defineShape(shapeName)
619 centers = []
620 shapes = []
621 for iy in range(ny):
622 for ix in range(nx):
623 x = int(ix*(width - 1)/(nx - 1)) + x0
624 y = int(iy*(height - 1)/(ny - 1)) + y0
626 im = afwImage.ImageD(kernel.getDimensions())
627 ksum = kernel.computeImage(im, False, x, y)
628 lab = "Kernel(%d,%d)=%.2f" % (x, y, ksum) if False else ""
629 mos.append(im, lab)
631 # SdssCentroidAlgorithm.measure requires an exposure of floats
632 exp = afwImage.makeExposure(afwImage.makeMaskedImage(im.convertF()))
634 w, h = im.getWidth(), im.getHeight()
635 centerX = im.getX0() + w//2
636 centerY = im.getY0() + h//2
637 src = table.makeRecord()
638 spans = afwGeom.SpanSet(exp.getBBox())
639 foot = afwDet.Footprint(spans)
640 foot.addPeak(centerX, centerY, 1)
641 src.setFootprint(foot)
643 try: # The centroider requires a psf, so this will fail if none is attached to exp
644 centroider.measure(src, exp)
645 centers.append((src.getX(), src.getY()))
647 shaper.measure(src, exp)
648 shapes.append((src.getIxx(), src.getIxy(), src.getIyy()))
649 except Exception:
650 pass
652 disp = afwDisplay.Display(frame=frame)
653 mos.makeMosaic(display=disp, title=title if title else "Model Kernel", mode=nx)
655 if centers and frame is not None:
656 disp = afwDisplay.Display(frame=frame)
657 i = 0
658 with disp.Buffering():
659 for cen, shape in zip(centers, shapes):
660 bbox = mos.getBBox(i)
661 i += 1
662 xc, yc = cen[0] + bbox.getMinX(), cen[1] + bbox.getMinY()
663 if showCenter:
664 disp.dot("+", xc, yc, ctype=afwDisplay.BLUE)
666 if showEllipticity:
667 ixx, ixy, iyy = shape
668 disp.dot("@:%g,%g,%g" % (ixx, ixy, iyy), xc, yc, ctype=afwDisplay.RED)
670 return mos
673def plotWhisker(results, newWcs):
674 """Plot whisker diagram of astromeric offsets between results.matches.
675 """
676 refCoordKey = results.matches[0].first.getTable().getCoordKey()
677 inCentroidKey = results.matches[0].second.getTable().getCentroidSlot().getMeasKey()
678 positions = [m.first.get(refCoordKey) for m in results.matches]
679 residuals = [m.first.get(refCoordKey).getOffsetFrom(
680 newWcs.pixelToSky(m.second.get(inCentroidKey))) for
681 m in results.matches]
682 import matplotlib.pyplot as plt
683 fig = plt.figure()
684 sp = fig.add_subplot(1, 1, 0)
685 xpos = [x[0].asDegrees() for x in positions]
686 ypos = [x[1].asDegrees() for x in positions]
687 xpos.append(0.02*(max(xpos) - min(xpos)) + min(xpos))
688 ypos.append(0.98*(max(ypos) - min(ypos)) + min(ypos))
689 xidxs = np.isfinite(xpos)
690 yidxs = np.isfinite(ypos)
691 X = np.asarray(xpos)[xidxs]
692 Y = np.asarray(ypos)[yidxs]
693 distance = [x[1].asArcseconds() for x in residuals]
694 distance.append(0.2)
695 distance = np.asarray(distance)[xidxs]
696 # NOTE: This assumes that the bearing is measured positive from +RA through North.
697 # From the documentation this is not clear.
698 bearing = [x[0].asRadians() for x in residuals]
699 bearing.append(0)
700 bearing = np.asarray(bearing)[xidxs]
701 U = (distance*np.cos(bearing))
702 V = (distance*np.sin(bearing))
703 sp.quiver(X, Y, U, V)
704 sp.set_title("WCS Residual")
705 plt.show()
708class DipoleTestImage(object):
709 """Utility class for dipole measurement testing.
711 Generate an image with simulated dipoles and noise; store the original
712 "pre-subtraction" images and catalogs as well.
713 Used to generate test data for DMTN-007 (http://dmtn-007.lsst.io).
714 """
716 def __init__(self, w=101, h=101, xcenPos=[27.], ycenPos=[25.], xcenNeg=[23.], ycenNeg=[25.],
717 psfSigma=2., flux=[30000.], fluxNeg=None, noise=10., gradientParams=None):
718 self.w = w
719 self.h = h
720 self.xcenPos = xcenPos
721 self.ycenPos = ycenPos
722 self.xcenNeg = xcenNeg
723 self.ycenNeg = ycenNeg
724 self.psfSigma = psfSigma
725 self.flux = flux
726 self.fluxNeg = fluxNeg
727 if fluxNeg is None:
728 self.fluxNeg = self.flux
729 self.noise = noise
730 self.gradientParams = gradientParams
731 self._makeDipoleImage()
733 def _makeDipoleImage(self):
734 """Generate an exposure and catalog with the given dipole source(s).
735 """
736 # Must seed the pos/neg images with different values to ensure they get different noise realizations
737 posImage, posCatalog = self._makeStarImage(
738 xc=self.xcenPos, yc=self.ycenPos, flux=self.flux, randomSeed=111)
740 negImage, negCatalog = self._makeStarImage(
741 xc=self.xcenNeg, yc=self.ycenNeg, flux=self.fluxNeg, randomSeed=222)
743 dipole = posImage.clone()
744 di = dipole.getMaskedImage()
745 di -= negImage.getMaskedImage()
747 self.diffim, self.posImage, self.posCatalog, self.negImage, self.negCatalog \
748 = dipole, posImage, posCatalog, negImage, negCatalog
750 def _makeStarImage(self, xc=[15.3], yc=[18.6], flux=[2500], schema=None, randomSeed=None):
751 """Generate an exposure and catalog with the given stellar source(s).
752 """
753 from lsst.meas.base.tests import TestDataset
754 bbox = geom.Box2I(geom.Point2I(0, 0), geom.Point2I(self.w - 1, self.h - 1))
755 dataset = TestDataset(bbox, psfSigma=self.psfSigma, threshold=1.)
757 for i in range(len(xc)):
758 dataset.addSource(instFlux=flux[i], centroid=geom.Point2D(xc[i], yc[i]))
760 if schema is None:
761 schema = TestDataset.makeMinimalSchema()
762 exposure, catalog = dataset.realize(noise=self.noise, schema=schema, randomSeed=randomSeed)
764 if self.gradientParams is not None:
765 y, x = np.mgrid[:self.w, :self.h]
766 gp = self.gradientParams
767 gradient = gp[0] + gp[1]*x + gp[2]*y
768 if len(self.gradientParams) > 3: # it includes a set of 2nd-order polynomial params
769 gradient += gp[3]*x*y + gp[4]*x*x + gp[5]*y*y
770 imgArr = exposure.image.array
771 imgArr += gradient
773 return exposure, catalog
775 def fitDipoleSource(self, source, **kwds):
776 alg = DipoleFitAlgorithm(self.diffim, self.posImage, self.negImage)
777 fitResult = alg.fitDipole(source, **kwds)
778 return fitResult
780 def detectDipoleSources(self, doMerge=True, diffim=None, detectSigma=5.5, grow=3, minBinSize=32):
781 """Utility function for detecting dipoles.
783 Detect pos/neg sources in the diffim, then merge them. A
784 bigger "grow" parameter leads to a larger footprint which
785 helps with dipole measurement for faint dipoles.
787 Parameters
788 ----------
789 doMerge : `bool`
790 Whether to merge the positive and negagive detections into a single
791 source table.
792 diffim : `lsst.afw.image.exposure.exposure.ExposureF`
793 Difference image on which to perform detection.
794 detectSigma : `float`
795 Threshold for object detection.
796 grow : `int`
797 Number of pixels to grow the footprints before merging.
798 minBinSize : `int`
799 Minimum bin size for the background (re)estimation (only applies if
800 the default leads to min(nBinX, nBinY) < fit order so the default
801 config parameter needs to be decreased, but not to a value smaller
802 than ``minBinSize``, in which case the fitting algorithm will take
803 over and decrease the fit order appropriately.)
805 Returns
806 -------
807 sources : `lsst.afw.table.SourceCatalog`
808 If doMerge=True, the merged source catalog is returned OR
809 detectTask : `lsst.meas.algorithms.SourceDetectionTask`
810 schema : `lsst.afw.table.Schema`
811 If doMerge=False, the source detection task and its schema are
812 returned.
813 """
814 if diffim is None:
815 diffim = self.diffim
817 # Start with a minimal schema - only the fields all SourceCatalogs need
818 schema = afwTable.SourceTable.makeMinimalSchema()
820 # Customize the detection task a bit (optional)
821 detectConfig = measAlg.SourceDetectionConfig()
822 detectConfig.returnOriginalFootprints = False # should be the default
824 # code from imageDifference.py:
825 detectConfig.thresholdPolarity = "both"
826 detectConfig.thresholdValue = detectSigma
827 # detectConfig.nSigmaToGrow = psfSigma
828 detectConfig.reEstimateBackground = True # if False, will fail often for faint sources on gradients?
829 detectConfig.thresholdType = "pixel_stdev"
830 detectConfig.excludeMaskPlanes = ["EDGE"]
831 # Test images are often quite small, so may need to adjust background binSize
832 while ((min(diffim.getWidth(), diffim.getHeight()))//detectConfig.background.binSize
833 < detectConfig.background.approxOrderX and detectConfig.background.binSize > minBinSize):
834 detectConfig.background.binSize = max(minBinSize, detectConfig.background.binSize//2)
836 # Create the detection task. We pass the schema so the task can declare a few flag fields
837 detectTask = measAlg.SourceDetectionTask(schema, config=detectConfig)
839 table = afwTable.SourceTable.make(schema)
840 catalog = detectTask.run(table, diffim)
842 # Now do the merge.
843 if doMerge:
844 fpSet = catalog.positive
845 fpSet.merge(catalog.negative, grow, grow, False)
846 sources = afwTable.SourceCatalog(table)
847 fpSet.makeSources(sources)
849 return sources
851 else:
852 return detectTask, schema
855def _sliceWidth(image, threshold, peaks, axis):
856 vec = image.take(peaks[1 - axis], axis=axis)
857 low = np.interp(threshold, vec[:peaks[axis] + 1], np.arange(peaks[axis] + 1))
858 high = np.interp(threshold, vec[:peaks[axis] - 1:-1], np.arange(len(vec) - 1, peaks[axis] - 1, -1))
859 return high - low
862def getPsfFwhm(psf, average=True, position=None):
863 """Directly calculate the horizontal and vertical widths
864 of a PSF at half its maximum value.
866 Parameters
867 ----------
868 psf : `~lsst.afw.detection.Psf`
869 Point spread function (PSF) to evaluate.
870 average : `bool`, optional
871 Set to return the average width over Y and X axes.
872 position : `~lsst.geom.Point2D`, optional
873 The position at which to evaluate the PSF. If `None`, then the
874 average position is used.
876 Returns
877 -------
878 psfSize : `float` | `tuple` [`float`]
879 The FWHM of the PSF computed at its average position.
880 Returns the widths along the Y and X axes,
881 or the average of the two if `average` is set.
883 See Also
884 --------
885 evaluateMeanPsfFwhm
886 """
887 if position is None:
888 position = psf.getAveragePosition()
889 image = psf.computeKernelImage(position).array
890 peak = psf.computePeak(position)
891 peakLocs = np.unravel_index(np.argmax(image), image.shape)
892 width = _sliceWidth(image, peak/2., peakLocs, axis=0), _sliceWidth(image, peak/2., peakLocs, axis=1)
893 return np.nanmean(width) if average else width
896def evaluateMeanPsfFwhm(exposure: afwImage.Exposure,
897 fwhmExposureBuffer: float, fwhmExposureGrid: int) -> float:
898 """Get the mean PSF FWHM by evaluating it on a grid within an exposure.
900 Parameters
901 ----------
902 exposure : `~lsst.afw.image.Exposure`
903 The exposure for which the mean FWHM of the PSF is to be computed.
904 The exposure must contain a `psf` attribute.
905 fwhmExposureBuffer : `float`
906 Fractional buffer margin to be left out of all sides of the image
907 during the construction of the grid to compute mean PSF FWHM in an
908 exposure.
909 fwhmExposureGrid : `int`
910 Grid size to compute the mean FWHM in an exposure.
912 Returns
913 -------
914 meanFwhm : `float`
915 The mean PSF FWHM on the exposure.
917 Raises
918 ------
919 ValueError
920 Raised if the PSF cannot be computed at any of the grid points.
922 See Also
923 --------
924 `getPsfFwhm`
925 `computeAveragePsf`
926 """
928 psf = exposure.psf
930 bbox = exposure.getBBox()
931 xmax, ymax = bbox.getMax()
932 xmin, ymin = bbox.getMin()
934 xbuffer = fwhmExposureBuffer*(xmax-xmin)
935 ybuffer = fwhmExposureBuffer*(ymax-ymin)
937 width = []
938 for (x, y) in itertools.product(np.linspace(xmin+xbuffer, xmax-xbuffer, fwhmExposureGrid),
939 np.linspace(ymin+ybuffer, ymax-ybuffer, fwhmExposureGrid)
940 ):
941 pos = geom.Point2D(x, y)
942 try:
943 fwhm = getPsfFwhm(psf, average=True, position=pos)
944 except InvalidParameterError:
945 _LOG.debug("Unable to compute PSF FWHM at position (%f, %f).", x, y)
946 continue
948 width.append(fwhm)
950 if not width:
951 raise ValueError("Unable to compute PSF FWHM at any position on the exposure.")
953 return np.nanmean(width)
956def computeAveragePsf(exposure: afwImage.Exposure,
957 psfExposureBuffer: float, psfExposureGrid: int) -> afwImage.ImageD:
958 """Get the average PSF by evaluating it on a grid within an exposure.
960 Parameters
961 ----------
962 exposure : `~lsst.afw.image.Exposure`
963 The exposure for which the average PSF is to be computed.
964 The exposure must contain a `psf` attribute.
965 psfExposureBuffer : `float`
966 Fractional buffer margin to be left out of all sides of the image
967 during the construction of the grid to compute average PSF in an
968 exposure.
969 psfExposureGrid : `int`
970 Grid size to compute the average PSF in an exposure.
972 Returns
973 -------
974 psfImage : `~lsst.afw.image.Image`
975 The average PSF across the exposure.
977 Raises
978 ------
979 ValueError
980 Raised if the PSF cannot be computed at any of the grid points.
982 See Also
983 --------
984 `evaluateMeanPsfFwhm`
985 """
987 psf = exposure.psf
989 bbox = exposure.getBBox()
990 xmax, ymax = bbox.getMax()
991 xmin, ymin = bbox.getMin()
993 xbuffer = psfExposureBuffer*(xmax-xmin)
994 ybuffer = psfExposureBuffer*(ymax-ymin)
996 nImg = 0
997 psfArray = None
998 for (x, y) in itertools.product(np.linspace(xmin+xbuffer, xmax-xbuffer, psfExposureGrid),
999 np.linspace(ymin+ybuffer, ymax-ybuffer, psfExposureGrid)
1000 ):
1001 pos = geom.Point2D(x, y)
1002 try:
1003 singleImage = psf.computeKernelImage(pos)
1004 except InvalidParameterError:
1005 _LOG.debug("Unable to compute PSF image at position (%f, %f).", x, y)
1006 continue
1008 if psfArray is None:
1009 psfArray = singleImage.array
1010 else:
1011 psfArray += singleImage.array
1012 nImg += 1
1014 if psfArray is None:
1015 raise ValueError("Unable to compute PSF image at any position on the exposure.")
1017 psfImage = afwImage.ImageD(psfArray/nImg)
1018 return psfImage
1021def detectTestSources(exposure, addMaskPlanes=None):
1022 """Minimal source detection wrapper suitable for unit tests.
1024 Parameters
1025 ----------
1026 exposure : `lsst.afw.image.Exposure`
1027 Exposure on which to run detection/measurement
1028 The exposure is modified in place to set the 'DETECTED' mask plane.
1029 addMaskPlanes : `list` of `str`, optional
1030 Additional mask planes to add to the maskedImage of the exposure.
1032 Returns
1033 -------
1034 selectSources
1035 Source catalog containing candidates
1036 """
1037 if addMaskPlanes is None:
1038 # add empty streak mask plane in lieu of maskStreaksTask
1039 # And add empty INJECTED and INJECTED_TEMPLATE mask planes
1040 addMaskPlanes = ["STREAK", "INJECTED", "INJECTED_TEMPLATE"]
1042 schema = afwTable.SourceTable.makeMinimalSchema()
1043 selectDetection = measAlg.SourceDetectionTask(schema=schema)
1044 selectMeasurement = measBase.SingleFrameMeasurementTask(schema=schema)
1045 table = afwTable.SourceTable.make(schema)
1047 detRet = selectDetection.run(
1048 table=table,
1049 exposure=exposure,
1050 sigma=None, # The appropriate sigma is calculated from the PSF
1051 doSmooth=True
1052 )
1053 for mp in addMaskPlanes:
1054 exposure.mask.addMaskPlane(mp)
1056 selectSources = detRet.sources
1057 selectMeasurement.run(measCat=selectSources, exposure=exposure)
1059 return selectSources
1062def makeFakeWcs():
1063 """Make a fake, affine Wcs.
1064 """
1065 crpix = geom.Point2D(123.45, 678.9)
1066 crval = geom.SpherePoint(0.1, 0.1, geom.degrees)
1067 cdMatrix = np.array([[5.19513851e-05, -2.81124812e-07],
1068 [-3.25186974e-07, -5.19112119e-05]])
1069 return afwGeom.makeSkyWcs(crpix, crval, cdMatrix)
1072def makeTestImage(seed=5, nSrc=20, psfSize=2., noiseLevel=5.,
1073 noiseSeed=6, fluxLevel=500., fluxRange=2.,
1074 kernelSize=32, templateBorderSize=0,
1075 background=None,
1076 xSize=256,
1077 ySize=256,
1078 x0=12345,
1079 y0=67890,
1080 calibration=1.,
1081 doApplyCalibration=False,
1082 xLoc=None,
1083 yLoc=None,
1084 flux=None,
1085 clearEdgeMask=False,
1086 addMaskPlanes=None,
1087 ):
1088 """Make a reproduceable PSF-convolved exposure for testing.
1090 Parameters
1091 ----------
1092 seed : `int`, optional
1093 Seed value to initialize the random number generator for sources.
1094 nSrc : `int`, optional
1095 Number of sources to simulate.
1096 psfSize : `float`, optional
1097 Width of the PSF of the simulated sources, in pixels.
1098 noiseLevel : `float`, optional
1099 Standard deviation of the noise to add to each pixel.
1100 noiseSeed : `int`, optional
1101 Seed value to initialize the random number generator for noise.
1102 fluxLevel : `float`, optional
1103 Reference flux of the simulated sources.
1104 fluxRange : `float`, optional
1105 Range in flux amplitude of the simulated sources.
1106 kernelSize : `int`, optional
1107 Size in pixels of the kernel for simulating sources.
1108 templateBorderSize : `int`, optional
1109 Size in pixels of the image border used to pad the image.
1110 background : `lsst.afw.math.Chebyshev1Function2D`, optional
1111 Optional background to add to the output image.
1112 xSize, ySize : `int`, optional
1113 Size in pixels of the simulated image.
1114 x0, y0 : `int`, optional
1115 Origin of the image.
1116 calibration : `float`, optional
1117 Conversion factor between instFlux and nJy.
1118 doApplyCalibration : `bool`, optional
1119 Apply the photometric calibration and return the image in nJy?
1120 xLoc, yLoc : `list` of `float`, optional
1121 User-specified coordinates of the simulated sources.
1122 If specified, must have length equal to ``nSrc``
1123 flux : `list` of `float`, optional
1124 User-specified fluxes of the simulated sources.
1125 If specified, must have length equal to ``nSrc``
1126 clearEdgeMask : `bool`, optional
1127 Clear the "EDGE" mask plane after source detection.
1128 addMaskPlanes : `list` of `str`, optional
1129 Mask plane names to add to the image.
1131 Returns
1132 -------
1133 modelExposure : `lsst.afw.image.Exposure`
1134 The model image, with the mask and variance planes.
1135 sourceCat : `lsst.afw.table.SourceCatalog`
1136 Catalog of sources detected on the model image.
1138 Raises
1139 ------
1140 ValueError
1141 If `xloc`, `yloc`, or `flux` are supplied with inconsistant lengths.
1142 """
1143 # Distance from the inner edge of the bounding box to avoid placing test
1144 # sources in the model images.
1145 bufferSize = kernelSize/2 + templateBorderSize + 1
1147 bbox = geom.Box2I(geom.Point2I(x0, y0), geom.Extent2I(xSize, ySize))
1148 if templateBorderSize > 0:
1149 bbox.grow(templateBorderSize)
1151 rng = np.random.RandomState(seed)
1152 rngNoise = np.random.RandomState(noiseSeed)
1153 x0, y0 = bbox.getBegin()
1154 xSize, ySize = bbox.getDimensions()
1155 if xLoc is None:
1156 xLoc = rng.rand(nSrc)*(xSize - 2*bufferSize) + bufferSize + x0
1157 else:
1158 if len(xLoc) != nSrc:
1159 raise ValueError("xLoc must have length equal to nSrc. %f supplied vs %f", len(xLoc), nSrc)
1160 if yLoc is None:
1161 yLoc = rng.rand(nSrc)*(ySize - 2*bufferSize) + bufferSize + y0
1162 else:
1163 if len(yLoc) != nSrc:
1164 raise ValueError("yLoc must have length equal to nSrc. %f supplied vs %f", len(yLoc), nSrc)
1166 if flux is None:
1167 flux = (rng.rand(nSrc)*(fluxRange - 1.) + 1.)*fluxLevel
1168 else:
1169 if len(flux) != nSrc:
1170 raise ValueError("flux must have length equal to nSrc. %f supplied vs %f", len(flux), nSrc)
1171 sigmas = [psfSize for src in range(nSrc)]
1172 coordList = list(zip(xLoc, yLoc, flux, sigmas))
1173 skyLevel = 0
1174 # Don't use the built in poisson noise: it modifies the global state of numpy random
1175 modelExposure = plantSources(bbox, kernelSize, skyLevel, coordList, addPoissonNoise=False)
1176 modelExposure.setWcs(makeFakeWcs())
1177 noise = rngNoise.randn(ySize, xSize)*noiseLevel
1178 noise -= np.mean(noise)
1179 modelExposure.variance.array = np.sqrt(np.abs(modelExposure.image.array)) + noiseLevel**2
1180 modelExposure.image.array += noise
1182 # Run source detection to set up the mask plane
1183 sourceCat = detectTestSources(modelExposure, addMaskPlanes=addMaskPlanes)
1184 if clearEdgeMask:
1185 modelExposure.mask &= ~modelExposure.mask.getPlaneBitMask("EDGE")
1186 modelExposure.setPhotoCalib(afwImage.PhotoCalib(calibration, 0., bbox))
1187 if background is not None:
1188 modelExposure.image += background
1189 modelExposure.maskedImage /= calibration
1190 modelExposure.info.setId(seed)
1191 if doApplyCalibration:
1192 modelExposure.maskedImage = modelExposure.photoCalib.calibrateImage(modelExposure.maskedImage)
1194 return modelExposure, sourceCat
1197def makeStats(badMaskPlanes=None):
1198 """Create a statistics control for configuring calculations on images.
1200 Parameters
1201 ----------
1202 badMaskPlanes : `list` of `str`, optional
1203 List of mask planes to exclude from calculations.
1205 Returns
1206 -------
1207 statsControl : ` lsst.afw.math.StatisticsControl`
1208 Statistics control object for configuring calculations on images.
1209 """
1210 if badMaskPlanes is None:
1211 badMaskPlanes = ("INTRP", "EDGE", "DETECTED", "SAT", "CR",
1212 "BAD", "NO_DATA", "DETECTED_NEGATIVE")
1213 statsControl = afwMath.StatisticsControl()
1214 statsControl.setNumSigmaClip(3.)
1215 statsControl.setNumIter(3)
1216 statsControl.setAndMask(afwImage.Mask.getPlaneBitMask(badMaskPlanes))
1217 return statsControl
1220def computeRobustStatistics(image, mask, statsCtrl, statistic=afwMath.MEANCLIP):
1221 """Calculate a robust mean of the variance plane of an exposure.
1223 Parameters
1224 ----------
1225 image : `lsst.afw.image.Image`
1226 Image or variance plane of an exposure to evaluate.
1227 mask : `lsst.afw.image.Mask`
1228 Mask plane to use for excluding pixels.
1229 statsCtrl : `lsst.afw.math.StatisticsControl`
1230 Statistics control object for configuring the calculation.
1231 statistic : `lsst.afw.math.Property`, optional
1232 The type of statistic to compute. Typical values are
1233 ``afwMath.MEANCLIP`` or ``afwMath.STDEVCLIP``.
1235 Returns
1236 -------
1237 value : `float`
1238 The result of the statistic calculated from the unflagged pixels.
1239 """
1240 statObj = afwMath.makeStatistics(image, mask, statistic, statsCtrl)
1241 return statObj.getValue(statistic)
1244def computePSFNoiseEquivalentArea(psf):
1245 """Compute the noise equivalent area for an image psf
1247 Parameters
1248 ----------
1249 psf : `lsst.afw.detection.Psf`
1251 Returns
1252 -------
1253 nea : `float`
1254 """
1255 psfImg = psf.computeImage(psf.getAveragePosition())
1256 nea = 1./np.sum(psfImg.array**2)
1257 return nea