Coverage for python/lsst/obs/subaru/strayLight/yStrayLight.py: 18%

91 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-10-11 02:51 -0700

1# Copyright (C) 2017 HSC Software Team 

2# Copyright (C) 2017 Sogo Mineo 

3# 

4# This program is free software: you can redistribute it and/or modify 

5# it under the terms of the GNU General Public License as published by 

6# the Free Software Foundation, either version 3 of the License, or 

7# (at your option) any later version. 

8# 

9# This program is distributed in the hope that it will be useful, 

10# but WITHOUT ANY WARRANTY; without even the implied warranty of 

11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

12# GNU General Public License for more details. 

13# 

14# You should have received a copy of the GNU General Public License 

15# along with this program. If not, see <http://www.gnu.org/licenses/>. 

16 

17__all__ = ["SubaruStrayLightTask"] 

18 

19import datetime 

20from typing import Optional 

21 

22import numpy 

23from astropy.io import fits 

24import scipy.interpolate 

25 

26from lsst.geom import Angle, degrees 

27from lsst.daf.butler import DeferredDatasetHandle 

28from lsst.ip.isr.straylight import StrayLightConfig, StrayLightTask, StrayLightData 

29 

30from . import waveletCompression 

31from .rotatorAngle import inrStartEnd 

32 

33 

34BAD_THRESHOLD = 500 # Threshold for identifying bad pixels in the reconstructed dark image 

35 

36 

37class SubaruStrayLightTask(StrayLightTask): 

38 """Remove stray light in the y-band 

39 

40 LEDs in an encoder in HSC are producing stray light on the detectors, 

41 producing the "Eye of Y-band" feature. It can be removed by 

42 subtracting open-shutter darks. However, because the pattern of stray 

43 light varies with rotator angle, many dark exposures are required. 

44 To reduce the data volume for the darks, the images have been 

45 compressed using wavelets. The code used to construct these is at: 

46 

47 https://hsc-gitlab.mtk.nao.ac.jp/sogo.mineo/ybackground/ 

48 

49 This Task retrieves the appropriate dark, uncompresses it and 

50 uses it to remove the stray light from an exposure. 

51 """ 

52 

53 ConfigClass = StrayLightConfig 

54 

55 def check(self, exposure): 

56 # Docstring inherited from StrayLightTask.check. 

57 detId = exposure.getDetector().getId() 

58 if not self.checkFilter(exposure): 

59 # No correction to be made 

60 return False 

61 if detId in range(104, 112): 

62 # No correction data: assume it's zero 

63 return False 

64 if exposure.getInfo().getVisitInfo().getDate().toPython() >= datetime.datetime(2018, 1, 1): 

65 # LEDs causing the stray light have been covered up. 

66 # We believe there is no remaining stray light. 

67 return False 

68 

69 return True 

70 

71 def run(self, exposure, strayLightData): 

72 """Subtract the y-band stray light 

73 

74 This relies on knowing the instrument rotator angle during the 

75 exposure. The FITS headers provide only the instrument rotator 

76 angle at the start of the exposure (INR_STR), but better 

77 stray light removal is obtained when we calculate the start and 

78 end instrument rotator angles ourselves (config parameter 

79 ``doRotatorAngleCorrection=True``). 

80 

81 Parameters 

82 ---------- 

83 exposure : `lsst.afw.image.Exposure` 

84 Exposure to correct. 

85 strayLightData : `SubaruStrayLightData` or 

86 `~lsst.daf.butler.DeferredDatasetHandle` 

87 An opaque object that contains any calibration data used to 

88 correct for stray light. 

89 """ 

90 if not self.check(exposure): 

91 return None 

92 

93 if strayLightData is None: 

94 raise RuntimeError("No strayLightData supplied for correction.") 

95 

96 if isinstance(strayLightData, DeferredDatasetHandle): 

97 # Get the deferred object. 

98 strayLightData = strayLightData.get() 

99 

100 exposureMetadata = exposure.getMetadata() 

101 detId = exposure.getDetector().getId() 

102 if self.config.doRotatorAngleCorrection: 

103 angleStart, angleEnd = inrStartEnd(exposure.getInfo().getVisitInfo()) 

104 self.log.debug( 

105 "(INR-STR, INR-END) = (%g, %g) (FITS header says (%g, %g)).", 

106 angleStart, angleEnd, 

107 exposureMetadata.getDouble('INR-STR'), exposureMetadata.getDouble('INR-END') 

108 ) 

109 else: 

110 angleStart = exposureMetadata.getDouble('INR-STR') 

111 angleEnd = None 

112 

113 self.log.info("Correcting y-band background.") 

114 

115 model = strayLightData.evaluate(angleStart*degrees, 

116 None if angleStart == angleEnd else angleEnd*degrees) 

117 

118 # Some regions don't have useful model values because the amplifier is 

119 # dead when the darks were taken 

120 # 

121 badAmps = {9: [0, 1, 2, 3], 33: [0, 1], 43: [0]} # Known bad amplifiers in the data: {ccdId: [ampId]} 

122 if detId in badAmps: 

123 isBad = numpy.zeros_like(model, dtype=bool) 

124 for ii in badAmps[detId]: 

125 amp = exposure.getDetector()[ii] 

126 box = amp.getBBox() 

127 isBad[box.getBeginY():box.getEndY(), box.getBeginX():box.getEndX()] = True 

128 mask = exposure.getMaskedImage().getMask() 

129 if numpy.all(isBad): 

130 model[:] = 0.0 

131 else: 

