Coverage for tests/test_image.py: 86%
143 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-16 02:39 -0700
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-16 02:39 -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 dataclasses
15import os
16from typing import TYPE_CHECKING, Any
18import astropy.io.fits
19import astropy.units as u
20import numpy as np
21import pytest
23import lsst.utils.tests
24from lsst.images import Box, DetectorFrame, Image
25from lsst.images.tests import (
26 RoundtripFits,
27 RoundtripJson,
28 RoundtripNdf,
29 assert_images_equal,
30 assert_sky_projections_equal,
31 assert_values_equal,
32 compare_image_to_legacy,
33 make_random_sky_projection,
34 reset_afw_mask_planes, # noqa: F401
35)
37try:
38 import h5py # noqa: F401
40 HAVE_H5PY = True
41except ImportError:
42 HAVE_H5PY = False
44if TYPE_CHECKING:
45 try:
46 from lsst.afw.image import MaskedImageReader as LegacyMaskedImageReader
47 except ImportError:
48 type LegacyMaskedImageReader = Any # type: ignore[no-redef]
50EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None)
52skip_no_h5py = pytest.mark.skipif(not HAVE_H5PY, reason="h5py is not installed")
55@dataclasses.dataclass
56class _LegacyTestData:
57 image: Image
58 reader: LegacyMaskedImageReader
61@pytest.fixture
62def legacy_test_data(reset_afw_mask_planes: None) -> _LegacyTestData: # noqa: F811
63 """Return an Image read directly from the legacy test dataset and a legacy
64 reader for that image.
66 Skips if TESTDATA_IMAGES_DIR is unset or lsst.afw.image is unavailable.
67 """
68 # reset_afw_mask_planes will have already skipped if afw is not available.
69 from lsst.afw.image import MaskedImageFitsReader
71 if EXTERNAL_DATA_DIR is None: 71 ↛ 73line 71 didn't jump to line 73 because the condition on line 71 was always true
72 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.")
73 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "visit_image.fits")
74 det_frame = DetectorFrame(instrument="Inst", visit=1234, detector=1, bbox=Box.factory[1:4096, 1:4096])
75 image = Image.read_legacy(filename, preserve_quantization=True, fits_wcs_frame=det_frame)
76 reader = MaskedImageFitsReader(filename)
77 return _LegacyTestData(image, reader)
80def test_basics() -> None:
81 """Test basic Image constructor patterns and slicing."""
82 image = Image(42, shape=(5, 5), metadata={"three": 3})
83 assert_values_equal(image.array, np.zeros([5, 5], dtype=np.int64) + 42)
84 assert image.metadata["three"] == 3
86 data = np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]])
87 image = Image(data)
88 subset = image[Box.factory[:3, 1:3]]
89 subset2 = image.absolute[:3, 1:3]
90 assert_images_equal(subset2, subset, expect_view=True)
92 assert_images_equal(image.copy(), image, expect_view=False)
94 # Add an explicit bounding box and then slice it.
95 image = Image(data, bbox=Box.factory[-2:1, 10:14])
96 with pytest.raises(IndexError):
97 # Same slice no longer works in absolute slicing because we have
98 # moved origin.
99 image.absolute[:3, 1:3]
100 # That slice does still work in local coordinates.
101 assert_values_equal(image.local[:3, 1:3].array, subset2.array)
102 # And we can write an equivalent slice in absolute coordinates.
103 assert_values_equal(image.absolute[:0, 11:13].array, np.array([[2, 3], [6, 7]]))
105 # Test __eq__ behavior.
106 assert image[...] == image
107 assert image.__eq__(data) == NotImplemented
108 assert image != list(data)
110 with pytest.raises(ValueError, match="Explicit bbox shape"):
111 # bbox does not match array shape.
112 Image(np.array([[1, 2, 3], [4, 5, 6]]), bbox=Box.factory[0:2, 0:4])
114 with pytest.raises(ValueError, match="Explicit shape"):
115 # shape does not match array shape.
116 Image(np.array([[2, 3, 4], [6, 7, 8]]), shape=[5, 2])
118 with pytest.raises(TypeError):
119 # shape and bbox both None.
120 Image()
122 with pytest.raises(ValueError, match="does not match bbox shape"):
123 # Shape mismatch.
124 Image(shape=[3, 6], bbox=Box.factory[-5:10, 0:10])
127def test_json_roundtrip() -> None:
128 """Test saving a tiny image to pure JSON."""
129 image = Image(
130 np.arange(15).reshape(5, 3),
131 yx0=(2, -1),
132 )
133 with RoundtripJson(image, "ImageV2") as roundtrip:
134 pass
135 assert_images_equal(image, roundtrip.result)
138def test_fits_roundtrip() -> None:
139 """Test saving a tiny image to FITS generically."""
140 image = Image(
141 np.arange(15).reshape(5, 3),
142 yx0=(2, -1),
143 )
144 with RoundtripFits(image, "ImageV2") as roundtrip:
145 subbox = Box.factory[3:5, 0:1]
146 assert_images_equal(image[subbox], roundtrip.get(bbox=subbox))
147 assert_images_equal(image, roundtrip.result)
150@skip_no_h5py
151def test_ndf_roundtrip() -> None:
152 """Test saving a tiny image to NDF."""
153 image = Image(
154 np.arange(15).reshape(5, 3),
155 yx0=(2, -1),
156 )
157 with RoundtripNdf(image, "ImageV2") as roundtrip:
158 pass
159 assert_images_equal(image, roundtrip.result)
162@skip_no_h5py
163def test_fits_ndf_consistency() -> None:
164 """Verify FITS and NDF round-trips produce equal Images."""
165 rng = np.random.default_rng(321)
166 image = Image(
167 rng.normal(100.0, 8.0, size=(60, 80)),
168 dtype=np.float64,
169 unit=u.nJy,
170 yx0=(0, 0),
171 )
172 with RoundtripFits(image) as fits_rt, RoundtripNdf(image) as ndf_rt:
173 assert_images_equal(image, fits_rt.result)
174 assert_images_equal(image, ndf_rt.result)
175 assert_images_equal(fits_rt.result, ndf_rt.result)
178def test_fits_json_consistency() -> None:
179 """Verify FITS and JSON round-trips produce equal Images."""
180 rng = np.random.default_rng(321)
181 image = Image(
182 rng.normal(100.0, 8.0, size=(60, 80)),
183 dtype=np.float64,
184 unit=u.nJy,
185 yx0=(0, 0),
186 )
187 with RoundtripFits(image) as fits_rt, RoundtripJson(image) as json_rt:
188 assert_images_equal(image, fits_rt.result)
189 assert_images_equal(image, json_rt.result)
190 assert_images_equal(fits_rt.result, json_rt.result)
193def test_quantity() -> None:
194 """Test quantity getter and setter on Image."""
195 data = np.array([[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0], [9.0, 10.0, 11.0, 12.0]])
196 data2 = data.copy() * 2.0
197 image = Image(data, unit=u.mJy, bbox=Box.factory[-2:1, 3:7])
199 q = image.quantity
200 assert q[1, 0] == 5.0 * u.mJy
201 image.quantity = image.array * 10.0 * u.uJy
202 q = image.quantity
203 assert q[1, 0] == 0.05 * u.mJy
205 image2 = Image(data2, unit=u.Jy)
206 image[Box.factory[-1:0, 5:7]] = image2.local[1:2, 2:4]
207 assert_values_equal(
208 image.array,
209 np.array([[0.01, 0.02, 0.03, 0.04], [0.05, 0.06, 14000.0, 16000.0], [0.09, 0.1, 0.11, 0.12]]),
210 rtol=1e-5,
211 )
214def test_read_write() -> None:
215 """Test round-trip through file using GeneralizedImage.write / read."""
216 data = np.array([[1.0, 2.0, np.nan, 4.0], [5.0, 6.0, 7.0, 8.0], [9.0, 10.0, 11.0, 12.0]])
217 md: dict[str, Any] = {"int": 1, "float": 42.0, "bool": False, "long string header": "This is a string"}
218 det_frame = DetectorFrame(instrument="Inst", visit=1234, detector=1, bbox=Box.factory[1:4096, 1:4096])
219 rng = np.random.default_rng(500)
220 sky_projection = make_random_sky_projection(rng, det_frame, Box.factory[1:4096, 1:4096])
222 image = Image(
223 data,
224 unit=u.dn,
225 metadata=md,
226 bbox=Box.factory[-2:1, 3:7],
227 sky_projection=sky_projection,
228 )
230 with lsst.utils.tests.getTempFilePath(".fits") as tmpFile:
231 image.write(tmpFile)
233 new = Image.read(tmpFile)
234 assert new == image
236 # __eq__ does not test all components.
237 assert new.metadata == image.metadata
238 assert_sky_projections_equal(new.sky_projection, image.sky_projection, expect_identity=False)
240 # Read subset.
241 subset = Image.read(tmpFile, bbox=Box.factory[-2:0, 5:7])
242 assert subset == image.absolute[-2:0, 5:7]
243 assert subset == image.local[0:2, 2:4]
244 assert str(subset) == "Image([y=-2:0, x=5:7], float64)"
245 assert (
246 repr(subset) == "Image(..., bbox=Box(y=Interval(start=-2, stop=0), x=Interval(start=5, stop=7)), "
247 "dtype=dtype('float64'))"
248 )
250 # Check that WCS headers were written out.
251 with astropy.io.fits.open(tmpFile) as hdul:
252 hdu1 = hdul[1]
253 hdr1 = hdu1.header
254 assert hdr1["CTYPE1"] == "RA---TAN"
257def test_legacy(legacy_test_data: _LegacyTestData) -> None:
258 """Test Image.read_legacy, Image.to_legacy, and Image.from_legacy."""
259 legacy_image = legacy_test_data.reader.readImage()
260 compare_image_to_legacy(legacy_test_data.image, legacy_image, expect_view=False)
261 # Converting back to afw will not share memory, because
262 # preserve_quantization=True makes the array read-only and to_legacy
263 # has to copy in that case.
264 compare_image_to_legacy(legacy_test_data.image, legacy_test_data.image.to_legacy(), expect_view=False)
265 # Converting from afw will always share memory.
266 image_view = Image.from_legacy(legacy_image)
267 compare_image_to_legacy(image_view, legacy_image, expect_view=True)
268 # Converting back to afw from the in-memory view will be another view.
269 compare_image_to_legacy(image_view, image_view.to_legacy(), expect_view=True)
270 # Write the image out in the new format, and test that we can read it
271 # back either way.
272 with RoundtripFits(legacy_test_data.image, storage_class="ImageV2") as roundtrip:
273 pass
274 assert_images_equal(roundtrip.result, legacy_test_data.image, expect_view=False)
277def test_legacy_butler_read(legacy_test_data: _LegacyTestData) -> None:
278 """Test that a round-tripped ImageV2 can be read back as a legacy afw
279 Image via Butler.
280 """
281 with RoundtripFits(legacy_test_data.image, storage_class="ImageV2") as roundtrip:
282 legacy_image = roundtrip.get(storageClass="Image")
283 assert isinstance(legacy_image, lsst.afw.image.Image)
284 compare_image_to_legacy(legacy_test_data.image, legacy_image, expect_view=False)
287def test_image_repr_str_pinned() -> None:
288 """Image str/repr match their documented forms."""
289 image = Image(np.zeros((5, 4), dtype=np.float32), bbox=Box.factory[0:5, 0:4])
290 assert str(image) == f"Image({image.bbox!s}, float32)"
291 assert repr(image) == f"Image(..., bbox={image.bbox!r}, dtype={image.array.dtype!r})"