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 Gen 2 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)
155 """Load an existing DcrModel from a Gen 3 repository.
159 availableCoaddRefs : `dict` of `int` : `lsst.daf.butler.DeferredDatasetHandle`
160 Dictionary of spatially relevant retrieved coadd patches,
161 indexed by their sequential patch number.
165 dcrModel : `lsst.pipe.tasks.DcrModel`
166 Best fit model of the true sky after correcting chromatic effects.
173 modelImages = [
None]*len(availableCoaddRefs)
175 for coaddRef
in availableCoaddRefs:
176 subfilter = coaddRef.dataId[
"subfilter"]
177 dcrCoadd = coaddRef.get()
178 if filterInfo
is None:
179 filterInfo = dcrCoadd.getFilter()
181 psf = dcrCoadd.getPsf()
185 variance = dcrCoadd.variance
186 if photoCalib
is None:
187 photoCalib = dcrCoadd.getPhotoCalib()
188 modelImages[subfilter] = dcrCoadd.image
189 return cls(modelImages, filterInfo, psf, mask, variance, photoCalib)
192 """Return the number of subfilters.
196 dcrNumSubfilters : `int`
197 The number of DCR subfilters in the model.
202 """Iterate over the subfilters of the DCR model.
207 Index of the current ``subfilter`` within the full band.
208 Negative indices are allowed, and count in reverse order
209 from the highest ``subfilter``.
213 modelImage : `lsst.afw.image.Image`
214 The DCR model for the given ``subfilter``.
219 If the requested ``subfilter`` is greater or equal to the number
220 of subfilters in the model.
222 if np.abs(subfilter) >= len(self):
223 raise IndexError(
"subfilter out of bounds.")
227 """Update the model image for one subfilter.
232 Index of the current subfilter within the full band.
233 maskedImage : `lsst.afw.image.Image`
234 The DCR model to set for the given ``subfilter``.
239 If the requested ``subfilter`` is greater or equal to the number
240 of subfilters in the model.
242 If the bounding box of the new image does not match.
244 if np.abs(subfilter) >= len(self):
245 raise IndexError(
"subfilter out of bounds.")
246 if maskedImage.getBBox() != self.
bbox:
247 raise ValueError(
"The bounding box of a subfilter must not change.")
252 """Return the filter of the model.
256 filter : `lsst.afw.image.Filter`
257 The filter definition, set in the current instruments' obs package.
263 """Return the psf of the model.
267 psf : `lsst.afw.detection.Psf`
268 Point spread function (PSF) of the model.
274 """Return the common bounding box of each subfilter image.
278 bbox : `lsst.afw.geom.Box2I`
279 Bounding box of the DCR model.
281 return self[0].getBBox()
285 """Return the common mask of each subfilter image.
289 mask : `lsst.afw.image.Mask`
290 Mask plane of the DCR model.
296 """Return the common variance of each subfilter image.
300 variance : `lsst.afw.image.Image`
301 Variance plane of the DCR model.
306 """Calculate a reference image from the average of the subfilter images.
310 bbox : `lsst.afw.geom.Box2I`, optional
311 Sub-region of the coadd. Returns the entire image if `None`.
315 refImage : `numpy.ndarray`
316 The reference image with no chromatic effects applied.
318 bbox = bbox
or self.
bbox
319 return np.mean([model[bbox].array
for model
in self], axis=0)
321 def assign(self, dcrSubModel, bbox=None):
322 """Update a sub-region of the ``DcrModel`` with new values.
326 dcrSubModel : `lsst.pipe.tasks.DcrModel`
327 New model of the true scene after correcting chromatic effects.
328 bbox : `lsst.afw.geom.Box2I`, optional
329 Sub-region of the coadd.
330 Defaults to the bounding box of ``dcrSubModel``.
335 If the new model has a different number of subfilters.
337 if len(dcrSubModel) != len(self):
338 raise ValueError(
"The number of DCR subfilters must be the same "
339 "between the old and new models.")
340 bbox = bbox
or self.
bbox
341 for model, subModel
in zip(self, dcrSubModel):
342 model.assign(subModel[bbox], bbox)
345 visitInfo=None, bbox=None, wcs=None, mask=None,
346 splitSubfilters=True, splitThreshold=0., amplifyModel=1.):
347 """Create a DCR-matched template image for an exposure.
351 exposure : `lsst.afw.image.Exposure`, optional
352 The input exposure to build a matched template for.
353 May be omitted if all of the metadata is supplied separately
354 order : `int`, optional
355 Interpolation order of the DCR shift.
356 visitInfo : `lsst.afw.image.VisitInfo`, optional
357 Metadata for the exposure. Ignored if ``exposure`` is set.
358 bbox : `lsst.afw.geom.Box2I`, optional
359 Sub-region of the coadd. Ignored if ``exposure`` is set.
360 wcs : `lsst.afw.geom.SkyWcs`, optional
361 Coordinate system definition (wcs) for the exposure.
362 Ignored if ``exposure`` is set.
363 mask : `lsst.afw.image.Mask`, optional
364 reference mask to use for the template image.
365 splitSubfilters : `bool`, optional
366 Calculate DCR for two evenly-spaced wavelengths in each subfilter,
367 instead of at the midpoint. Default: True
368 splitThreshold : `float`, optional
369 Minimum DCR difference within a subfilter required to use ``splitSubfilters``
370 amplifyModel : `float`, optional
371 Multiplication factor to amplify differences between model planes.
372 Used to speed convergence of iterative forward modeling.
376 templateImage : `lsst.afw.image.ImageF`
377 The DCR-matched template
382 If neither ``exposure`` or all of ``visitInfo``, ``bbox``, and ``wcs`` are set.
385 raise ValueError(
"'filterInfo' must be set for the DcrModel in order to calculate DCR.")
386 if exposure
is not None:
387 visitInfo = exposure.getInfo().getVisitInfo()
388 bbox = exposure.getBBox()
389 wcs = exposure.getInfo().getWcs()
390 elif visitInfo
is None or bbox
is None or wcs
is None:
391 raise ValueError(
"Either exposure or visitInfo, bbox, and wcs must be set.")
392 dcrShift =
calculateDcr(visitInfo, wcs, self.
filter, len(self), splitSubfilters=splitSubfilters)
393 templateImage = afwImage.ImageF(bbox)
395 for subfilter, dcr
in enumerate(dcrShift):
397 model = (self[subfilter][bbox].array - refModel)*amplifyModel + refModel
399 model = self[subfilter][bbox].array
400 templateImage.array +=
applyDcr(model, dcr, splitSubfilters=splitSubfilters,
401 splitThreshold=splitThreshold, order=order)
405 visitInfo=None, bbox=None, wcs=None, mask=None):
406 """Wrapper to create an exposure from a template image.
410 exposure : `lsst.afw.image.Exposure`, optional
411 The input exposure to build a matched template for.
412 May be omitted if all of the metadata is supplied separately
413 visitInfo : `lsst.afw.image.VisitInfo`, optional
414 Metadata for the exposure. Ignored if ``exposure`` is set.
415 bbox : `lsst.afw.geom.Box2I`, optional
416 Sub-region of the coadd. Ignored if ``exposure`` is set.
417 wcs : `lsst.afw.geom.SkyWcs`, optional
418 Coordinate system definition (wcs) for the exposure.
419 Ignored if ``exposure`` is set.
420 mask : `lsst.afw.image.Mask`, optional
421 reference mask to use for the template image.
425 templateExposure : `lsst.afw.image.exposureF`
426 The DCR-matched template
429 bbox = exposure.getBBox()
431 bbox=bbox, wcs=wcs, mask=mask)
432 maskedImage = afwImage.MaskedImageF(bbox)
433 maskedImage.image = templateImage[bbox]
434 maskedImage.mask = self.
mask[bbox]
435 maskedImage.variance = self.
variance[bbox]
439 templateExposure = afwImage.ExposureF(bbox, wcs)
440 templateExposure.setMaskedImage(maskedImage[bbox])
441 templateExposure.setPsf(self.
psf)
442 templateExposure.setFilter(self.
filter)
444 raise RuntimeError(
"No PhotoCalib set for the DcrModel. "
445 "If the DcrModel was created from a masked image"
446 " you must also specify the photoCalib.")
447 templateExposure.setPhotoCalib(self.
photoCalib)
448 return templateExposure
451 """Average two iterations' solutions to reduce oscillations.
455 modelImages : `list` of `lsst.afw.image.Image`
456 The new DCR model images from the current iteration.
457 The values will be modified in place.
458 bbox : `lsst.afw.geom.Box2I`
459 Sub-region of the coadd
460 gain : `float`, optional
461 Relative weight to give the new solution when updating the model.
462 Defaults to 1.0, which gives equal weight to both solutions.
465 for model, newModel
in zip(self, modelImages):
467 newModel += model[bbox]
468 newModel /= 1. + gain
471 regularizationWidth=2):
472 """Restrict large variations in the model between iterations.
477 Index of the current subfilter within the full band.
478 newModel : `lsst.afw.image.Image`
479 The new DCR model for one subfilter from the current iteration.
480 Values in ``newModel`` that are extreme compared with the last
481 iteration are modified in place.
482 bbox : `lsst.afw.geom.Box2I`
484 regularizationFactor : `float`
485 Maximum relative change of the model allowed between iterations.
486 regularizationWidth : int, optional
487 Minimum radius of a region to include in regularization, in pixels.
489 refImage = self[subfilter][bbox].array
490 highThreshold = np.abs(refImage)*regularizationFactor
491 lowThreshold = refImage/regularizationFactor
492 newImage = newModel.array
494 regularizationWidth=regularizationWidth)
497 regularizationWidth=2, mask=None, convergenceMaskPlanes="DETECTED"):
498 """Restrict large variations in the model between subfilters.
502 modelImages : `list` of `lsst.afw.image.Image`
503 The new DCR model images from the current iteration.
504 The values will be modified in place.
505 bbox : `lsst.afw.geom.Box2I`
507 statsCtrl : `lsst.afw.math.StatisticsControl`
508 Statistics control object for coaddition.
509 regularizationFactor : `float`
510 Maximum relative change of the model allowed between subfilters.
511 regularizationWidth : `int`, optional
512 Minimum radius of a region to include in regularization, in pixels.
513 mask : `lsst.afw.image.Mask`, optional
514 Optional alternate mask
515 convergenceMaskPlanes : `list` of `str`, or `str`, optional
516 Mask planes to use to calculate convergence.
520 This implementation of frequency regularization restricts each subfilter
521 image to be a smoothly-varying function times a reference image.
525 maxDiff = np.sqrt(regularizationFactor)
526 noiseLevel = self.
calculateNoiseCutoff(modelImages[0], statsCtrl, bufferSize=5, mask=mask, bbox=bbox)
528 badPixels = np.isnan(referenceImage) | (referenceImage <= 0.)
529 if np.sum(~badPixels) == 0:
532 referenceImage[badPixels] = 0.
533 filterWidth = regularizationWidth
534 fwhm = 2.*filterWidth
537 smoothRef = ndimage.filters.gaussian_filter(referenceImage, filterWidth, mode=
'constant')
540 smoothRef += 3.*noiseLevel
542 lowThreshold = smoothRef/maxDiff
543 highThreshold = smoothRef*maxDiff
544 for model
in modelImages:
546 highThreshold=highThreshold,
547 lowThreshold=lowThreshold,
548 regularizationWidth=regularizationWidth)
549 smoothModel = ndimage.filters.gaussian_filter(model.array, filterWidth, mode=
'constant')
550 smoothModel += 3.*noiseLevel
551 relativeModel = smoothModel/smoothRef
554 relativeModel2 = ndimage.filters.gaussian_filter(relativeModel, filterWidth/alpha)
555 relativeModel += alpha*(relativeModel - relativeModel2)
556 model.array = relativeModel*referenceImage
559 convergenceMaskPlanes="DETECTED", mask=None, bbox=None):
560 """Helper function to calculate the background noise level of an image.
564 image : `lsst.afw.image.Image`
565 The input image to evaluate the background noise properties.
566 statsCtrl : `lsst.afw.math.StatisticsControl`
567 Statistics control object for coaddition.
569 Number of additional pixels to exclude
570 from the edges of the bounding box.
571 convergenceMaskPlanes : `list` of `str`, or `str`
572 Mask planes to use to calculate convergence.
573 mask : `lsst.afw.image.Mask`, Optional
574 Optional alternate mask
575 bbox : `lsst.afw.geom.Box2I`, optional
576 Sub-region of the masked image to calculate the noise level over.
580 noiseCutoff : `float`
581 The threshold value to treat pixels as noise in an image..
586 mask = self.
mask[bbox]
588 bboxShrink.grow(-bufferSize)
589 convergeMask = mask.getPlaneBitMask(convergenceMaskPlanes)
591 backgroundPixels = mask[bboxShrink].array & (statsCtrl.getAndMask() | convergeMask) == 0
592 noiseCutoff = np.std(image[bboxShrink].array[backgroundPixels])
596 """Restrict image values to be between upper and lower limits.
598 This method flags all pixels in an image that are outside of the given
599 threshold values. The threshold values are taken from a reference image,
600 so noisy pixels are likely to get flagged. In order to exclude those
601 noisy pixels, the array of flags is eroded and dilated, which removes
602 isolated pixels outside of the thresholds from the list of pixels to be
603 modified. Pixels that remain flagged after this operation have their
604 values set to the appropriate upper or lower threshold value.
608 image : `numpy.ndarray`
609 The image to apply the thresholds to.
610 The values will be modified in place.
611 highThreshold : `numpy.ndarray`, optional
612 Array of upper limit values for each pixel of ``image``.
613 lowThreshold : `numpy.ndarray`, optional
614 Array of lower limit values for each pixel of ``image``.
615 regularizationWidth : `int`, optional
616 Minimum radius of a region to include in regularization, in pixels.
621 filterStructure = ndimage.iterate_structure(ndimage.generate_binary_structure(2, 1),
623 if highThreshold
is not None:
624 highPixels = image > highThreshold
625 if regularizationWidth > 0:
627 highPixels = ndimage.morphology.binary_opening(highPixels, structure=filterStructure)
628 image[highPixels] = highThreshold[highPixels]
629 if lowThreshold
is not None:
630 lowPixels = image < lowThreshold
631 if regularizationWidth > 0:
633 lowPixels = ndimage.morphology.binary_opening(lowPixels, structure=filterStructure)
634 image[lowPixels] = lowThreshold[lowPixels]
637 def applyDcr(image, dcr, useInverse=False, splitSubfilters=False, splitThreshold=0.,
638 doPrefilter=True, order=3):
639 """Shift an image along the X and Y directions.
643 image : `numpy.ndarray`
644 The input image to shift.
646 Shift calculated with ``calculateDcr``.
647 Uses numpy axes ordering (Y, X).
648 If ``splitSubfilters`` is set, each element is itself a `tuple`
649 of two `float`, corresponding to the DCR shift at the two wavelengths.
650 Otherwise, each element is a `float` corresponding to the DCR shift at
651 the effective wavelength of the subfilter.
652 useInverse : `bool`, optional
653 Apply the shift in the opposite direction. Default: False
654 splitSubfilters : `bool`, optional
655 Calculate DCR for two evenly-spaced wavelengths in each subfilter,
656 instead of at the midpoint. Default: False
657 splitThreshold : `float`, optional
658 Minimum DCR difference within a subfilter required to use ``splitSubfilters``
659 doPrefilter : `bool`, optional
660 Spline filter the image before shifting, if set. Filtering is required,
661 so only set to False if the image is already filtered.
662 Filtering takes ~20% of the time of shifting, so if `applyDcr` will be
663 called repeatedly on the same image it is more efficient to precalculate
665 order : `int`, optional
666 The order of the spline interpolation, default is 3.
670 shiftedImage : `numpy.ndarray`
671 A copy of the input image with the specified shift applied.
674 prefilteredImage = ndimage.spline_filter(image, order=order)
676 prefilteredImage = image
678 shiftAmp = np.max(np.abs([_dcr0 - _dcr1
for _dcr0, _dcr1
in zip(dcr[0], dcr[1])]))
679 if shiftAmp >= splitThreshold:
681 shift = [-1.*s
for s
in dcr[0]]
682 shift1 = [-1.*s
for s
in dcr[1]]
686 shiftedImage = ndimage.shift(prefilteredImage, shift, prefilter=
False, order=order)
687 shiftedImage += ndimage.shift(prefilteredImage, shift1, prefilter=
False, order=order)
693 dcr = (np.mean(dcr[0]), np.mean(dcr[1]))
695 shift = [-1.*s
for s
in dcr]
698 shiftedImage = ndimage.shift(prefilteredImage, shift, prefilter=
False, order=order)
702 def calculateDcr(visitInfo, wcs, filterInfo, dcrNumSubfilters, splitSubfilters=False):
703 """Calculate the shift in pixels of an exposure due to DCR.
707 visitInfo : `lsst.afw.image.VisitInfo`
708 Metadata for the exposure.
709 wcs : `lsst.afw.geom.SkyWcs`
710 Coordinate system definition (wcs) for the exposure.
711 filterInfo : `lsst.afw.image.Filter`
712 The filter definition, set in the current instruments' obs package.
713 dcrNumSubfilters : `int`
714 Number of sub-filters used to model chromatic effects within a band.
715 splitSubfilters : `bool`, optional
716 Calculate DCR for two evenly-spaced wavelengths in each subfilter,
717 instead of at the midpoint. Default: False
721 dcrShift : `tuple` of two `float`
722 The 2D shift due to DCR, in pixels.
723 Uses numpy axes ordering (Y, X).
727 weight = [0.75, 0.25]
728 lambdaEff = filterInfo.getFilterProperty().getLambdaEff()
732 elevation=visitInfo.getBoresightAzAlt().getLatitude(),
733 observatory=visitInfo.getObservatory(),
734 weather=visitInfo.getWeather())
736 elevation=visitInfo.getBoresightAzAlt().getLatitude(),
737 observatory=visitInfo.getObservatory(),
738 weather=visitInfo.getWeather())
740 diffRefractPix0 = diffRefractAmp0.asArcseconds()/wcs.getPixelScale().asArcseconds()
741 diffRefractPix1 = diffRefractAmp1.asArcseconds()/wcs.getPixelScale().asArcseconds()
742 diffRefractArr = [diffRefractPix0*weight[0] + diffRefractPix1*weight[1],
743 diffRefractPix0*weight[1] + diffRefractPix1*weight[0]]
744 shiftX = [diffRefractPix*np.sin(rotation.asRadians())
for diffRefractPix
in diffRefractArr]
745 shiftY = [diffRefractPix*np.cos(rotation.asRadians())
for diffRefractPix
in diffRefractArr]
746 dcrShift.append(((shiftY[0], shiftX[0]), (shiftY[1], shiftX[1])))
748 diffRefractAmp = (diffRefractAmp0 + diffRefractAmp1)/2.
749 diffRefractPix = diffRefractAmp.asArcseconds()/wcs.getPixelScale().asArcseconds()
750 shiftX = diffRefractPix*np.sin(rotation.asRadians())
751 shiftY = diffRefractPix*np.cos(rotation.asRadians())
752 dcrShift.append((shiftY, shiftX))
757 """Calculate the total sky rotation angle of an exposure.
761 visitInfo : `lsst.afw.image.VisitInfo`
762 Metadata for the exposure.
763 wcs : `lsst.afw.geom.SkyWcs`
764 Coordinate system definition (wcs) for the exposure.
769 The rotation of the image axis, East from North.
770 Equal to the parallactic angle plus any additional rotation of the
772 A rotation angle of 0 degrees is defined with
773 North along the +y axis and East along the +x axis.
774 A rotation angle of 90 degrees is defined with
775 North along the +x axis and East along the -y axis.
777 parAngle = visitInfo.getBoresightParAngle().asRadians()
778 cd = wcs.getCdMatrix()
780 cdAngle = (np.arctan2(-cd[0, 1], cd[0, 0]) + np.arctan2(cd[1, 0], cd[1, 1]))/2.
781 rotAngle = (cdAngle + parAngle)*geom.radians
783 cdAngle = (np.arctan2(cd[0, 1], -cd[0, 0]) + np.arctan2(cd[1, 0], cd[1, 1]))/2.
784 rotAngle = (cdAngle - parAngle)*geom.radians
789 """Iterate over the wavelength endpoints of subfilters.
793 filterInfo : `lsst.afw.image.Filter`
794 The filter definition, set in the current instruments' obs package.
795 dcrNumSubfilters : `int`
796 Number of sub-filters used to model chromatic effects within a band.
800 `tuple` of two `float`
801 The next set of wavelength endpoints for a subfilter, in nm.
803 lambdaMin = filterInfo.getFilterProperty().getLambdaMin()
804 lambdaMax = filterInfo.getFilterProperty().getLambdaMax()
805 wlStep = (lambdaMax - lambdaMin)/dcrNumSubfilters
806 for wl
in np.linspace(lambdaMin, lambdaMax, dcrNumSubfilters, endpoint=
False):
807 yield (wl, wl + wlStep)