Coverage for python/lsst/drp/tasks/single_frame_detect_and_measure.py: 91%
130 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:09 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:09 +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 dataclasses
26import lsst.afw.table as afwTable
27import lsst.geom
28import lsst.meas.algorithms
29import lsst.meas.deblender
30import lsst.meas.extensions.photometryKron
31import lsst.meas.extensions.shapeHSM
32import lsst.meas.extensions.trailedSources
33import lsst.pex.config as pexConfig
34import lsst.pipe.base as pipeBase
35from lsst.pipe.base import connectionTypes
38class SingleFrameDetectAndMeasureConnections(
39 pipeBase.PipelineTaskConnections, dimensions=("instrument", "visit", "detector")
40):
41 # inputs
42 exposure = connectionTypes.Input(
43 doc="Exposure to be calibrated, and detected and measured on. With "
44 "image_type='future' this is a `lsst.images.VisitImage`, which carries its own "
45 "background; the separate input_background connection is dropped in that mode.",
46 name="preliminary_visit_image",
47 storageClass="Exposure",
48 dimensions=["instrument", "visit", "detector"],
49 )
50 input_background = connectionTypes.Input(
51 doc="Background models estimated during calibration task. Only used with image_type='legacy'.",
52 name="preliminary_visit_image_background",
53 storageClass="Background",
54 dimensions=("instrument", "visit", "detector"),
55 )
57 # outputs
58 output_exposure = connectionTypes.Output(
59 doc="The input exposure with its DETECTED mask plane replaced by the detections made "
60 "in this task. The image and variance planes are identical to the input.",
61 name="visit_image",
62 storageClass="ExposureF",
63 dimensions=["instrument", "visit", "detector"],
64 )
65 sources = connectionTypes.Output(
66 doc="Catalog of measured sources detected on the calibrated exposure.",
67 name="single_visit_star_reprocessed_unstandardized",
68 storageClass="ArrowAstropy",
69 dimensions=["instrument", "visit", "detector"],
70 )
71 sources_footprints = connectionTypes.Output(
72 doc="Catalog of measured sources detected on the calibrated exposure; includes source footprints.",
73 name="single_visit_star_reprocessed_footprints",
74 storageClass="SourceCatalog",
75 dimensions=["instrument", "visit", "detector"],
76 )
77 background = connectionTypes.Output(
78 doc=(
79 "Total background model: the input background plus the background fit in this task, "
80 "in the same units as the input exposure. The fit from this task is not subtracted "
81 "from output_exposure. Only written with image_type='legacy'."
82 ),
83 name="preliminary_visit_image_reprocessed_background",
84 dimensions=("instrument", "visit", "detector"),
85 storageClass="Background",
86 )
88 def __init__(self, *, config=None):
89 super().__init__(config=config)
90 if config.image_type == "future":
91 self.exposure = dataclasses.replace(self.exposure, storageClass="VisitImage")
92 self.output_exposure = dataclasses.replace(self.output_exposure, storageClass="VisitImage")
93 del self.input_background
94 del self.background
97class SingleFrameDetectAndMeasureConfig(
98 pipeBase.PipelineTaskConfig, pipelineConnections=SingleFrameDetectAndMeasureConnections
99):
100 # To generate catalog ids consistently across subtasks.
101 id_generator = lsst.meas.base.DetectorVisitIdGeneratorConfig.make_field()
103 detection = pexConfig.ConfigurableField(
104 target=lsst.meas.algorithms.SourceDetectionTask,
105 doc="Task to detect sources to return in the output catalog.",
106 )
107 sky_sources = pexConfig.ConfigurableField(
108 target=lsst.meas.algorithms.SkyObjectsTask,
109 doc="Task to generate sky sources ('empty' regions where there are no detections).",
110 )
111 deblend = pexConfig.ConfigurableField(
112 target=lsst.meas.deblender.SourceDeblendTask,
113 doc="Task to split blended sources into their components.",
114 )
115 measurement = pexConfig.ConfigurableField(
116 target=lsst.meas.base.SingleFrameMeasurementTask,
117 doc="Task to measure sources to return in the output catalog.",
118 )
119 normalized_calibration_flux = pexConfig.ConfigurableField(
120 target=lsst.meas.algorithms.NormalizedCalibrationFluxTask,
121 doc="Task to normalize the calibration flux (e.g. compensated tophats).",
122 )
123 apply_aperture_correction = pexConfig.ConfigurableField(
124 target=lsst.meas.base.ApplyApCorrTask,
125 doc="Task to apply aperture corrections to the measured sources.",
126 )
127 set_primary_flags = pexConfig.ConfigurableField(
128 target=lsst.meas.algorithms.setPrimaryFlags.SetPrimaryFlagsTask,
129 doc="Task to add isPrimary to the catalog.",
130 )
131 catalog_calculation = pexConfig.ConfigurableField(
132 target=lsst.meas.base.CatalogCalculationTask,
133 doc="Task to compute catalog values using only the catalog entries.",
134 )
135 do_add_sky_sources = pexConfig.Field(
136 dtype=bool,
137 default=True,
138 doc="Generate sky sources?",
139 )
140 image_type = pexConfig.ChoiceField[str](
141 "Which image type to read and write for the visit image.",
142 allowed={
143 "legacy": "Read and write an `lsst.afw.image.Exposure`, whose background is a separate "
144 "dataset, and write the total background as a separate dataset in turn.",
145 "future": "Read and write an `lsst.images.VisitImage`, which carries its own backgrounds. "
146 "The output keeps the input's subtracted background, and adds the total background "
147 "fit in this task as a background that is not subtracted.",
148 },
149 optional=False,
150 default="legacy",
151 )
153 def setDefaults(self):
154 super().setDefaults()
156 # Re-estimate the background
157 self.detection.reEstimateBackground = True
158 self.detection.doTempLocalBackground = False
160 self.measurement.plugins = [
161 "base_SdssShape",
162 "ext_trailedSources_Naive",
163 "base_SkyCoord",
164 "base_PixelFlags",
165 "base_SdssCentroid",
166 "ext_shapeHSM_HsmSourceMoments",
167 "ext_shapeHSM_HsmPsfMoments",
168 "base_GaussianFlux",
169 "base_LocalPhotoCalib",
170 "base_LocalBackground",
171 "base_LocalWcs",
172 "base_PsfFlux",
173 "base_CircularApertureFlux",
174 "base_ClassificationSizeExtendedness",
175 "base_CompensatedTophatFlux",
176 ]
177 # NOTE: these apertures were selected for HSC, and may not be
178 # what we want for LSSTCam.
179 self.measurement.plugins["base_CircularApertureFlux"].radii = [
180 3.0,
181 4.5,
182 6.0,
183 9.0,
184 12.0,
185 17.0,
186 25.0,
187 35.0,
188 50.0,
189 70.0,
190 ]
191 lsst.meas.extensions.shapeHSM.configure_hsm(self.measurement)
193 # TODO DM-46306: should make this the ApertureFlux default!
194 # Use a large aperture to be independent of seeing in calibration
195 self.measurement.plugins["base_CircularApertureFlux"].maxSincRadius = 12.0
197 # Only apply calibration fluxes, do not measure them.
198 self.normalized_calibration_flux.do_measure_ap_corr = False
201class SingleFrameDetectAndMeasureTask(pipeBase.PipelineTask):
202 """Use the visit-level calibrations to perform detection and measurement
203 on the single frame exposures and produce a "final" exposure and catalog.
204 """
206 ConfigClass = SingleFrameDetectAndMeasureConfig
207 _DefaultName = "singleFrameDetectAndMeasure"
209 def __init__(self, schema=None, **kwargs):
210 super().__init__(**kwargs)
212 if schema is None: 212 ↛ 215line 212 didn't jump to line 215 because the condition on line 212 was always true
213 schema = afwTable.SourceTable.makeMinimalSchema()
215 self.makeSubtask("detection", schema=schema)
216 self.makeSubtask("sky_sources", schema=schema)
217 self.makeSubtask("deblend", schema=schema)
218 self.makeSubtask("measurement", schema=schema)
219 self.makeSubtask("normalized_calibration_flux", schema=schema)
220 self.makeSubtask("apply_aperture_correction", schema=schema)
221 self.makeSubtask("catalog_calculation", schema=schema)
222 self.makeSubtask("set_primary_flags", schema=schema, isSingleFrame=True)
224 schema.addField(
225 "visit",
226 type="L",
227 doc="Visit this source appeared on.",
228 )
229 schema.addField(
230 "detector",
231 type="U",
232 doc="Detector this source appeared on.",
233 )
235 self.schema = schema
237 def runQuantum(self, butlerQC, inputRefs, outputRefs):
238 inputs = butlerQC.get(inputRefs)
239 id_generator = self.config.id_generator.apply(butlerQC.quantum.dataId)
241 exposure = inputs.pop("exposure")
242 if self.config.image_type == "future":
243 # The background arrives as a component of the VisitImage rather
244 # than as its own input; it is combined with the background fit
245 # here when that fit is attached to the output image.
246 visit_image = exposure
247 exposure = visit_image.to_legacy()
248 input_background = None
249 else:
250 input_background = inputs.pop("input_background")
252 # This should not happen with a properly configured execution context.
253 assert not inputs, "runQuantum got more inputs than expected"
255 # Specify the fields that `annotate` needs below, to ensure they
256 # exist, even as None.
257 result = pipeBase.Struct(
258 output_exposure=None,
259 sources=None,
260 sources_footprints=None,
261 )
262 try:
263 self.run(
264 exposure=exposure,
265 input_background=input_background,
266 id_generator=id_generator,
267 result=result,
268 )
269 except pipeBase.AlgorithmError as e:
270 error = pipeBase.AnnotatedPartialOutputsError.annotate(
271 e, self, result.sources_footprints, log=self.log
272 )
273 butlerQC.put(result, outputRefs)
274 raise error from e
276 if self.config.image_type == "future":
277 result.output_exposure = self._make_future_output(visit_image, result)
278 butlerQC.put(result, outputRefs)
280 def _make_future_output(self, visit_image, result):
281 """Copy the outputs of `run` onto the input `lsst.images.VisitImage`.
283 Parameters
284 ----------
285 visit_image : `lsst.images.VisitImage`
286 The input image, modified in place. Its subtracted background is
287 kept as-is.
288 result : `lsst.pipe.base.Struct`
289 The result of `run`, with ``output_exposure`` and ``background``.
291 Returns
292 -------
293 visit_image : `lsst.images.VisitImage`
294 The input image, with its DETECTED mask plane replaced by the
295 detections made in `run`, and the total background attached as a
296 background that is not subtracted.
297 """
298 from lsst.images.fields import SumField, field_from_legacy_background
300 legacy_mask = result.output_exposure.mask
301 detected = legacy_mask.array & legacy_mask.getPlaneBitMask("DETECTED")
302 visit_image.mask.clear("DETECTED")
303 visit_image.mask.set("DETECTED", detected)
305 background = field_from_legacy_background(
306 result.background, bounds=visit_image.bbox, unit=visit_image.unit
307 )
308 # The new fit was made on the already-subtracted image, so add the
309 # subtracted background to give a total relative to the original
310 # image.
311 if visit_image.backgrounds.subtracted is not None: 311 ↛ 313line 311 didn't jump to line 313 because the condition on line 311 was always true
312 background = SumField([visit_image.backgrounds.subtracted.field, background])
313 visit_image.backgrounds.add(
314 "sourceDetection",
315 background,
316 description="Total background fit when generating the reprocessed Source catalog. Not "
317 "subtracted from the image: restore the subtracted background before subtracting this one.",
318 )
319 return visit_image
321 def run(
322 self,
323 exposure,
324 input_background=None,
325 id_generator=None,
326 result=None,
327 ):
328 """Detect and measure sources on the exposure(s) (snap combined as
329 necessary), and make a "final" Processed Visit Image using all of the
330 supplied metadata, plus a catalog measured on it.
331 Stripped-down version of `ReprocessVisitImageTask`.
333 Parameters
334 ----------
335 exposure : `lsst.afw.image.Exposure`
336 Initial calibrated exposure.
337 The DETECTED mask plane will be modified in place; the image and
338 variance planes are not modified.
339 input_background : `lsst.afw.math.BackgroundList`, optional
340 Background already subtracted from ``exposure``; appended to with
341 the background fit here, so that the returned ``background`` is the
342 total. If `None`, only the background fit in this task is returned.
343 id_generator : `lsst.meas.base.IdGenerator`, optional
344 Object that generates source IDs and provides random seeds.
345 result : `lsst.pipe.base.Struct`, optional
346 Result struct that is modified to allow saving of partial outputs
347 for some failure conditions. If the task completes successfully,
348 this is also returned.
350 Returns
351 -------
352 result : `lsst.pipe.base.Struct`
353 Results as a struct with attributes:
355 ``output_exposure``
356 ``exposure``, with its DETECTED mask plane replaced by the
357 detections of ``sources``. (`lsst.afw.image.Exposure`)
358 ``sources``
359 Sources that were measured on the exposure, with calibrated
360 fluxes and magnitudes. (`astropy.table.Table`)
361 ``sources_footprints``
362 Footprints of sources that were measured on the exposure.
363 (`lsst.afw.table.SourceCatalog`)
364 ``background``
365 ``input_background`` plus the background that was fit to the
366 exposure when detecting ``sources``, in the same units as
367 ``exposure``. The fit is not subtracted from
368 ``output_exposure``.
369 (`lsst.afw.math.BackgroundList`)
370 """
371 if exposure.apCorrMap is None: 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true
372 raise pipeBase.NoWorkFound("Exposure is missing an aperture correction map.")
373 if exposure.wcs is None: 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true
374 raise pipeBase.NoWorkFound("Exposure is missing a WCS.")
375 if result is None: 375 ↛ 377line 375 didn't jump to line 377 because the condition on line 375 was always true
376 result = pipeBase.Struct()
377 if id_generator is None: 377 ↛ 380line 377 didn't jump to line 380 because the condition on line 377 was always true
378 id_generator = lsst.meas.base.IdGenerator()
380 table = afwTable.SourceTable.make(self.schema, id_generator.make_table_id_factory())
382 # Detection subtracts its background fit from the image in place, so
383 # detect and measure on a copy to lmake sure the image and variance
384 # remain identical.
385 output_exposure = exposure
386 exposure = exposure.clone()
388 detections = self.detection.run(
389 table=table,
390 exposure=exposure,
391 background=input_background,
392 )
393 sources = detections.sources
394 result.background = detections.background
396 if self.config.do_add_sky_sources: 396 ↛ 399line 396 didn't jump to line 399 because the condition on line 396 was always true
397 self.sky_sources.run(exposure.mask, id_generator.catalog_id, sources)
399 self.deblend.run(exposure=exposure, sources=sources)
400 # The deblender may not produce a contiguous catalog; ensure
401 # contiguity for subsequent tasks.
402 if not sources.isContiguous(): 402 ↛ 405line 402 didn't jump to line 405 because the condition on line 402 was always true
403 sources = sources.copy(deep=True)
405 self.measurement.run(sources, exposure)
406 self.normalized_calibration_flux.run(exposure=exposure, catalog=sources)
407 self.apply_aperture_correction.run(sources, exposure.apCorrMap)
408 self.catalog_calculation.run(sources)
409 self.set_primary_flags.run(sources)
411 sources["visit"] = exposure.visitInfo.id
412 sources["detector"] = exposure.info.getDetector().getId()
413 result.sources_footprints = sources
414 result.sources = sources.asAstropy()
416 detected = output_exposure.mask.getPlaneBitMask("DETECTED")
417 output_exposure.mask.array &= ~detected
418 output_exposure.mask.array |= exposure.mask.array & detected
419 result.output_exposure = output_exposure
421 return result