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-05-06 08:57 +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.pex.config as pexConfig 

31import lsst.pipe.base as pipeBase 

32from lsst.pipe.base import connectionTypes 

33 

34 

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 ) 

51 

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 ) 

76 

77 

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

83 

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 ) 

121 

122 def setDefaults(self): 

123 super().setDefaults() 

124 

125 # Re-estimate the background 

126 self.detection.reEstimateBackground = True 

127 self.detection.doTempLocalBackground = False 

128 

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) 

159 

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 

163 

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

165 self.normalized_calibration_flux.do_measure_ap_corr = False 

166 

167 

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

172 

173 ConfigClass = SingleFrameDetectAndMeasureConfig 

174 _DefaultName = "singleFrameDetectAndMeasure" 

175 

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

177 super().__init__(**kwargs) 

178 

179 if schema is None: 

180 schema = afwTable.SourceTable.makeMinimalSchema() 

181 

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) 

190 

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 ) 

201 

202 self.schema = schema 

203 

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

205 inputs = butlerQC.get(inputRefs) 

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

207 

208 exposure = inputs.pop("exposure") 

209 input_background = inputs.pop("input_background") 

210 

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

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

213 

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 

233 

234 butlerQC.put(result, outputRefs) 

235 

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

247 

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. 

259 

260 Returns 

261 ------- 

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

263 Results as a struct with attributes: 

264 

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

284 

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

286 

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 

294 

295 if self.config.do_add_sky_sources: 

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

297 

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) 

303 

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) 

309 

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

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

312 result.sources_footprints = sources 

313 result.sources = sources.asAstropy() 

314 

315 return result