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-15 09:58 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 09:58 +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/>.
22import unittest
23from unittest import mock
25import astropy.units as u
26import numpy as np
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
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
52def make_exposure_and_background():
53 """Build a synthetic calibrated exposure with a few point sources.
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())
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
91class ConnectionsTestCase(lsst.utils.tests.TestCase):
92 """Test how image_type wires up the connections."""
94 def make_connections(self, image_type):
95 config = make_config(image_type)
96 return config.connections.ConnectionsClass(config=config)
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)
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)
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)
122class RunQuantumTestCase(lsst.utils.tests.TestCase):
123 """Test what `runQuantum` hands to `run` for each input image type."""
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 )
133 def run_quantum(self, image_type, **inputs):
134 """Run a quantum with `run` mocked out.
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.
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
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
172 kwargs, butlerQC = self.run_quantum("legacy", exposure=exposure, input_background=background)
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, [])
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)
186 kwargs, butlerQC = self.run_quantum("future", exposure=image)
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)
196class RunImageOutputTestCase(lsst.utils.tests.TestCase):
197 """Test the output exposure on a synthetic image."""
199 def setUp(self):
200 self.exposure, self.background = make_exposure_and_background()
201 self.input_exposure = self.exposure.clone()
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)
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]))
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)
232 result = task.run(exposure=self.exposure, input_background=self.background)
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)
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()
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)
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)
281class _FakeRefs:
282 """Stand-in for an input or output ``QuantizedConnection``."""
284 def __init__(self, **kwargs):
285 self.__dict__.update(kwargs)
288class _RecordingQuantumContext:
289 """Minimal `~lsst.pipe.base.QuantumContext` that records what was put.
291 Parameters
292 ----------
293 dataId : `lsst.daf.butler.DataCoordinate`
294 Data ID to report as the quantum data ID.
295 """
297 def __init__(self, dataId):
298 self.quantum = _FakeRefs(dataId=dataId)
299 self.put_values = None
301 def get(self, refs):
302 return dict(refs.__dict__)
304 def put(self, values, refs):
305 self.put_values = values
308class _FakeVisitImage:
309 """Stand-in for an `lsst.images.VisitImage` input.
311 ``runQuantum`` only calls `to_legacy` on the image it is given before
312 calling `run`, so this records that call instead.
314 Parameters
315 ----------
316 legacy : `object`
317 The value `to_legacy` returns.
318 """
320 def __init__(self, legacy):
321 self.legacy = legacy
322 self.to_legacy_calls = 0
324 def to_legacy(self):
325 self.to_legacy_calls += 1
326 return self.legacy
329def setup_module(module):
330 lsst.utils.tests.init()
333class MemoryTestCase(lsst.utils.tests.MemoryTestCase):
334 pass
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()