Coverage for python/lsst/ip/diffim/dcrModel.py : 10%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# This file is part of ip_diffim.
2#
3# LSST Data Management System
4# This product includes software developed by the
5# LSST Project (http://www.lsst.org/).
6# See COPYRIGHT file at the top of the source tree.
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published by
10# the Free Software Foundation, either version 3 of the License, or
11# (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the LSST License Statement and
19# the GNU General Public License along with this program. If not,
20# see <https://www.lsstcorp.org/LegalNotices/>.
21#
23import numpy as np
24from scipy import ndimage
25from lsst.afw.coord.refraction import differentialRefraction
26import lsst.afw.image as afwImage
27import lsst.geom as geom
29__all__ = ["DcrModel", "applyDcr", "calculateDcr", "calculateImageParallacticAngle"]
32class DcrModel:
33 """A model of the true sky after correcting chromatic effects.
35 Attributes
36 ----------
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
42 Notes
43 -----
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.
50 """
52 def __init__(self, modelImages, filterInfo=None, psf=None, mask=None, variance=None, photoCalib=None):
53 self.dcrNumSubfilters = len(modelImages)
54 self.modelImages = modelImages
55 self._filter = filterInfo
56 self._psf = psf
57 self._mask = mask
58 self._variance = variance
59 self.photoCalib = photoCalib
61 @classmethod
62 def fromImage(cls, maskedImage, dcrNumSubfilters, filterInfo=None, psf=None, photoCalib=None):
63 """Initialize a DcrModel by dividing a coadd between the subfilters.
65 Parameters
66 ----------
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.
81 Returns
82 -------
83 dcrModel : `lsst.pipe.tasks.DcrModel`
84 Best fit model of the true sky after correcting chromatic effects.
86 Raises
87 ------
88 ValueError
89 If there are any unmasked NAN values in ``maskedImage``.
90 """
91 # NANs will potentially contaminate the entire image,
92 # depending on the shift or convolution type used.
93 model = maskedImage.image.clone()
94 mask = maskedImage.mask.clone()
95 # We divide the variance by N and not N**2 because we will assume each
96 # subfilter is independent. That means that the significance of
97 # detected sources will be lower by a factor of sqrt(N) in the
98 # subfilter images, but we will recover it when we combine the
99 # subfilter images to construct matched templates.
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)
108 @classmethod
109 def fromDataRef(cls, dataRef, datasetType="dcrCoadd", numSubfilters=None, **kwargs):
110 """Load an existing DcrModel from a Gen 2 repository.
112 Parameters
113 ----------
114 dataRef : `lsst.daf.persistence.ButlerDataRef`
115 Data reference defining the patch for coaddition and the
116 reference Warp
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.
121 **kwargs
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`
126 Returns
127 -------
128 dcrModel : `lsst.pipe.tasks.DcrModel`
129 Best fit model of the true sky after correcting chromatic effects.
130 """
131 modelImages = []
132 filterInfo = None
133 psf = None
134 mask = None
135 variance = None
136 photoCalib = None
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()
142 if psf is None:
143 psf = dcrCoadd.getPsf()
144 if mask is None:
145 mask = dcrCoadd.mask
146 if variance is None:
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)
153 @classmethod
154 def fromQuantum(cls, availableCoaddRefs):
155 """Load an existing DcrModel from a Gen 3 repository.
157 Parameters
158 ----------
159 availableCoaddRefs : `dict` of `int` : `lsst.daf.butler.DeferredDatasetHandle`
160 Dictionary of spatially relevant retrieved coadd patches,
161 indexed by their sequential patch number.
163 Returns
164 -------
165 dcrModel : `lsst.pipe.tasks.DcrModel`
166 Best fit model of the true sky after correcting chromatic effects.
167 """
168 filterInfo = None
169 psf = None
170 mask = None
171 variance = None
172 photoCalib = None
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()
180 if psf is None:
181 psf = dcrCoadd.getPsf()
182 if mask is None:
183 mask = dcrCoadd.mask
184 if variance is None:
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)
191 def __len__(self):
192 """Return the number of subfilters.
194 Returns
195 -------
196 dcrNumSubfilters : `int`
197 The number of DCR subfilters in the model.
198 """
199 return self.dcrNumSubfilters
201 def __getitem__(self, subfilter):
202 """Iterate over the subfilters of the DCR model.
204 Parameters
205 ----------
206 subfilter : `int`
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``.
211 Returns
212 -------
213 modelImage : `lsst.afw.image.Image`
214 The DCR model for the given ``subfilter``.
216 Raises
217 ------
218 IndexError
219 If the requested ``subfilter`` is greater or equal to the number
220 of subfilters in the model.
221 """
222 if np.abs(subfilter) >= len(self):
223 raise IndexError("subfilter out of bounds.")
224 return self.modelImages[subfilter]
226 def __setitem__(self, subfilter, maskedImage):
227 """Update the model image for one subfilter.
229 Parameters
230 ----------
231 subfilter : `int`
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``.
236 Raises
237 ------
238 IndexError
239 If the requested ``subfilter`` is greater or equal to the number
240 of subfilters in the model.
241 ValueError
242 If the bounding box of the new image does not match.
243 """
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.")
248 self.modelImages[subfilter] = maskedImage
250 @property
251 def filter(self):
252 """Return the filter of the model.
254 Returns
255 -------
256 filter : `lsst.afw.image.Filter`
257 The filter definition, set in the current instruments' obs package.
258 """
259 return self._filter
261 @property
262 def psf(self):
263 """Return the psf of the model.
265 Returns
266 -------
267 psf : `lsst.afw.detection.Psf`
268 Point spread function (PSF) of the model.
269 """
270 return self._psf
272 @property
273 def bbox(self):
274 """Return the common bounding box of each subfilter image.
276 Returns
277 -------
278 bbox : `lsst.afw.geom.Box2I`
279 Bounding box of the DCR model.
280 """
281 return self[0].getBBox()
283 @property
284 def mask(self):
285 """Return the common mask of each subfilter image.
287 Returns
288 -------
289 mask : `lsst.afw.image.Mask`
290 Mask plane of the DCR model.
291 """
292 return self._mask
294 @property
295 def variance(self):
296 """Return the common variance of each subfilter image.
298 Returns
299 -------
300 variance : `lsst.afw.image.Image`
301 Variance plane of the DCR model.
302 """
303 return self._variance
305 def getReferenceImage(self, bbox=None):
306 """Calculate a reference image from the average of the subfilter images.
308 Parameters
309 ----------
310 bbox : `lsst.afw.geom.Box2I`, optional
311 Sub-region of the coadd. Returns the entire image if `None`.
313 Returns
314 -------
315 refImage : `numpy.ndarray`
316 The reference image with no chromatic effects applied.
317 """
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.
324 Parameters
325 ----------
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``.
332 Raises
333 ------
334 ValueError
335 If the new model has a different number of subfilters.
336 """
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)
344 def buildMatchedTemplate(self, exposure=None, order=3,
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.
349 Parameters
350 ----------
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.
374 Returns
375 -------
376 templateImage : `lsst.afw.image.ImageF`
377 The DCR-matched template
379 Raises
380 ------
381 ValueError
382 If neither ``exposure`` or all of ``visitInfo``, ``bbox``, and ``wcs`` are set.
383 """
384 if self.filter is None:
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)
394 refModel = self.getReferenceImage(bbox)
395 for subfilter, dcr in enumerate(dcrShift):
396 if amplifyModel > 1:
397 model = (self[subfilter][bbox].array - refModel)*amplifyModel + refModel
398 else:
399 model = self[subfilter][bbox].array
400 templateImage.array += applyDcr(model, dcr, splitSubfilters=splitSubfilters,
401 splitThreshold=splitThreshold, order=order)
402 return templateImage
404 def buildMatchedExposure(self, exposure=None,
405 visitInfo=None, bbox=None, wcs=None, mask=None):
406 """Wrapper to create an exposure from a template image.
408 Parameters
409 ----------
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.
423 Returns
424 -------
425 templateExposure : `lsst.afw.image.exposureF`
426 The DCR-matched template
427 """
428 if bbox is None:
429 bbox = exposure.getBBox()
430 templateImage = self.buildMatchedTemplate(exposure=exposure, visitInfo=visitInfo,
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]
436 # The variance of the stacked image will be `dcrNumSubfilters`
437 # times the variance of the individual subfilters.
438 maskedImage.variance *= self.dcrNumSubfilters
439 templateExposure = afwImage.ExposureF(bbox, wcs)
440 templateExposure.setMaskedImage(maskedImage[bbox])
441 templateExposure.setPsf(self.psf)
442 templateExposure.setFilter(self.filter)
443 if self.photoCalib is None:
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
450 def conditionDcrModel(self, modelImages, bbox, gain=1.):
451 """Average two iterations' solutions to reduce oscillations.
453 Parameters
454 ----------
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.
463 """
464 # Calculate weighted averages of the images.
465 for model, newModel in zip(self, modelImages):
466 newModel *= gain
467 newModel += model[bbox]
468 newModel /= 1. + gain
470 def regularizeModelIter(self, subfilter, newModel, bbox, regularizationFactor,
471 regularizationWidth=2):
472 """Restrict large variations in the model between iterations.
474 Parameters
475 ----------
476 subfilter : `int`
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`
483 Sub-region to coadd
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.
488 """
489 refImage = self[subfilter][bbox].array
490 highThreshold = np.abs(refImage)*regularizationFactor
491 lowThreshold = refImage/regularizationFactor
492 newImage = newModel.array
493 self.applyImageThresholds(newImage, highThreshold=highThreshold, lowThreshold=lowThreshold,
494 regularizationWidth=regularizationWidth)
496 def regularizeModelFreq(self, modelImages, bbox, statsCtrl, regularizationFactor,
497 regularizationWidth=2, mask=None, convergenceMaskPlanes="DETECTED"):
498 """Restrict large variations in the model between subfilters.
500 Parameters
501 ----------
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`
506 Sub-region to coadd
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.
518 Notes
519 -----
520 This implementation of frequency regularization restricts each subfilter
521 image to be a smoothly-varying function times a reference image.
522 """
523 # ``regularizationFactor`` is the maximum change between subfilter images, so the maximum difference
524 # between one subfilter image and the average will be the square root of that.
525 maxDiff = np.sqrt(regularizationFactor)
526 noiseLevel = self.calculateNoiseCutoff(modelImages[0], statsCtrl, bufferSize=5, mask=mask, bbox=bbox)
527 referenceImage = self.getReferenceImage(bbox)
528 badPixels = np.isnan(referenceImage) | (referenceImage <= 0.)
529 if np.sum(~badPixels) == 0:
530 # Skip regularization if there are no valid pixels
531 return
532 referenceImage[badPixels] = 0.
533 filterWidth = regularizationWidth
534 fwhm = 2.*filterWidth
535 # The noise should be lower in the smoothed image by sqrt(Nsmooth) ~ fwhm pixels
536 noiseLevel /= fwhm
537 smoothRef = ndimage.filters.gaussian_filter(referenceImage, filterWidth, mode='constant')
538 # Add a three sigma offset to both the reference and model to prevent dividing by zero.
539 # Note that this will also slightly suppress faint variations in color.
540 smoothRef += 3.*noiseLevel
542 lowThreshold = smoothRef/maxDiff
543 highThreshold = smoothRef*maxDiff
544 for model in modelImages:
545 self.applyImageThresholds(model.array,
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
552 # Now sharpen the smoothed relativeModel using an alpha of 3.
553 alpha = 3.
554 relativeModel2 = ndimage.filters.gaussian_filter(relativeModel, filterWidth/alpha)
555 relativeModel += alpha*(relativeModel - relativeModel2)
556 model.array = relativeModel*referenceImage
558 def calculateNoiseCutoff(self, image, statsCtrl, bufferSize,
559 convergenceMaskPlanes="DETECTED", mask=None, bbox=None):
560 """Helper function to calculate the background noise level of an image.
562 Parameters
563 ----------
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.
568 bufferSize : `int`
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.
578 Returns
579 -------
580 noiseCutoff : `float`
581 The threshold value to treat pixels as noise in an image..
582 """
583 if bbox is None:
584 bbox = self.bbox
585 if mask is None:
586 mask = self.mask[bbox]
587 bboxShrink = geom.Box2I(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])
593 return noiseCutoff
595 def applyImageThresholds(self, image, highThreshold=None, lowThreshold=None, regularizationWidth=2):
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.
606 Parameters
607 ----------
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.
617 """
618 # Generate the structure for binary erosion and dilation, which is used to remove noise-like pixels.
619 # Groups of pixels with a radius smaller than ``regularizationWidth``
620 # will be excluded from regularization.
621 filterStructure = ndimage.iterate_structure(ndimage.generate_binary_structure(2, 1),
622 regularizationWidth)
623 if highThreshold is not None:
624 highPixels = image > highThreshold
625 if regularizationWidth > 0:
626 # Erode and dilate ``highPixels`` to exclude noisy pixels.
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:
632 # Erode and dilate ``lowPixels`` to exclude noisy pixels.
633 lowPixels = ndimage.morphology.binary_opening(lowPixels, structure=filterStructure)
634 image[lowPixels] = lowThreshold[lowPixels]
637def applyDcr(image, dcr, useInverse=False, splitSubfilters=False, splitThreshold=0.,
638 doPrefilter=True, order=3):
639 """Shift an image along the X and Y directions.
641 Parameters
642 ----------
643 image : `numpy.ndarray`
644 The input image to shift.
645 dcr : `tuple`
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
664 the filter.
665 order : `int`, optional
666 The order of the spline interpolation, default is 3.
668 Returns
669 -------
670 shiftedImage : `numpy.ndarray`
671 A copy of the input image with the specified shift applied.
672 """
673 if doPrefilter:
674 prefilteredImage = ndimage.spline_filter(image, order=order)
675 else:
676 prefilteredImage = image
677 if splitSubfilters:
678 shiftAmp = np.max(np.abs([_dcr0 - _dcr1 for _dcr0, _dcr1 in zip(dcr[0], dcr[1])]))
679 if shiftAmp >= splitThreshold:
680 if useInverse:
681 shift = [-1.*s for s in dcr[0]]
682 shift1 = [-1.*s for s in dcr[1]]
683 else:
684 shift = dcr[0]
685 shift1 = dcr[1]
686 shiftedImage = ndimage.shift(prefilteredImage, shift, prefilter=False, order=order)
687 shiftedImage += ndimage.shift(prefilteredImage, shift1, prefilter=False, order=order)
688 shiftedImage /= 2.
689 return shiftedImage
690 else:
691 # If the difference in the DCR shifts is less than the threshold,
692 # then just use the average shift for efficiency.
693 dcr = (np.mean(dcr[0]), np.mean(dcr[1]))
694 if useInverse:
695 shift = [-1.*s for s in dcr]
696 else:
697 shift = dcr
698 shiftedImage = ndimage.shift(prefilteredImage, shift, prefilter=False, order=order)
699 return shiftedImage
702def calculateDcr(visitInfo, wcs, filterInfo, dcrNumSubfilters, splitSubfilters=False):
703 """Calculate the shift in pixels of an exposure due to DCR.
705 Parameters
706 ----------
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
719 Returns
720 -------
721 dcrShift : `tuple` of two `float`
722 The 2D shift due to DCR, in pixels.
723 Uses numpy axes ordering (Y, X).
724 """
725 rotation = calculateImageParallacticAngle(visitInfo, wcs)
726 dcrShift = []
727 weight = [0.75, 0.25]
728 lambdaEff = filterInfo.getFilterProperty().getLambdaEff()
729 for wl0, wl1 in wavelengthGenerator(filterInfo, dcrNumSubfilters):
730 # Note that diffRefractAmp can be negative, since it's relative to the midpoint of the full band
731 diffRefractAmp0 = differentialRefraction(wavelength=wl0, wavelengthRef=lambdaEff,
732 elevation=visitInfo.getBoresightAzAlt().getLatitude(),
733 observatory=visitInfo.getObservatory(),
734 weather=visitInfo.getWeather())
735 diffRefractAmp1 = differentialRefraction(wavelength=wl1, wavelengthRef=lambdaEff,
736 elevation=visitInfo.getBoresightAzAlt().getLatitude(),
737 observatory=visitInfo.getObservatory(),
738 weather=visitInfo.getWeather())
739 if splitSubfilters:
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])))
747 else:
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))
753 return dcrShift
756def calculateImageParallacticAngle(visitInfo, wcs):
757 """Calculate the total sky rotation angle of an exposure.
759 Parameters
760 ----------
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.
766 Returns
767 -------
768 `lsst.geom.Angle`
769 The rotation of the image axis, East from North.
770 Equal to the parallactic angle plus any additional rotation of the
771 coordinate system.
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.
776 """
777 parAngle = visitInfo.getBoresightParAngle().asRadians()
778 cd = wcs.getCdMatrix()
779 if wcs.isFlipped:
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
782 else:
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
785 return rotAngle
788def wavelengthGenerator(filterInfo, dcrNumSubfilters):
789 """Iterate over the wavelength endpoints of subfilters.
791 Parameters
792 ----------
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.
798 Yields
799 ------
800 `tuple` of two `float`
801 The next set of wavelength endpoints for a subfilter, in nm.
802 """
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)