Coverage for python / lsst / drp / tasks / single_frame_detect_and_measure.py: 0%
96 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 09:32 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 09:32 +0000
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_SkyCoord",
131 "base_PixelFlags",
132 "base_SdssCentroid",
133 "ext_shapeHSM_HsmSourceMoments",
134 "ext_shapeHSM_HsmPsfMoments",
135 "base_GaussianFlux",
136 "base_LocalPhotoCalib",
137 "base_LocalBackground",
138 "base_LocalWcs",
139 "base_PsfFlux",
140 "base_CircularApertureFlux",
141 "base_ClassificationSizeExtendedness",
142 "base_CompensatedTophatFlux",
143 ]
144 # NOTE: these apertures were selected for HSC, and may not be
145 # what we want for LSSTCam.
146 self.measurement.plugins["base_CircularApertureFlux"].radii = [
147 3.0,
148 4.5,
149 6.0,
150 9.0,
151 12.0,
152 17.0,
153 25.0,
154 35.0,
155 50.0,
156 70.0,
157 ]
158 lsst.meas.extensions.shapeHSM.configure_hsm(self.measurement)
160 # TODO DM-46306: should make this the ApertureFlux default!
161 # Use a large aperture to be independent of seeing in calibration
162 self.measurement.plugins["base_CircularApertureFlux"].maxSincRadius = 12.0
164 # Only apply calibration fluxes, do not measure them.
165 self.normalized_calibration_flux.do_measure_ap_corr = False
168class SingleFrameDetectAndMeasureTask(pipeBase.PipelineTask):
169 """Use the visit-level calibrations to perform detection and measurement
170 on the single frame exposures and produce a "final" exposure and catalog.
171 """
173 ConfigClass = SingleFrameDetectAndMeasureConfig
174 _DefaultName = "singleFrameDetectAndMeasure"
176 def __init__(self, schema=None, **kwargs):
177 super().__init__(**kwargs)
179 if schema is None:
180 schema = afwTable.SourceTable.makeMinimalSchema()
182 self.makeSubtask("detection", schema=schema)
183 self.makeSubtask("sky_sources", schema=schema)
184 self.makeSubtask("deblend", schema=schema)
185 self.makeSubtask("measurement", schema=schema)
186 self.makeSubtask("normalized_calibration_flux", schema=schema)
187 self.makeSubtask("apply_aperture_correction", schema=schema)
188 self.makeSubtask("catalog_calculation", schema=schema)
189 self.makeSubtask("set_primary_flags", schema=schema, isSingleFrame=True)
191 schema.addField(
192 "visit",
193 type="L",
194 doc="Visit this source appeared on.",
195 )
196 schema.addField(
197 "detector",
198 type="U",
199 doc="Detector this source appeared on.",
200 )
202 self.schema = schema
204 def runQuantum(self, butlerQC, inputRefs, outputRefs):
205 inputs = butlerQC.get(inputRefs)
206 id_generator = self.config.id_generator.apply(butlerQC.quantum.dataId)
208 exposure = inputs.pop("exposure")
209 input_background = inputs.pop("input_background")
211 # This should not happen with a properly configured execution context.
212 assert not inputs, "runQuantum got more inputs than expected"
214 # Specify the fields that `annotate` needs below, to ensure they
215 # exist, even as None.
216 result = pipeBase.Struct(
217 sources=None,
218 sources_footprints=None,
219 )
220 try:
221 self.run(
222 exposure=exposure,
223 input_background=input_background,
224 id_generator=id_generator,
225 result=result,
226 )
227 except pipeBase.AlgorithmError as e:
228 error = pipeBase.AnnotatedPartialOutputsError.annotate(
229 e, self, result.sources_footprints, log=self.log
230 )
231 butlerQC.put(result, outputRefs)
232 raise error from e
234 butlerQC.put(result, outputRefs)
236 def run(
237 self,
238 exposure,
239 input_background,
240 id_generator=None,
241 result=None,
242 ):
243 """Detect and measure sources on the exposure(s) (snap combined as
244 necessary), and make a "final" Processed Visit Image using all of the
245 supplied metadata, plus a catalog measured on it.
246 Stripped-down version of `ReprocessVisitImageTask`.
248 Parameters
249 ----------
250 exposure : `lsst.afw.image.Exposure`
251 Initial calibrated exposure.
252 The DETECTED mask plane will be modified in place.
253 id_generator : `lsst.meas.base.IdGenerator`, optional
254 Object that generates source IDs and provides random seeds.
255 result : `lsst.pipe.base.Struct`, optional
256 Result struct that is modified to allow saving of partial outputs
257 for some failure conditions. If the task completes successfully,
258 this is also returned.
260 Returns
261 -------
262 result : `lsst.pipe.base.Struct`
263 Results as a struct with attributes:
265 ``sources``
266 Sources that were measured on the exposure, with calibrated
267 fluxes and magnitudes. (`astropy.table.Table`)
268 ``sources_footprints``
269 Footprints of sources that were measured on the exposure.
270 (`lsst.afw.table.SourceCatalog`)
271 ``background``
272 Total background that was fit to, and subtracted from the
273 exposure when detecting ``sources``, in the same nJy units as
274 ``exposure``. (`lsst.afw.math.BackgroundList`)
275 """
276 if exposure.apCorrMap is None:
277 raise pipeBase.NoWorkFound("Exposure is missing an aperture correction map.")
278 if exposure.wcs is None:
279 raise pipeBase.NoWorkFound("Exposure is missing a WCS.")
280 if result is None:
281 result = pipeBase.Struct()
282 if id_generator is None:
283 id_generator = lsst.meas.base.IdGenerator()
285 table = afwTable.SourceTable.make(self.schema, id_generator.make_table_id_factory())
287 detections = self.detection.run(
288 table=table,
289 exposure=exposure,
290 background=input_background,
291 )
292 sources = detections.sources
293 result.background = detections.background
295 if self.config.do_add_sky_sources:
296 self.sky_sources.run(exposure.mask, id_generator.catalog_id, sources)
298 self.deblend.run(exposure=exposure, sources=sources)
299 # The deblender may not produce a contiguous catalog; ensure
300 # contiguity for subsequent tasks.
301 if not sources.isContiguous():
302 sources = sources.copy(deep=True)
304 self.measurement.run(sources, exposure)
305 self.normalized_calibration_flux.run(exposure=exposure, catalog=sources)
306 self.apply_aperture_correction.run(sources, exposure.apCorrMap)
307 self.catalog_calculation.run(sources)
308 self.set_primary_flags.run(sources)
310 sources["visit"] = exposure.visitInfo.id
311 sources["detector"] = exposure.info.getDetector().getId()
312 result.sources_footprints = sources
313 result.sources = sources.asAstropy()
315 return result