Coverage for tests/test_metrics.py: 96%
73 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-01 02:04 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-01 02:04 -0700
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/>.
22"""Tests for ``setDeblenderMetrics``."""
24import unittest
26import lsst.meas.extensions.scarlet as mes
27import lsst.scarlet.lite as scl
28import lsst.utils.tests
29import numpy as np
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)
39def _build_blend(source_specs):
40 """Build a minimal scarlet Blend from synthetic-source specs.
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)
73class TestSetDeblenderMetrics(lsst.utils.tests.TestCase):
74 """Tests for ``setDeblenderMetrics`` in
75 ``lsst.meas.extensions.scarlet.metrics``.
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 """
83 def test_setDeblenderMetrics_isolated(self):
84 """A single-source blend has all four metrics equal to zero in
85 every band.
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])])
96 mes.metrics.setDeblenderMetrics(blend)
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)
105 def test_setDeblenderMetrics_two_disjoint(self):
106 """Two non-overlapping sources both have zero overlap metrics.
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 ])
121 mes.metrics.setDeblenderMetrics(blend)
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)
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.
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.
144 Expected values per band, identical for both sources by
145 symmetry:
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 ])
165 mes.metrics.setDeblenderMetrics(blend)
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 )
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``.
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.
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])])
206 mes.metrics.setDeblenderMetrics(blend)
208 zeros = np.zeros(len(BANDS), dtype=np.float64)
209 np.testing.assert_array_equal(
210 blend.sources[0].metrics.blendedness, zeros
211 )
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.
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.
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)``.
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 )
254 mes.metrics.setDeblenderMetrics(blend)
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)
262def setup_module(module):
263 lsst.utils.tests.init()
266class MemoryTester(lsst.utils.tests.MemoryTestCase):
267 pass
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()