Hide keyboard shortcuts

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# 

22 

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 

28 

29__all__ = ["DcrModel", "applyDcr", "calculateDcr", "calculateImageParallacticAngle"] 

30 

31 

32class DcrModel: 

33 """A model of the true sky after correcting chromatic effects. 

34 

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 

41 

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 """ 

51 

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 

60 

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. 

64 

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. 

80 

81 Returns 

82 ------- 

83 dcrModel : `lsst.pipe.tasks.DcrModel` 

84 Best fit model of the true sky after correcting chromatic effects. 

85 

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) 

107 

108 @classmethod 

109 def fromDataRef(cls, dataRef, datasetType="dcrCoadd", numSubfilters=None, **kwargs): 

110 """Load an existing DcrModel from a repository. 

111 

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` 

125 

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) 

152 

153 def __len__(self): 

154 """Return the number of subfilters. 

155 

156 Returns 

157 ------- 

158 dcrNumSubfilters : `int` 

159 The number of DCR subfilters in the model. 

160 """ 

161 return self.dcrNumSubfilters 

162 

163 def __getitem__(self, subfilter): 

164 """Iterate over the subfilters of the DCR model. 

165 

166 Parameters 

167 ---------- 

168 subfilter : `int` 

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``. 

172 

173 Returns 

174 ------- 

175 modelImage : `lsst.afw.image.Image` 

176 The DCR model for the given ``subfilter``. 

177 

178 Raises 

179 ------ 

180 IndexError 

181 If the requested ``subfilter`` is greater or equal to the number 

182 of subfilters in the model. 

183 """ 

184 if np.abs(subfilter) >= len(self): 

185 raise IndexError("subfilter out of bounds.") 

186 return self.modelImages[subfilter] 

187 

188 def __setitem__(self, subfilter, maskedImage): 

189 """Update the model image for one subfilter. 

190 

191 Parameters 

192 ---------- 

193 subfilter : `int` 

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``. 

197 

198 Raises 

199 ------ 

200 IndexError 

201 If the requested ``subfilter`` is greater or equal to the number 

202 of subfilters in the model. 

203 ValueError 

204 If the bounding box of the new image does not match. 

205 """ 

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.") 

210 self.modelImages[subfilter] = maskedImage 

211 

212 @property 

213 def filter(self): 

214 """Return the filter of the model. 

215 

216 Returns 

217 ------- 

218 filter : `lsst.afw.image.Filter` 

219 The filter definition, set in the current instruments' obs package. 

220 """ 

221 return self._filter 

222 

223 @property 

224 def psf(self): 

225 """Return the psf of the model. 

226 

227 Returns 

228 ------- 

229 psf : `lsst.afw.detection.Psf` 

230 Point spread function (PSF) of the model. 

231 """ 

232 return self._psf 

233 

234 @property 

235 def bbox(self): 

236 """Return the common bounding box of each subfilter image. 

237 

238 Returns 

239 ------- 

240 bbox : `lsst.afw.geom.Box2I` 

241 Bounding box of the DCR model. 

242 """ 

243 return self[0].getBBox() 

244 

245 @property 

246 def mask(self): 

247 """Return the common mask of each subfilter image. 

248 

249 Returns 

250 ------- 

251 mask : `lsst.afw.image.Mask` 

252 Mask plane of the DCR model. 

253 """ 

254 return self._mask 

255 

256 @property 

257 def variance(self): 

258 """Return the common variance of each subfilter image. 

259 

260 Returns 

261 ------- 

262 variance : `lsst.afw.image.Image` 

263 Variance plane of the DCR model. 

264 """ 

265 return self._variance 

266 

267 def getReferenceImage(self, bbox=None): 

268 """Calculate a reference image from the average of the subfilter images. 

269 

270 Parameters 

271 ---------- 

272 bbox : `lsst.afw.geom.Box2I`, optional 

273 Sub-region of the coadd. Returns the entire image if `None`. 

274 

275 Returns 

276 ------- 

277 refImage : `numpy.ndarray` 

278 The reference image with no chromatic effects applied. 

