Coverage for python/lsst/drp/tasks/single_frame_detect_and_measure.py: 33%
92 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-06 02:30 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-06 02:30 -0800
1# This file is part of drp_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/>.
22__all__ = ["SingleFrameDetectAndMeasureTask", "SingleFrameDetectAndMeasureConfig"]
24import lsst.afw.table as afwTable
25import lsst.geom
26import lsst.meas.algorithms
27import lsst.meas.deblender
28import lsst.meas.extensions.photometryKron
29import lsst.meas.extensions.shapeHSM
30import lsst.pex.config as pexConfig
31import lsst.pipe.base as pipeBase
32from lsst.pipe.base import connectionTypes
35class SingleFrameDetectAndMeasureConnections(
36 pipeBase.PipelineTaskConnections, dimensions=("instrument", "visit", "detector")
37):
38 # inputs
39 exposure = connectionTypes.Input(
40 doc="Exposure to be calibrated, and detected and measured on.",
41 name="preliminary_visit_image",
42 storageClass="Exposure",
43 dimensions=["instrument", "visit", "detector"],
44 )
45 input_background = connectionTypes.Input(
46 doc="Background models estimated during calibration task; calibrated to be in nJy units.",
47 name="preliminary_visit_image_background",
48 storageClass="Background",
49 dimensions=("instrument", "visit", "detector"),
50 )
52 # outputs
53 sources = connectionTypes.Output(
54 doc="Catalog of measured sources detected on the calibrated exposure.",
55 name="single_visit_star_reprocessed_unstandardized",
56 storageClass="ArrowAstropy",
57 dimensions=["instrument", "visit", "detector"],
58 )
59 sources_footprints = connectionTypes.Output(
60 doc="Catalog of measured sources detected on the calibrated exposure; includes source footprints.",
61 name="single_visit_star_reprocessed_footprints",
62 storageClass="SourceCatalog",
63 dimensions=["instrument", "visit", "detector"],
64 )
65 background = connectionTypes.Output(
66 doc=(
67 "Total background model including new detections in this task. "
68 "Note that the background model has units of ADU, while the corresponding "
69 "image has units of nJy - the image must be 'uncalibrated' before the background "
70 "can be restored."
71 ),
72 name="preliminary_visit_image_reprocessed_background",
73 dimensions=("instrument", "visit", "detector"),
74 storageClass="Background",
75 )
78class SingleFrameDetectAndMeasureConfig(
79 pipeBase.PipelineTaskConfig, pipelineConnections=SingleFrameDetectAndMeasureConnections
80):
81 # To generate catalog ids consistently across subtasks.
82 id_generator = lsst.meas.base.DetectorVisitIdGeneratorConfig.make_field()
84 detection = pexConfig.ConfigurableField(
85 target=lsst.meas.algorithms.SourceDetectionTask,
86 doc="Task to detect sources to return in the output catalog.",
87 )
88 sky_sources = pexConfig.ConfigurableField(
89 target=lsst.meas.algorithms.SkyObjectsTask,
90 doc="Task to generate sky sources ('empty' regions where there are no detections).",
91 )
92 deblend = pexConfig.ConfigurableField(
93 target=lsst.meas.deblender.SourceDeblendTask,
94 doc="Task to split blended sources into their components.",
95 )
96 measurement = pexConfig.ConfigurableField(
97 target=lsst.meas.base.SingleFrameMeasurementTask,
98 doc="Task to measure sources to return in the output catalog.",
99 )
100 normalized_calibration_flux = pexConfig.ConfigurableField(
101 target=lsst.meas.algorithms.NormalizedCalibrationFluxTask,
102 doc="Task to normalize the calibration flux (e.g. compensated tophats).",
103 )
104 apply_aperture_correction = pexConfig.ConfigurableField(
105 target=lsst.meas.base.ApplyApCorrTask,
106 doc="Task to apply aperture corrections to the measured sources.",
107 )
108 set_primary_flags = pexConfig.ConfigurableField(
109 target=lsst.meas.algorithms.setPrimaryFlags.SetPrimaryFlagsTask,
110 doc="Task to add isPrimary to the catalog.",
111 )
112 catalog_calculation = pexConfig.ConfigurableField(
113 target=lsst.meas.base.CatalogCalculationTask,
114 doc="Task to compute catalog values using only the catalog entries.",
115 )
116 do_add_sky_sources = pexConfig.Field(
117 dtype=bool,
118 default=True,
119 doc="Generate sky sources?",
120 )
122 def setDefaults(self):
123 super().setDefaults()
125 # Re-estimate the background
126 self.detection.reEstimateBackground = True
127 self.detection.doTempLocalBackground = False
129 self.measurement.plugins = [
130 "base_PixelFlags",
131 "base_SdssCentroid",
132 "ext_shapeHSM_HsmSourceMoments",
133 "ext_shapeHSM_HsmPsfMoments",
134 "base_GaussianFlux",
135 "base_PsfFlux",
136 "base_CircularApertureFlux",
137 "base_ClassificationSizeExtendedness",
138 "base_CompensatedTophatFlux",
139 ]
140 # NOTE: these apertures were selected for HSC, and may not be
141 # what we want for LSSTCam.
142 self.measurement.plugins["base_CircularApertureFlux"].radii = [
143 3.0,
144 4.5,
145 6.0,
146 9.0,
147 12.0,
148 17.0,
149 25.0,
150 35.0,
151 50.0,
152 70.0,
153 ]
154 lsst.meas.extensions.shapeHSM.configure_hsm(self.measurement)
156 # TODO DM-46306: should make this the ApertureFlux default!
157 # Use a large aperture to be independent of seeing in calibration
158 self.measurement.plugins["base_CircularApertureFlux"].maxSincRadius = 12.0
160 # Only apply calibration fluxes, do not measure them.
161 self.normalized_calibration_flux.do_measure_ap_corr = False
164class SingleFrameDetectAndMeasureTask(pipeBase.PipelineTask):
165 """Use the visit-level calibrations to perform detection and measurement
166 on the single frame exposures and produce a "final" exposure and catalog.
167 """
169 ConfigClass = SingleFrameDetectAndMeasureConfig
170 _DefaultName = "singleFrameDetectAndMeasure"
172 def __init__(self, schema=None, **kwargs):
173 super().__init__(**kwargs)
175 if schema is None:
176 schema = afwTable.SourceTable.makeMinimalSchema()
178 self.makeSubtask("detection", schema=schema)
179 self.makeSubtask("sky_sources", schema=schema)
180 self.makeSubtask("deblend", schema=schema)
181 self.makeSubtask("measurement", schema=schema)
182 self.makeSubtask("normalized_calibration_flux", schema=schema)
183 self.makeSubtask("apply_aperture_correction", schema=schema)
184 self.makeSubtask("catalog_calculation", schema=schema)
185 self.makeSubtask("set_primary_flags", schema=schema, isSingleFrame=True)
187 schema.addField(
188 "visit",
189 type="L",
190 doc="Visit this source appeared on.",
191 )
192 schema.addField(
193 "detector",
194 type="U",
195 doc="Detector this source appeared on.",
196 )
198 self.schema = schema
200 def runQuantum(self, butlerQC, inputRefs, outputRefs):
201 inputs = butlerQC.get(inputRefs)
202 id_generator = self.config.id_generator.apply(butlerQC.quantum.dataId)
204 exposure = inputs.pop("exposure")
205 input_background = inputs.pop("input_background")
207 # This should not happen with a properly configured execution context.
208 assert not inputs, "runQuantum got more inputs than expected"
210 # Specify the fields that `annotate` needs below, to ensure they
211 # exist, even as None.
212 result = pipeBase.Struct(
213 sources=None,
214 sources_footprints=None,
215 )
216 try:
217 self.run(
218 exposure=exposure,
219 input_background=input_background,
220 id_generator=id_generator,
221 result=result,
222 )
223 except pipeBase.AlgorithmError as e:
224 error = pipeBase.AnnotatedPartialOutputsError.annotate(
225 e, self, result.sources_footprints, log=self.log
226 )
227 butlerQC.put(result, outputRefs)
228 raise error from e
230 butlerQC.put(result, outputRefs)
232 def run(
233 self,
234 exposure,
235 input_background,
236 id_generator=None,
237 result=None,
238 ):
239 """Detect and measure sources on the exposure(s) (snap combined as
240 necessary), and make a "final" Processed Visit Image using all of the
241 supplied metadata, plus a catalog measured on it.
242 Stripped-down version of `ReprocessVisitImageTask`.
244 Parameters
245 ----------
246 exposure : `lsst.afw.image.Exposure`
247 Initial calibrated exposure.
248 The DETECTED mask plane will be modified in place.
249 id_generator : `lsst.meas.base.IdGenerator`, optional
250 Object that generates source IDs and provides random seeds.
251 result : `lsst.pipe.base.Struct`, optional
252 Result struct that is modified to allow saving of partial outputs
253 for some failure conditions. If the task completes successfully,
254 this is also returned.
256 Returns
257 -------
258 result : `lsst.pipe.base.Struct`
259 Results as a struct with attributes:
261 ``sources``
262 Sources that were measured on the exposure, with calibrated
263 fluxes and magnitudes. (`astropy.table.Table`)
264 ``sources_footprints``
265 Footprints of sources that were measured on the exposure.
266 (`lsst.afw.table.SourceCatalog`)
267 ``background``
268 Total background that was fit to, and subtracted from the
269 exposure when detecting ``sources``, in the same nJy units as
270 ``exposure``. (`lsst.afw.math.BackgroundList`)
271 """
272 if result is None:
273 result = pipeBase.Struct()
274 if id_generator is None:
275 id_generator = lsst.meas.base.IdGenerator()
277 table = afwTable.SourceTable.make(self.schema, id_generator.make_table_id_factory())
279 detections = self.detection.run(
280 table=table,
281 exposure=exposure,
282 background=input_background,
283 )
284 sources = detections.sources
285 result.background = detections.background
287 if self.config.do_add_sky_sources:
288 self.sky_sources.run(exposure.mask, id_generator.catalog_id, sources)
290 self.deblend.run(exposure=exposure, sources=sources)
291 # The deblender may not produce a contiguous catalog; ensure
292 # contiguity for subsequent tasks.
293 if not sources.isContiguous():
294 sources = sources.copy(deep=True)
296 self.measurement.run(sources, exposure)
297 self.normalized_calibration_flux.run(exposure=exposure, catalog=sources)
298 self.apply_aperture_correction.run(sources, exposure.apCorrMap)
299 self.catalog_calculation.run(sources)
300 self.set_primary_flags.run(sources)
302 sources["visit"] = exposure.visitInfo.id
303 sources["detector"] = exposure.info.getDetector().getId()
304 result.sources_footprints = sources
305 result.sources = sources.asAstropy()
307 return result