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# 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.ip.isr.straylight import StrayLightConfig, StrayLightTask, StrayLightData 

28 

29from . import waveletCompression 

30from .rotatorAngle import inrStartEnd 

31 

32 

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

34 

35 

36# TODO DM-16805: This doesn't match the rest of the obs_subaru/ISR code. 

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 ConfigClass = StrayLightConfig 

53 

54 def readIsrData(self, dataRef, rawExposure): 

55 # Docstring inherited from StrayLightTask.runIsrTask. 

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

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

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

59 if not self.check(rawExposure): 

60 return None 

61 

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

63 

64 def check(self, exposure): 

65 # Docstring inherited from StrayLightTask.check. 

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

67 if not self.checkFilter(exposure): 

68 # No correction to be made 

69 return False 

70 if detId in range(104, 112): 

71 # No correction data: assume it's zero 

72 return False 

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

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

75 # We believe there is no remaining stray light. 

76 return False 

77 

78 return True 

79 

80 def run(self, exposure, strayLightData): 

81 """Subtract the y-band stray light 

82 

83 This relies on knowing the instrument rotator angle during the 

84 exposure. The FITS headers provide only the instrument rotator 

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

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

87 end instrument rotator angles ourselves (config parameter 

88 ``doRotatorAngleCorrection=True``). 

89 

90 Parameters 

91 ---------- 

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

93 Exposure to correct. 

94 strayLightData : `SubaruStrayLightData` 

95 An opaque object that contains any calibration data used to 

96 correct for stray light. 

97 """ 

98 if not self.check(exposure): 

99 return None 

100 

101 if strayLightData is None: 

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

103 

104 exposureMetadata = exposure.getMetadata() 

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

106 if self.config.doRotatorAngleCorrection: 

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

108 self.log.debug( 

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

110 angleStart, angleEnd, 

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

112 ) 

113 else: 

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

115 angleEnd = None 

116 

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

118 

119 model = strayLightData.evaluate(angleStart*degrees, 

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

121 

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

123 # dead when the darks were taken 

124 # 

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

126 if detId in badAmps: 

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

128 for ii in badAmps[detId]: 

129 amp = exposure.getDetector()[ii] 

130 box = amp.getBBox() 

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

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

133 if numpy.all(isBad): 

134 model[:] = 0.0 

135 else: 

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

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

138 

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

140 exposure.image.array -= model 

141 

142 

143class SubaruStrayLightData(StrayLightData): 

144 """Lazy-load object that reads and integrates the wavelet-compressed 

145 HSC y-band stray-light model. 

146 

147 Parameters 

148 ---------- 

149 filename : `str` 

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

151 """ 

152 

153 def __init__(self, filename): 

154 self._filename = filename 

155 

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

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

158 

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

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

161 sufficient approximation for the relatively short exposure times 

162 typical for HSC. 

163 

164 Parameters 

165 ---------- 

166 angle_start : `float` 

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

168 angle_end : `float`, optional 

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

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

171 `angle_start`. 

172 

173 Returns 

174 ------- 

175 ccd_img : `numpy.ndarray` 

176 Background data for this exposure. 

177 """ 

178 hdulist = fits.open(self._filename) 

179 header = hdulist[0].header 

180 

181 # full-size ccd height & channel width 

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

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

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

185 angle_scale_level = header["WTLEVEL3"] 

186 

187 ccd_w = ch_w * len(hdulist) 

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

189 

190 for ch, hdu in enumerate(hdulist): 

191 volume = _upscale_volume(hdu.data, angle_scale_level) 

192 

193 if angle_end is None: 

194 img = volume(angle_start.asDegrees()) 

195 else: 

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

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

198 

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

200 

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

202 # when the darks were taken 

203 # is_bad = ccd_img > BAD_THRESHOLD 

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

205 

206 return ccd_img 

207 

208 

209def _upscale_image(img, target_shape, level): 

210 """ 

211 Upscale the given image to `target_shape` . 

212 

213 @param img (numpy.array[][]) 

214 Compressed image. `img.shape` must agree 

215 with waveletCompression.scaled_size(target_shape, level) 

216 @param target_shape ((int, int)) 

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

218 @param level (int or tuple of int) 

219 Level of multiresolution analysis (or synthesis) 

220 

221 @return (numpy.array[][]) 

222 """ 

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

224 

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

226 large_img[:h, :w] = img 

227 

228 return waveletCompression.icdf_9_7(large_img, level) 

229 

230 

231def _upscale_volume(volume, level): 

232 """ 

233 Upscale the given volume (= sequence of images) along the 0-th axis, 

234 and return an instance of a interpolation object that interpolates 

235 the 0-th axis. The 0-th axis is the instrument rotation. 

236 

237 @param volume (numpy.array[][][]) 

238 Sequence of images. 

239 @param level (int) 

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

241 

242 @return (scipy.interpolate.CubicSpline) 

243 You get a slice of the volume at a specific angle (in degrees) 

244 by calling the returned value as `ret_value(angle)` . 

245 """ 

246 angles = 720 

247 _, h, w = volume.shape 

248 

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

250 

251 layers = waveletCompression.scaled_size(angles, level) 

252 large_volume[:layers] = volume 

253 

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

255 large_volume[-1] = large_volume[0] 

256 

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

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