Coverage for tests/test_source.py: 98%

123 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-09 02:24 -0700

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/>. 

21 

22import unittest 

23 

24import numpy as np 

25 

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 

33 

34 

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) 

48 

49 def test_constructor(self): 

50 source = mes.source.IsolatedSource( 

51 model=self.component._model, 

52 peak=self.component.peak, 

53 ) 

54 

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) 

60 

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() 

67 

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) 

74 

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) 

81 

82 self.assertTupleEqual(source_copy.component.peak, source.component.peak) 

83 

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 ) 

92 

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 ) 

99 

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 ) 

108 

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 ) 

118 

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 ) 

125 

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 ) 

134 

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"] 

140 

141 with self.assertRaises(IndexError): 

142 # "x" is not an a band in the model 

143 source["r":"x"] 

144 

145 with self.assertRaises(IndexError): 

146 # "x" is not an a band in the model 

147 source["x":"i"] 

148 

149 with self.assertRaises(IndexError): 

150 # "x" is not an a band in the model 

151 source["g", "x", "i"] 

152 

153 with self.assertRaises(IndexError): 

154 # The box doesn't overlap with the model 

155 source[scl.Box((0, 0), (10, 10))] 

156 

157 with self.assertRaises(IndexError): 

158 # Users must provide a Box, not a tuple, for spatial dimensions 

159 source[:, 10:20, 10:20] 

160 

161 with self.assertRaises(IndexError): 

162 # Users must provide a Box, not a slice, for spatial dimensions 

163 source[1:] 

164 

165 with self.assertRaises(IndexError): 

166 # Users must provide a Box, not an int, for spatial dimensions 

167 source[1] 

168 

169 with self.assertRaises(IndexError): 

170 # Users must provide a Box, not a tuple, for spatial dimensions 

171 source[0, 1] 

172 

173 def test_from_footprint_roundtrip(self): 

174 """``IsolatedSource.from_footprint`` produces a source whose 

175 peak and bbox match the input afw Footprint. 

176 

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) 

184 

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) 

190 

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) 

204 

205 source = mes.source.IsolatedSource.from_footprint( 

206 footprint, mCoadd, dtype=np.float32 

207 ) 

208 

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)) 

212 

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. 

217 

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) 

228 

229 source = mes.source.IsolatedSource(model=model_image, peak=peak) 

230 data = source.to_data() 

231 

232 self.assertTupleEqual(data.origin, bbox.origin) 

233 self.assertTupleEqual(data.peak, peak) 

234 np.testing.assert_array_equal(data.span_array, morph > 0) 

235 

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 

249 

250 source2 = data.to_source(observation) 

251 

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) 

255 

256 

257def setup_module(module): 

258 lsst.utils.tests.init() 

259 

260 

261class MemoryTester(lsst.utils.tests.MemoryTestCase): 

262 pass 

263 

264 

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()