Coverage for tests/test_image.py: 83%

142 statements  

« 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. 

11 

12from __future__ import annotations 

13 

14import dataclasses 

15import os 

16from typing import TYPE_CHECKING, Any 

17 

18import astropy.io.fits 

19import astropy.units as u 

20import numpy as np 

21import pytest 

22 

23import lsst.utils.tests 

24from lsst.images import Box, DetectorFrame, Image 

25from lsst.images.tests import ( 

26 RoundtripFits, 

27 RoundtripJson, 

28 RoundtripNdf, 

29 assert_close, 

30 assert_images_equal, 

31 assert_sky_projections_equal, 

32 compare_image_to_legacy, 

33 make_random_sky_projection, 

34) 

35 

36try: 

37 import h5py # noqa: F401 

38 

39 HAVE_H5PY = True 

40except ImportError: 

41 HAVE_H5PY = False 

42 

43if TYPE_CHECKING: 

44 try: 

45 from lsst.afw.image import MaskedImageReader as LegacyMaskedImageReader 

46 except ImportError: 

47 type LegacyMaskedImageReader = Any # type: ignore[no-redef] 

48 

49EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None) 

50 

51skip_no_h5py = pytest.mark.skipif(not HAVE_H5PY, reason="h5py is not installed") 

52 

53 

54@dataclasses.dataclass 

55class _LegacyTestData: 

56 image: Image 

57 reader: LegacyMaskedImageReader 

58 

59 

60@pytest.fixture(scope="session") 

61def legacy_test_data() -> _LegacyTestData: 

62 """Return an Image read directly from the legacy test dataset and a legacy 

63 reader for that image. 

64 

65 Skips if TESTDATA_IMAGES_DIR is unset or lsst.afw.image is unavailable. 

66 """ 

67 if EXTERNAL_DATA_DIR is None: 67 ↛ 69line 67 didn't jump to line 69 because the condition on line 67 was always true

68 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.") 

69 try: 

70 from lsst.afw.image import MaskedImageFitsReader 

71 except ImportError: 

72 pytest.skip("'lsst.afw.image' could not be imported.") 

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) 

78 

79 

80def test_basics() -> None: 

81 """Test basic Image constructor patterns and slicing.""" 

82 image = Image(42, shape=(5, 5), metadata={"three": 3}) 

83 assert_close(image.array, np.zeros([5, 5], dtype=np.int64) + 42) 

84 assert image.metadata["three"] == 3 

85 

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) 

91 

92 assert_images_equal(image.copy(), image, expect_view=False) 

93 

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_close(image.local[:3, 1:3].array, subset2.array) 

102 # And we can write an equivalent slice in absolute coordinates. 

103 assert_close(image.absolute[:0, 11:13].array, np.array([[2, 3], [6, 7]])) 

104 

105 # Test __eq__ behavior. 

106 assert image[...] == image 

107 assert image.__eq__(data) == NotImplemented 

108 assert image != list(data) 

109 

110 with pytest.raises(ValueError): 

111 # bbox does not match array shape. 

112 Image(np.array([[1, 2, 3], [4, 5, 6]]), bbox=Box.factory[0:2, 0:4]) 

113 

114 with pytest.raises(ValueError): 

115 # shape does not match array shape. 

116 Image(np.array([[2, 3, 4], [6, 7, 8]]), shape=[5, 2]) 

117 

118 with pytest.raises(TypeError): 

119 # shape and bbox both None. 

120 Image() 

121 

122 with pytest.raises(ValueError): 

123 # Shape mismatch. 

124 Image(shape=[3, 6], bbox=Box.factory[-5:10, 0:10]) 

125 

126 

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) 

136 

137 

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) 

148 

149 

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) 

160 

161 

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) 

176 

177 

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) 

191 

192 

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]) 

198 

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 

204 

205 image2 = Image(data2, unit=u.Jy) 