279 """ 

280 bbox = bbox or self.bbox 

281 return np.mean([model[bbox].array for model in self], axis=0) 

282 

283 def assign(self, dcrSubModel, bbox=None): 

284 """Update a sub-region of the ``DcrModel`` with new values. 

285 

286 Parameters 

287 ---------- 

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``. 

293 

294 Raises 

295 ------ 

296 ValueError 

297 If the new model has a different number of subfilters. 

298 """ 

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) 

305 

306 def buildMatchedTemplate(self, exposure=None, order=3, 

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. 

310 

311 Parameters 

312 ---------- 

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. 

335 

336 Returns 

337 ------- 

338 templateImage : `lsst.afw.image.ImageF` 

339 The DCR-matched template 

340 

341 Raises 

342 ------ 

343 ValueError 

344 If neither ``exposure`` or all of ``visitInfo``, ``bbox``, and ``wcs`` are set. 

345 """ 

346 if self.filter is None: 

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) 

356 refModel = self.getReferenceImage(bbox) 

357 for subfilter, dcr in enumerate(dcrShift): 

358 if amplifyModel > 1: 

359 model = (self[subfilter][bbox].array - refModel)*amplifyModel + refModel 

360 else: 

361 model = self[subfilter][bbox].array 

362 templateImage.array += applyDcr(model, dcr, splitSubfilters=splitSubfilters, 

363 splitThreshold=splitThreshold, order=order) 

364 return templateImage 

365 

366 def buildMatchedExposure(self, exposure=None, 

367 visitInfo=None, bbox=None, wcs=None, mask=None): 

368 """Wrapper to create an exposure from a template image. 

369 

370 Parameters 

371 ---------- 

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. 

384 

385 Returns 

386 ------- 

387 templateExposure : `lsst.afw.image.exposureF` 

388 The DCR-matched template 

389 """ 

390 if bbox is None: 

391 bbox = exposure.getBBox() 

