Coverage for tests/test_psfs.py: 50%
118 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -0700
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -0700
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
22from lsst.images.psfs import GaussianPointSpreadFunction, PiffWrapper, PointSpreadFunction, PSFExWrapper
23from lsst.images.psfs._piff import _ArchivePiffWriter
24from lsst.images.tests import RoundtripFits, RoundtripJson, RoundtripNdf, compare_psf_to_legacy
26try:
27 import h5py # noqa: F401
29 HAVE_H5PY = True
30except ImportError:
31 HAVE_H5PY = False
33try:
34 from lsst.afw.detection import Psf as LegacyPsf
35except ImportError:
36 type LegacyPsf = Any # type: ignore[no-redef]
38EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None)
40skip_no_h5py = pytest.mark.skipif(not HAVE_H5PY, reason="h5py is not installed")
43@pytest.fixture(scope="session")
44def legacy_piff_psf_and_bbox() -> tuple[LegacyPsf, Box]:
45 """Return a legacy-wrapped Piff PSF and its bounding box.
47 Skips if TESTDATA_IMAGES_DIR is unset, piff is unavailable, or afw is
48 unavailable.
49 """
50 if EXTERNAL_DATA_DIR is None: 50 ↛ 52line 50 didn't jump to line 52 because the condition on line 50 was always true
51 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.")
52 try:
53 import piff # noqa: F401
55 from lsst.afw.image import ExposureFitsReader
56 except ImportError:
57 pytest.skip("'piff' or 'lsst.afw.image' could not be imported.")
58 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "visit_image.fits")
59 reader = ExposureFitsReader(filename)
60 legacy_psf = reader.readPsf()
61 bounds = Box.from_legacy(reader.readBBox())
62 return legacy_psf, bounds
65@pytest.fixture(scope="session")
66def legacy_psfex_psf_and_bbox() -> tuple[LegacyPsf, Box]:
67 """Return a legacy PSFEx PSF and its bounding box
69 Skips if TESTDATA_IMAGES_DIR is unset, afw is unavailable, or psfex is
70 unavailable.
71 """
72 if EXTERNAL_DATA_DIR is None: 72 ↛ 74line 72 didn't jump to line 74 because the condition on line 72 was always true
73 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.")
74 try:
75 from lsst.afw.image import ExposureFitsReader
76 from lsst.meas.extensions.psfex import PsfexPsf # noqa: F401
77 except ImportError:
78 pytest.skip("'lsst.afw.image' or 'lsst.meas.extensions.psfex' could not be imported.")
79 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "preliminary_visit_image.fits")
80 reader = ExposureFitsReader(filename)
81 legacy_psf = reader.readPsf()
82 bounds = Box.from_legacy(reader.readBBox())
83 return legacy_psf, bounds
86def test_gaussian() -> None:
87 """Test the built-in Gaussian PSF implementation."""
88 bounds = Box.factory[-1024:1024, -2048:2048]
89 psf = GaussianPointSpreadFunction(2.5, bounds=bounds, stamp_size=33)
90 assert psf.bounds == bounds
92 kernel = psf.compute_kernel_image(x=5.0, y=3.0)
93 assert kernel.bbox == psf.kernel_bbox
94 assert abs(float(kernel.array.sum()) - 1.0) < 1e-6
95 center = kernel.array.shape[0] // 2
96 assert np.unravel_index(np.argmax(kernel.array), kernel.array.shape) == (center, center)
98 stellar = psf.compute_stellar_image(x=5.25, y=3.75)
99 assert stellar.bbox == psf.compute_stellar_bbox(x=5.25, y=3.75)
100 assert abs(float(stellar.array.sum()) - 1.0) < 1e-6
101 assert stellar.array[center - 1, center] > stellar.array[center + 1, center]
102 assert stellar.array[center, center] > stellar.array[center, center - 1]
103 assert stellar.array[center, center] > stellar.array[center - 1, center]
105 with RoundtripFits(psf) as roundtrip:
106 assert roundtrip.result == psf, f"{roundtrip.result} != {psf}"
108 with pytest.raises(ValueError):
109 # Even stamp size.
110 GaussianPointSpreadFunction(2.5, bounds=bounds, stamp_size=32)
112 with pytest.raises(ValueError):
113 # Negative stamp size.
114 GaussianPointSpreadFunction(2.5, bounds=bounds, stamp_size=-33)
116 with pytest.raises(ValueError):
117 # Negative sigma.
118 GaussianPointSpreadFunction(-2.5, bounds=bounds, stamp_size=33)
121def test_piff_writer_normalizes_tuple_metadata(): # intentionally untyped
122 """Test that Piff metadata is normalized to JSON-like values."""
123 writer = _ArchivePiffWriter()
124 writer.write_struct(
125 "interp",
126 {
127 "keys": ("u", "v"),
128 "scale": np.float64(1.5),
129 "flags": [np.bool_(True), np.int64(3)],
130 },
131 )
132 model = writer.serialize(None) # type: ignore[arg-type]
133 assert model.structs["interp"]["keys"] == ["u", "v"]
134 assert model.structs["interp"]["scale"] == 1.5
135 assert model.structs["interp"]["flags"] == [True, 3]
136 with warnings.catch_warnings():
137 warnings.simplefilter("error", UserWarning)
138 model.model_dump_json()
141def test_piff(legacy_piff_psf_and_bbox: tuple[LegacyPsf, Box]) -> None:
142 """Test round-tripping a legacy Piff PSF through FITS, JSON, and NDF
143 archives.
144 """
145 from piff import PSF
147 legacy_psf, bounds = legacy_piff_psf_and_bbox
148 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds)
149 assert isinstance(psf, PiffWrapper)
150 assert psf.bounds == bounds
151 assert isinstance(psf.piff_psf, PSF)
152 compare_psf_to_legacy(psf, legacy_psf)
153 with RoundtripFits(psf) as roundtrip1:
154 pass
155 compare_psf_to_legacy(roundtrip1.result, legacy_psf)
156 with RoundtripJson(psf) as roundtrip2:
157 pass
158 compare_psf_to_legacy(roundtrip2.result, legacy_psf)
159 if not HAVE_H5PY:
160 pytest.skip("h5py is not available.")
161 with RoundtripNdf(psf) as roundtrip3:
162 pass
163 compare_psf_to_legacy(roundtrip3.result, legacy_psf)
164 legacy_psf_2 = roundtrip1.result.to_legacy()
165 compare_psf_to_legacy(psf, legacy_psf_2)
166 assert legacy_psf.getAveragePosition() == legacy_psf_2.getAveragePosition()
169def test_psfex(legacy_psfex_psf_and_bbox: tuple[LegacyPsf, Box]) -> None:
170 """Test wrapping a legacy PSFEx PSF and round-tripping through FITS and
171 JSON.
172 """
173 from lsst.meas.extensions.psfex import PsfexPsf
175 legacy_psf, bounds = legacy_psfex_psf_and_bbox
176 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds)
177 assert isinstance(psf, PSFExWrapper)
178 assert psf.bounds == bounds
179 assert isinstance(psf.legacy_psf, PsfexPsf)
180 compare_psf_to_legacy(psf, legacy_psf)
181 with RoundtripFits(psf) as roundtrip1:
182 pass
183 compare_psf_to_legacy(roundtrip1.result, legacy_psf)
184 with RoundtripJson(psf) as roundtrip2:
185 pass
186 compare_psf_to_legacy(roundtrip2.result, legacy_psf)