Coverage for tests/test_metrics.py: 96%

73 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-25 08:23 +0000

1# This file is part of meas_extensions_scarlet. 

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"""Tests for ``setDeblenderMetrics``.""" 

23 

24import unittest 

25 

26import lsst.meas.extensions.scarlet as mes 

27import lsst.scarlet.lite as scl 

28import lsst.utils.tests 

29import numpy as np 

30 

31 

32# Three-band setup shared by every test. setDeblenderMetrics only reads 

33# source models and the blend's bounding box, so arbitrary band names 

34# and dummy (zero-image / unit-variance) observations are sufficient. 

35BANDS = ("g", "r", "i") 

36IMAGE_SHAPE = (20, 20) 

37 

38 

39def _build_blend(source_specs): 

40 """Build a minimal scarlet Blend from synthetic-source specs. 

41 

42 Parameters 

43 ---------- 

44 source_specs : `list` [`tuple`] 

45 One tuple per source: ``(morph, origin, peak, spectrum)``. 

46 ``morph`` is a 2D ``numpy.ndarray``; ``origin`` is the 

47 ``(y, x)`` bottom-left corner of the morph in image 

48 coordinates; ``peak`` is the ``(y, x)`` of the source's 

49 peak; ``spectrum`` is the per-band amplitude. 

50 """ 

51 n_bands = len(BANDS) 

52 obs_shape = (n_bands,) + IMAGE_SHAPE 

53 psfs = np.eye(5, dtype=np.float32)[None].repeat(n_bands, axis=0) 

54 observation = scl.Observation( 

55 images=np.zeros(obs_shape, dtype=np.float32), 

56 variance=np.ones(obs_shape, dtype=np.float32), 

57 weights=np.ones(obs_shape, dtype=np.float32), 

58 psfs=psfs, 

59 bands=BANDS, 

60 ) 

61 sources = [] 

62 for morph, origin, peak, spectrum in source_specs: 

63 model = ( 

64 np.asarray(morph, dtype=np.float32)[None, :, :] 

65 * np.asarray(spectrum, dtype=np.float32)[:, None, None] 

66 ) 

67 image = scl.Image(model, yx0=origin, bands=BANDS) 

68 component = scl.component.CubeComponent(model=image, peak=peak) 

69 sources.append(scl.Source([component])) 

70 return scl.Blend(sources=sources, observation=observation) 

71 

72 

73class TestSetDeblenderMetrics(lsst.utils.tests.TestCase): 

74 """Tests for ``setDeblenderMetrics`` in 

75 ``lsst.meas.extensions.scarlet.metrics``. 

76 

77 The function operates on an ``scl.Blend`` and assigns a 

78 ``DeblenderMetrics`` instance to each source's ``metrics`` attribute. 

79 The synthetic blends below use uniform-flux rectangular morphs so 

80 every metric has a closed-form expected value. 

81 """ 

82 

83 def test_setDeblenderMetrics_isolated(self): 

84 """A single-source blend has all four metrics equal to zero in 

85 every band. 

86 

87 With no neighbor model, ``neighborOverlap`` is identically zero 

88 so ``maxOverlap``, ``fluxOverlap``, and ``fluxOverlapFraction`` 

89 are zero. ``blendedness`` is ``1 - sum(m²) / sum(M·m)``; with 

90 ``M ≡ m`` over the source's support the ratio is 1 and 

91 blendedness is 0. 

92 """ 

93 morph = np.ones((5, 5), dtype=np.float32) 

94 blend = _build_blend([(morph, (5, 5), (7, 7), [1.0, 1.0, 1.0])]) 

95 

96 mes.metrics.setDeblenderMetrics(blend) 

97 

98 zeros = np.zeros(len(BANDS), dtype=np.float64) 

99 metrics = blend.sources[0].metrics 

100 np.testing.assert_array_equal(metrics.maxOverlap, zeros) 

101 np.testing.assert_array_equal(metrics.fluxOverlap, zeros) 

102 np.testing.assert_array_equal(metrics.fluxOverlapFraction, zeros) 

103 np.testing.assert_array_equal(metrics.blendedness, zeros) 

