lsst.ip.diffim  22.0.1-20-gd9f96f2e+82dc926501
modelPsfMatch.py
Go to the documentation of this file.
1 # This file is part of ip_diffim.
2 #
3 # Developed for the LSST Data Management System.
4 # This product includes software developed by the LSST Project
5 # (https://www.lsst.org).
6 # See the COPYRIGHT file at the top-level directory of this distribution
7 # for details of code ownership.
8 #
9 # This program is free software: you can redistribute it and/or modify
10 # it under the terms of the GNU General Public License as published by
11 # the Free Software Foundation, either version 3 of the License, or
12 # (at your option) any later version.
13 #
14 # This program is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 # GNU General Public License for more details.
18 #
19 # You should have received a copy of the GNU General Public License
20 # along with this program. If not, see <https://www.gnu.org/licenses/>.
21 
22 import numpy as np
23 
24 from . import diffimLib
25 import lsst.afw.display as afwDisplay
26 import lsst.afw.image as afwImage
27 import lsst.afw.math as afwMath
28 import lsst.geom as geom
29 import lsst.log as log
30 import lsst.pex.config as pexConfig
31 import lsst.pipe.base as pipeBase
32 from lsst.utils.timer import timeMethod
33 from .makeKernelBasisList import makeKernelBasisList
34 from .psfMatch import PsfMatchTask, PsfMatchConfigAL
35 from . import utils as dituils
36 
37 __all__ = ("ModelPsfMatchTask", "ModelPsfMatchConfig")
38 
39 sigma2fwhm = 2.*np.sqrt(2.*np.log(2.))
40 
41 
43  nextInt = int(np.ceil(x))
44  return nextInt + 1 if nextInt%2 == 0 else nextInt
45 
46 
47 class ModelPsfMatchConfig(pexConfig.Config):
48  """Configuration for model-to-model Psf matching"""
49 
50  kernel = pexConfig.ConfigChoiceField(
51  doc="kernel type",
52  typemap=dict(
53  AL=PsfMatchConfigAL,
54  ),
55  default="AL",
56  )
57  doAutoPadPsf = pexConfig.Field(
58  dtype=bool,
59  doc=("If too small, automatically pad the science Psf? "
60  "Pad to smallest dimensions appropriate for the matching kernel dimensions, "
61  "as specified by autoPadPsfTo. If false, pad by the padPsfBy config."),
62  default=True,
63  )
64  autoPadPsfTo = pexConfig.RangeField(
65  dtype=float,
66  doc=("Minimum Science Psf dimensions as a fraction of matching kernel dimensions. "
67  "If the dimensions of the Psf to be matched are less than the "
68  "matching kernel dimensions * autoPadPsfTo, pad Science Psf to this size. "
69  "Ignored if doAutoPadPsf=False."),
70  default=1.4,
71  min=1.0,
72  max=2.0
73  )
74  padPsfBy = pexConfig.Field(
75  dtype=int,
76  doc="Pixels (even) to pad Science Psf by before matching. Ignored if doAutoPadPsf=True",
77  default=0,
78  )
79 
80  def setDefaults(self):
81  # No sigma clipping
82  self.kernelkernel.active.singleKernelClipping = False
83  self.kernelkernel.active.kernelSumClipping = False
84  self.kernelkernel.active.spatialKernelClipping = False
85  self.kernelkernel.active.checkConditionNumber = False
86 
87  # Variance is ill defined
88  self.kernelkernel.active.constantVarianceWeighting = True
89 
90  # Do not change specified kernel size
91  self.kernelkernel.active.scaleByFwhm = False
92 
93 
95  """Matching of two model Psfs, and application of the Psf-matching kernel to an input Exposure
96 
97  Notes
98  -----
99 
100  This Task differs from ImagePsfMatchTask in that it matches two Psf _models_, by realizing
101  them in an Exposure-sized SpatialCellSet and then inserting each Psf-image pair into KernelCandidates.
102  Because none of the pairs of sources that are to be matched should be invalid, all sigma clipping is
103  turned off in ModelPsfMatchConfig. And because there is no tracked _variance_ in the Psf images, the
104  debugging and logging QA info should be interpreted with caution.
105 
106  One item of note is that the sizes of Psf models are fixed (e.g. its defined as a 21x21 matrix). When the
107  Psf-matching kernel is being solved for, the Psf "image" is convolved with each kernel basis function,
108  leading to a loss of information around the borders.
109  This pixel loss will be problematic for the numerical
110  stability of the kernel solution if the size of the convolution kernel
111  (set by ModelPsfMatchConfig.kernelSize) is much bigger than: psfSize//2.
112  Thus the sizes of Psf-model matching kernels are typically smaller
113  than their image-matching counterparts. If the size of the kernel is too small, the convolved stars will
114  look "boxy"; if the kernel is too large, the kernel solution will be "noisy". This is a trade-off that
115  needs careful attention for a given dataset.
116 
117  The primary use case for this Task is in matching an Exposure to a
118  constant-across-the-sky Psf model for the purposes of image coaddition.
119  It is important to note that in the code, the "template" Psf is the Psf
120  that the science image gets matched to. In this sense the order of template and science image are
121  reversed, compared to ImagePsfMatchTask, which operates on the template image.
122 
123  Debug variables
124 
125  The `lsst.pipe.base.cmdLineTask.CmdLineTask` command line task interface supports a
126  flag -d/--debug to import debug.py from your PYTHONPATH. The relevant contents of debug.py
127  for this Task include:
128 
129  .. code-block:: py
130 
131  import sys
132  import lsstDebug
133  def DebugInfo(name):
134  di = lsstDebug.getInfo(name)
135  if name == "lsst.ip.diffim.psfMatch":
136  di.display = True # global
137  di.maskTransparency = 80 # mask transparency
138  di.displayCandidates = True # show all the candidates and residuals
139  di.displayKernelBasis = False # show kernel basis functions
140  di.displayKernelMosaic = True # show kernel realized across the image
141  di.plotKernelSpatialModel = False # show coefficients of spatial model
142  di.showBadCandidates = True # show the bad candidates (red) along with good (green)
143  elif name == "lsst.ip.diffim.modelPsfMatch":
144  di.display = True # global
145  di.maskTransparency = 30 # mask transparency
146  di.displaySpatialCells = True # show spatial cells before the fit
147  return di
148  lsstDebug.Info = DebugInfo
149  lsstDebug.frame = 1
150 
151  Note that if you want addional logging info, you may add to your scripts:
152 
153  .. code-block:: py
154 
155  import lsst.log.utils as logUtils
156  logUtils.traceSetAt("ip.diffim", 4)
157 
158  Examples
159  --------
160  A complete example of using ModelPsfMatchTask
161 
162  This code is modelPsfMatchTask.py in the examples directory, and can be run as e.g.
163 
164  .. code-block :: none
165 
166  examples/modelPsfMatchTask.py
167  examples/modelPsfMatchTask.py --debug
168  examples/modelPsfMatchTask.py --debug --template /path/to/templateExp.fits
169  --science /path/to/scienceExp.fits
170 
171  Create a subclass of ModelPsfMatchTask that accepts two exposures.
172  Note that the "template" exposure contains the Psf that will get matched to,
173  and the "science" exposure is the one that will be convolved:
174 
175  .. code-block :: none
176 
177  class MyModelPsfMatchTask(ModelPsfMatchTask):
178  def __init__(self, *args, **kwargs):
179  ModelPsfMatchTask.__init__(self, *args, **kwargs)
180  def run(self, templateExp, scienceExp):
181  return ModelPsfMatchTask.run(self, scienceExp, templateExp.getPsf())
182 
183  And allow the user the freedom to either run the script in default mode,
184  or point to their own images on disk. Note that these
185  images must be readable as an lsst.afw.image.Exposure:
186 
187  .. code-block :: none
188 
189  if __name__ == "__main__":
190  import argparse
191  parser = argparse.ArgumentParser(description="Demonstrate the use of ModelPsfMatchTask")
192  parser.add_argument("--debug", "-d", action="store_true", help="Load debug.py?", default=False)
193  parser.add_argument("--template", "-t", help="Template Exposure to use", default=None)
194  parser.add_argument("--science", "-s", help="Science Exposure to use", default=None)
195  args = parser.parse_args()
196 
197  We have enabled some minor display debugging in this script via the –debug option.
198  However, if you have an lsstDebug debug.py in your PYTHONPATH you will get additional
199  debugging displays. The following block checks for this script:
200 
201  .. code-block :: none
202 
203  if args.debug:
204  try:
205  import debug
206  # Since I am displaying 2 images here, set the starting frame number for the LSST debug LSST
207  debug.lsstDebug.frame = 3
208  except ImportError as e:
209  print(e, file=sys.stderr)
210 
211  Finally, we call a run method that we define below.
212  First set up a Config and modify some of the parameters.
213  In particular we don't want to "grow" the sizes of the kernel or KernelCandidates,
214  since we are operating with fixed–size images (i.e. the size of the input Psf models).
215 
216  .. code-block :: none
217 
218  def run(args):
219  #
220  # Create the Config and use sum of gaussian basis
221  #
222  config = ModelPsfMatchTask.ConfigClass()
223  config.kernel.active.scaleByFwhm = False
224 
225  Make sure the images (if any) that were sent to the script exist on disk and are readable.
226  If no images are sent, make some fake data up for the sake of this example script
227  (have a look at the code if you want more details on generateFakeData):
228 
229  .. code-block :: none
230 
231  # Run the requested method of the Task
232  if args.template is not None and args.science is not None:
233  if not os.path.isfile(args.template):
234  raise FileNotFoundError("Template image %s does not exist" % (args.template))
235  if not os.path.isfile(args.science):
236  raise FileNotFoundError("Science image %s does not exist" % (args.science))
237  try:
238  templateExp = afwImage.ExposureF(args.template)
239  except Exception as e:
240  raise RuntimeError("Cannot read template image %s" % (args.template))
241  try:
242  scienceExp = afwImage.ExposureF(args.science)
243  except Exception as e:
244  raise RuntimeError("Cannot read science image %s" % (args.science))
245  else:
246  templateExp, scienceExp = generateFakeData()
247  config.kernel.active.sizeCellX = 128
248  config.kernel.active.sizeCellY = 128
249 
250  .. code-block :: none
251 
252  if args.debug:
253  afwDisplay.Display(frame=1).mtv(templateExp, title="Example script: Input Template")
254  afwDisplay.Display(frame=2).mtv(scienceExp, title="Example script: Input Science Image")
255 
256  Create and run the Task:
257 
258  .. code-block :: none
259 
260  # Create the Task
261  psfMatchTask = MyModelPsfMatchTask(config=config)
262  # Run the Task
263  result = psfMatchTask.run(templateExp, scienceExp)
264 
265  And finally provide optional debugging display of the Psf-matched (via the Psf models) science image:
266 
267  .. code-block :: none
268 
269  if args.debug:
270  # See if the LSST debug has incremented the frame number; if not start with frame 3
271  try:
272  frame = debug.lsstDebug.frame + 1
273  except Exception:
274  frame = 3
275  afwDisplay.Display(frame=frame).mtv(result.psfMatchedExposure,
276  title="Example script: Matched Science Image")
277 
278  """
279  ConfigClass = ModelPsfMatchConfig
280 
281  def __init__(self, *args, **kwargs):
282  """Create a ModelPsfMatchTask
283 
284  Parameters
285  ----------
286  *args
287  arguments to be passed to lsst.ip.diffim.PsfMatchTask.__init__
288  **kwargs
289  keyword arguments to be passed to lsst.ip.diffim.PsfMatchTask.__init__
290 
291  Notes
292  -----
293  Upon initialization, the kernel configuration is defined by self.config.kernel.active. This Task
294  does have a run() method, which is the default way to call the Task.
295  """
296  PsfMatchTask.__init__(self, *args, **kwargs)
297  self.kConfigkConfigkConfig = self.config.kernel.active
298 
299  @timeMethod
300  def run(self, exposure, referencePsfModel, kernelSum=1.0):
301  """Psf-match an exposure to a model Psf
302 
303  Parameters
304  ----------
305  exposure : `lsst.afw.image.Exposure`
306  Exposure to Psf-match to the reference Psf model;
307  it must return a valid PSF model via exposure.getPsf()
308  referencePsfModel : `lsst.afw.detection.Psf`
309  The Psf model to match to
310  kernelSum : `float`, optional
311  A multipicative factor to apply to the kernel sum (default=1.0)
312 
313  Returns
314  -------
315  result : `struct`
316  - ``psfMatchedExposure`` : the Psf-matched Exposure.
317  This has the same parent bbox, Wcs, PhotoCalib and
318  Filter as the input Exposure but no Psf.
319  In theory the Psf should equal referencePsfModel but
320  the match is likely not exact.
321  - ``psfMatchingKernel`` : the spatially varying Psf-matching kernel
322  - ``kernelCellSet`` : SpatialCellSet used to solve for the Psf-matching kernel
323  - ``referencePsfModel`` : Validated and/or modified reference model used
324 
325  Raises
326  ------
327  RuntimeError
328  if the Exposure does not contain a Psf model
329  """
330  if not exposure.hasPsf():
331  raise RuntimeError("exposure does not contain a Psf model")
332 
333  maskedImage = exposure.getMaskedImage()
334 
335  self.log.info("compute Psf-matching kernel")
336  result = self._buildCellSet_buildCellSet_buildCellSet(exposure, referencePsfModel)
337  kernelCellSet = result.kernelCellSet
338  referencePsfModel = result.referencePsfModel
339  fwhmScience = exposure.getPsf().computeShape().getDeterminantRadius()*sigma2fwhm
340  fwhmModel = referencePsfModel.computeShape().getDeterminantRadius()*sigma2fwhm
341 
342  basisList = makeKernelBasisList(self.kConfigkConfigkConfig, fwhmScience, fwhmModel, metadata=self.metadata)
343  spatialSolution, psfMatchingKernel, backgroundModel = self._solve_solve(kernelCellSet, basisList)
344 
345  if psfMatchingKernel.isSpatiallyVarying():
346  sParameters = np.array(psfMatchingKernel.getSpatialParameters())
347  sParameters[0][0] = kernelSum
348  psfMatchingKernel.setSpatialParameters(sParameters)
349  else:
350  kParameters = np.array(psfMatchingKernel.getKernelParameters())
351  kParameters[0] = kernelSum
352  psfMatchingKernel.setKernelParameters(kParameters)
353 
354  self.log.info("Psf-match science exposure to reference")
355  psfMatchedExposure = afwImage.ExposureF(exposure.getBBox(), exposure.getWcs())
356  psfMatchedExposure.setFilterLabel(exposure.getFilterLabel())
357  psfMatchedExposure.setPhotoCalib(exposure.getPhotoCalib())
358  psfMatchedExposure.getInfo().setVisitInfo(exposure.getInfo().getVisitInfo())
359  psfMatchedExposure.setPsf(referencePsfModel)
360  psfMatchedMaskedImage = psfMatchedExposure.getMaskedImage()
361 
362  # Normalize the psf-matching kernel while convolving since its magnitude is meaningless
363  # when PSF-matching one model to another.
364  convolutionControl = afwMath.ConvolutionControl()
365  convolutionControl.setDoNormalize(True)
366  afwMath.convolve(psfMatchedMaskedImage, maskedImage, psfMatchingKernel, convolutionControl)
367 
368  self.log.info("done")
369  return pipeBase.Struct(psfMatchedExposure=psfMatchedExposure,
370  psfMatchingKernel=psfMatchingKernel,
371  kernelCellSet=kernelCellSet,
372  metadata=self.metadata,
373  )
374 
375  def _diagnostic(self, kernelCellSet, spatialSolution, spatialKernel, spatialBg):
376  """Print diagnostic information on spatial kernel and background fit
377 
378  The debugging diagnostics are not really useful here, since the images we are matching have
379  no variance. Thus override the _diagnostic method to generate no logging information"""
380  return
381 
382  def _buildCellSet(self, exposure, referencePsfModel):
383  """Build a SpatialCellSet for use with the solve method
384 
385  Parameters
386  ----------
387  exposure : `lsst.afw.image.Exposure`
388  The science exposure that will be convolved; must contain a Psf
389  referencePsfModel : `lsst.afw.detection.Psf`
390  Psf model to match to
391 
392  Returns
393  -------
394  result : `struct`
395  - ``kernelCellSet`` : a SpatialCellSet to be used by self._solve
396  - ``referencePsfModel`` : Validated and/or modified
397  reference model used to populate the SpatialCellSet
398 
399  Notes
400  -----
401  If the reference Psf model and science Psf model have different dimensions,
402  adjust the referencePsfModel (the model to which the exposure PSF will be matched)
403  to match that of the science Psf. If the science Psf dimensions vary across the image,
404  as is common with a WarpedPsf, either pad or clip (depending on config.padPsf)
405  the dimensions to be constant.
406  """
407  sizeCellX = self.kConfig.sizeCellX
408  sizeCellY = self.kConfig.sizeCellY
409 
410  scienceBBox = exposure.getBBox()
411  # Extend for proper spatial matching kernel all the way to edge, especially for narrow strips
412  scienceBBox.grow(geom.Extent2I(sizeCellX, sizeCellY))
413 
414  sciencePsfModel = exposure.getPsf()
415 
416  dimenR = referencePsfModel.getLocalKernel().getDimensions()
417  psfWidth, psfHeight = dimenR
418 
419  regionSizeX, regionSizeY = scienceBBox.getDimensions()
420  scienceX0, scienceY0 = scienceBBox.getMin()
421 
422  kernelCellSet = afwMath.SpatialCellSet(geom.Box2I(scienceBBox), sizeCellX, sizeCellY)
423 
424  nCellX = regionSizeX//sizeCellX
425  nCellY = regionSizeY//sizeCellY
426 
427  if nCellX == 0 or nCellY == 0:
428  raise ValueError("Exposure dimensions=%s and sizeCell=(%s, %s). Insufficient area to match" %
429  (scienceBBox.getDimensions(), sizeCellX, sizeCellY))
430 
431  # Survey the PSF dimensions of the Spatial Cell Set
432  # to identify the minimum enclosed or maximum bounding square BBox.
433  widthList = []
434  heightList = []
435  for row in range(nCellY):
436  posY = sizeCellY*row + sizeCellY//2 + scienceY0
437  for col in range(nCellX):
438  posX = sizeCellX*col + sizeCellX//2 + scienceX0
439  widthS, heightS = sciencePsfModel.computeBBox(geom.Point2D(posX, posY)).getDimensions()
440  widthList.append(widthS)
441  heightList.append(heightS)
442 
443  psfSize = max(max(heightList), max(widthList))
444 
445  if self.config.doAutoPadPsf:
446  minPsfSize = nextOddInteger(self.kConfig.kernelSize*self.config.autoPadPsfTo)
447  paddingPix = max(0, minPsfSize - psfSize)
448  else:
449  if self.config.padPsfBy % 2 != 0:
450  raise ValueError("Config padPsfBy (%i pixels) must be even number." %
451  self.config.padPsfBy)
452  paddingPix = self.config.padPsfBy
453 
454  if paddingPix > 0:
455  self.log.debug("Padding Science PSF from (%d, %d) to (%d, %d) pixels",
456  psfSize, psfSize, paddingPix + psfSize, paddingPix + psfSize)
457  psfSize += paddingPix
458 
459  # Check that PSF is larger than the matching kernel
460  maxKernelSize = psfSize - 1
461  if maxKernelSize % 2 == 0:
462  maxKernelSize -= 1
463  if self.kConfig.kernelSize > maxKernelSize:
464  message = """
465  Kernel size (%d) too big to match Psfs of size %d.
466  Please reconfigure by setting one of the following:
467  1) kernel size to <= %d
468  2) doAutoPadPsf=True
469  3) padPsfBy to >= %s
470  """ % (self.kConfig.kernelSize, psfSize,
471  maxKernelSize, self.kConfig.kernelSize - maxKernelSize)
472  raise ValueError(message)
473 
474  dimenS = geom.Extent2I(psfSize, psfSize)
475 
476  if (dimenR != dimenS):
477  try:
478  referencePsfModel = referencePsfModel.resized(psfSize, psfSize)
479  self.log.info("Adjusted dimensions of reference PSF model from %s to %s", dimenR, dimenS)
480  except Exception as e:
481  self.log.warning("Zero padding or clipping the reference PSF model of type %s and dimensions"
482  " %s to the science Psf dimensions %s because: %s",
483  referencePsfModel.__class__.__name__, dimenR, dimenS, e)
484  dimenR = dimenS
485 
486  ps = pexConfig.makePropertySet(self.kConfig)
487  for row in range(nCellY):
488  # place at center of cell
489  posY = sizeCellY*row + sizeCellY//2 + scienceY0
490 
491  for col in range(nCellX):
492  # place at center of cell
493  posX = sizeCellX*col + sizeCellX//2 + scienceX0
494 
495  log.log("TRACE4." + self.log.name, log.DEBUG,
496  "Creating Psf candidate at %.1f %.1f", posX, posY)
497 
498  # reference kernel image, at location of science subimage
499  referenceMI = self._makePsfMaskedImage(referencePsfModel, posX, posY, dimensions=dimenR)
500 
501  # kernel image we are going to convolve
502  scienceMI = self._makePsfMaskedImage(sciencePsfModel, posX, posY, dimensions=dimenR)
503 
504  # The image to convolve is the science image, to the reference Psf.
505  kc = diffimLib.makeKernelCandidate(posX, posY, scienceMI, referenceMI, ps)
506  kernelCellSet.insertCandidate(kc)
507 
508  import lsstDebug
509  display = lsstDebug.Info(__name__).display
510  displaySpatialCells = lsstDebug.Info(__name__).displaySpatialCells
511  maskTransparency = lsstDebug.Info(__name__).maskTransparency
512  if not maskTransparency:
513  maskTransparency = 0
514  if display:
515  afwDisplay.setDefaultMaskTransparency(maskTransparency)
516  if display and displaySpatialCells:
517  dituils.showKernelSpatialCells(exposure.getMaskedImage(), kernelCellSet,
518  symb="o", ctype=afwDisplay.CYAN, ctypeUnused=afwDisplay.YELLOW,
519  ctypeBad=afwDisplay.RED, size=4, frame=lsstDebug.frame,
520  title="Image to be convolved")
521  lsstDebug.frame += 1
522  return pipeBase.Struct(kernelCellSet=kernelCellSet,
523  referencePsfModel=referencePsfModel,
524  )
525 
526  def _makePsfMaskedImage(self, psfModel, posX, posY, dimensions=None):
527  """Return a MaskedImage of the a PSF Model of specified dimensions
528  """
529  rawKernel = psfModel.computeKernelImage(geom.Point2D(posX, posY)).convertF()
530  if dimensions is None:
531  dimensions = rawKernel.getDimensions()
532  if rawKernel.getDimensions() == dimensions:
533  kernelIm = rawKernel
534  else:
535  # make image of proper size
536  kernelIm = afwImage.ImageF(dimensions)
537  bboxToPlace = geom.Box2I(geom.Point2I((dimensions.getX() - rawKernel.getWidth())//2,
538  (dimensions.getY() - rawKernel.getHeight())//2),
539  rawKernel.getDimensions())
540  kernelIm.assign(rawKernel, bboxToPlace)
541 
542  kernelMask = afwImage.Mask(dimensions, 0x0)
543  kernelVar = afwImage.ImageF(dimensions, 1.0)
544  return afwImage.MaskedImageF(kernelIm, kernelMask, kernelVar)
def run(self, exposure, referencePsfModel, kernelSum=1.0)
def _buildCellSet(self, exposure, referencePsfModel)
def _solve(self, kernelCellSet, basisList, returnOnExcept=False)
Definition: psfMatch.py:879
void convolve(OutImageT &convolvedImage, InImageT const &inImage, KernelT const &kernel, ConvolutionControl const &convolutionControl=ConvolutionControl())
def makeKernelBasisList(config, targetFwhmPix=None, referenceFwhmPix=None, basisDegGauss=None, basisSigmaGauss=None, metadata=None)