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

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/>.
17__all__ = ["SubaruStrayLightTask"]
19import datetime
20from typing import Optional
22import numpy
23from astropy.io import fits
24import scipy.interpolate
26from lsst.geom import Angle, degrees
27from lsst.ip.isr.straylight import StrayLightConfig, StrayLightTask, StrayLightData
29from . import waveletCompression
30from .rotatorAngle import inrStartEnd
33BAD_THRESHOLD = 500 # Threshold for identifying bad pixels in the reconstructed dark image
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
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:
47 https://hsc-gitlab.mtk.nao.ac.jp/sogo.mineo/ybackground/
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
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
62 return SubaruStrayLightData(dataRef.get("yBackground_filename")[0])
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
78 return True
80 def run(self, exposure, strayLightData):
81 """Subtract the y-band stray light
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``).
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
101 if strayLightData is None:
102 raise RuntimeError("No strayLightData supplied for correction.")
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})).".format(
110 angleStart, angleEnd,
111 exposureMetadata.getDouble('INR-STR'), exposureMetadata.getDouble('INR-END'))
112 )
113 else:
114 angleStart = exposureMetadata.getDouble('INR-STR')
115 angleEnd = None
117 self.log.info("Correcting y-band background.")
119 model = strayLightData.evaluate(angleStart*degrees,
120 None if angleStart == angleEnd else angleEnd*degrees)
122 # Some regions don't have useful model values because the amplifier is dead when the darks were taken
123 #
124 badAmps = {9: [0, 1, 2, 3], 33: [0, 1], 43: [0]} # Known bad amplifiers in the data: {ccdId: [ampId]}
125 if detId in badAmps:
126 isBad = numpy.zeros_like(model, dtype=bool)
127 for ii in badAmps[detId]:
128 amp = exposure.getDetector()[ii]
129 box = amp.getBBox()
130 isBad[box.getBeginY():box.getEndY(), box.getBeginX():box.getEndX()] = True
131 mask = exposure.getMaskedImage().getMask()
132 if numpy.all(isBad):
133 model[:] = 0.0
134 else:
135 model[isBad] = numpy.median(model[~isBad])
136 mask.array[isBad] |= mask.getPlaneBitMask("SUSPECT")
138 model *= exposure.getInfo().getVisitInfo().getExposureTime()
139 exposure.image.array -= model
142class SubaruStrayLightData(StrayLightData):
143 """Lazy-load object that reads and integrates the wavelet-compressed
144 HSC y-band stray-light model.
146 Parameters
147 ----------
148 filename : `str`
149 Full path to a FITS files containing the stray-light model.
150 """
152 def __init__(self, filename):
153 self._filename = filename
155 def evaluate(self, angle_start: Angle, angle_end: Optional[Angle] = None):
156 """Get y-band background image array for a range of angles.
158 It is hypothesized that the instrument rotator rotates at a constant
159 angular velocity. This is not strictly true, but should be a
160 sufficient approximation for the relatively short exposure times
161 typical for HSC.
163 Parameters
164 ----------
165 angle_start : `float`
166 Instrument rotation angle in degrees at the start of the exposure.
167 angle_end : `float`, optional
168 Instrument rotation angle in degrees at the end of the exposure.
169 If not provided, the returned array will reflect a snapshot at
170 `angle_start`.
172 Returns
173 -------
174 ccd_img : `numpy.ndarray`
175 Background data for this exposure.
176 """
177 hdulist = fits.open(self._filename)
178 header = hdulist[0].header
180 # full-size ccd height & channel width
181 ccd_h, ch_w = header["F_NAXIS2"], header["F_NAXIS1"]
182 # saved data is compressed to 1/2**scale_level of the original size
183 image_scale_level = header["WTLEVEL2"], header["WTLEVEL1"]
184 angle_scale_level = header["WTLEVEL3"]
186 ccd_w = ch_w * len(hdulist)
187 ccd_img = numpy.empty(shape=(ccd_h, ccd_w), dtype=numpy.float32)
189 for ch, hdu in enumerate(hdulist):
190 volume = _upscale_volume(hdu.data, angle_scale_level)
192 if angle_end is None:
193 img = volume(angle_start.asDegrees())
194 else:
195 img = (volume.integrate(angle_start.asDegrees(), angle_end.asDegrees()) *
196 (1.0 / (angle_end.asDegrees() - angle_start.asDegrees())))
198 ccd_img[:, ch_w*ch:ch_w*(ch+1)] = _upscale_image(img, (ccd_h, ch_w), image_scale_level)
200 # Some regions don't have useful values because the amplifier is dead when the darks were taken
201 # is_bad = ccd_img > BAD_THRESHOLD
202 # ccd_img[is_bad] = numpy.median(ccd_img[~is_bad])
204 return ccd_img
207def _upscale_image(img, target_shape, level):
208 """
209 Upscale the given image to `target_shape` .
211 @param img (numpy.array[][])
212 Compressed image. `img.shape` must agree
213 with waveletCompression.scaled_size(target_shape, level)
214 @param target_shape ((int, int))
215 The shape of upscaled image, which is to be returned.
216 @param level (int or tuple of int)
217 Level of multiresolution analysis (or synthesis)
219 @return (numpy.array[][])
220 """
221 h, w = waveletCompression.scaled_size(target_shape, level)
223 large_img = numpy.zeros(shape=target_shape, dtype=float)
224 large_img[:h, :w] = img
226 return waveletCompression.icdf_9_7(large_img, level)
229def _upscale_volume(volume, level):
230 """
231 Upscale the given volume (= sequence of images) along the 0-th axis,
232 and return an instance of a interpolation object that interpolates
233 the 0-th axis. The 0-th axis is the instrument rotation.
235 @param volume (numpy.array[][][])
236 Sequence of images.
237 @param level (int)
238 Level of multiresolution analysis along the 0-th axis.
240 @return (scipy.interpolate.CubicSpline)
241 You get a slice of the volume at a specific angle (in degrees)
242 by calling the returned value as `ret_value(angle)` .
243 """
244 angles = 720
245 _, h, w = volume.shape
247 large_volume = numpy.zeros(shape=(angles+1, h, w), dtype=float)
249 layers = waveletCompression.scaled_size(angles, level)
250 large_volume[:layers] = volume
252 large_volume[:-1] = waveletCompression.periodic_icdf_9_7_1d(large_volume[:-1], level, axis=0)
253 large_volume[-1] = large_volume[0]
255 x = numpy.arange(angles+1) / 2.0
256 return scipy.interpolate.CubicSpline(x, large_volume, axis=0, bc_type="periodic")