132 model[isBad] = numpy.median(model[~isBad]) 

133 mask.array[isBad] |= mask.getPlaneBitMask("SUSPECT") 

134 

135 model *= exposure.getInfo().getVisitInfo().getExposureTime() 

136 exposure.image.array -= model 

137 

138 

139class SubaruStrayLightData(StrayLightData): 

140 """Object that reads and integrates the wavelet-compressed 

141 HSC y-band stray-light model. 

142 

143 Parameters 

144 ---------- 

145 filename : `str` 

146 Full path to a FITS files containing the stray-light model. 

147 """ 

148 

149 @classmethod 

150 def readFits(cls, filename, **kwargs): 

151 calib = cls() 

152 

153 with fits.open(filename) as hdulist: 

154 calib.ampData = [hdu.data for hdu in hdulist] 

155 calib.setMetadata(hdulist[0].header) 

156 

157 calib.log.info("Finished reading straylightData.") 

158 return calib 

159 

160 def evaluate(self, angle_start: Angle, angle_end: Optional[Angle] = None): 

161 """Get y-band background image array for a range of angles. 

162 

163 It is hypothesized that the instrument rotator rotates at a constant 

164 angular velocity. This is not strictly true, but should be a 

165 sufficient approximation for the relatively short exposure times 

166 typical for HSC. 

167 

168 Parameters 

169 ---------- 

170 angle_start : `float` 

171 Instrument rotation angle in degrees at the start of the exposure. 

172 angle_end : `float`, optional 

173 Instrument rotation angle in degrees at the end of the exposure. 

174 If not provided, the returned array will reflect a snapshot at 

175 `angle_start`. 

176 

177 Returns 

178 ------- 

179 ccd_img : `numpy.ndarray` 

180 Background data for this exposure. 

181 """ 

182 header = self.getMetadata() 

183 

184 # full-size ccd height & channel width 

185 ccd_h, ch_w = header["F_NAXIS2"], header["F_NAXIS1"] 

186 # saved data is compressed to 1/2**scale_level of the original size 

187 image_scale_level = header["WTLEVEL2"], header["WTLEVEL1"] 

188 angle_scale_level = header["WTLEVEL3"] 

189 

190 ccd_w = ch_w * len(self.ampData) 

191 ccd_img = numpy.empty(shape=(ccd_h, ccd_w), dtype=numpy.float32) 

192 

193 for ch, hdu in enumerate(self.ampData): 

194 volume = _upscale_volume(hdu, angle_scale_level) 

195 

196 if angle_end is None: 

197 img = volume(angle_start.asDegrees()) 

198 else: 

199 img = (volume.integrate(angle_start.asDegrees(), angle_end.asDegrees()) 

200 * (1.0 / (angle_end.asDegrees() - angle_start.asDegrees()))) 

201 

202 ccd_img[:, ch_w*ch:ch_w*(ch+1)] = _upscale_image(img, (ccd_h, ch_w), image_scale_level) 

203 

204 # Some regions don't have useful values because the amplifier is dead 

205 # when the darks were taken 

206 # is_bad = ccd_img > BAD_THRESHOLD 

207 # ccd_img[is_bad] = numpy.median(ccd_img[~is_bad]) 

208 

209 return ccd_img 

210 

211 

212def _upscale_image(img, target_shape, level): 

213 """Upscale the given image to `target_shape` . 

214 

215 Parameters 

216 ---------- 

217 img : `numpy.array`, (Nx, Ny) 

218 Compressed image. ``img.shape`` must agree 

219 with waveletCompression.scaled_size(target_shape, level) 

220 target_shape : `tuple` [`int`, `int`] 

221 The shape of upscaled image, which is to be returned. 

222 level : `int` or `tuple` [`int`] 

223 Level of multiresolution analysis (or synthesis) 

224 

225 Returns 

226 ------- 

227 resized : `numpy.array`, (Nu, Nv) 

228 Upscaled image with the ``target_shape``. 

229 """ 

230 h, w = waveletCompression.scaled_size(target_shape, level) 

231 

232 large_img = numpy.zeros(shape=target_shape, dtype=float) 

233 large_img[:h, :w] = img 

234 

235 return waveletCompression.icdf_9_7(large_img, level) 

236 

237 

238def _upscale_volume(volume, level): 

239 """Upscale the given volume (= sequence of images) along the 0-th 

240 axis, and return an instance of a interpolation object that 

241 interpolates the 0-th axis. The 0-th axis is the instrument 

242 rotation. 

243 

244 Parameters 

245 ---------- 

246 volume : `numpy.array`, (Nx, Ny, Nz) 

247 Sequence of images. 

248 level : `int` 

249 Level of multiresolution analysis along the 0-th axis. 

250 

251 interpolator : callable 

252 An object that returns a slice of the volume at a specific 

253 angle (in degrees), with one positional argument: 

254 

255 - ``angle``: The angle in degrees. 

256 """ 

257 angles = 720 

258 _, h, w = volume.shape 

259 

260 large_volume = numpy.zeros(shape=(angles+1, h, w), dtype=float) 

261 

262 layers = waveletCompression.scaled_size(angles, level) 

263 large_volume[:layers] = volume 

264 

265 large_volume[:-1] = waveletCompression.periodic_icdf_9_7_1d(large_volume[:-1], level, axis=0) 

266 large_volume[-1] = large_volume[0] 

267 

268 x = numpy.arange(angles+1) / 2.0 

269 return scipy.interpolate.CubicSpline(x, large_volume, axis=0, bc_type="periodic")