Coverage for tests/test_psfs.py: 65%
175 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 09:44 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 09:44 +0000
1# This file is part of lsst-images.
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# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
12from __future__ import annotations
14import os
15import warnings
16from typing import Any
18import numpy as np
19import pytest
21from lsst.images import Box, Image
22from lsst.images.describe import DescribableMixin, Report
23from lsst.images.psfs import GaussianPointSpreadFunction, PiffWrapper, PointSpreadFunction, PSFExWrapper
24from lsst.images.psfs._piff import _ArchivePiffWriter
25from lsst.images.tests import (
26 RoundtripFits,
27 RoundtripJson,
28 RoundtripNdf,
29 compare_psf_to_legacy,
30 reset_afw_mask_planes, # noqa: F401
31)
33try:
34 import h5py # noqa: F401
36 HAVE_H5PY = True
37except ImportError:
38 HAVE_H5PY = False
40try:
41 from lsst.afw.detection import Psf as LegacyPsf
42except ImportError:
43 type LegacyPsf = Any # type: ignore[no-redef]
45EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None)
47skip_no_h5py = pytest.mark.skipif(not HAVE_H5PY, reason="h5py is not installed")
50@pytest.fixture
51def legacy_piff_psf_and_bbox(reset_afw_mask_planes: None) -> tuple[LegacyPsf, Box]: # noqa: F811
52 """Return a legacy-wrapped Piff PSF and its bounding box.
54 Skips if TESTDATA_IMAGES_DIR is unset, piff is unavailable, or afw is
55 unavailable.
56 """
57 # reset_afw_mask_planes will have already skipped if afw is not available.
58 from lsst.afw.image import ExposureFitsReader
60 if EXTERNAL_DATA_DIR is None: 60 ↛ 62line 60 didn't jump to line 62 because the condition on line 60 was always true
61 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.")
62 try:
63 import piff # noqa: F401
64 except ImportError:
65 pytest.skip("'piff' could not be imported.")
66 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "visit_image.fits")
67 reader = ExposureFitsReader(filename)
68 legacy_psf = reader.readPsf()
69 bounds = Box.from_legacy(reader.readBBox())
70 return legacy_psf, bounds
73@pytest.fixture
74def legacy_psfex_psf_and_bbox(reset_afw_mask_planes: None) -> tuple[LegacyPsf, Box]: # noqa: F811
75 """Return a legacy PSFEx PSF and its bounding box
77 Skips if TESTDATA_IMAGES_DIR is unset, afw is unavailable, or psfex is
78 unavailable.
79 """
80 if EXTERNAL_DATA_DIR is None: 80 ↛ 82line 80 didn't jump to line 82 because the condition on line 80 was always true
81 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.")
82 try:
83 from lsst.afw.image import ExposureFitsReader
84 from lsst.meas.extensions.psfex import PsfexPsf # noqa: F401
85 except ImportError:
86 pytest.skip("'lsst.afw.image' or 'lsst.meas.extensions.psfex' could not be imported.")
87 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "preliminary_visit_image.fits")
88 reader = ExposureFitsReader(filename)
89 legacy_psf = reader.readPsf()
90 bounds = Box.from_legacy(reader.readBBox())
91 return legacy_psf, bounds
94def test_gaussian_psf_repr_pinned() -> None:
95 """GaussianPointSpreadFunction repr matches its documented form."""
96 psf = GaussianPointSpreadFunction(2.5, bounds=Box.factory[0:10, 0:10], stamp_size=21)
97 assert repr(psf) == f"GaussianPointSpreadFunction(2.5, stamp_size=21, bounds={psf.bounds!r})"
100def test_gaussian_psf_describe() -> None:
101 """GaussianPointSpreadFunction._describe returns a Report."""
102 psf = GaussianPointSpreadFunction(2.5, bounds=Box.factory[0:10, 0:10], stamp_size=21)
103 assert isinstance(psf, DescribableMixin)
104 report = psf._describe()
105 assert isinstance(report, Report)
106 assert report.type_name == "GaussianPointSpreadFunction"
107 labels = {f.label for f in report.fields}
108 assert "sigma" in labels
109 assert "stamp_size" in labels
110 assert "bounds" in labels
113def test_psf_base_describe() -> None:
114 """Base _describe uses type(self).__name__ and includes bounds/kernel_bbox.
116 GaussianPointSpreadFunction overrides _describe, so a minimal concrete
117 subclass is used to exercise the actual base implementation.
118 """
120 class _MinimalPSF(PointSpreadFunction):
121 """Minimal concrete subclass that exercises the base _describe path."""
123 @property
124 def bounds(self) -> Box:
125 return Box.factory[-5:5, -5:5]
127 @property
128 def kernel_bbox(self) -> Box:
129 return Box.factory[-2:3, -2:3]
131 def compute_kernel_image(self, *, x: float, y: float) -> Image:
132 arr = np.zeros((5, 5))
133 arr[2, 2] = 1.0
134 return Image(arr, bbox=self.kernel_bbox)
136 def compute_stellar_image(self, *, x: float, y: float) -> Image:
137 return self.compute_kernel_image(x=x, y=y)
139 def compute_stellar_bbox(self, *, x: float, y: float) -> Box:
140 return self.kernel_bbox
142 psf = _MinimalPSF()
143 report = psf._describe()
144 assert report.type_name == "_MinimalPSF"
145 labels = {f.label for f in report.fields}
146 assert "bounds" in labels
147 assert "kernel_bbox" in labels
150def test_gaussian() -> None:
151 """Test the built-in Gaussian PSF implementation."""
152 bounds = Box.factory[-1024:1024, -2048:2048]
153 psf = GaussianPointSpreadFunction(2.5, bounds=bounds, stamp_size=33)
154 assert psf.bounds == bounds
156 kernel = psf.compute_kernel_image(x=5.0, y=3.0)
157 assert kernel.bbox == psf.kernel_bbox
158 assert abs(float(kernel.array.sum()) - 1.0) < 1e-6
159 center = kernel.array.shape[0] // 2
160 assert np.unravel_index(np.argmax(kernel.array), kernel.array.shape) == (center, center)
162 stellar = psf.compute_stellar_image(x=5.25, y=3.75)
163 assert stellar.bbox == psf.compute_stellar_bbox(x=5.25, y=3.75)
164 assert abs(float(stellar.array.sum()) - 1.0) < 1e-6
165 assert stellar.array[center - 1, center] > stellar.array[center + 1, center]
166 assert stellar.array[center, center] > stellar.array[center, center - 1]
167 assert stellar.array[center, center] > stellar.array[center - 1, center]
169 with RoundtripFits(psf) as roundtrip:
170 assert roundtrip.result == psf, f"{roundtrip.result} != {psf}"
172 with pytest.raises(ValueError, match="stamp_size must be odd"):
173 # Even stamp size.
174 GaussianPointSpreadFunction(2.5, bounds=bounds, stamp_size=32)
176 with pytest.raises(ValueError, match="stamp_size must be positive"):
177 # Negative stamp size.
178 GaussianPointSpreadFunction(2.5, bounds=bounds, stamp_size=-33)
180 with pytest.raises(ValueError, match="sigma must be positive"):
181 # Negative sigma.
182 GaussianPointSpreadFunction(-2.5, bounds=bounds, stamp_size=33)
185def test_gaussian_from_legacy(reset_afw_mask_planes: None) -> None: # noqa: F811
186 """Test converting a legacy GaussianPsf to and from
187 GaussianPointSpreadFunction.
188 """
189 # reset_afw_mask_planes will have already skipped if afw is not available.
190 from lsst.afw.detection import GaussianPsf
191 from lsst.geom import Point2D
193 bounds = Box.factory[0:64, 0:48]
194 legacy_psf = GaussianPsf(33, 33, 2.5)
195 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds)
196 assert isinstance(psf, GaussianPointSpreadFunction)
197 assert psf == GaussianPointSpreadFunction(2.5, bounds=bounds, stamp_size=33)
198 # The kernels match to round-off, so compare_psf_to_legacy (which checks
199 # exact equality) cannot be used.
200 for x, y in [(10.0, 10.0), (10.3, 20.7)]:
201 legacy_kernel = Image.from_legacy(legacy_psf.computeKernelImage(Point2D(x, y)))
202 kernel = psf.compute_kernel_image(x=x, y=y)
203 assert kernel.bbox == legacy_kernel.bbox
204 np.testing.assert_allclose(kernel.array, legacy_kernel.array, rtol=0, atol=1e-15)
205 legacy_psf_2 = psf.to_legacy()
206 assert isinstance(legacy_psf_2, GaussianPsf)
207 assert legacy_psf_2.getSigma() == legacy_psf.getSigma()
208 assert legacy_psf_2.getDimensions() == legacy_psf.getDimensions()
210 with pytest.raises(ValueError, match="must be square"):
211 PointSpreadFunction.from_legacy(GaussianPsf(33, 31, 2.5), bounds)
214def test_piff_writer_normalizes_tuple_metadata(): # intentionally untyped
215 """Test that Piff metadata is normalized to JSON-like values."""
216 writer = _ArchivePiffWriter()
217 writer.write_struct(
218 "interp",
219 {
220 "keys": ("u", "v"),
221 "scale": np.float64(1.5),
222 "flags": [np.bool_(True), np.int64(3)],
223 },
224 )
225 model = writer.serialize(None) # type: ignore[arg-type]
226 assert model.structs["interp"]["keys"] == ["u", "v"]
227 assert model.structs["interp"]["scale"] == 1.5
228 assert model.structs["interp"]["flags"] == [True, 3]
229 with warnings.catch_warnings():
230 warnings.simplefilter("error", UserWarning)
231 model.model_dump_json()
234def test_piff(legacy_piff_psf_and_bbox: tuple[LegacyPsf, Box]) -> None:
235 """Test round-tripping a legacy Piff PSF through FITS and JSON archives,
236 and converting it back to a legacy PSF.
237 """
238 from piff import PSF
240 legacy_psf, bounds = legacy_piff_psf_and_bbox
241 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds)
242 assert isinstance(psf, PiffWrapper)
243 assert psf.bounds == bounds
244 assert isinstance(psf.piff_psf, PSF)
245 compare_psf_to_legacy(psf, legacy_psf)
246 with RoundtripFits(psf) as roundtrip1:
247 pass
248 compare_psf_to_legacy(roundtrip1.result, legacy_psf)
249 with RoundtripJson(psf) as roundtrip2:
250 pass
251 compare_psf_to_legacy(roundtrip2.result, legacy_psf)
252 legacy_psf_2 = roundtrip1.result.to_legacy()
253 compare_psf_to_legacy(psf, legacy_psf_2)
254 assert legacy_psf.getAveragePosition() == legacy_psf_2.getAveragePosition()
257@skip_no_h5py
258def test_piff_ndf_roundtrip(legacy_piff_psf_and_bbox: tuple[LegacyPsf, Box]) -> None:
259 """Test round-tripping a legacy Piff PSF through an NDF archive."""
260 legacy_psf, bounds = legacy_piff_psf_and_bbox
261 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds)
262 with RoundtripNdf(psf) as roundtrip:
263 pass
264 compare_psf_to_legacy(roundtrip.result, legacy_psf)
267def test_psfex(legacy_psfex_psf_and_bbox: tuple[LegacyPsf, Box]) -> None:
268 """Test wrapping a legacy PSFEx PSF and round-tripping through FITS and
269 JSON.
270 """
271 from lsst.meas.extensions.psfex import PsfexPsf
273 legacy_psf, bounds = legacy_psfex_psf_and_bbox
274 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds)
275 assert isinstance(psf, PSFExWrapper)
276 assert psf.bounds == bounds
277 assert isinstance(psf.legacy_psf, PsfexPsf)
278 compare_psf_to_legacy(psf, legacy_psf)
279 with RoundtripFits(psf) as roundtrip1:
280 pass
281 compare_psf_to_legacy(roundtrip1.result, legacy_psf)
282 with RoundtripJson(psf) as roundtrip2:
283 pass
284 compare_psf_to_legacy(roundtrip2.result, legacy_psf)