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

93 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-09 04:35 -0700

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 # NOTE: these apertures were selected for HSC, and may not be 

130 # what we want for LSSTCam. 

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

132 3.0, 

133 4.5, 

134 6.0, 

135 9.0, 

136 12.0, 

137 17.0, 

138 25.0, 

139 35.0, 

140 50.0, 

141 70.0, 

142 ] 

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

144 self.measurement.plugins.names |= ["base_Jacobian", "base_FPPosition", "ext_photometryKron_KronFlux"] 

145 self.measurement.plugins["base_Jacobian"].pixelScale = 0.2 

146 

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

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

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

150 

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

152 self.normalized_calibration_flux.do_measure_ap_corr = False 

153 

154 

155class SingleFrameDetectAndMeasureTask(pipeBase.PipelineTask): 

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

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

158 """ 

159 

160 ConfigClass = SingleFrameDetectAndMeasureConfig 

161 _DefaultName = "singleFrameDetectAndMeasure" 

162 

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

164 super().__init__(**kwargs) 

165 

166 if schema is None: 

167 schema = afwTable.SourceTable.makeMinimalSchema() 

168 

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

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

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

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

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

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

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

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

177 

178 schema.addField( 

179 "visit", 

180 type="L", 

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

182 ) 

183 schema.addField( 

184 "detector", 

185 type="U", 

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

187 ) 

188 

189 self.schema = schema 

190 

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

192 inputs = butlerQC.get(inputRefs) 

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

194 

195 exposure = inputs.pop("exposure") 

196 input_background = inputs.pop("input_background") 

197 

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

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

200 

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

202 # exist, even as None. 

203 result = pipeBase.Struct( 

204 sources=None, 

205 sources_footprints=None, 

206 ) 

207 try: 

208 self.run( 

209 exposure=exposure, 

210 input_background=input_background, 

211 id_generator=id_generator, 

212 result=result, 

213 ) 

214 except pipeBase.AlgorithmError as e: 

215 error = pipeBase.AnnotatedPartialOutputsError.annotate( 

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

217 ) 

218 butlerQC.put(result, outputRefs) 

219 raise error from e 

220 

221 butlerQC.put(result, outputRefs) 

222 

223 def run( 

224 self, 

225 exposure, 

226 input_background, 

227 id_generator=None, 

228 result=None, 

229 ): 

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

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

232 supplied metadata, plus a catalog measured on it. 

233 Stripped-down version of `ReprocessVisitImageTask`. 

234 

235 Parameters 

236 ---------- 

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

238 Initial calibrated exposure. 

239 The DETECTED mask plane will be modified in place. 

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

241 Object that generates source IDs and provides random seeds. 

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

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

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

245 this is also returned. 

246 

247 Returns 

248 ------- 

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

250 Results as a struct with attributes: 

251 

252 ``sources`` 

253 Sources that were measured on the exposure, with calibrated 

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

255 ``sources_footprints`` 

256 Footprints of sources that were measured on the exposure. 

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

258 ``background`` 

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

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

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

262 """ 

263 if result is None: 

264 result = pipeBase.Struct() 

265 if id_generator is None: 

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

267 

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

269 

270 detections = self.detection.run( 

271 table=table, 

272 exposure=exposure, 

273 background=input_background, 

274 ) 

275 sources = detections.sources 

276 result.background = detections.background 

277 

278 if self.config.do_add_sky_sources: 

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

280 

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

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

283 # contiguity for subsequent tasks. 

284 if not sources.isContiguous(): 

285 sources = sources.copy(deep=True) 

286 

287 self.measurement.run(sources, exposure) 

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

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

290 self.catalog_calculation.run(sources) 

291 self.set_primary_flags.run(sources) 

292 

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

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

295 result.sources_footprints = sources 

296 result.sources = sources.asAstropy() 

297 

298 return result