Coverage for python/lsst/pipe/tasks/rewrite_images/_rewrite_visit_image.py: 0%

87 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-02 02:05 -0700

1# This file is part of pipe_tasks. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

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

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# GNU General Public License for more details. 

18# 

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

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

21 

22from __future__ import annotations 

23 

24__all__ = ("RewriteVisitImageConnections", "RewriteVisitImageTask", "RewriteVisitImageConfig") 

25 

26from typing import ClassVar 

27 

28import astropy.units 

29import numpy as np 

30 

31import lsst.pipe.base.connectionTypes as cT 

32from lsst.afw.image import PhotoCalib 

33from lsst.afw.math import BackgroundList, BackgroundMI 

34from lsst.images import VisitImage 

35from lsst.images.fields import field_from_legacy_background, field_from_legacy_photo_calib 

36from lsst.pex.config import ChoiceField, Field 

37from lsst.pipe.base import ( 

38 InputQuantizedConnection, 

39 OutputQuantizedConnection, 

40 PipelineTask, 

41 PipelineTaskConfig, 

42 PipelineTaskConnections, 

43 QuantumContext, 

44 Struct, 

45) 

46 

47 

48class RewriteVisitImageConnections( 

49 PipelineTaskConnections, 

50 dimensions={"visit", "detector"}, 

51 defaultTemplates={"legacy_prefix": "legacy_", "future_prefix": ""}, 

52): 

53 legacy_exposure = cT.Input( 

54 "{legacy_prefix}visit_image", 

55 # We expect the repository storage class to be ExposureF, but we set 

56 # the storage class we want to VisitImage so the butler can do most 

57 # of the conversion on read, and in doing so preserve the quantization 

58 # so we don't have doubly-lossless compression. 

59 storageClass="VisitImage", 

60 dimensions={"visit", "detector"}, 

61 deferLoad=True, # So we can pass preserve_quantization=True as a parameter. 

62 doc="The input image to convert.", 

63 ) 

64 visit_summary = cT.Input( 

65 "visit_summary", 

66 storageClass="ExposureCatalog", 

67 dimensions={"visit"}, 

68 doc="A visit summary catalog with the PhotoCalib that was already applied to the image's pixels.", 

69 ) 

70 photo_calib = cT.Input( 

71 "initial_photo_calib_detector", 

72 storageClass="PhotoCalib", 

73 dimensions={"visit", "detector"}, 

74 doc="The PhotoCalib that was already applied to the image's pixels.", 

75 ) 

76 subtracted_background = cT.Input( 

77 "visit_image_background", 

78 storageClass="Background", 

79 dimensions={"visit", "detector"}, 

80 doc="The background model that was subtracted from this image.", 

81 ) 

82 alternate_background = cT.Input( 

83 "skyCorr", 

84 storageClass="Background", 

85 dimensions={"visit", "detector"}, 

86 doc="A different background model that was not subtracted from the image.", 

87 ) 

88 future_visit_image = cT.Output( 

89 "{future_prefix}visit_image", 

90 storageClass="VisitImage", 

91 dimensions={"visit", "detector"}, 

92 doc="The output VisitImage.", 

93 ) 

94 

95 config: RewriteVisitImageConfig 

96 

97 def __init__(self, config: RewriteVisitImageConfig | None): 

98 super().__init__(config=config) 

99 match self.config.photo_calib_source: 

100 case "attached": 

101 del self.visit_summary 

102 del self.photo_calib 

103 case "visit_summary": 

104 del self.photo_calib 

105 case "standalone": 

106 del self.visit_summary 

107 if self.config.alternate_background_type is None: 

108 del self.alternate_background 

109 

110 

111class RewriteVisitImageConfig( 

112 PipelineTaskConfig, 

113 pipelineConnections=RewriteVisitImageConnections, 

114): 

115 photo_calib_source = ChoiceField[str]( 

116 "Which kind of PhotoCalib to load.", 

117 dtype=str, 

118 allowed={ 

119 "attached": "The input image has uncalibrated pixels and a nontrivial PhotoCalib attached to it.", 

120 "visit_summary": "Load the visit_summary connection to find the PhotoCalib already applied.", 

121 "standalone": "Load the photo_calib connection to find the PhotoCalib already applied.", 

122 }, 

123 default="visit_summary", 

124 ) 

125 subtracted_background_description = Field( 

126 "Description of the subtracted background, to be stored with the image.", 

127 dtype=str, 

128 default="Background subtracted from the image when generating the Source catalog.", 

129 ) 

130 alternate_background_name = Field( 

131 "Name for the alternate background attached to the image. " 

132 "ignored if alternate_background_type is None.", 

133 dtype=str, 

134 default="skyCorr", 

135 ) 

136 alternate_background_description = Field( 

137 "Description for the alternate background attached to the image. " 

138 "Ignored if alternate_background_type is None.", 

139 dtype=str, 

140 default=( 

141 "An alternate large-scale visit-level background subtracted from the input images that go into " 

142 "pretty_coadd and RGB color images. May preserve more low surface-brightness features, but may " 

143 "also be more affected by scattered light and other artifacts." 

144 ), 

145 ) 

