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