Coverage for tests/test_single_frame_detect_and_measure.py: 98%

167 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 

22import unittest 

23from unittest import mock 

24 

25import astropy.units as u 

26import numpy as np 

27 

28import lsst.afw.cameraGeom.testUtils 

29import lsst.afw.image 

30import lsst.daf.butler 

31import lsst.geom 

32import lsst.meas.algorithms 

33import lsst.meas.base.tests 

34import lsst.pipe.base.testUtils 

35import lsst.utils.tests 

36from lsst.drp.tasks.single_frame_detect_and_measure import ( 

37 SingleFrameDetectAndMeasureConfig, 

38 SingleFrameDetectAndMeasureTask, 

39) 

40from lsst.images import VisitImage 

41from lsst.images.fields import SumField, field_from_legacy_background 

42from lsst.images.tests import get_dp2_exposure_record 

43 

44 

45def make_config(image_type): 

46 """Return a config with ``image_type`` set to the given value.""" 

47 config = SingleFrameDetectAndMeasureConfig() 

48 config.image_type = image_type 

49 return config 

50 

51 

52def make_exposure_and_background(): 

53 """Build a synthetic calibrated exposure with a few point sources. 

54 

55 Returns 

56 ------- 

57 exposure : `lsst.afw.image.ExposureF` 

58 Background-subtracted exposure ready for detection and measurement, 

59 with no DETECTED pixels. 

60 background : `lsst.afw.math.BackgroundList` 

61 Background subtracted from ``exposure``. 

62 """ 

63 bbox = lsst.geom.Box2I(lsst.geom.Point2I(5, 4), lsst.geom.Point2I(205, 184)) 

64 dataset = lsst.meas.base.tests.TestDataset( 

65 bbox, 

66 crval=lsst.geom.SpherePoint(245.0, -45.0, lsst.geom.degrees), 

67 calibration=12.3, 

68 detector=42, 

69 visitId=98765, 

70 ) 

71 psf_scale = np.sqrt(4 * np.pi * (dataset.psfShape.getDeterminantRadius()) ** 2) 

72 noise = 10.0 

73 for flux, centroid in [ 

74 (45 * noise * psf_scale, (40, 70)), 

75 (150 * noise * psf_scale, (50, 120)), 

76 (400 * noise * psf_scale, (92, 35)), 

77 (1000 * noise * psf_scale, (175, 154)), 

78 ]: 

79 dataset.addSource(instFlux=flux, centroid=lsst.geom.Point2D(*centroid)) 

80 exposure, _ = dataset.realize(noise=noise, schema=dataset.makeMinimalSchema()) 

81 exposure.mask.clearMaskPlane(exposure.mask.getMaskPlane("DETECTED")) 

82 exposure.info.setApCorrMap(lsst.afw.image.ApCorrMap()) 

83 

84 background_config = lsst.meas.algorithms.SubtractBackgroundTask.ConfigClass() 

85 background_config.approxOrderX = 1 

86 background_task = lsst.meas.algorithms.SubtractBackgroundTask(config=background_config) 

87 background = background_task.run(exposure).background 

88 return exposure, background 

89 

90 

91class ConnectionsTestCase(lsst.utils.tests.TestCase): 

92 """Test how image_type wires up the connections.""" 

93 

94 def make_connections(self, image_type): 

95 config = make_config(image_type) 

96 return config.connections.ConnectionsClass(config=config) 

97 

98 def test_lint_connections(self): 

99 for image_type in ("legacy", "future"): 

100 with self.subTest(image_type=image_type): 

101 config = make_config(image_type) 

102 lsst.pipe.base.testUtils.lintConnections(config.connections.ConnectionsClass) 

103 

104 def test_legacy(self): 

105 connections = self.make_connections("legacy") 

106 self.assertEqual(connections.exposure.storageClass, "Exposure") 

107 self.assertEqual(connections.output_exposure.storageClass, "ExposureF") 

108 self.assertIn("input_background", connections.inputs) 

109 self.assertIn("background", connections.outputs) 

110 

111 def test_future(self): 

112 """The backgrounds are part of the images in future mode, so both 

113 background connections are dropped. 

114 """ 

115 connections = self.make_connections("future") 

116 self.assertEqual(connections.exposure.storageClass, "VisitImage") 

117 self.assertEqual(connections.output_exposure.storageClass, "VisitImage") 

118 self.assertNotIn("input_background", connections.inputs) 

119 self.assertNotIn("background", connections.outputs) 

120 

121 

122class RunQuantumTestCase(lsst.utils.tests.TestCase): 

123 """Test what `runQuantum` hands to `run` for each input image type.""" 

124 

