Coverage for python/lsst/obs/subaru/strayLight/yStrayLight.py: 21%
95 statements
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-15 01:35 +0000
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-15 01:35 +0000
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.daf.butler import DeferredDatasetHandle
28from lsst.ip.isr.straylight import StrayLightConfig, StrayLightTask, StrayLightData
30from . import waveletCompression
31from .rotatorAngle import inrStartEnd
34BAD_THRESHOLD = 500 # Threshold for identifying bad pixels in the reconstructed dark image
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 """
53 ConfigClass = StrayLightConfig
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
63 return SubaruStrayLightData.readFits(dataRef.get("yBackground_filename")[0])
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
79 return True
81 def run(self, exposure, strayLightData):
82 """Subtract the y-band stray light
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``).
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
103 if strayLightData is None:
104 raise RuntimeError("No strayLightData supplied for correction.")
106 if isinstance(strayLightData, DeferredDatasetHandle):
107 # Get the deferred object.
108 strayLightData = strayLightData.get()
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
123 self.log.info("Correcting y-band background.")
125 model = strayLightData.evaluate(angleStart*degrees,
126 None if angleStart == angleEnd else angleEnd*degrees)
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")
145 model *= exposure.getInfo().getVisitInfo().getExposureTime()
146 exposure.image.array -= model
149class SubaruStrayLightData(StrayLightData):
150 """Object that reads and integrates the wavelet-compressed
151 HSC y-band stray-light model.
153 Parameters
154 ----------
155 filename : `str`
156 Full path to a FITS files containing the stray-light model.
157 """
159 @classmethod
160 def readFits(cls, filename, **kwargs):
161 calib = cls()
163 with fits.open(filename) as hdulist:
164 calib.ampData = [hdu.data for hdu in hdulist]
165 calib.setMetadata(hdulist[0].header)
167 calib.log.info("Finished reading straylightData.")
168 return calib
170 def evaluate(self, angle_start: Angle, angle_end: Optional[Angle] = None):
171 """Get y-band background image array for a range of angles.
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.
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`.
187 Returns
188 -------
189 ccd_img : `numpy.ndarray`
190 Background data for this exposure.
191 """
192 header = self.getMetadata()
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"]
200 ccd_w = ch_w * len(self.ampData)
201 ccd_img = numpy.empty(shape=(ccd_h, ccd_w), dtype=numpy.float32)
203 for ch, hdu in enumerate(self.ampData):
204 volume = _upscale_volume(hdu, angle_scale_level)
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())))
212 ccd_img[:, ch_w*ch:ch_w*(ch+1)] = _upscale_image(img, (ccd_h, ch_w), image_scale_level)
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])
219 return ccd_img
222def _upscale_image(img, target_shape, level):
223 """Upscale the given image to `target_shape` .
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)
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)
242 large_img = numpy.zeros(shape=target_shape, dtype=float)
243 large_img[:h, :w] = img
245 return waveletCompression.icdf_9_7(large_img, level)
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.
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.
261 interpolator : callable
262 An object that returns a slice of the volume at a specific
263 angle (in degrees), with one positional argument:
265 - ``angle``: The angle in degrees.
266 """
267 angles = 720
268 _, h, w = volume.shape
270 large_volume = numpy.zeros(shape=(angles+1, h, w), dtype=float)
272 layers = waveletCompression.scaled_size(angles, level)
273 large_volume[:layers] = volume
275 large_volume[:-1] = waveletCompression.periodic_icdf_9_7_1d(large_volume[:-1], level, axis=0)
276 large_volume[-1] = large_volume[0]
278 x = numpy.arange(angles+1) / 2.0
279 return scipy.interpolate.CubicSpline(x, large_volume, axis=0, bc_type="periodic")