lsst.ip.diffim g7a89bf037d+fb85dcac23
Loading...
Searching...
No Matches
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
22import numpy as np
23
24from . import diffimLib
25import lsst.afw.display as afwDisplay
26import lsst.afw.image as afwImage
27import lsst.afw.math as afwMath
28import lsst.geom as geom
29import lsst.pex.config as pexConfig
30import lsst.pipe.base as pipeBase
31from lsst.utils.logging import getTraceLogger
32from lsst.utils.timer import timeMethod
33from .makeKernelBasisList import makeKernelBasisList
34from .psfMatch import PsfMatchTask, PsfMatchConfigAL
35from . import utils as dituils
36
37__all__ = ("ModelPsfMatchTask", "ModelPsfMatchConfig")
38
39sigma2fwhm = 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
47class 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.kernel.active.singleKernelClipping = False
83 self.kernel.active.kernelSumClipping = False
84 self.kernel.active.spatialKernelClipping = False
85 self.kernel.active.checkConditionNumber = False
86
87 # Variance is ill defined
88 self.kernel.active.constantVarianceWeighting = True
89
90 # Do not change specified kernel size
91 self.kernel.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 ``pipetask`` command line interface supports a
126 flag --debug to import @b 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.utils.logging as logUtils
156 logUtils.trace_set_at("lsst.ip.diffim", 4)
157
158 Examples
159 --------
160 A complete example of using ModelPsfMatchTask
161
162 Create a subclass of ModelPsfMatchTask that accepts two exposures.
163 Note that the "template" exposure contains the Psf that will get matched to,
164 and the "science" exposure is the one that will be convolved:
165
166 .. code-block :: none
167
168 class MyModelPsfMatchTask(ModelPsfMatchTask):
169 def __init__(self, *args, **kwargs):
170 ModelPsfMatchTask.__init__(self, *args, **kwargs)
171 def run(self, templateExp, scienceExp):
172 return ModelPsfMatchTask.run(self, scienceExp, templateExp.getPsf())
173
174 And allow the user the freedom to either run the script in default mode,
175 or point to their own images on disk. Note that these
176 images must be readable as an lsst.afw.image.Exposure:
177
178 .. code-block :: none
179
180 if __name__ == "__main__":
181 import argparse
182 parser = argparse.ArgumentParser(description="Demonstrate the use of ModelPsfMatchTask")
183 parser.add_argument("--debug", "-d", action="store_true", help="Load debug.py?", default=False)
184 parser.add_argument("--template", "-t", help="Template Exposure to use", default=None)
185 parser.add_argument("--science", "-s", help="Science Exposure to use", default=None)
186 args = parser.parse_args()
187
188 We have enabled some minor display debugging in this script via the –debug option.
189 However, if you have an lsstDebug debug.py in your PYTHONPATH you will get additional
190 debugging displays. The following block checks for this script:
191
192 .. code-block :: none
193
194 if args.debug:
195 try:
196 import debug
197 # Since I am displaying 2 images here, set the starting frame number for the LSST debug LSST
198 debug.lsstDebug.frame = 3
199 except ImportError as e:
200 print(e, file=sys.stderr)
201
202 Finally, we call a run method that we define below.
203 First set up a Config and modify some of the parameters.
204 In particular we don't want to "grow" the sizes of the kernel or KernelCandidates,
205 since we are operating with fixed–size images (i.e. the size of the input Psf models).
206
207 .. code-block :: none
208
209 def run(args):
210 #
211 # Create the Config and use sum of gaussian basis
212 #
213 config = ModelPsfMatchTask.ConfigClass()
214 config.kernel.active.scaleByFwhm = False
215
216 Make sure the images (if any) that were sent to the script exist on disk and are readable.
217 If no images are sent, make some fake data up for the sake of this example script
218 (have a look at the code if you want more details on generateFakeData):
219
220 .. code-block :: none
221
222 # Run the requested method of the Task
223 if args.template is not None and args.science is not None:
224 if not os.path.isfile(args.template):
225 raise FileNotFoundError("Template image %s does not exist" % (args.template))
226 if not os.path.isfile(args.science):
227 raise FileNotFoundError("Science image %s does not exist" % (args.science))
228 try:
229 templateExp = afwImage.ExposureF(args.template)
230 except Exception as e:
231 raise RuntimeError("Cannot read template image %s" % (args.template))
232 try:
233 scienceExp = afwImage.ExposureF(args.science)
234 except Exception as e:
235 raise RuntimeError("Cannot read science image %s" % (args.science))
236 else:
237 templateExp, scienceExp = generateFakeData()
238 config.kernel.active.sizeCellX = 128
239 config.kernel.active.sizeCellY = 128
240
241 .. code-block :: none
242
243 if args.debug:
244 afwDisplay.Display(frame=1).mtv(templateExp, title="Example script: Input Template")
245 afwDisplay.Display(frame=2).mtv(scienceExp, title="Example script: Input Science Image")
246
247 Create and run the Task:
248
249 .. code-block :: none
250
251 # Create the Task
252 psfMatchTask = MyModelPsfMatchTask(config=config)
253 # Run the Task
254 result = psfMatchTask.run(templateExp, scienceExp)
255
256 And finally provide optional debugging display of the Psf-matched (via the Psf models) science image:
257
258 .. code-block :: none
259
260 if args.debug:
261 # See if the LSST debug has incremented the frame number; if not start with frame 3
262 try:
263 frame = debug.lsstDebug.frame + 1
264 except Exception:
265 frame = 3
266 afwDisplay.Display(frame=frame).mtv(result.psfMatchedExposure,
267 title="Example script: Matched Science Image")
268
269 """
270 ConfigClass = ModelPsfMatchConfig
271
272 def __init__(self, *args, **kwargs):
273 """Create a ModelPsfMatchTask
274
275 Parameters
276 ----------
277 *args
278 arguments to be passed to lsst.ip.diffim.PsfMatchTask.__init__
279 **kwargs
280 keyword arguments to be passed to lsst.ip.diffim.PsfMatchTask.__init__
281
282 Notes
283 -----
284 Upon initialization, the kernel configuration is defined by self.config.kernel.active. This Task
285 does have a run() method, which is the default way to call the Task.
286 """
287 PsfMatchTask.__init__(self, *args, **kwargs)
288 self.kConfigkConfig = self.config.kernel.active
289
290 @timeMethod
291 def run(self, exposure, referencePsfModel, kernelSum=1.0):
292 """Psf-match an exposure to a model Psf
293
294 Parameters
295 ----------
296 exposure : `lsst.afw.image.Exposure`
297 Exposure to Psf-match to the reference Psf model;
298 it must return a valid PSF model via exposure.getPsf()
299 referencePsfModel : `lsst.afw.detection.Psf`
300 The Psf model to match to
301 kernelSum : `float`, optional
302 A multipicative factor to apply to the kernel sum (default=1.0)
303
304 Returns
305 -------
306 result : `struct`
307 - ``psfMatchedExposure`` : the Psf-matched Exposure.
308 This has the same parent bbox, Wcs, PhotoCalib and
309 Filter as the input Exposure but no Psf.
310 In theory the Psf should equal referencePsfModel but
311 the match is likely not exact.
312 - ``psfMatchingKernel`` : the spatially varying Psf-matching kernel
313 - ``kernelCellSet`` : SpatialCellSet used to solve for the Psf-matching kernel
314 - ``referencePsfModel`` : Validated and/or modified reference model used
315
316 Raises
317 ------
318 RuntimeError
319 if the Exposure does not contain a Psf model
320 """
321 if not exposure.hasPsf():
322 raise RuntimeError("exposure does not contain a Psf model")
323
324 maskedImage = exposure.getMaskedImage()
325
326 self.log.info("compute Psf-matching kernel")
327 result = self._buildCellSet_buildCellSet(exposure, referencePsfModel)
328 kernelCellSet = result.kernelCellSet
329 referencePsfModel = result.referencePsfModel
330 # TODO: This should be evaluated at (or close to) the center of the
331 # exposure's bounding box in DM-32756.
332 sciAvgPos = exposure.getPsf().getAveragePosition()
333 modelAvgPos = referencePsfModel.getAveragePosition()
334 fwhmScience = exposure.getPsf().computeShape(sciAvgPos).getDeterminantRadius()*sigma2fwhm
335 fwhmModel = referencePsfModel.computeShape(modelAvgPos).getDeterminantRadius()*sigma2fwhm
336
337 basisList = makeKernelBasisList(self.kConfigkConfig, fwhmScience, fwhmModel, metadata=self.metadata)
338 spatialSolution, psfMatchingKernel, backgroundModel = self._solve(kernelCellSet, basisList)
339
340 if psfMatchingKernel.isSpatiallyVarying():
341 sParameters = np.array(psfMatchingKernel.getSpatialParameters())
342 sParameters[0][0] = kernelSum
343 psfMatchingKernel.setSpatialParameters(sParameters)
344 else:
345 kParameters = np.array(psfMatchingKernel.getKernelParameters())
346 kParameters[0] = kernelSum
347 psfMatchingKernel.setKernelParameters(kParameters)
348
349 self.log.info("Psf-match science exposure to reference")
350 psfMatchedExposure = afwImage.ExposureF(exposure.getBBox(), exposure.getWcs())
351 psfMatchedExposure.info.id = exposure.info.id
352 psfMatchedExposure.setFilter(exposure.getFilter())
353 psfMatchedExposure.setPhotoCalib(exposure.getPhotoCalib())
354 psfMatchedExposure.getInfo().setVisitInfo(exposure.getInfo().getVisitInfo())
355 psfMatchedExposure.setPsf(referencePsfModel)
356 psfMatchedMaskedImage = psfMatchedExposure.getMaskedImage()
357
358 # Normalize the psf-matching kernel while convolving since its magnitude is meaningless
359 # when PSF-matching one model to another.
360 convolutionControl = afwMath.ConvolutionControl()
361 convolutionControl.setDoNormalize(True)
362 afwMath.convolve(psfMatchedMaskedImage, maskedImage, psfMatchingKernel, convolutionControl)
363
364 self.log.info("done")
365 return pipeBase.Struct(psfMatchedExposure=psfMatchedExposure,
366 psfMatchingKernel=psfMatchingKernel,
367 kernelCellSet=kernelCellSet,
368 metadata=self.metadata,
369 )
370
371 def _diagnostic(self, kernelCellSet, spatialSolution, spatialKernel, spatialBg):
372 """Print diagnostic information on spatial kernel and background fit
373
374 The debugging diagnostics are not really useful here, since the images we are matching have
375 no variance. Thus override the _diagnostic method to generate no logging information"""
376 return
377
378 def _buildCellSet(self, exposure, referencePsfModel):
379 """Build a SpatialCellSet for use with the solve method
380
381 Parameters
382 ----------
383 exposure : `lsst.afw.image.Exposure`
384 The science exposure that will be convolved; must contain a Psf
385 referencePsfModel : `lsst.afw.detection.Psf`
386 Psf model to match to
387
388 Returns
389 -------
390 result : `struct`
391 - ``kernelCellSet`` : a SpatialCellSet to be used by self._solve
392 - ``referencePsfModel`` : Validated and/or modified
393 reference model used to populate the SpatialCellSet
394
395 Notes
396 -----
397 If the reference Psf model and science Psf model have different dimensions,
398 adjust the referencePsfModel (the model to which the exposure PSF will be matched)
399 to match that of the science Psf. If the science Psf dimensions vary across the image,
400 as is common with a WarpedPsf, either pad or clip (depending on config.padPsf)
401 the dimensions to be constant.
402 """
403 sizeCellX = self.kConfigkConfig.sizeCellX
404 sizeCellY = self.kConfigkConfig.sizeCellY
405
406 scienceBBox = exposure.getBBox()
407 # Extend for proper spatial matching kernel all the way to edge, especially for narrow strips
408 scienceBBox.grow(geom.Extent2I(sizeCellX, sizeCellY))
409
410 sciencePsfModel = exposure.getPsf()
411
412 dimenR = referencePsfModel.getLocalKernel(scienceBBox.getCenter()).getDimensions()
413
414 regionSizeX, regionSizeY = scienceBBox.getDimensions()
415 scienceX0, scienceY0 = scienceBBox.getMin()
416
417 kernelCellSet = afwMath.SpatialCellSet(geom.Box2I(scienceBBox), sizeCellX, sizeCellY)
418
419 nCellX = regionSizeX//sizeCellX
420 nCellY = regionSizeY//sizeCellY
421
422 if nCellX == 0 or nCellY == 0:
423 raise ValueError("Exposure dimensions=%s and sizeCell=(%s, %s). Insufficient area to match" %
424 (scienceBBox.getDimensions(), sizeCellX, sizeCellY))
425
426 # Survey the PSF dimensions of the Spatial Cell Set
427 # to identify the minimum enclosed or maximum bounding square BBox.
428 widthList = []
429 heightList = []
430 for row in range(nCellY):
431 posY = sizeCellY*row + sizeCellY//2 + scienceY0
432 for col in range(nCellX):
433 posX = sizeCellX*col + sizeCellX//2 + scienceX0
434 widthS, heightS = sciencePsfModel.computeBBox(geom.Point2D(posX, posY)).getDimensions()
435 widthList.append(widthS)
436 heightList.append(heightS)
437
438 psfSize = max(max(heightList), max(widthList))
439
440 if self.config.doAutoPadPsf:
441 minPsfSize = nextOddInteger(self.kConfigkConfig.kernelSize*self.config.autoPadPsfTo)
442 paddingPix = max(0, minPsfSize - psfSize)
443 else:
444 if self.config.padPsfBy % 2 != 0:
445 raise ValueError("Config padPsfBy (%i pixels) must be even number." %
446 self.config.padPsfBy)
447 paddingPix = self.config.padPsfBy
448
449 if paddingPix > 0:
450 self.log.debug("Padding Science PSF from (%d, %d) to (%d, %d) pixels",
451 psfSize, psfSize, paddingPix + psfSize, paddingPix + psfSize)
452 psfSize += paddingPix
453
454 # Check that PSF is larger than the matching kernel
455 maxKernelSize = psfSize - 1
456 if maxKernelSize % 2 == 0:
457 maxKernelSize -= 1
458 if self.kConfigkConfig.kernelSize > maxKernelSize:
459 message = """
460 Kernel size (%d) too big to match Psfs of size %d.
461 Please reconfigure by setting one of the following:
462 1) kernel size to <= %d
463 2) doAutoPadPsf=True
464 3) padPsfBy to >= %s
465 """ % (self.kConfig.kernelSize, psfSize,
466 maxKernelSize, self.kConfigkConfig.kernelSize - maxKernelSize)
467 raise ValueError(message)
468
469 dimenS = geom.Extent2I(psfSize, psfSize)
470
471 if (dimenR != dimenS):
472 try:
473 referencePsfModel = referencePsfModel.resized(psfSize, psfSize)
474 self.log.info("Adjusted dimensions of reference PSF model from %s to %s", dimenR, dimenS)
475 except Exception as e:
476 self.log.warning("Zero padding or clipping the reference PSF model of type %s and dimensions"
477 " %s to the science Psf dimensions %s because: %s",
478 referencePsfModel.__class__.__name__, dimenR, dimenS, e)
479 dimenR = dimenS
480
481 ps = pexConfig.makePropertySet(self.kConfigkConfig)
482 for row in range(nCellY):
483 # place at center of cell
484 posY = sizeCellY*row + sizeCellY//2 + scienceY0
485
486 for col in range(nCellX):
487 # place at center of cell
488 posX = sizeCellX*col + sizeCellX//2 + scienceX0
489
490 getTraceLogger(self.log, 4).debug("Creating Psf candidate at %.1f %.1f", posX, posY)
491
492 # reference kernel image, at location of science subimage
493 referenceMI = self._makePsfMaskedImage(referencePsfModel, posX, posY, dimensions=dimenR)
494
495 # kernel image we are going to convolve
496 scienceMI = self._makePsfMaskedImage(sciencePsfModel, posX, posY, dimensions=dimenR)
497
498 # The image to convolve is the science image, to the reference Psf.
499 kc = diffimLib.makeKernelCandidate(posX, posY, scienceMI, referenceMI, ps)
500 kernelCellSet.insertCandidate(kc)
501
502 import lsstDebug
503 display = lsstDebug.Info(__name__).display
504 displaySpatialCells = lsstDebug.Info(__name__).displaySpatialCells
505 maskTransparency = lsstDebug.Info(__name__).maskTransparency
506 if not maskTransparency:
507 maskTransparency = 0
508 if display:
509 afwDisplay.setDefaultMaskTransparency(maskTransparency)
510 if display and displaySpatialCells:
511 dituils.showKernelSpatialCells(exposure.getMaskedImage(), kernelCellSet,
512 symb="o", ctype=afwDisplay.CYAN, ctypeUnused=afwDisplay.YELLOW,
513 ctypeBad=afwDisplay.RED, size=4, frame=lsstDebug.frame,
514 title="Image to be convolved")
515 lsstDebug.frame += 1
516 return pipeBase.Struct(kernelCellSet=kernelCellSet,
517 referencePsfModel=referencePsfModel,
518 )
519
520 def _makePsfMaskedImage(self, psfModel, posX, posY, dimensions=None):
521 """Return a MaskedImage of the a PSF Model of specified dimensions
522 """
523 rawKernel = psfModel.computeKernelImage(geom.Point2D(posX, posY)).convertF()
524 if dimensions is None:
525 dimensions = rawKernel.getDimensions()
526 if rawKernel.getDimensions() == dimensions:
527 kernelIm = rawKernel
528 else:
529 # make image of proper size
530 kernelIm = afwImage.ImageF(dimensions)
531 bboxToPlace = geom.Box2I(geom.Point2I((dimensions.getX() - rawKernel.getWidth())//2,
532 (dimensions.getY() - rawKernel.getHeight())//2),
533 rawKernel.getDimensions())
534 kernelIm.assign(rawKernel, bboxToPlace)
535
536 kernelMask = afwImage.Mask(dimensions, 0x0)
537 kernelVar = afwImage.ImageF(dimensions, 1.0)
538 return afwImage.MaskedImageF(kernelIm, kernelMask, kernelVar)
def _makePsfMaskedImage(self, psfModel, posX, posY, dimensions=None)
def run(self, exposure, referencePsfModel, kernelSum=1.0)
def _buildCellSet(self, exposure, referencePsfModel)
def _solve(self, kernelCellSet, basisList, returnOnExcept=False)
Definition: psfMatch.py:881
void convolve(OutImageT &convolvedImage, InImageT const &inImage, KernelT const &kernel, ConvolutionControl const &convolutionControl=ConvolutionControl())