Coverage for python / lsst / drp / tasks / single_frame_detect_and_measure.py: 0%

92 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 18:49 +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_PsfFlux", 

137 "base_CircularApertureFlux", 

138 "base_ClassificationSizeExtendedness", 

139 "base_CompensatedTophatFlux", 

140 ] 

141 # NOTE: these apertures were selected for HSC, and may not be 

142 # what we want for LSSTCam. 

143 self.measurement.plugins["base_CircularApertureFlux"].radii = [ 

144 3.0, 

145 4.5, 

146 6.0, 

147 9.0, 

148 12.0, 

149 17.0, 

150 25.0, 

151 35.0, 

152 50.0, 

153 70.0, 

154 ] 

155 lsst.meas.extensions.shapeHSM.configure_hsm(self.measurement) 

156 

157 # TODO DM-46306: should make this the ApertureFlux default! 

158 # Use a large aperture to be independent of seeing in calibration 

159 self.measurement.plugins["base_CircularApertureFlux"].maxSincRadius = 12.0 

160 

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

162 self.normalized_calibration_flux.do_measure_ap_corr = False 

163 

164 

165class SingleFrameDetectAndMeasureTask(pipeBase.PipelineTask): 

166 """Use the visit-level calibrations to perform detection and measurement 

167 on the single frame exposures and produce a "final" exposure and catalog. 

168 """ 

169 

170 ConfigClass = SingleFrameDetectAndMeasureConfig 

171 _DefaultName = "singleFrameDetectAndMeasure" 

172 

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

174 super().__init__(**kwargs) 

175 

176 if schema is None: 

177 schema = afwTable.SourceTable.makeMinimalSchema() 

178 

179 self.makeSubtask("detection", schema=schema) 

180 self.makeSubtask("sky_sources", schema=schema) 

181 self.makeSubtask("deblend", schema=schema) 

182 self.makeSubtask("measurement", schema=schema) 

183 self.makeSubtask("normalized_calibration_flux", schema=schema) 

184 self.makeSubtask("apply_aperture_correction", schema=schema) 

185 self.makeSubtask("catalog_calculation", schema=schema) 

186 self.makeSubtask("set_primary_flags", schema=schema, isSingleFrame=True) 

187 

188 schema.addField( 

189 "visit", 

190 type="L", 

191 doc="Visit this source appeared on.", 

192 ) 

193 schema.addField( 

194 "detector", 

195 type="U", 

196 doc="Detector this source appeared on.", 

197 ) 

198 

199 self.schema = schema 

200 

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

202 inputs = butlerQC.get(inputRefs) 

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

204 

205 exposure = inputs.pop("exposure") 

206 input_background = inputs.pop("input_background") 

207 

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

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

210 

211 # Specify the fields that `annotate` needs below, to ensure they 

212 # exist, even as None. 

213 result = pipeBase.Struct( 

214 sources=None, 

215 sources_footprints=None, 

216 ) 

217 try: 

218 self.run( 

219 exposure=exposure, 

220 input_background=input_background, 

221 id_generator=id_generator, 

222 result=result, 

223 ) 

224 except pipeBase.AlgorithmError as e: 

225 error = pipeBase.AnnotatedPartialOutputsError.annotate( 

226 e, self, result.sources_footprints, log=self.log 

227 ) 

228 butlerQC.put(result, outputRefs) 

229 raise error from e 

230 

231 butlerQC.put(result, outputRefs) 

232 

233 def run( 

234 self, 

235 exposure, 

236 input_background, 

237 id_generator=None, 

238 result=None, 

239 ): 

240 """Detect and measure sources on the exposure(s) (snap combined as 

241 necessary), and make a "final" Processed Visit Image using all of the 

242 supplied metadata, plus a catalog measured on it. 

243 Stripped-down version of `ReprocessVisitImageTask`. 

244 

245 Parameters 

246 ---------- 

247 exposure : `lsst.afw.image.Exposure` 

248 Initial calibrated exposure. 

249 The DETECTED mask plane will be modified in place. 

250 id_generator : `lsst.meas.base.IdGenerator`, optional 

251 Object that generates source IDs and provides random seeds. 

252 result : `lsst.pipe.base.Struct`, optional 

253 Result struct that is modified to allow saving of partial outputs 

254 for some failure conditions. If the task completes successfully, 

255 this is also returned. 

256 

257 Returns 

258 ------- 

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

260 Results as a struct with attributes: 

261 

262 ``sources`` 

263 Sources that were measured on the exposure, with calibrated 

264 fluxes and magnitudes. (`astropy.table.Table`) 

265 ``sources_footprints`` 

266 Footprints of sources that were measured on the exposure. 

267 (`lsst.afw.table.SourceCatalog`) 

268 ``background`` 

269 Total background that was fit to, and subtracted from the 

270 exposure when detecting ``sources``, in the same nJy units as 

271 ``exposure``. (`lsst.afw.math.BackgroundList`) 

272 """ 

273 if result is None: 

274 result = pipeBase.Struct() 

275 if id_generator is None: 

276 id_generator = lsst.meas.base.IdGenerator() 

277 

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

279 

280 detections = self.detection.run( 

281 table=table, 

282 exposure=exposure, 

283 background=input_background, 

284 ) 

285 sources = detections.sources 

286 result.background = detections.background 

287 

288 if self.config.do_add_sky_sources: 

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

290 

291 self.deblend.run(exposure=exposure, sources=sources) 

292 # The deblender may not produce a contiguous catalog; ensure 

293 # contiguity for subsequent tasks. 

294 if not sources.isContiguous(): 

295 sources = sources.copy(deep=True) 

296 

297 self.measurement.run(sources, exposure) 

298 self.normalized_calibration_flux.run(exposure=exposure, catalog=sources) 

299 self.apply_aperture_correction.run(sources, exposure.apCorrMap) 

300 self.catalog_calculation.run(sources) 

301 self.set_primary_flags.run(sources) 

302 

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

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

305 result.sources_footprints = sources 

306 result.sources = sources.asAstropy() 

307 

308 return result