Coverage for python/lsst/pipe/tasks/rewrite_images/_rewrite_visit_image.py: 0%
87 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-10 10:29 -0700
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-10 10:29 -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 )
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 )
95 config: RewriteVisitImageConfig
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
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 )
172class RewriteVisitImageTask(PipelineTask):
173 ConfigClass: ClassVar[type[RewriteVisitImageConfig]] = RewriteVisitImageConfig
174 config: RewriteVisitImageConfig
175 _DefaultName = "rewriteVisitImage"
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)
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)
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)