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-20 09:48 +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/>. 

21 

22__all__ = ["SingleFrameDetectAndMeasureTask", "SingleFrameDetectAndMeasureConfig"] 

23 

24import dataclasses 

25 

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 

36 

37 

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 ) 

56 

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 ) 

87 

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 

95 

96 

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() 

102 

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 ) 

152 

153 def setDefaults(self): 

154 super().setDefaults() 

155 

156 # Re-estimate the background 

157 self.detection.reEstimateBackground = True 

158 self.detection.doTempLocalBackground = False 

159 

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) 

192 

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 

196 

197 # Only apply calibration fluxes, do not measure them. 

198 self.normalized_calibration_flux.do_measure_ap_corr = False 

199 

200 

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 """ 

205 

206 ConfigClass = SingleFrameDetectAndMeasureConfig 

207 _DefaultName = "singleFrameDetectAndMeasure" 

208 

209 def __init__(self, schema=None, **kwargs): 

210 super().__init__(**kwargs) 

211 

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() 

214 

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) 

223 

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 ) 

234 

235 self.schema = schema 

236 

237 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

238 inputs = butlerQC.get(inputRefs) 

239 id_generator = self.config.id_generator.apply(butlerQC.quantum.dataId) 

240 

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") 

251 

252 # This should not happen with a properly configured execution context. 

253 assert not inputs, "runQuantum got more inputs than expected" 

254 

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 

275 

276 if self.config.image_type == "future": 

277 result.output_exposure = self._make_future_output(visit_image, result) 

278 butlerQC.put(result, outputRefs) 

279 

280 def _make_future_output(self, visit_image, result): 

281 """Copy the outputs of `run` onto the input `lsst.images.VisitImage`. 

282 

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``. 

290 

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 

299 

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) 

304 

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 

320 

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`. 

332 

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. 

349 

350 Returns 

351 ------- 

352 result : `lsst.pipe.base.Struct` 

353 Results as a struct with attributes: 

354 

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() 

379 

380 table = afwTable.SourceTable.make(self.schema, id_generator.make_table_id_factory()) 

381 

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() 

387 

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 

395 

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) 

398 

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) 

404 

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) 

410 

411 sources["visit"] = exposure.visitInfo.id 

412 sources["detector"] = exposure.info.getDetector().getId() 

413 result.sources_footprints = sources 

414 result.sources = sources.asAstropy() 

415 

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 

420 

421 return result