24 from scipy
import ndimage
29 __all__ = [
"DcrModel",
"applyDcr",
"calculateDcr",
"calculateImageParallacticAngle"]
33 """A model of the true sky after correcting chromatic effects.
37 dcrNumSubfilters : `int`
38 Number of sub-filters used to model chromatic effects within a band.
39 modelImages : `list` of `lsst.afw.image.Image`
40 A list of masked images, each containing the model for one subfilter
44 The ``DcrModel`` contains an estimate of the true sky, at a higher
45 wavelength resolution than the input observations. It can be forward-
46 modeled to produce Differential Chromatic Refraction (DCR) matched
47 templates for a given ``Exposure``, and provides utilities for conditioning
48 the model in ``dcrAssembleCoadd`` to avoid oscillating solutions between
49 iterations of forward modeling or between the subfilters of the model.
52 def __init__(self, modelImages, filterInfo=None, psf=None, mask=None, variance=None, photoCalib=None):
62 def fromImage(cls, maskedImage, dcrNumSubfilters, filterInfo=None, psf=None, photoCalib=None):
63 """Initialize a DcrModel by dividing a coadd between the subfilters.
67 maskedImage : `lsst.afw.image.MaskedImage`
68 Input coadded image to divide equally between the subfilters.
69 dcrNumSubfilters : `int`
70 Number of sub-filters used to model chromatic effects within a band.
71 filterInfo : `lsst.afw.image.Filter`, optional
72 The filter definition, set in the current instruments' obs package.
73 Required for any calculation of DCR, including making matched templates.
74 psf : `lsst.afw.detection.Psf`, optional
75 Point spread function (PSF) of the model.
76 Required if the ``DcrModel`` will be persisted.
77 photoCalib : `lsst.afw.image.PhotoCalib`, optional
78 Calibration to convert instrumental flux and
79 flux error to nanoJansky.
83 dcrModel : `lsst.pipe.tasks.DcrModel`
84 Best fit model of the true sky after correcting chromatic effects.
89 If there are any unmasked NAN values in ``maskedImage``.
93 model = maskedImage.image.clone()
94 mask = maskedImage.mask.clone()
100 variance = maskedImage.variance.clone()
101 variance /= dcrNumSubfilters
102 model /= dcrNumSubfilters
103 modelImages = [model, ]
104 for subfilter
in range(1, dcrNumSubfilters):
105 modelImages.append(model.clone())
106 return cls(modelImages, filterInfo, psf, mask, variance, photoCalib=photoCalib)
109 def fromDataRef(cls, dataRef, datasetType="dcrCoadd", numSubfilters=None, **kwargs):
110 """Load an existing DcrModel from a repository.
114 dataRef : `lsst.daf.persistence.ButlerDataRef`
115 Data reference defining the patch for coaddition and the
117 datasetType : `str`, optional
118 Name of the DcrModel in the registry {"dcrCoadd", "dcrCoadd_sub"}
119 numSubfilters : `int`
120 Number of sub-filters used to model chromatic effects within a band.
122 Additional keyword arguments to pass to look up the model in the data registry.
123 Common keywords and their types include: ``tract``:`str`, ``patch``:`str`,
124 ``bbox``:`lsst.afw.geom.Box2I`
128 dcrModel : `lsst.pipe.tasks.DcrModel`
129 Best fit model of the true sky after correcting chromatic effects.
137 for subfilter
in range(numSubfilters):
138 dcrCoadd = dataRef.get(datasetType, subfilter=subfilter,
139 numSubfilters=numSubfilters, **kwargs)
140 if filterInfo
is None:
141 filterInfo = dcrCoadd.getFilter()
143 psf = dcrCoadd.getPsf()
147 variance = dcrCoadd.variance
148 if photoCalib
is None:
149 photoCalib = dcrCoadd.getPhotoCalib()
150 modelImages.append(dcrCoadd.image)
151 return cls(modelImages, filterInfo, psf, mask, variance, photoCalib)
154 """Return the number of subfilters.
158 dcrNumSubfilters : `int`
159 The number of DCR subfilters in the model.
164 """Iterate over the subfilters of the DCR model.
169 Index of the current ``subfilter`` within the full band.
170 Negative indices are allowed, and count in reverse order
171 from the highest ``subfilter``.
175 modelImage : `lsst.afw.image.Image`
176 The DCR model for the given ``subfilter``.
181 If the requested ``subfilter`` is greater or equal to the number
182 of subfilters in the model.
184 if np.abs(subfilter) >= len(self):
185 raise IndexError(
"subfilter out of bounds.")
189 """Update the model image for one subfilter.
194 Index of the current subfilter within the full band.
195 maskedImage : `lsst.afw.image.Image`
196 The DCR model to set for the given ``subfilter``.
201 If the requested ``subfilter`` is greater or equal to the number
202 of subfilters in the model.
204 If the bounding box of the new image does not match.
206 if np.abs(subfilter) >= len(self):
207 raise IndexError(
"subfilter out of bounds.")
208 if maskedImage.getBBox() != self.
bbox:
209 raise ValueError(
"The bounding box of a subfilter must not change.")
214 """Return the filter of the model.
218 filter : `lsst.afw.image.Filter`
219 The filter definition, set in the current instruments' obs package.
225 """Return the psf of the model.
229 psf : `lsst.afw.detection.Psf`
230 Point spread function (PSF) of the model.
236 """Return the common bounding box of each subfilter image.
240 bbox : `lsst.afw.geom.Box2I`
241 Bounding box of the DCR model.
243 return self[0].getBBox()
247 """Return the common mask of each subfilter image.
251 mask : `lsst.afw.image.Mask`
252 Mask plane of the DCR model.
258 """Return the common variance of each subfilter image.
262 variance : `lsst.afw.image.Image`
263 Variance plane of the DCR model.
268 """Calculate a reference image from the average of the subfilter images.
272 bbox : `lsst.afw.geom.Box2I`, optional
273 Sub-region of the coadd. Returns the entire image if `None`.
277 refImage : `numpy.ndarray`
278 The reference image with no chromatic effects applied.
280 bbox = bbox
or self.
bbox
281 return np.mean([model[bbox].array
for model
in self], axis=0)
283 def assign(self, dcrSubModel, bbox=None):
284 """Update a sub-region of the ``DcrModel`` with new values.
288 dcrSubModel : `lsst.pipe.tasks.DcrModel`
289 New model of the true scene after correcting chromatic effects.
290 bbox : `lsst.afw.geom.Box2I`, optional
291 Sub-region of the coadd.
292 Defaults to the bounding box of ``dcrSubModel``.
297 If the new model has a different number of subfilters.
299 if len(dcrSubModel) != len(self):
300 raise ValueError(
"The number of DCR subfilters must be the same "
301 "between the old and new models.")
302 bbox = bbox
or self.
bbox
303 for model, subModel
in zip(self, dcrSubModel):
304 model.assign(subModel[bbox], bbox)
307 visitInfo=None, bbox=None, wcs=None, mask=None,
308 splitSubfilters=True, splitThreshold=0., amplifyModel=1.):
309 """Create a DCR-matched template image for an exposure.
313 exposure : `lsst.afw.image.Exposure`, optional
314 The input exposure to build a matched template for.
315 May be omitted if all of the metadata is supplied separately
316 order : `int`, optional
317 Interpolation order of the DCR shift.
318 visitInfo : `lsst.afw.image.VisitInfo`, optional
319 Metadata for the exposure. Ignored if ``exposure`` is set.
320 bbox : `lsst.afw.geom.Box2I`, optional
321 Sub-region of the coadd. Ignored if ``exposure`` is set.
322 wcs : `lsst.afw.geom.SkyWcs`, optional
323 Coordinate system definition (wcs) for the exposure.
324 Ignored if ``exposure`` is set.
325 mask : `lsst.afw.image.Mask`, optional
326 reference mask to use for the template image.
327 splitSubfilters : `bool`, optional
328 Calculate DCR for two evenly-spaced wavelengths in each subfilter,
329 instead of at the midpoint. Default: True
330 splitThreshold : `float`, optional
331 Minimum DCR difference within a subfilter required to use ``splitSubfilters``
332 amplifyModel : `float`, optional
333 Multiplication factor to amplify differences between model planes.
334 Used to speed convergence of iterative forward modeling.
338 templateImage : `lsst.afw.image.ImageF`
339 The DCR-matched template
344 If neither ``exposure`` or all of ``visitInfo``, ``bbox``, and ``wcs`` are set.
347 raise ValueError(
"'filterInfo' must be set for the DcrModel in order to calculate DCR.")
348 if exposure
is not None:
349 visitInfo = exposure.getInfo().getVisitInfo()
350 bbox = exposure.getBBox()
351 wcs = exposure.getInfo().getWcs()
352 elif visitInfo
is None or bbox
is None or wcs
is None:
353 raise ValueError(
"Either exposure or visitInfo, bbox, and wcs must be set.")
354 dcrShift =
calculateDcr(visitInfo, wcs, self.
filter, len(self), splitSubfilters=splitSubfilters)
355 templateImage = afwImage.ImageF(bbox)
357 for subfilter, dcr
in enumerate(dcrShift):
359 model = (self[subfilter][bbox].array - refModel)*amplifyModel + refModel
361 model = self[subfilter][bbox].array
362 templateImage.array +=
applyDcr(model, dcr, splitSubfilters=splitSubfilters,
363 splitThreshold=splitThreshold, order=order)
367 visitInfo=None, bbox=None, wcs=None, mask=None):
368 """Wrapper to create an exposure from a template image.
372 exposure : `lsst.afw.image.Exposure`, optional
373 The input exposure to build a matched template for.
374 May be omitted if all of the metadata is supplied separately
375 visitInfo : `lsst.afw.image.VisitInfo`, optional
376 Metadata for the exposure. Ignored if ``exposure`` is set.
377 bbox : `lsst.afw.geom.Box2I`, optional
378 Sub-region of the coadd. Ignored if ``exposure`` is set.
379 wcs : `lsst.afw.geom.SkyWcs`, optional
380 Coordinate system definition (wcs) for the exposure.
381 Ignored if ``exposure`` is set.
382 mask : `lsst.afw.image.Mask`, optional
383 reference mask to use for the template image.
387 templateExposure : `lsst.afw.image.exposureF`
388 The DCR-matched template
391 bbox = exposure.getBBox()
393 bbox=bbox, wcs=wcs, mask=mask)
394 maskedImage = afwImage.MaskedImageF(bbox)
395 maskedImage.image = templateImage[bbox]
396 maskedImage.mask = self.
mask[bbox]
397 maskedImage.variance = self.
variance[bbox]
398 templateExposure = afwImage.ExposureF(bbox, wcs)
399 templateExposure.setMaskedImage(maskedImage[bbox])
400 templateExposure.setPsf(self.
psf)
401 templateExposure.setFilter(self.
filter)
403 raise RuntimeError(
"No PhotoCalib set for the DcrModel. "
404 "If the DcrModel was created from a masked image"
405 " you must also specify the photoCalib.")
406 templateExposure.setPhotoCalib(self.
photoCalib)
407 return templateExposure
410 """Average two iterations' solutions to reduce oscillations.
414 modelImages : `list` of `lsst.afw.image.Image`
415 The new DCR model images from the current iteration.
416 The values will be modified in place.
417 bbox : `lsst.afw.geom.Box2I`
418 Sub-region of the coadd
419 gain : `float`, optional
420 Relative weight to give the new solution when updating the model.
421 Defaults to 1.0, which gives equal weight to both solutions.
424 for model, newModel
in zip(self, modelImages):
426 newModel += model[bbox]
427 newModel /= 1. + gain
430 regularizationWidth=2):
431 """Restrict large variations in the model between iterations.
436 Index of the current subfilter within the full band.
437 newModel : `lsst.afw.image.Image`
438 The new DCR model for one subfilter from the current iteration.
439 Values in ``newModel`` that are extreme compared with the last
440 iteration are modified in place.
441 bbox : `lsst.afw.geom.Box2I`
443 regularizationFactor : `float`
444 Maximum relative change of the model allowed between iterations.
445 regularizationWidth : int, optional
446 Minimum radius of a region to include in regularization, in pixels.
448 refImage = self[subfilter][bbox].array
449 highThreshold = np.abs(refImage)*regularizationFactor
450 lowThreshold = refImage/regularizationFactor
451 newImage = newModel.array
453 regularizationWidth=regularizationWidth)
456 regularizationWidth=2, mask=None, convergenceMaskPlanes="DETECTED"):
457 """Restrict large variations in the model between subfilters.
461 modelImages : `list` of `lsst.afw.image.Image`
462 The new DCR model images from the current iteration.
463 The values will be modified in place.
464 bbox : `lsst.afw.geom.Box2I`
466 statsCtrl : `lsst.afw.math.StatisticsControl`
467 Statistics control object for coaddition.
468 regularizationFactor : `float`
469 Maximum relative change of the model allowed between subfilters.
470 regularizationWidth : `int`, optional
471 Minimum radius of a region to include in regularization, in pixels.
472 mask : `lsst.afw.image.Mask`, optional
473 Optional alternate mask
474 convergenceMaskPlanes : `list` of `str`, or `str`, optional
475 Mask planes to use to calculate convergence.
479 This implementation of frequency regularization restricts each subfilter
480 image to be a smoothly-varying function times a reference image.
484 maxDiff = np.sqrt(regularizationFactor)
485 noiseLevel = self.
calculateNoiseCutoff(modelImages[0], statsCtrl, bufferSize=5, mask=mask, bbox=bbox)
487 badPixels = np.isnan(referenceImage) | (referenceImage <= 0.)
488 if np.sum(~badPixels) == 0:
491 referenceImage[badPixels] = 0.
492 filterWidth = regularizationWidth
493 fwhm = 2.*filterWidth
496 smoothRef = ndimage.filters.gaussian_filter(referenceImage, filterWidth, mode=
'constant')
499 smoothRef += 3.*noiseLevel
501 lowThreshold = smoothRef/maxDiff
502 highThreshold = smoothRef*maxDiff
503 for model
in modelImages:
505 highThreshold=highThreshold,
506 lowThreshold=lowThreshold,
507 regularizationWidth=regularizationWidth)
508 smoothModel = ndimage.filters.gaussian_filter(model.array, filterWidth, mode=
'constant')
509 smoothModel += 3.*noiseLevel
510 relativeModel = smoothModel/smoothRef
513 relativeModel2 = ndimage.filters.gaussian_filter(relativeModel, filterWidth/alpha)
514 relativeModel += alpha*(relativeModel - relativeModel2)
515 model.array = relativeModel*referenceImage
518 convergenceMaskPlanes="DETECTED", mask=None, bbox=None):
519 """Helper function to calculate the background noise level of an image.
523 image : `lsst.afw.image.Image`
524 The input image to evaluate the background noise properties.
525 statsCtrl : `lsst.afw.math.StatisticsControl`
526 Statistics control object for coaddition.
528 Number of additional pixels to exclude
529 from the edges of the bounding box.
530 convergenceMaskPlanes : `list` of `str`, or `str`
531 Mask planes to use to calculate convergence.
532 mask : `lsst.afw.image.Mask`, Optional
533 Optional alternate mask
534 bbox : `lsst.afw.geom.Box2I`, optional
535 Sub-region of the masked image to calculate the noise level over.
539 noiseCutoff : `float`
540 The threshold value to treat pixels as noise in an image..
545 mask = self.
mask[bbox]
547 bboxShrink.grow(-bufferSize)
548 convergeMask = mask.getPlaneBitMask(convergenceMaskPlanes)
550 backgroundPixels = mask[bboxShrink].array & (statsCtrl.getAndMask() | convergeMask) == 0
551 noiseCutoff = np.std(image[bboxShrink].array[backgroundPixels])
555 """Restrict image values to be between upper and lower limits.
557 This method flags all pixels in an image that are outside of the given
558 threshold values. The threshold values are taken from a reference image,
559 so noisy pixels are likely to get flagged. In order to exclude those
560 noisy pixels, the array of flags is eroded and dilated, which removes
561 isolated pixels outside of the thresholds from the list of pixels to be
562 modified. Pixels that remain flagged after this operation have their
563 values set to the appropriate upper or lower threshold value.
567 image : `numpy.ndarray`
568 The image to apply the thresholds to.
569 The values will be modified in place.
570 highThreshold : `numpy.ndarray`, optional
571 Array of upper limit values for each pixel of ``image``.
572 lowThreshold : `numpy.ndarray`, optional
573 Array of lower limit values for each pixel of ``image``.
574 regularizationWidth : `int`, optional
575 Minimum radius of a region to include in regularization, in pixels.
580 filterStructure = ndimage.iterate_structure(ndimage.generate_binary_structure(2, 1),
582 if highThreshold
is not None:
583 highPixels = image > highThreshold
584 if regularizationWidth > 0:
586 highPixels = ndimage.morphology.binary_opening(highPixels, structure=filterStructure)
587 image[highPixels] = highThreshold[highPixels]
588 if lowThreshold
is not None:
589 lowPixels = image < lowThreshold
590 if regularizationWidth > 0:
592 lowPixels = ndimage.morphology.binary_opening(lowPixels, structure=filterStructure)
593 image[lowPixels] = lowThreshold[lowPixels]
596 def applyDcr(image, dcr, useInverse=False, splitSubfilters=False, splitThreshold=0.,
597 doPrefilter=True, order=3):
598 """Shift an image along the X and Y directions.
602 image : `numpy.ndarray`
603 The input image to shift.
605 Shift calculated with ``calculateDcr``.
606 Uses numpy axes ordering (Y, X).
607 If ``splitSubfilters`` is set, each element is itself a `tuple`
608 of two `float`, corresponding to the DCR shift at the two wavelengths.
609 Otherwise, each element is a `float` corresponding to the DCR shift at
610 the effective wavelength of the subfilter.
611 useInverse : `bool`, optional
612 Apply the shift in the opposite direction. Default: False
613 splitSubfilters : `bool`, optional
614 Calculate DCR for two evenly-spaced wavelengths in each subfilter,
615 instead of at the midpoint. Default: False
616 splitThreshold : `float`, optional
617 Minimum DCR difference within a subfilter required to use ``splitSubfilters``
618 doPrefilter : `bool`, optional
619 Spline filter the image before shifting, if set. Filtering is required,
620 so only set to False if the image is already filtered.
621 Filtering takes ~20% of the time of shifting, so if `applyDcr` will be
622 called repeatedly on the same image it is more efficient to precalculate
624 order : `int`, optional
625 The order of the spline interpolation, default is 3.
629 shiftedImage : `numpy.ndarray`
630 A copy of the input image with the specified shift applied.
633 prefilteredImage = ndimage.spline_filter(image, order=order)
635 prefilteredImage = image
637 shiftAmp = np.max(np.abs([_dcr0 - _dcr1
for _dcr0, _dcr1
in zip(dcr[0], dcr[1])]))
638 if shiftAmp >= splitThreshold:
640 shift = [-1.*s
for s
in dcr[0]]
641 shift1 = [-1.*s
for s
in dcr[1]]
645 shiftedImage = ndimage.shift(prefilteredImage, shift, prefilter=
False, order=order)
646 shiftedImage += ndimage.shift(prefilteredImage, shift1, prefilter=
False, order=order)
652 dcr = (np.mean(dcr[0]), np.mean(dcr[1]))
654 shift = [-1.*s
for s
in dcr]
657 shiftedImage = ndimage.shift(prefilteredImage, shift, prefilter=
False, order=order)
661 def calculateDcr(visitInfo, wcs, filterInfo, dcrNumSubfilters, splitSubfilters=False):
662 """Calculate the shift in pixels of an exposure due to DCR.
666 visitInfo : `lsst.afw.image.VisitInfo`
667 Metadata for the exposure.
668 wcs : `lsst.afw.geom.SkyWcs`
669 Coordinate system definition (wcs) for the exposure.
670 filterInfo : `lsst.afw.image.Filter`
671 The filter definition, set in the current instruments' obs package.
672 dcrNumSubfilters : `int`
673 Number of sub-filters used to model chromatic effects within a band.
674 splitSubfilters : `bool`, optional
675 Calculate DCR for two evenly-spaced wavelengths in each subfilter,
676 instead of at the midpoint. Default: False
680 dcrShift : `tuple` of two `float`
681 The 2D shift due to DCR, in pixels.
682 Uses numpy axes ordering (Y, X).
686 weight = [0.75, 0.25]
687 lambdaEff = filterInfo.getFilterProperty().getLambdaEff()
690 diffRefractAmp0 = differentialRefraction(wavelength=wl0, wavelengthRef=lambdaEff,
691 elevation=visitInfo.getBoresightAzAlt().getLatitude(),
692 observatory=visitInfo.getObservatory(),
693 weather=visitInfo.getWeather())
694 diffRefractAmp1 = differentialRefraction(wavelength=wl1, wavelengthRef=lambdaEff,
695 elevation=visitInfo.getBoresightAzAlt().getLatitude(),
696 observatory=visitInfo.getObservatory(),
697 weather=visitInfo.getWeather())
699 diffRefractPix0 = diffRefractAmp0.asArcseconds()/wcs.getPixelScale().asArcseconds()
700 diffRefractPix1 = diffRefractAmp1.asArcseconds()/wcs.getPixelScale().asArcseconds()
701 diffRefractArr = [diffRefractPix0*weight[0] + diffRefractPix1*weight[1],
702 diffRefractPix0*weight[1] + diffRefractPix1*weight[0]]
703 shiftX = [diffRefractPix*np.sin(rotation.asRadians())
for diffRefractPix
in diffRefractArr]
704 shiftY = [diffRefractPix*np.cos(rotation.asRadians())
for diffRefractPix
in diffRefractArr]
705 dcrShift.append(((shiftY[0], shiftX[0]), (shiftY[1], shiftX[1])))
707 diffRefractAmp = (diffRefractAmp0 + diffRefractAmp1)/2.
708 diffRefractPix = diffRefractAmp.asArcseconds()/wcs.getPixelScale().asArcseconds()
709 shiftX = diffRefractPix*np.sin(rotation.asRadians())
710 shiftY = diffRefractPix*np.cos(rotation.asRadians())
711 dcrShift.append((shiftY, shiftX))
716 """Calculate the total sky rotation angle of an exposure.
720 visitInfo : `lsst.afw.image.VisitInfo`
721 Metadata for the exposure.
722 wcs : `lsst.afw.geom.SkyWcs`
723 Coordinate system definition (wcs) for the exposure.
728 The rotation of the image axis, East from North.
729 Equal to the parallactic angle plus any additional rotation of the
731 A rotation angle of 0 degrees is defined with
732 North along the +y axis and East along the +x axis.
733 A rotation angle of 90 degrees is defined with
734 North along the +x axis and East along the -y axis.
736 parAngle = visitInfo.getBoresightParAngle().asRadians()
737 cd = wcs.getCdMatrix()
739 cdAngle = (np.arctan2(-cd[0, 1], cd[0, 0]) + np.arctan2(cd[1, 0], cd[1, 1]))/2.
740 rotAngle = (cdAngle + parAngle)*geom.radians
742 cdAngle = (np.arctan2(cd[0, 1], -cd[0, 0]) + np.arctan2(cd[1, 0], cd[1, 1]))/2.
743 rotAngle = (cdAngle - parAngle)*geom.radians
748 """Iterate over the wavelength endpoints of subfilters.
752 filterInfo : `lsst.afw.image.Filter`
753 The filter definition, set in the current instruments' obs package.
754 dcrNumSubfilters : `int`
755 Number of sub-filters used to model chromatic effects within a band.
759 `tuple` of two `float`
760 The next set of wavelength endpoints for a subfilter, in nm.
762 lambdaMin = filterInfo.getFilterProperty().getLambdaMin()
763 lambdaMax = filterInfo.getFilterProperty().getLambdaMax()
764 wlStep = (lambdaMax - lambdaMin)/dcrNumSubfilters
765 for wl
in np.linspace(lambdaMin, lambdaMax, dcrNumSubfilters, endpoint=
False):
766 yield (wl, wl + wlStep)