392 templateImage = self.buildMatchedTemplate(exposure=exposure, visitInfo=visitInfo, 

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 # The variance of the stacked image will be `dcrNumSubfilters` 

399 # times the variance of the individual subfilters. 

400 maskedImage.variance *= self.dcrNumSubfilters 

401 templateExposure = afwImage.ExposureF(bbox, wcs) 

402 templateExposure.setMaskedImage(maskedImage[bbox]) 

403 templateExposure.setPsf(self.psf) 

404 templateExposure.setFilter(self.filter) 

405 if self.photoCalib is None: 

406 raise RuntimeError("No PhotoCalib set for the DcrModel. " 

407 "If the DcrModel was created from a masked image" 

408 " you must also specify the photoCalib.") 

409 templateExposure.setPhotoCalib(self.photoCalib) 

410 return templateExposure 

411 

412 def conditionDcrModel(self, modelImages, bbox, gain=1.): 

413 """Average two iterations' solutions to reduce oscillations. 

414 

415 Parameters 

416 ---------- 

417 modelImages : `list` of `lsst.afw.image.Image` 

418 The new DCR model images from the current iteration. 

419 The values will be modified in place. 

420 bbox : `lsst.afw.geom.Box2I` 

421 Sub-region of the coadd 

422 gain : `float`, optional 

423 Relative weight to give the new solution when updating the model. 

424 Defaults to 1.0, which gives equal weight to both solutions. 

425 """ 

426 # Calculate weighted averages of the images. 

427 for model, newModel in zip(self, modelImages): 

428 newModel *= gain 

429 newModel += model[bbox] 

430 newModel /= 1. + gain 

431 

432 def regularizeModelIter(self, subfilter, newModel, bbox, regularizationFactor, 

433 regularizationWidth=2): 

434 """Restrict large variations in the model between iterations. 

435 

436 Parameters 

437 ---------- 

438 subfilter : `int` 

439 Index of the current subfilter within the full band. 

440 newModel : `lsst.afw.image.Image` 

441 The new DCR model for one subfilter from the current iteration. 

442 Values in ``newModel`` that are extreme compared with the last 

443 iteration are modified in place. 

444 bbox : `lsst.afw.geom.Box2I` 

445 Sub-region to coadd 

446 regularizationFactor : `float` 

447 Maximum relative change of the model allowed between iterations. 

448 regularizationWidth : int, optional 

449 Minimum radius of a region to include in regularization, in pixels. 

450 """ 

451 refImage = self[subfilter][bbox].array 

452 highThreshold = np.abs(refImage)*regularizationFactor 

453 lowThreshold = refImage/regularizationFactor 

454 newImage = newModel.array 

455 self.applyImageThresholds(newImage, highThreshold=highThreshold, lowThreshold=lowThreshold, 

456 regularizationWidth=regularizationWidth) 

457 

458 def regularizeModelFreq(self, modelImages, bbox, statsCtrl, regularizationFactor, 

459 regularizationWidth=2, mask=None, convergenceMaskPlanes="DETECTED"): 

460 """Restrict large variations in the model between subfilters. 

461 

462 Parameters 

463 ---------- 

464 modelImages : `list` of `lsst.afw.image.Image` 

465 The new DCR model images from the current iteration. 

466 The values will be modified in place. 

467 bbox : `lsst.afw.geom.Box2I` 

468 Sub-region to coadd 

469 statsCtrl : `lsst.afw.math.StatisticsControl` 

470 Statistics control object for coaddition. 

471 regularizationFactor : `float` 

472 Maximum relative change of the model allowed between subfilters. 

473 regularizationWidth : `int`, optional 

474 Minimum radius of a region to include in regularization, in pixels. 

475 mask : `lsst.afw.image.Mask`, optional 

476 Optional alternate mask 

477 convergenceMaskPlanes : `list` of `str`, or `str`, optional 

478 Mask planes to use to calculate convergence. 

479 

480 Notes 

481 ----- 

482 This implementation of frequency regularization restricts each subfilter 

483 image to be a smoothly-varying function times a reference image. 

484 """ 

485 # ``regularizationFactor`` is the maximum change between subfilter images, so the maximum difference 

486 # between one subfilter image and the average will be the square root of that. 

487 maxDiff = np.sqrt(regularizationFactor) 

488 noiseLevel = self.calculateNoiseCutoff(modelImages[0], statsCtrl, bufferSize=5, mask=mask, bbox=bbox) 

489 referenceImage = self.getReferenceImage(bbox) 

490 badPixels = np.isnan(referenceImage) | (referenceImage <= 0.) 

491 if np.sum(~badPixels) == 0: 

492 # Skip regularization if there are no valid pixels 

493 return 

494 referenceImage[badPixels] = 0. 

495 filterWidth = regularizationWidth 

496 fwhm = 2.*filterWidth 

497 # The noise should be lower in the smoothed image by sqrt(Nsmooth) ~ fwhm pixels 

498 noiseLevel /= fwhm 

499 smoothRef = ndimage.filters.gaussian_filter(referenceImage, filterWidth, mode='constant') 

500 # Add a three sigma offset to both the reference and model to prevent dividing by zero. 

501 # Note that this will also slightly suppress faint variations in color. 

502 smoothRef += 3.*noiseLevel 

503 

504 lowThreshold = smoothRef/maxDiff 

505 highThreshold = smoothRef*maxDiff 

506 for model in modelImages: 

507 self.applyImageThresholds(model.array, 

508 highThreshold=highThreshold, 

509 lowThreshold=lowThreshold, 

510 regularizationWidth=regularizationWidth) 

511 smoothModel = ndimage.filters.gaussian_filter(model.array, filterWidth, mode='constant') 

512 smoothModel += 3.*noiseLevel 

513 relativeModel = smoothModel/smoothRef 

514 # Now sharpen the smoothed relativeModel using an alpha of 3. 

515 alpha = 3. 

516 relativeModel2 = ndimage.filters.gaussian_filter(relativeModel, filterWidth/alpha) 

517 relativeModel += alpha*(relativeModel - relativeModel2) 

518 model.array = relativeModel*referenceImage 

519 

520 def calculateNoiseCutoff(self, image, statsCtrl, bufferSize, 

521 convergenceMaskPlanes="DETECTED", mask=None, bbox=None): 

522 """Helper function to calculate the background noise level of an image. 

523 

524 Parameters 

525 ---------- 

526 image : `lsst.afw.image.Image` 

527 The input image to evaluate the background noise properties. 

528 statsCtrl : `lsst.afw.math.StatisticsControl` 

529 Statistics control object for coaddition. 

530 bufferSize : `int` 

531 Number of additional pixels to exclude 

532 from the edges of the bounding box. 

533 convergenceMaskPlanes : `list` of `str`, or `str` 

534 Mask planes to use to calculate convergence. 

535 mask : `lsst.afw.image.Mask`, Optional 

536 Optional alternate mask 

537 bbox : `lsst.afw.geom.Box2I`, optional 

538 Sub-region of the masked image to calculate the noise level over. 

539 

540 Returns 

541 ------- 

542 noiseCutoff : `float` 

543 The threshold value to treat pixels as noise in an image.. 

544 """ 

545 if bbox is None: 

546 bbox = self.bbox 

547 if mask is None: 

548 mask = self.mask[bbox] 

549 bboxShrink = geom.Box2I(bbox) 

550 bboxShrink.grow(-bufferSize) 

551 convergeMask = mask.getPlaneBitMask(convergenceMaskPlanes) 

552 

553 backgroundPixels = mask[bboxShrink].array & (statsCtrl.getAndMask() | convergeMask) == 0 

554 noiseCutoff = np.std(image[bboxShrink].array[backgroundPixels]) 

555 return noiseCutoff 

556 

557 def applyImageThresholds(self, image, highThreshold=None, lowThreshold=None, regularizationWidth=2): 

558 """Restrict image values to be between upper and lower limits. 

559 

560 This method flags all pixels in an image that are outside of the given 

561 threshold values. The threshold values are taken from a reference image, 

562 so noisy pixels are likely to get flagged. In order to exclude those 

563 noisy pixels, the array of flags is eroded and dilated, which removes 

564 isolated pixels outside of the thresholds from the list of pixels to be 

565 modified. Pixels that remain flagged after this operation have their 

566 values set to the appropriate upper or lower threshold value. 

567 

568 Parameters 

569 ---------- 

570 image : `numpy.ndarray` 

571 The image to apply the thresholds to. 

572 The values will be modified in place. 

573 highThreshold : `numpy.ndarray`, optional 

574 Array of upper limit values for each pixel of ``image``. 

575 lowThreshold : `numpy.ndarray`, optional 

576 Array of lower limit values for each pixel of ``image``. 

577 regularizationWidth : `int`, optional 

578 Minimum radius of a region to include in regularization, in pixels. 

579 """ 

580 # Generate the structure for binary erosion and dilation, which is used to remove noise-like pixels. 

581 # Groups of pixels with a radius smaller than ``regularizationWidth`` 

582 # will be excluded from regularization. 

583 filterStructure = ndimage.iterate_structure(ndimage.generate_binary_structure(2, 1), 

584 regularizationWidth) 

585 if highThreshold is not None: 

586 highPixels = image > highThreshold 

587 if regularizationWidth > 0: 

588 # Erode and dilate ``highPixels`` to exclude noisy pixels. 

589 highPixels = ndimage.morphology.binary_opening(highPixels, structure=filterStructure) 

590 image[highPixels] = highThreshold[highPixels] 

591 if lowThreshold is not None: 

592 lowPixels = image < lowThreshold 

593 if regularizationWidth > 0: 

594 # Erode and dilate ``lowPixels`` to exclude noisy pixels. 

595 lowPixels = ndimage.morphology.binary_opening(lowPixels, structure=filterStructure) 

596 image[lowPixels] = lowThreshold[lowPixels] 

597 

598 

599def applyDcr(image, dcr, useInverse=False, splitSubfilters=False, splitThreshold=0., 

600 doPrefilter=True, order=3): 

601 """Shift an image along the X and Y directions. 

602 

603 Parameters 

604 ---------- 

605 image : `numpy.ndarray` 

606 The input image to shift. 

607 dcr : `tuple` 

608 Shift calculated with ``calculateDcr``. 

609 Uses numpy axes ordering (Y, X). 

610 If ``splitSubfilters`` is set, each element is itself a `tuple` 

611 of two `float`, corresponding to the DCR shift at the two wavelengths. 

612 Otherwise, each element is a `float` corresponding to the DCR shift at 

613 the effective wavelength of the subfilter. 

614 useInverse : `bool`, optional 

615 Apply the shift in the opposite direction. Default: False 

616 splitSubfilters : `bool`, optional 

617 Calculate DCR for two evenly-spaced wavelengths in each subfilter, 

618 instead of at the midpoint. Default: False 

619 splitThreshold : `float`, optional 

620 Minimum DCR difference within a subfilter required to use ``splitSubfilters`` 

621 doPrefilter : `bool`, optional 

622 Spline filter the image before shifting, if set. Filtering is required, 

623 so only set to False if the image is already filtered. 

624 Filtering takes ~20% of the time of shifting, so if `applyDcr` will be 

625 called repeatedly on the same image it is more efficient to precalculate 

626 the filter. 

627 order : `int`, optional 

628 The order of the spline interpolation, default is 3. 

629 

630 Returns 

631 ------- 

632 shiftedImage : `numpy.ndarray` 

633 A copy of the input image with the specified shift applied. 

634 """ 

635 if doPrefilter: 

636 prefilteredImage = ndimage.spline_filter(image, order=order) 

637 else: 

638 prefilteredImage = image 

639 if splitSubfilters: 

640 shiftAmp = np.max(np.abs([_dcr0 - _dcr1 for _dcr0, _dcr1 in zip(dcr[0], dcr[1])])) 

641 if shiftAmp >= splitThreshold: 

642 if useInverse: 

643 shift = [-1.*s for s in dcr[0]] 

644 shift1 = [-1.*s for s in dcr[1]] 

645 else: 

646 shift = dcr[0] 

647 shift1 = dcr[1] 

648 shiftedImage = ndimage.shift(prefilteredImage, shift, prefilter=False, order=order) 

649 shiftedImage += ndimage.shift(prefilteredImage, shift1, prefilter=False, order=order) 

650 shiftedImage /= 2. 

651 return shiftedImage 

652 else: 

653 # If the difference in the DCR shifts is less than the threshold, 

654 # then just use the average shift for efficiency. 

655 dcr = (np.mean(dcr[0]), np.mean(dcr[1])) 

656 if useInverse: 

657 shift = [-1.*s for s in dcr] 

658 else: 

659 shift = dcr 

660 shiftedImage = ndimage.shift(prefilteredImage, shift, prefilter=False, order=order) 

661 return shiftedImage 

662 

663 

664def calculateDcr(visitInfo, wcs, filterInfo, dcrNumSubfilters, splitSubfilters=False): 

665 """Calculate the shift in pixels of an exposure due to DCR. 

666 

667 Parameters 

668 ---------- 

669 visitInfo : `lsst.afw.image.VisitInfo` 

670 Metadata for the exposure. 

671 wcs : `lsst.afw.geom.SkyWcs` 

672 Coordinate system definition (wcs) for the exposure. 

673 filterInfo : `lsst.afw.image.Filter` 

674 The filter definition, set in the current instruments' obs package. 

675 dcrNumSubfilters : `int` 

676 Number of sub-filters used to model chromatic effects within a band. 

677 splitSubfilters : `bool`, optional 

678 Calculate DCR for two evenly-spaced wavelengths in each subfilter, 

679 instead of at the midpoint. Default: False 

680 

681 Returns 

682 ------- 

683 dcrShift : `tuple` of two `float` 

684 The 2D shift due to DCR, in pixels. 

685 Uses numpy axes ordering (Y, X). 

686 """ 

687 rotation = calculateImageParallacticAngle(visitInfo, wcs) 

688 dcrShift = [] 

689 weight = [0.75, 0.25] 

690 lambdaEff = filterInfo.getFilterProperty().getLambdaEff() 

691 for wl0, wl1 in wavelengthGenerator(filterInfo, dcrNumSubfilters): 

692 # Note that diffRefractAmp can be negative, since it's relative to the midpoint of the full band 

693 diffRefractAmp0 = differentialRefraction(wavelength=wl0, wavelengthRef=lambdaEff, 

694 elevation=visitInfo.getBoresightAzAlt().getLatitude(), 

695 observatory=visitInfo.getObservatory(), 

696 weather=visitInfo.getWeather()) 

697 diffRefractAmp1 = differentialRefraction(wavelength=wl1, wavelengthRef=lambdaEff, 

698 elevation=visitInfo.getBoresightAzAlt().getLatitude(), 

699 observatory=visitInfo.getObservatory(), 

700 weather=visitInfo.getWeather()) 

701 if splitSubfilters: 

702 diffRefractPix0 = diffRefractAmp0.asArcseconds()/wcs.getPixelScale().asArcseconds() 

703 diffRefractPix1 = diffRefractAmp1.asArcseconds()/wcs.getPixelScale().asArcseconds() 

704 diffRefractArr = [diffRefractPix0*weight[0] + diffRefractPix1*weight[1], 

705 diffRefractPix0*weight[1] + diffRefractPix1*weight[0]] 

706 shiftX = [diffRefractPix*np.sin(rotation.asRadians()) for diffRefractPix in diffRefractArr] 

707 shiftY = [diffRefractPix*np.cos(rotation.asRadians()) for diffRefractPix in diffRefractArr] 

708 dcrShift.append(((shiftY[0], shiftX[0]), (shiftY[1], shiftX[1]))) 

709 else: 

710 diffRefractAmp = (diffRefractAmp0 + diffRefractAmp1)/2. 

711 diffRefractPix = diffRefractAmp.asArcseconds()/wcs.getPixelScale().asArcseconds() 

712 shiftX = diffRefractPix*np.sin(rotation.asRadians()) 

713 shiftY = diffRefractPix*np.cos(rotation.asRadians()) 

714 dcrShift.append((shiftY, shiftX)) 

715 return dcrShift 

716 

717 

718def calculateImageParallacticAngle(visitInfo, wcs): 

719 """Calculate the total sky rotation angle of an exposure. 

720 

721 Parameters 

722 ---------- 

723 visitInfo : `lsst.afw.image.VisitInfo` 

724 Metadata for the exposure. 

725 wcs : `lsst.afw.geom.SkyWcs` 

726 Coordinate system definition (wcs) for the exposure. 

727 

728 Returns 

729 ------- 

730 `lsst.geom.Angle` 

731 The rotation of the image axis, East from North. 

732 Equal to the parallactic angle plus any additional rotation of the 

733 coordinate system. 

734 A rotation angle of 0 degrees is defined with 

735 North along the +y axis and East along the +x axis. 

736 A rotation angle of 90 degrees is defined with 

737 North along the +x axis and East along the -y axis. 

738 """ 

739 parAngle = visitInfo.getBoresightParAngle().asRadians() 

740 cd = wcs.getCdMatrix() 

741 if wcs.isFlipped: 

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 

744 else: 

745 cdAngle = (np.arctan2(cd[0, 1], -cd[0, 0]) + np.arctan2(cd[1, 0], cd[1, 1]))/2. 

746 rotAngle = (cdAngle - parAngle)*geom.radians 

747 return rotAngle 

748 

749 

750def wavelengthGenerator(filterInfo, dcrNumSubfilters): 

751 """Iterate over the wavelength endpoints of subfilters. 

752 

753 Parameters 

754 ---------- 

755 filterInfo : `lsst.afw.image.Filter` 

756 The filter definition, set in the current instruments' obs package. 

757 dcrNumSubfilters : `int` 

758 Number of sub-filters used to model chromatic effects within a band. 

759 

760 Yields 

761 ------ 

762 `tuple` of two `float` 

763 The next set of wavelength endpoints for a subfilter, in nm. 

764 """ 

765 lambdaMin = filterInfo.getFilterProperty().getLambdaMin() 

766 lambdaMax = filterInfo.getFilterProperty().getLambdaMax() 

767 wlStep = (lambdaMax - lambdaMin)/dcrNumSubfilters 

768 for wl in np.linspace(lambdaMin, lambdaMax, dcrNumSubfilters, endpoint=False): 

769 yield (wl, wl + wlStep)