146 alternate_background_type = ChoiceField( 

147 "How the alternate background relates to the subtracted background.", 

148 dtype=str, 

149 allowed={ 

150 "independent": ( 

151 "The alternate background should be subtracted after restoring the subtracted background." 

152 ), 

153 "differential_composed": ( 

154 "The alternate background starts with terms that invert the subtracted background." 

155 ), 

156 "differential_fit": ( 

157 "The alternate background was fit to the already-subtracted image, and " 

158 "can only be subtracted from the already-subtracted image." 

159 ), 

160 }, 

161 default="differential_composed", 

162 optional=True, 

163 ) 

164 instrumental_unit = Field( 

165 "Unit for instrumental flux pixels (i.e. uncalibrated side of a PhotoCalib, " 

166 "and the units of all background inputs).", 

167 dtype=str, 

168 default="electron", 

169 ) 

170 

171 

172class RewriteVisitImageTask(PipelineTask): 

173 ConfigClass: ClassVar[type[RewriteVisitImageConfig]] = RewriteVisitImageConfig 

174 config: RewriteVisitImageConfig 

175 _DefaultName = "rewriteVisitImage" 

176 

177 def runQuantum( 

178 self, 

179 butlerQC: QuantumContext, 

180 inputRefs: InputQuantizedConnection, 

181 outputRefs: OutputQuantizedConnection, 

182 ) -> None: 

183 inputs = butlerQC.get(inputRefs) 

184 visit_image = inputs.pop("legacy_exposure").get(parameters={"preserve_quantization": True}) 

185 photo_calib: PhotoCalib | None 

186 match self.config.photo_calib_source: 

187 case "attached": 

188 photo_calib = None 

189 case "visit_summary": 

190 visit_summary = inputs.pop("visit_summary") 

191 photo_calib = visit_summary.find(butlerQC.quantum.dataId["detector"]).getPhotoCalib() 

192 case "standalone": 

193 photo_calib = inputs.pop("photo_calib") 

194 outputs = self.run(visit_image, photo_calib=photo_calib, **inputs) 

195 butlerQC.put(outputs, outputRefs) 

196 

197 def run( 

198 self, 

199 visit_image: VisitImage, 

200 *, 

201 photo_calib: PhotoCalib | None = None, 

202 subtracted_background: BackgroundList, 

203 alternate_background: BackgroundList | None = None, 

204 ) -> Struct: 

205 instrumental_unit = astropy.units.Unit(self.config.instrumental_unit) 

206 if photo_calib is not None: 

207 visit_image.photometric_scaling = field_from_legacy_photo_calib( 

208 photo_calib, bounds=visit_image.bbox, instrumental_unit=instrumental_unit 

209 ) 

210 visit_image.backgrounds.add( 

211 "subtracted", 

212 field_from_legacy_background( 

213 subtracted_background, bounds=visit_image.bbox, unit=instrumental_unit 

214 ), 

215 self.config.subtracted_background_description, 

216 is_subtracted=True, 

217 ) 

218 if alternate_background is not None: 

219 assert self.config.alternate_background_name is not None, ( 

220 "Configuration and arguments are inconsistent." 

221 ) 

222 match self.config.alternate_background_type: 

223 case "independent": 

224 pass 

225 case "differential_composed": 

226 for (subtracted_term, *_), (alternate_term, *_) in zip( 

227 subtracted_background, alternate_background 

228 ): 

229 if not self._do_backgrounds_cancel(subtracted_term, alternate_term): 

230 raise RuntimeError( 

231 "alternate_background_type='differential_composed', but the alternate " 

232 "background does not start with the inverse of the subtracted background." 

233 ) 

234 alternate_background = BackgroundList( 

235 *[ 

236 alternate_background[n] 

237 for n in range(len(subtracted_background), len(alternate_background)) 

238 ] 

239 ) 

240 case "differential_fit": 

241 alternate_background = BackgroundList(*subtracted_background, *alternate_background) 

242 visit_image.backgrounds.add( 

243 self.config.alternate_background_name, 

244 field_from_legacy_background( 

245 alternate_background, bounds=visit_image.bbox, unit=instrumental_unit 

246 ), 

247 self.config.alternate_background_description, 

248 is_subtracted=True, 

249 ) 

250 return Struct(future_visit_image=visit_image) 

251 

252 def _do_backgrounds_cancel(self, bg1: BackgroundMI, bg2: BackgroundMI) -> bool: 

253 ctrl1 = bg1.getBackgroundControl() 

254 ctrl2 = bg2.getBackgroundControl() 

255 if ctrl1.getInterpStyle() != ctrl2.getInterpStyle(): 

256 return False 

257 if ctrl1.getApproximateControl().getStyle() != ctrl2.getApproximateControl().getStyle(): 

258 return False 

259 bins1 = bg1.getStatsImage() 

260 bins2 = bg2.getStatsImage() 

261 if bins1.getBBox() != bins2.getBBox(): 

262 return False 

263 return np.array_equal(bins1.image.array, -bins2.image.array, equal_nan=True)