Coverage for tests/test_source.py: 98%
123 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-29 22:35 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-29 22:35 +0000
1# This file is part of lsst.scarlet.lite.
2#
3# Developed for the LSST Data Management System.
4# (https://www.lsst.org).
5# See the COPYRIGHT file at the top-level directory of this distribution
6# for details of code ownership.
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published by
10# the Free Software Foundation, either version 3 of the License, or
11# (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# This product includes software developed by the LSST Project
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
24import numpy as np
26import lsst.afw.image as afwImage
27import lsst.geom as geom
28import lsst.meas.extensions.scarlet as mes
29import lsst.scarlet.lite as scl
30import lsst.utils.tests
31from lsst.afw.detection import Footprint, PeakTable
32from lsst.afw.geom import SpanSet
35class ScarletTestCase(lsst.utils.tests.TestCase):
36 """A base TestCase for scarlet tests.
37 """
38 def setUp(self) -> None:
39 super().setUp()
40 self.bands = tuple("grizy")
41 peak = (27, 32)
42 bbox = scl.Box((15, 15), (20, 25))
43 morph = scl.utils.integrated_circular_gaussian(sigma=0.8).astype(np.float32)
44 spectrum = np.arange(5, dtype=np.float32)
45 model = morph[None, :, :] * spectrum[:, None, None]
46 model_image = scl.Image(model, yx0=bbox.origin, bands=self.bands)
47 self.component = scl.component.CubeComponent(model=model_image, peak=peak)
49 def test_constructor(self):
50 source = mes.source.IsolatedSource(
51 model=self.component._model,
52 peak=self.component.peak,
53 )
55 np.testing.assert_array_equal(
56 source.component._model.data,
57 self.component._model.data,
58 )
59 self.assertEqual(source.component.peak, self.component.peak)
61 def test_copy(self):
62 source = mes.source.IsolatedSource(
63 model=self.component._model,
64 peak=self.component.peak,
65 )
66 source_copy = source.copy()
68 self.assertIsNot(source_copy, source)
69 self.assertIs(
70 source_copy.component._model.data,
71 source.component._model.data,
72 )
73 self.assertEqual(source_copy.component.peak, source.component.peak)
75 def test_deep_copy(self):
76 source = mes.source.IsolatedSource(
77 model=self.component._model,
78 peak=self.component.peak,
79 )
80 source_copy = source.copy(deep=True)
82 self.assertTupleEqual(source_copy.component.peak, source.component.peak)
84 np.testing.assert_array_equal(
85 source_copy.component._model.data,
86 source.component._model.data,
87 )
88 self.assertIsNot(
89 source_copy.component._model.data,
90 source.component._model.data,
91 )
93 with self.assertRaises(AssertionError):
94 source_copy.component._model._data -= 1
95 np.testing.assert_array_equal(
96 source_copy.component._model.data,
97 source.component._model.data,
98 )
100 def test_slice(self):
101 source = mes.source.IsolatedSource(self.component._model, self.component.peak)
102 source_sliced = source["g":"r"]
103 self.assertTupleEqual(source_sliced.bands, ("g", "r"))
104 np.testing.assert_array_equal(
105 source_sliced.get_model().data,
106 source.get_model().data[:2],
107 )
109 def test_reorder(self):
110 source = mes.source.IsolatedSource(self.component._model, self.component.peak)
111 indices = ("i", "g", "r")
112 source_reordered = source[indices]
113 self.assertTupleEqual(source_reordered.bands, indices)
114 np.testing.assert_array_equal(
115 source_reordered.get_model().data,
116 source.get_model().data[[2, 0, 1]],
117 )
119 source_reordered = source["igr"]
120 self.assertTupleEqual(source_reordered.bands, indices)
121 np.testing.assert_array_equal(
122 source_reordered.get_model().data,
123 source.get_model().data[[2, 0, 1]],
124 )
126 def test_subset(self):
127 source = mes.source.IsolatedSource(self.component._model, self.component.peak)
128 source_subset = source[("r",)]
129 self.assertTupleEqual(source_subset.bands, ("r",))
130 np.testing.assert_array_equal(
131 source_subset.get_model().data,
132 source.get_model().data[1:2],
133 )
135 def test_indexing_errors(self):
136 source = mes.source.IsolatedSource(self.component._model, self.component.peak)
137 with self.assertRaises(IndexError):
138 # "x" is not an a band in the model
139 source["x"]
141 with self.assertRaises(IndexError):
142 # "x" is not an a band in the model
143 source["r":"x"]
145 with self.assertRaises(IndexError):
146 # "x" is not an a band in the model
147 source["x":"i"]
149 with self.assertRaises(IndexError):
150 # "x" is not an a band in the model
151 source["g", "x", "i"]
153 with self.assertRaises(IndexError):
154 # The box doesn't overlap with the model
155 source[scl.Box((0, 0), (10, 10))]
157 with self.assertRaises(IndexError):
158 # Users must provide a Box, not a tuple, for spatial dimensions
159 source[:, 10:20, 10:20]
161 with self.assertRaises(IndexError):
162 # Users must provide a Box, not a slice, for spatial dimensions
163 source[1:]
165 with self.assertRaises(IndexError):
166 # Users must provide a Box, not an int, for spatial dimensions
167 source[1]
169 with self.assertRaises(IndexError):
170 # Users must provide a Box, not a tuple, for spatial dimensions
171 source[0, 1]
173 def test_from_footprint_roundtrip(self):
174 """``IsolatedSource.from_footprint`` produces a source whose
175 peak and bbox match the input afw Footprint.
177 Uses a circular 7×7 mask so the bbox is a real bounding box
178 rather than coincident with the footprint shape, and so the
179 peak position is non-trivially distinct from the bbox origin.
180 """
181 bands = tuple("gri")
182 h, w = 7, 7
183 y0, x0 = 5, 3 # bbox origin in (y, x)
185 circle = scl.utils.get_circle_mask(h, dtype=np.int32)
186 afw_mask = afwImage.Mask(circle, xy0=geom.Point2I(x0, y0))
187 footprint = Footprint(SpanSet.fromMask(afw_mask), PeakTable.makeMinimalSchema())
188 peak_y, peak_x = y0 + h // 2, x0 + w // 2
189 footprint.addPeak(peak_x, peak_y, 100.0)
191 # 3-band 20×20 coadd of ones; ``from_footprint`` will mask
192 # the coadd's data with the footprint spans inside the bbox.
193 image_shape = (3, 20, 20)
194 masked = afwImage.MultibandMaskedImage.fromArrays(
195 bands,
196 np.ones(image_shape, dtype=np.float32),
197 None,
198 np.ones(image_shape, dtype=np.float32),
199 )
200 coadds = [
201 afwImage.Exposure(img, dtype=img.image.array.dtype) for img in masked
202 ]
203 mCoadd = afwImage.MultibandExposure.fromExposures(bands, coadds)
205 source = mes.source.IsolatedSource.from_footprint(
206 footprint, mCoadd, dtype=np.float32
207 )
209 self.assertTupleEqual(source.peak, (peak_y, peak_x))
210 self.assertTupleEqual(source.bbox.origin, (y0, x0))
211 self.assertTupleEqual(source.bbox.shape, (h, w))
213 def test_to_data_from_data_roundtrip(self):
214 """``to_data`` → ``IsolatedSourceData.to_source`` against an
215 observation matching the source's model recovers the source's
216 model data, peak, and bbox origin.
218 Uses a circular morph so ``span_array`` is not trivially
219 all-True; reconstruction must actually mask by the span set.
220 """
221 bands = tuple("gri")
222 morph = scl.utils.get_circle_mask(15, dtype=np.float32)
223 spectrum = np.arange(1, 1 + len(bands), dtype=np.float32)
224 model_data = morph[None, :, :] * spectrum[:, None, None]
225 peak = (27, 32)
226 bbox = scl.Box((15, 15), (20, 25))
227 model_image = scl.Image(model_data, yx0=bbox.origin, bands=bands)
229 source = mes.source.IsolatedSource(model=model_image, peak=peak)
230 data = source.to_data()
232 self.assertTupleEqual(data.origin, bbox.origin)
233 self.assertTupleEqual(data.peak, peak)
234 np.testing.assert_array_equal(data.span_array, morph > 0)
236 # Round-trip via to_source: build an Observation whose images
237 # equal the source model (a delta PSF makes the observation
238 # bypass any convolution).
239 psfs = np.zeros((len(bands), 1, 1), dtype=np.float32)
240 psfs[:, 0, 0] = 1.0
241 observation = scl.Observation.empty(
242 bands=bands,
243 psfs=psfs,
244 model_psf=psfs[:1],
245 bbox=bbox,
246 dtype=np.float32,
247 )
248 observation.images = model_image
250 source2 = data.to_source(observation)
252 self.assertTupleEqual(source2.peak, peak)
253 self.assertTupleEqual(source2.bbox.origin, bbox.origin)
254 np.testing.assert_array_equal(source2.get_model().data, model_data)
257def setup_module(module):
258 lsst.utils.tests.init()
261class MemoryTester(lsst.utils.tests.MemoryTestCase):
262 pass
265if __name__ == "__main__": 265 ↛ 266line 265 didn't jump to line 266 because the condition on line 265 was never true
266 lsst.utils.tests.init()
267 unittest.main()