104 

105 def test_setDeblenderMetrics_two_disjoint(self): 

106 """Two non-overlapping sources both have zero overlap metrics. 

107 

108 The morphs occupy disjoint bboxes so each source's footprint 

109 contains no neighbor flux; ``neighborOverlap`` is zero, and the 

110 blendedness collapses to the isolated-source case for each 

111 source independently. 

112 """ 

113 morph = np.ones((3, 3), dtype=np.float32) 

114 # First morph: rows 2-4, cols 2-4. Second: rows 12-14, cols 12-14. 

115 # Clear gap of ≥ 7 pixels in each axis. 

116 blend = _build_blend([ 

117 (morph, (2, 2), (3, 3), [1.0, 1.0, 1.0]), 

118 (morph, (12, 12), (13, 13), [1.0, 1.0, 1.0]), 

119 ]) 

120 

121 mes.metrics.setDeblenderMetrics(blend) 

122 

123 zeros = np.zeros(len(BANDS), dtype=np.float64) 

124 for src in blend.sources: 

125 np.testing.assert_array_equal(src.metrics.maxOverlap, zeros) 

126 np.testing.assert_array_equal(src.metrics.fluxOverlap, zeros) 

127 np.testing.assert_array_equal( 

128 src.metrics.fluxOverlapFraction, zeros 

129 ) 

130 np.testing.assert_array_equal(src.metrics.blendedness, zeros) 

131 

132 def test_setDeblenderMetrics_overlapping_sources(self): 

133 """Two uniform 5×5 sources that overlap in a 3×3 region produce 

134 analytically derivable overlap metrics. 

135 

136 Setup 

137 ----- 

138 - Each source: 5×5 morph filled with 1.0, flat spectrum 

139 ``(1, 1, 1)``. 

140 - Source A at origin ``(5, 5)`` covers rows 5-9, cols 5-9. 

141 - Source B at origin ``(7, 7)`` covers rows 7-11, cols 7-11. 

142 - Overlap region: rows 7-9, cols 7-9 — nine pixels. 

143 

144 Expected values per band, identical for both sources by 

145 symmetry: 

146 

147 - ``maxOverlap = 1.0`` — the neighbor's contribution at any 

148 overlap pixel. 

149 - ``fluxOverlap = 9.0`` — sum of neighbor flux over the nine 

150 overlap pixels. 

151 - ``fluxOverlapFraction = 9 / 25 = 0.36`` — overlap flux over 

152 this source's total flux of 25. 

153 - ``blendedness = 1 - sum(m²) / sum(M·m)``. 

154 Non-overlap pixels (16) contribute ``m² = 1, M·m = 1``; 

155 overlap pixels (9) contribute ``m² = 1, M·m = 2``. 

156 ``sum(m²) = 25``, ``sum(M·m) = 16 + 18 = 34``. 

157 ``blendedness = 1 - 25/34 = 9/34 ≈ 0.2647``. 

158 """ 

159 morph = np.ones((5, 5), dtype=np.float32) 

160 blend = _build_blend([ 

161 (morph, (5, 5), (7, 7), [1.0, 1.0, 1.0]), 

162 (morph, (7, 7), (9, 9), [1.0, 1.0, 1.0]), 

163 ]) 

164 

165 mes.metrics.setDeblenderMetrics(blend) 

166 

167 n_bands = len(BANDS) 

168 for src in blend.sources: 

169 np.testing.assert_allclose( 

170 src.metrics.maxOverlap, [1.0] * n_bands 

171 ) 

172 np.testing.assert_allclose( 

173 src.metrics.fluxOverlap, [9.0] * n_bands 

174 ) 

175 np.testing.assert_allclose( 

176 src.metrics.fluxOverlapFraction, 

177 [9.0 / 25.0] * n_bands, 

178 ) 

179 np.testing.assert_allclose( 

180 src.metrics.blendedness, 

181 [9.0 / 34.0] * n_bands, 

182 atol=1e-6, 

183 ) 

184 

185 def test_setDeblenderMetrics_zero_model_band_blendedness_is_zero(self): 

