Coverage for python/lsst/pipe/tasks/rewrite_images/_rewrite_visit_image.py: 0%
95 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 09:53 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 09:53 +0000
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/>.
22from __future__ import annotations
24__all__ = ("RewriteVisitImageConnections", "RewriteVisitImageTask", "RewriteVisitImageConfig")
26from typing import ClassVar
28import astropy.units
29import numpy as np
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)
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 minimum=0,
82 )
83 alternate_background = cT.Input(
84 "skyCorr",
85 storageClass="Background",
86 dimensions={"visit", "detector"},
87 doc="A different background model that was not subtracted from the image.",
88 minimum=0,
89 )
90 future_visit_image = cT.Output(
91 "{future_prefix}visit_image",
92 storageClass="VisitImage",
93 dimensions={"visit", "detector"},
94 doc="The output VisitImage.",
95 )
96 exposure_record_proxy = cT.Input(
97 "raw",
98 storageClass="Exposure",
99 dimensions={"exposure", "detector"},
100 doc="An arbitrary input used to provide an 'exposure' dimension record."
101 )
103 config: RewriteVisitImageConfig
105 def __init__(self, config: RewriteVisitImageConfig | None):
106 super().__init__(config=config)
107 match self.config.photo_calib_source:
108 case "attached":
109 del self.visit_summary
110 del self.photo_calib
111 case "visit_summary":
112 del self.photo_calib
113 case "standalone":
114 del self.visit_summary
115 if self.config.alternate_background_type is None:
116 del self.alternate_background
119class RewriteVisitImageConfig(
120 PipelineTaskConfig,
121 pipelineConnections=RewriteVisitImageConnections,
122):
123 photo_calib_source = ChoiceField[str](
124 "Which kind of PhotoCalib to load.",
125 dtype=str,
126 allowed={
127 "attached": "The input image has uncalibrated pixels and a nontrivial PhotoCalib attached to it.",
128 "visit_summary": "Load the visit_summary connection to find the PhotoCalib already applied.",
129 "standalone": "Load the photo_calib connection to find the PhotoCalib already applied.",
130 },
131 default="visit_summary",
132 )
133 subtracted_background_description = Field(
134 "Description of the subtracted background, to be stored with the image.",
135 dtype=str,
136 default="Background subtracted from the image when generating the Source catalog.",
137 )
138 alternate_background_name = Field(
139 "Name for the alternate background attached to the image. "
140 "ignored if alternate_background_type is None.",
141 dtype=str,
142 default="skyCorr",
143 )
144 alternate_background_description = Field(
145 "Description for the alternate background attached to the image. "
146 "Ignored if alternate_background_type is None.",
147 dtype=str,
148 default=(
149 "An alternate large-scale visit-level background subtracted from the input images that go into "
150 "pretty_coadd and RGB color images. May preserve more low surface-brightness features, but may "
151 "also be more affected by scattered light and other artifacts."
152 ),
153 )
154 alternate_background_type = ChoiceField(
155 "How the alternate background relates to the subtracted background.",
156 dtype=str,
157 allowed={
158 "independent": (
159 "The alternate background should be subtracted after restoring the subtracted background."
160 ),
161 "differential_composed": (
162 "The alternate background starts with terms that invert the subtracted background."
163 ),
164 "differential_fit": (
165 "The alternate background was fit to the already-subtracted image, and "
166 "can only be subtracted from the already-subtracted image."
167 ),
168 },
169 default="differential_composed",
170 optional=True,
171 )
172 instrumental_unit = Field(
173 "Unit for instrumental flux pixels (i.e. uncalibrated side of a PhotoCalib, "
174 "and the units of all background inputs).",
175 dtype=str,
176 default="electron",
177 )
180class RewriteVisitImageTask(PipelineTask):
181 ConfigClass: ClassVar[type[RewriteVisitImageConfig]] = RewriteVisitImageConfig
182 config: RewriteVisitImageConfig
183 _DefaultName = "rewriteVisitImage"
185 def runQuantum(
186 self,
187 butlerQC: QuantumContext,
188 inputRefs: InputQuantizedConnection,
189 outputRefs: OutputQuantizedConnection,
190 ) -> None:
191 exposure_record = inputRefs.exposure_record_proxy.dataId.records["exposure"]
192 del inputRefs.exposure_record_proxy
193 inputs = butlerQC.get(inputRefs)
194 visit_image = inputs.pop("legacy_exposure").get(
195 parameters={"preserve_quantization": True, "exposure_record": exposure_record}
196 )
197 photo_calib: PhotoCalib | None
198 match self.config.photo_calib_source:
199 case "attached":
200 photo_calib = None
201 case "visit_summary":
202 visit_summary = inputs.pop("visit_summary")
203 photo_calib = visit_summary.find(butlerQC.quantum.dataId["detector"]).getPhotoCalib()
204 case "standalone":
205 photo_calib = inputs.pop("photo_calib")
206 outputs = self.run(visit_image, photo_calib=photo_calib, **inputs)
207 butlerQC.put(outputs, outputRefs)
209 def run(
210 self,
211 visit_image: VisitImage,
212 *,
213 photo_calib: PhotoCalib | None = None,
214 subtracted_background: BackgroundList | None = None,
215 alternate_background: BackgroundList | None = None,
216 ) -> Struct:
217 instrumental_unit = astropy.units.Unit(self.config.instrumental_unit)
218 if photo_calib is not None:
219 visit_image.photometric_scaling = field_from_legacy_photo_calib(
220 photo_calib, bounds=visit_image.bbox, instrumental_unit=instrumental_unit
221 )
222 if subtracted_background is not None:
223 # TODO[DM-56044]: this ideally wouldn't be possible - we'd write
224 # out the background whenever we write out a visit_image, even if
225 # there's a write-partial-outputs failure.
226 visit_image.backgrounds.add(
227 "subtracted",
228 field_from_legacy_background(
229 subtracted_background, bounds=visit_image.bbox, unit=instrumental_unit
230 ),
231 self.config.subtracted_background_description,
232 is_subtracted=True,
233 )
234 if alternate_background is not None:
235 assert self.config.alternate_background_name is not None, (
236 "Configuration and arguments are inconsistent."
237 )
238 match self.config.alternate_background_type:
239 case "independent":
240 pass
241 case "differential_composed":
242 if subtracted_background is None:
243 # We can't add the alternate background here either,
244 # because we don't have its baseline.
245 return Struct(future_visit_image=visit_image)
246 for (subtracted_term, *_), (alternate_term, *_) in zip(
247 subtracted_background, alternate_background
248 ):
249 if not self._do_backgrounds_cancel(subtracted_term, alternate_term):
250 raise RuntimeError(
251 "alternate_background_type='differential_composed', but the alternate "
252 "background does not start with the inverse of the subtracted background."
253 )
254 alternate_background = BackgroundList(
255 *[
256 alternate_background[n]
257 for n in range(len(subtracted_background), len(alternate_background))
258 ]
259 )
260 case "differential_fit":
261 if subtracted_background is None:
262 # We can't add the alternate background here either,
263 # because we don't have its baseline.
264 return Struct(future_visit_image=visit_image)
265 alternate_background = BackgroundList(*subtracted_background, *alternate_background)
266 visit_image.backgrounds.add(
267 self.config.alternate_background_name,
268 field_from_legacy_background(
269 alternate_background, bounds=visit_image.bbox, unit=instrumental_unit
270 ),
271 self.config.alternate_background_description,
272 is_subtracted=True,
273 )
274 return Struct(future_visit_image=visit_image)
276 def _do_backgrounds_cancel(self, bg1: BackgroundMI, bg2: BackgroundMI) -> bool:
277 ctrl1 = bg1.getBackgroundControl()
278 ctrl2 = bg2.getBackgroundControl()
279 if ctrl1.getInterpStyle() != ctrl2.getInterpStyle():
280 return False
281 if ctrl1.getApproximateControl().getStyle() != ctrl2.getApproximateControl().getStyle():
282 return False
283 bins1 = bg1.getStatsImage()
284 bins2 = bg2.getStatsImage()
285 if bins1.getBBox() != bins2.getBBox():
286 return False
287 return np.array_equal(bins1.image.array, -bins2.image.array, equal_nan=True)