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-08 09:20 +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 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 

34 

35 

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 ) 

52 

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 ) 

77 

78 

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

84 

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 ) 

122 

123 def setDefaults(self): 

124 super().setDefaults() 

125 

126 # Re-estimate the background 

127 self.detection.reEstimateBackground = True 

128 self.detection.doTempLocalBackground = False 

129 

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) 

162 

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 

166 

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

168 self.normalized_calibration_flux.do_measure_ap_corr = False 

169 

170 

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

175 

176 ConfigClass = SingleFrameDetectAndMeasureConfig 

177 _DefaultName = "singleFrameDetectAndMeasure" 

178 

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

180 super().__init__(**kwargs) 

181 

182 if schema is None: 

183 schema = afwTable.SourceTable.makeMinimalSchema() 

184 

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) 

193 

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 ) 

204 

205 self.schema = schema 

206 

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

208 inputs = butlerQC.get(inputRefs) 

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

210 

211 exposure = inputs.pop("exposure") 

212 input_background = inputs.pop("input_background") 

213 

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

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

216 

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 

236 

237 butlerQC.put(result, outputRefs) 

238 

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

250 

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. 

262 

263 Returns 

264 ------- 

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

266 Results as a struct with attributes: 

267 

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

287 

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

289 

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 

297 

298 if self.config.do_add_sky_sources: 

299 self.sky_sources.run(exposure.mask, id_generator.catalog_id, sources) 

300 

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) 

306 

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) 

312 

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

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

315 result.sources_footprints = sources 

316 result.sources = sources.asAstropy() 

317 

318 return result