125 def setUp(self): 

126 self.dataId = lsst.daf.butler.DataCoordinate.standardize( 

127 instrument="I", 

128 visit=42, 

129 detector=12, 

130 universe=lsst.daf.butler.DimensionUniverse(), 

131 ) 

132 

133 def run_quantum(self, image_type, **inputs): 

134 """Run a quantum with `run` mocked out. 

135 

136 Parameters 

137 ---------- 

138 image_type : `str` 

139 Value to set ``config.image_type`` to. 

140 **inputs 

141 The inputs the quantum provides, as if read from the butler. 

142 

143 Returns 

144 ------- 

145 kwargs : `dict` 

146 The keyword arguments `runQuantum` called `run` with. 

147 butlerQC : `_RecordingQuantumContext` 

148 The context that was used, holding what was put. 

149 """ 

150 config = make_config(image_type) 

151 # The default dimension packer reads the instrument record, which a 

152 # data ID standardized outside a registry does not carry. 

153 config.id_generator.packer.name = "observation" 

154 config.id_generator.packer["observation"].n_observations = 10000 

155 config.id_generator.packer["observation"].n_detectors = 99 

156 task = SingleFrameDetectAndMeasureTask(config=config) 

157 butlerQC = _RecordingQuantumContext(self.dataId) 

158 with ( 

159 mock.patch.object(task, "run") as mock_run, 

160 mock.patch.object(task, "_make_future_output") as mock_make_future_output, 

161 ): 

162 task.runQuantum(butlerQC, _FakeRefs(**inputs), _FakeRefs()) 

163 mock_run.assert_called_once() 

164 self.make_future_output_calls = mock_make_future_output.call_args_list 

165 return mock_run.call_args.kwargs, butlerQC 

166 

167 def test_run_quantum_legacy(self): 

168 """The exposure and the separate background input both reach `run`.""" 

169 exposure = mock.sentinel.exposure 

170 background = mock.sentinel.background 

171 

172 kwargs, butlerQC = self.run_quantum("legacy", exposure=exposure, input_background=background) 

173 

174 self.assertIs(kwargs["exposure"], exposure) 

175 self.assertIs(kwargs["input_background"], background) 

176 self.assertIsNotNone(butlerQC.put_values) 

177 self.assertEqual(self.make_future_output_calls, []) 

178 

179 def test_run_quantum_future(self): 

180 """The image is converted to a legacy Exposure before `run` sees it, 

181 `run` fits its own background, having been given none, and the 

182 input image becomes the output. 

183 """ 

184 image = _FakeVisitImage(mock.sentinel.legacy_exposure) 

185 

186 kwargs, butlerQC = self.run_quantum("future", exposure=image) 

187 

188 self.assertIs(kwargs["exposure"], mock.sentinel.legacy_exposure) 

189 self.assertEqual(image.to_legacy_calls, 1) 

190 self.assertIsNone(kwargs["input_background"]) 

191 self.assertIsNotNone(butlerQC.put_values) 

192 self.assertEqual(len(self.make_future_output_calls), 1) 

193 self.assertIs(self.make_future_output_calls[0].args[0], image) 

194 

195 

196class RunImageOutputTestCase(lsst.utils.tests.TestCase): 

197 """Test the output exposure on a synthetic image.""" 

198 

199 def setUp(self): 

200 self.exposure, self.background = make_exposure_and_background() 

201 self.input_exposure = self.exposure.clone() 

202 

203 def make_task(self, image_type): 

204 config = make_config(image_type) 

205 # Adjust the config for a small test image. 

206 config.detection.background.approxOrderX = 1 

207 config.sky_sources.nSources = 2 

208 return SingleFrameDetectAndMeasureTask(config=config) 

209 

210 def check_mask(self, output_mask, sources): 

211 """Check that only DETECTED changed, and that it covers the 

212 footprints of ``sources``. 

213 """ 

214 detected = self.input_exposure.mask.getPlaneBitMask("DETECTED") 

215 np.testing.assert_array_equal(output_mask & ~detected, self.input_exposure.mask.array & ~detected) 

216 is_detected = (output_mask & detected) > 0 

217 self.assertTrue(np.any(is_detected)) 

218 x0, y0 = self.exposure.getXY0() 

219 for source in sources: 

220 if source["sky_source"]: 

221 continue 

222 ys, xs = source.getFootprint().spans.indices() 

223 self.assertTrue(np.all(is_detected[ys - y0, xs - x0])) 

224 

225 def test_run(self): 

226 """The output exposure is the input exposure, with only its DETECTED 

227 mask plane replaced. 

228 """ 

229 task = self.make_task("legacy") 

