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