206 image[Box.factory[-1:0, 5:7]] = image2.local[1:2, 2:4] 

207 assert_close( 

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 ) 

211 

212 

213def test_read_write() -> None: 

214 """Test round-trip through file using GeneralizedImage.write / read.""" 

215 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]]) 

216 md: dict[str, Any] = {"int": 1, "float": 42.0, "bool": False, "long string header": "This is a string"} 

217 det_frame = DetectorFrame(instrument="Inst", visit=1234, detector=1, bbox=Box.factory[1:4096, 1:4096]) 

218 rng = np.random.default_rng(500) 

219 sky_projection = make_random_sky_projection(rng, det_frame, Box.factory[1:4096, 1:4096]) 

220 

221 image = Image( 

222 data, 

223 unit=u.dn, 

224 metadata=md, 

225 bbox=Box.factory[-2:1, 3:7], 

226 sky_projection=sky_projection, 

227 ) 

228 

229 with lsst.utils.tests.getTempFilePath(".fits") as tmpFile: 

230 image.write(tmpFile) 

231 

232 new = Image.read(tmpFile) 

233 assert new == image 

234 

235 # __eq__ does not test all components. 

236 assert new.metadata == image.metadata 

237 assert_sky_projections_equal(new.sky_projection, image.sky_projection, expect_identity=False) 

238 

239 # Read subset. 

240 subset = Image.read(tmpFile, bbox=Box.factory[-2:0, 5:7]) 

241 assert subset == image.absolute[-2:0, 5:7] 

242 assert subset == image.local[0:2, 2:4] 

243 assert str(subset) == "Image([y=-2:0, x=5:7], float64)" 

244 assert ( 

245 repr(subset) == "Image(..., bbox=Box(y=Interval(start=-2, stop=0), x=Interval(start=5, stop=7)), " 

246 "dtype=dtype('float64'))" 

247 ) 

248 

249 # Check that WCS headers were written out. 

250 with astropy.io.fits.open(tmpFile) as hdul: 

251 hdu1 = hdul[1] 

252 hdr1 = hdu1.header 

253 assert hdr1["CTYPE1"] == "RA---TAN" 

254 

255 

256def test_legacy(legacy_test_data: _LegacyTestData) -> None: 

257 """Test Image.read_legacy, Image.to_legacy, and Image.from_legacy.""" 

258 legacy_image = legacy_test_data.reader.readImage() 

259 compare_image_to_legacy(legacy_test_data.image, legacy_image, expect_view=False) 

260 # Converting back to afw will not share memory, because 

261 # preserve_quantization=True makes the array read-only and to_legacy 

262 # has to copy in that case. 

263 compare_image_to_legacy(legacy_test_data.image, legacy_test_data.image.to_legacy(), expect_view=False) 

264 # Converting from afw will always share memory. 

265 image_view = Image.from_legacy(legacy_image) 

266 compare_image_to_legacy(image_view, legacy_image, expect_view=True) 

267 # Converting back to afw from the in-memory view will be another view. 

268 compare_image_to_legacy(image_view, image_view.to_legacy(), expect_view=True) 

269 # Write the image out in the new format, and test that we can read it 

270 # back either way. 

271 with RoundtripFits(legacy_test_data.image, storage_class="ImageV2") as roundtrip: 

272 pass 

273 assert_images_equal(roundtrip.result, legacy_test_data.image, expect_view=False) 

274 

275 

276def test_legacy_butler_read(legacy_test_data: _LegacyTestData) -> None: 

277 """Test that a round-tripped ImageV2 can be read back as a legacy afw 

278 Image via Butler. 

279 """ 

280 with RoundtripFits(legacy_test_data.image, storage_class="ImageV2") as roundtrip: 

281 legacy_image = roundtrip.get(storageClass="Image") 

282 assert isinstance(legacy_image, lsst.afw.image.Image) 

283 compare_image_to_legacy(legacy_test_data.image, legacy_image, expect_view=False)