230 n_input_background = len(self.background) 

231 

232 result = task.run(exposure=self.exposure, input_background=self.background) 

233 

234 self.assertIs(result.output_exposure, self.exposure) 

235 np.testing.assert_array_equal(result.output_exposure.image.array, self.input_exposure.image.array) 

236 np.testing.assert_array_equal( 

237 result.output_exposure.variance.array, self.input_exposure.variance.array 

238 ) 

239 self.check_mask(result.output_exposure.mask.array, result.sources_footprints) 

240 # The background fit here is added to the input background. 

241 self.assertGreater(len(result.background), n_input_background) 

242 

243 def test_make_future_output(self): 

244 """The future output keeps the input's subtracted background and adds 

245 the total background, not subtracted. 

246 """ 

247 task = self.make_task("future") 

248 # The TestDataset detector has no amplifier geometry, which 

249 # `VisitImage` requires. 

250 self.exposure.setDetector(list(lsst.afw.cameraGeom.testUtils.CameraWrapper().camera)[0]) 

251 self.exposure.setFilter(lsst.afw.image.FilterLabel(band="r", physical="r_57")) 

252 record = get_dp2_exposure_record(lsst.daf.butler.DimensionUniverse()) 

253 record = type(record)(**(record.toDict() | {"id": self.exposure.visitInfo.id})) 

254 image = VisitImage.from_legacy(self.exposure, unit=u.nJy, exposure_record=record) 

255 subtracted = field_from_legacy_background(self.background, bounds=image.bbox, unit=image.unit) 

256 image.backgrounds.add("subtracted", subtracted, is_subtracted=True) 

257 input_image = image.copy() 

258 

259 legacy = image.to_legacy() 

260 # An empty aperture correction map is not carried by `VisitImage`. 

261 legacy.info.setApCorrMap(lsst.afw.image.ApCorrMap()) 

262 result = task.run(exposure=legacy, input_background=None) 

263 output = task._make_future_output(image, result) 

264 

265 self.assertIs(output, image) 

266 np.testing.assert_array_equal(output.image.array, input_image.image.array) 

267 np.testing.assert_array_equal(output.variance.array, input_image.variance.array) 

268 self.assertEqual(output.mask.compare(input_image.mask).keys(), {"DETECTED"}) 

269 np.testing.assert_array_equal( 

270 output.mask.get("DETECTED"), 

271 result.output_exposure.mask.array & result.output_exposure.mask.getPlaneBitMask("DETECTED") > 0, 

272 ) 

273 self.assertEqual(output.backgrounds.subtracted.name, "subtracted") 

274 self.assertIs(output.backgrounds.subtracted.field, subtracted) 

275 source_detection = output.backgrounds["sourceDetection"].field 

276 self.assertIsInstance(source_detection, SumField) 

277 self.assertIs(source_detection.operands[0], subtracted) 

278 self.assertEqual(source_detection.unit, image.unit) 

279 

280 

281class _FakeRefs: 

282 """Stand-in for an input or output ``QuantizedConnection``.""" 

283 

284 def __init__(self, **kwargs): 

285 self.__dict__.update(kwargs) 

286 

287 

288class _RecordingQuantumContext: 

289 """Minimal `~lsst.pipe.base.QuantumContext` that records what was put. 

290 

291 Parameters 

292 ---------- 

293 dataId : `lsst.daf.butler.DataCoordinate` 

294 Data ID to report as the quantum data ID. 

295 """ 

296 

297 def __init__(self, dataId): 

298 self.quantum = _FakeRefs(dataId=dataId) 

299 self.put_values = None 

300 

301 def get(self, refs): 

302 return dict(refs.__dict__) 

303 

304 def put(self, values, refs): 

305 self.put_values = values 

306 

307 

308class _FakeVisitImage: 

309 """Stand-in for an `lsst.images.VisitImage` input. 

310 

311 ``runQuantum`` only calls `to_legacy` on the image it is given before 

312 calling `run`, so this records that call instead. 

313 

314 Parameters 

315 ---------- 

316 legacy : `object` 

317 The value `to_legacy` returns. 

318 """ 

319 

320 def __init__(self, legacy): 

321 self.legacy = legacy 

322 self.to_legacy_calls = 0 

323 

324 def to_legacy(self): 

325 self.to_legacy_calls += 1 

326 return self.legacy 

327 

328 

329def setup_module(module): 

330 lsst.utils.tests.init() 

331 

332 

333class MemoryTestCase(lsst.utils.tests.MemoryTestCase): 

334 pass 

335 

336 

337if __name__ == "__main__": 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true

338 lsst.utils.tests.init() 

339 unittest.main()