186 """A source whose model is identically zero in every band 

187 receives ``blendedness == 0``, not ``NaN``. 

188 

189 Per finding U-3 of the ``audits/audit-2026-05-05.md`` audit, 

190 the blendedness formula ``1 - sum(m²) / sum(M·m)`` was 

191 computed unguarded. When the source's model is zero 

192 everywhere, both numerator and denominator vanish and the 

193 ratio is ``NaN``, which then propagated into the 

194 ``deblend_blendedness`` schema field. The fix mirrors the 

195 existing ``fluxOverlapFraction`` guard pattern and defaults 

196 to ``0`` when the source has no flux. 

197 

198 The single-source blend below uses an all-zero morph so 

199 ``model * model`` and ``blendModel * model`` are both 

200 identically zero per band; the unguarded code returns ``NaN`` 

201 per band, the guarded code returns ``0``. 

202 """ 

203 morph = np.zeros((5, 5), dtype=np.float32) 

204 blend = _build_blend([(morph, (5, 5), (7, 7), [1.0, 1.0, 1.0])]) 

205 

206 mes.metrics.setDeblenderMetrics(blend) 

207 

208 zeros = np.zeros(len(BANDS), dtype=np.float64) 

209 np.testing.assert_array_equal( 

210 blend.sources[0].metrics.blendedness, zeros 

211 ) 

212 

213 def test_setDeblenderMetrics_counts_negative_pixels_as_support(self): 

214 """A source with a negative-only pixel still sees neighbor 

215 overlap at that pixel. 

216 

217 Per finding U-2 of the ``audits/audit-2026-05-05.md`` audit, 

218 ``setDeblenderMetrics`` previously built its per-source 

219 footprint with ``np.bitwise_or.reduce(model > 0, axis=0)``, 

220 which dropped every pixel whose model values were all ``≤ 0`` 

221 in every band. A negative-only pixel was therefore invisible 

222 to ``maxOverlap`` / ``fluxOverlap``, even if a neighbor put 

223 flux there. The canonical helper uses ``np.any != 0`` so the 

224 support tracks the spatial extent of the model regardless of 

225 sign. 

226 

227 Setup 

228 ----- 

229 - Source A: 3×3 morph at origin ``(5, 5)``, all zero except 

230 ``-1.0`` at relative ``(1, 1)`` (absolute ``(6, 6)``). Flat 

231 spectrum ``(1, 1, 1)``. 

232 - Source B: 1×1 morph at origin ``(6, 6)``, value ``+5.0``. 

233 Flat spectrum ``(1, 1, 1)``. 

234 

235 Source A's support after the fix is the single pixel ``(6, 6)``; 

236 the rest of its bbox stays zero in every band and is correctly 

237 excluded by ``any != 0`` too. ``neighborOverlap`` at ``(6, 6)`` 

238 is ``(blendModel - model_A) = (4 - (-1)) = 5`` per band; A's 

239 footprint masks every other pixel to zero. So per-band 

240 ``maxOverlap = fluxOverlap = 5.0``. Under the bug, A's 

241 footprint is empty, ``neighborOverlap`` is zero everywhere, 

242 and both metrics are ``0``. 

243 """ 

244 morphA = np.zeros((3, 3), dtype=np.float32) 

245 morphA[1, 1] = -1.0 

246 morphB = np.array([[5.0]], dtype=np.float32) 

247 blend = _build_blend( 

248 [ 

249 (morphA, (5, 5), (6, 6), [1.0, 1.0, 1.0]), 

250 (morphB, (6, 6), (6, 6), [1.0, 1.0, 1.0]), 

251 ] 

252 ) 

253 

254 mes.metrics.setDeblenderMetrics(blend) 

255 

256 n_bands = len(BANDS) 

257 metricsA = blend.sources[0].metrics 

258 np.testing.assert_allclose(metricsA.maxOverlap, [5.0] * n_bands) 

259 np.testing.assert_allclose(metricsA.fluxOverlap, [5.0] * n_bands) 

260 

261 

262def setup_module(module): 

263 lsst.utils.tests.init() 

264 

265 

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

267 pass 

268 

269 

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

271 lsst.utils.tests.init() 

272 unittest.main()