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

95 statements  

« prev     ^ index     » next       coverage.py v7.4.3, created at 2024-03-14 05:02 -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 readIsrData(self, dataRef, rawExposure): 

56 # Docstring inherited from StrayLightTask.runIsrTask. 

57 # Note that this is run only in Gen2; in Gen3 we will rely on having 

58 # a proper butler-recognized dataset type with the right validity 

59 # ranges (though this has not yet been implemented). 

60 if not self.check(rawExposure): 

61 return None 

62 

63 return SubaruStrayLightData.readFits(dataRef.get("yBackground_filename")[0]) 

64 

65 def check(self, exposure): 

66 # Docstring inherited from StrayLightTask.check. 

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

68 if not self.checkFilter(exposure): 

69 # No correction to be made 

70 return False 

71 if detId in range(104, 112): 

72 # No correction data: assume it's zero 

73 return False 

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

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

76 # We believe there is no remaining stray light. 

77 return False 

78 

79 return True 

80 

81 def run(self, exposure, strayLightData): 

82 """Subtract the y-band stray light 

83 

84 This relies on knowing the instrument rotator angle during the 

85 exposure. The FITS headers provide only the instrument rotator 

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

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

88 end instrument rotator angles ourselves (config parameter 

89 ``doRotatorAngleCorrection=True``). 

90 

91 Parameters 

92 ---------- 

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

94 Exposure to correct. 

95 strayLightData : `SubaruStrayLightData` or 

96 `~lsst.daf.butler.DeferredDatasetHandle` 

97 An opaque object that contains any calibration data used to 

98 correct for stray light. 

99 """ 

100 if not self.check(exposure): 

101 return None 

102 

103 if strayLightData is None: 

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

105 

106 if isinstance(strayLightData, DeferredDatasetHandle): 

107 # Get the deferred object. 

108 strayLightData = strayLightData.get() 

109 

110 exposureMetadata = exposure.getMetadata() 

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

112 if self.config.doRotatorAngleCorrection: 

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

114 self.log.debug( 

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

116 angleStart, angleEnd, 

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

118 ) 

119 else: 

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

121 angleEnd = None 

122 

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

124 

125 model = strayLightData.evaluate(angleStart*degrees, 

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

127 

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

129 # dead when the darks were taken 

130 # 

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

132 if detId in badAmps: 

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

134 for ii in badAmps[detId]: 

135 amp = exposure.getDetector()[ii] 

136 box = amp.getBBox() 

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

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

139 if numpy.all(isBad): 

140 model[:] = 0.0 

141 else: 

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

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

144 

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

146 exposure.image.array -= model 

147 

148 

149class SubaruStrayLightData(StrayLightData): 

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

151 HSC y-band stray-light model. 

152 

153 Parameters 

154 ---------- 

155 filename : `str` 

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

157 """ 

158 

159 @classmethod 

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

161 calib = cls() 

162 

163 with fits.open(filename) as hdulist: 

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

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

166 

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

168 return calib 

169 

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

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

172 

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

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

175 sufficient approximation for the relatively short exposure times 

176 typical for HSC. 

177 

178 Parameters 

179 ---------- 

180 angle_start : `float` 

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

182 angle_end : `float`, optional 

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

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

185 `angle_start`. 

186 

187 Returns 

188 ------- 

189 ccd_img : `numpy.ndarray` 

190 Background data for this exposure. 

191 """ 

192 header = self.getMetadata() 

193 

194 # full-size ccd height & channel width 

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

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

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

198 angle_scale_level = header["WTLEVEL3"] 

199 

200 ccd_w = ch_w * len(self.ampData) 

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

202 

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

204 volume = _upscale_volume(hdu, angle_scale_level) 

205 

206 if angle_end is None: 

207 img = volume(angle_start.asDegrees()) 

208 else: 

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

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

211 

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

213 

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

215 # when the darks were taken 

216 # is_bad = ccd_img > BAD_THRESHOLD 

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

218 

219 return ccd_img 

220 

221 

222def _upscale_image(img, target_shape, level): 

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

224 

225 Parameters 

226 ---------- 

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

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

229 with waveletCompression.scaled_size(target_shape, level) 

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

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

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

233 Level of multiresolution analysis (or synthesis) 

234 

235 Returns 

236 ------- 

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

238 Upscaled image with the ``target_shape``. 

239 """ 

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

241 

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

243 large_img[:h, :w] = img 

244 

245 return waveletCompression.icdf_9_7(large_img, level) 

246 

247 

248def _upscale_volume(volume, level): 

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

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

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

252 rotation. 

253 

254 Parameters 

255 ---------- 

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

257 Sequence of images. 

258 level : `int` 

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

260 

261 interpolator : callable 

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

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

264 

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

266 """ 

267 angles = 720 

268 _, h, w = volume.shape 

269 

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

271 

272 layers = waveletCompression.scaled_size(angles, level) 

273 large_volume[:layers] = volume 

274 

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

276 large_volume[-1] = large_volume[0] 

277 

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

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