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 Gen 2 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 @classmethod 

154 def fromQuantum(cls, availableCoaddRefs): 

155 """Load an existing DcrModel from a Gen 3 repository. 

156 

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. 

162 

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) 

174 

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) 

190 

191 def __len__(self): 

192 """Return the number of subfilters. 

193 

194 Returns 

195 ------- 

196 dcrNumSubfilters : `int` 

197 The number of DCR subfilters in the model. 

198 """ 

199 return self.dcrNumSubfilters 

200 

201 def __getitem__(self, subfilter): 

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

203 

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

210 

211 Returns 

212 ------- 

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

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

215 

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] 

225 

226 def __setitem__(self, subfilter, maskedImage): 

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

228 

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

235 

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 

249 

250 @property 

251 def filter(self): 

252 """Return the filter of the model. 

253 

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 

260 

261 @property 

262 def psf(self): 

263 """Return the psf of the model. 

264 

265 Returns 

266 ------- 

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

268 Point spread function (PSF) of the model. 

269 """ 

270 return self._psf 

271 

272 @property 

273 def bbox(self): 

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

275 

276 Returns 

277 ------- 

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

279 Bounding box of the DCR model. 

280 """ 

281 return self[0].getBBox() 

282 

283 @property 

284 def mask(self): 

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

286 

287 Returns 

288 ------- 

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

290 Mask plane of the DCR model. 

291 """ 

292 return self._mask 

293 

294 @property 

295 def variance(self): 

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

297 

298 Returns 

299 ------- 

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

301 Variance plane of the DCR model. 

302 """ 

303 return self._variance 

304 

305 def getReferenceImage(self, bbox=None): 

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

307 

308 Parameters 

309 ---------- 

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

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

312 

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) 

320 

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

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

323 

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

331 

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) 

343 

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. 

348 

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. 

373 

374 Returns 

375 ------- 

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

377 The DCR-matched template 

378 

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 

403 

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. 

407 

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. 

422 

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 

449 

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

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

452 

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 

469 

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

471 regularizationWidth=2): 

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

473 

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) 

495 

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

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

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

499 

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. 

517 

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 

541 

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 

557 

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. 

561 

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. 

577 

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) 

590 

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

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

593 return noiseCutoff 

594 

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

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

597 

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. 

605 

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] 

635 

636 

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. 

640 

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. 

667 

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 

700 

701 

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

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

704 

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 

718 

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 

754 

755 

756def calculateImageParallacticAngle(visitInfo, wcs): 

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

758 

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. 

765 

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 

786 

787 

788def wavelengthGenerator(filterInfo, dcrNumSubfilters): 

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

790 

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. 

797 

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)