Coverage for tests/test_masked_image.py: 90%

180 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 pathlib import Path 

17from typing import Any 

18 

19import astropy.io.fits 

20import astropy.units as u 

21import numpy as np 

22import pytest 

23 

24from lsst.images import Box, Image, MaskedImage, MaskPlane, MaskSchema, get_legacy_visit_image_mask_planes 

25from lsst.images.fits import FitsCompressionOptions 

26from lsst.images.tests import ( 

27 RoundtripFits, 

28 RoundtripJson, 

29 RoundtripNdf, 

30 assert_masked_images_equal, 

31 compare_masked_image_to_legacy, 

32) 

33 

34try: 

35 import h5py # noqa: F401 

36 

37 HAVE_H5PY = True 

38except ImportError: 

39 HAVE_H5PY = False 

40 

41try: 

42 from lsst.afw.image import MaskedImageReader as LegacyMaskedImageReader 

43 

44except ImportError: 

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

46 

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

48 

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

50 

51 

52@dataclasses.dataclass 

53class _LegacyTestData: 

54 masked_image: MaskedImage 

55 reader: LegacyMaskedImageReader 

56 plane_map: dict[str, MaskPlane] 

57 

58 

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

60def legacy_test_data() -> _LegacyTestData: 

61 """Return a Mask read directly from the legacy test dataset and a legacy 

62 reader for that image. 

63 

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

65 """ 

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

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

68 try: 

69 from lsst.afw.image import MaskedImageFitsReader 

70 except ImportError: 

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

72 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "visit_image.fits") 

73 plane_map = get_legacy_visit_image_mask_planes() 

74 masked_image = MaskedImage.read_legacy(filename, plane_map=plane_map) 

75 reader = MaskedImageFitsReader(filename) 

76 return _LegacyTestData(masked_image=masked_image, reader=reader, plane_map=plane_map) 

77 

78 

79def make_masked_image() -> MaskedImage: 

80 """Return a freshly-constructed MaskedImage with BAD and HUNGRY mask 

81 planes set. 

82 """ 

83 rng = np.random.default_rng(500) 

84 masked_image = MaskedImage( 

85 Image(rng.normal(100.0, 8.0, size=(200, 251)), dtype=np.float64, unit=u.nJy, yx0=(5, 8)), 

86 mask_schema=MaskSchema( 

87 [ 

88 MaskPlane("BAD", "Pixel is very bad, possibly downright evil."), 

89 MaskPlane("HUNGRY", "Pixel hasn't had enough to eat today."), 

90 ] 

91 ), 

92 metadata={"fifty": "5 * 10"}, 

93 ) 

94 masked_image.mask.array |= np.multiply.outer( 

95 masked_image.image.array < 102.0, 

96 masked_image.mask.schema.bitmask("BAD"), 

97 ) 

98 masked_image.mask.array |= np.multiply.outer( 

99 masked_image.image.array > 98.0, 

100 masked_image.mask.schema.bitmask("HUNGRY"), 

101 ) 

102 masked_image.variance.array = rng.normal(64.0, 0.5, size=masked_image.bbox.shape) 

103 return masked_image 

104 

105 

106def test_construction() -> None: 

107 """Verify the MaskedImage constructed by make_masked_image has the 

108 expected attributes. 

109 """ 

110 mi = make_masked_image() 

111 assert mi.bbox == Box.factory[5:205, 8:259] 

112 assert mi.mask.bbox == mi.bbox 

113 assert mi.variance.bbox == mi.bbox 

114 assert mi.image.array.shape == mi.bbox.shape 

115 assert mi.mask.array.shape == mi.bbox.shape + (1,) 

116 assert mi.variance.array.shape == mi.bbox.shape 

117 assert mi.unit == u.nJy 

118 assert mi.variance.unit == u.nJy**2 

119 assert mi.metadata == {"fifty": "5 * 10"} 

120 # The checks below are subject to the vagaries of the RNG, but we want 

121 # the seed to be such that they all pass, or other tests will be weaker. 

122 assert np.sum(mi.mask.array == mi.mask.schema.bitmask("BAD")) > 0 

123 assert np.sum(mi.mask.array == mi.mask.schema.bitmask("HUNGRY")) > 0 

124 assert np.sum(mi.mask.array == mi.mask.schema.bitmask("BAD", "HUNGRY")) > 0 

125 

126 assert mi[...] is not mi 

127 assert str(mi) == "MaskedImage(Image([y=5:205, x=8:259], float64), ['BAD', 'HUNGRY'])" 

128 assert ( 

129 repr(mi) 

130 == "MaskedImage(Image(..., bbox=Box(y=Interval(start=5, stop=205), x=Interval(start=8, stop=259)), " 

131 "dtype=dtype('float64')), mask_schema=MaskSchema([MaskPlane(name='BAD', description='Pixel is " 

132 "very bad, possibly downright evil.'), MaskPlane(name='HUNGRY', description=\"Pixel hasn't had " 

133 "enough to eat today.\")], dtype=dtype('uint8')))" 

134 ) 

135 copy = mi.copy() 

136 original = mi.image.array[0, 0] 

137 copy.image.array[0, 0] = 38.0 

138 assert mi.image.array[0, 0] == original 

139 assert copy.image.array[0, 0] == 38.0 

140 

141 # Test error conditions. 

142 with pytest.raises(ValueError): 

143 # Disagreement over mask bbox. 

144 MaskedImage(Image(42.0, shape=(5, 6)), mask=mi.mask) 

145 with pytest.raises(TypeError): 

146 # No mask definition. 

147 MaskedImage(mi.image, variance=mi.variance) 

148 with pytest.raises(TypeError): 

149 # Can not provide mask and mask schema. 

150 MaskedImage( 

151 Image(42.0, shape=(5, 5)), 

152 mask=mi.mask, 

153 mask_schema=mi.mask.schema, 

154 ) 

155 with pytest.raises(ValueError): 

156 # image and variance bbox disagreement. 

157 MaskedImage( 

158 Image(42.0, shape=(5, 5)), 

159 mask_schema=mi.mask.schema, 

160 variance=mi.variance, 

161 ) 

162 with pytest.raises(ValueError): 

163 # no image unit but there is variance unit. 

164 MaskedImage( 

165 Image(42.0, shape=(5, 5)), 

166 mask_schema=mi.mask.schema, 

167 variance=Image(1.0, shape=(5, 5), unit=u.nJy), 

168 ) 

169 with pytest.raises(ValueError): 

170 # image and variance units disagree. 

171 MaskedImage( 

172 Image(42.0, shape=(5, 5), unit=u.nJy), 

173 mask_schema=mi.mask.schema, 

174 variance=Image(1.0, shape=(5, 5), unit=u.nJy), 

175 ) 

176 

177 

178def test_subset() -> None: 

179 """Verify assignment of a subset into a MaskedImage copy.""" 

180 mi = make_masked_image() 

181 copy = mi.copy() 

182 subset = copy.local[0:10, 20:30].copy() 

183 subset.image[...] = Image(42.0, shape=(10, 10), unit=u.nJy) 

184 copy[subset.bbox] = subset 

185 assert copy.image.array[0, 20] == 42.0 

186 assert copy.image.array[0, 0] == mi.image.array[0, 0] 

187 

188 

189def test_mask_setter() -> None: 

190 """Verify the mask plane can be replaced with one grown by add_plane.""" 

191 mi = make_masked_image() 

192 bad = mi.mask.get("BAD") 

193 mi.mask = mi.mask.add_plane("OUTSIDE_STENCIL", "Pixel lies outside the stencil.") 

194 assert "OUTSIDE_STENCIL" in mi.mask.schema.names 

195 assert mi.mask.bbox == mi.image.bbox 

196 np.testing.assert_array_equal(mi.mask.get("BAD"), bad) 

197 assert not mi.mask.get("OUTSIDE_STENCIL").any() 

198 # A mask whose bounding box disagrees with the image is rejected. 

199 with pytest.raises(ValueError): 

200 mi.mask = mi.mask[Box.factory[10:20, 12:22]] 

201 

202 

203def test_fits_roundtrip() -> None: 

204 """Verify MaskedImage round-trips correctly through FITS, including 

205 subimage reads. 

206 """ 

207 mi = make_masked_image() 

208 subbox = Box.factory[11:20, 25:30] 

209 subslices = (slice(6, 15), slice(17, 22)) 

210 np.testing.assert_array_equal(mi.image.array[subslices], mi.image[subbox].array) 

211 with RoundtripFits(mi, "MaskedImageV2") as roundtrip: 

212 subimage = roundtrip.get(bbox=subbox) 

213 # Check that we used lossless compression (the default). 

214 fits = roundtrip.inspect() 

215 assert fits[1].header["ZCMPTYPE"] == "GZIP_2" 

216 assert fits[2].header["ZCMPTYPE"] == "GZIP_2" 

217 assert fits[3].header["ZCMPTYPE"] == "GZIP_2" 

218 assert_masked_images_equal(roundtrip.result, mi, expect_view=False) 

219 assert_masked_images_equal(subimage, roundtrip.result[subbox], expect_view=False) 

220 

221 

222def test_fits_roundtrip_legacy_read() -> None: 

223 """Verify a round-tripped MaskedImageV2 can be read back as a legacy afw 

224 MaskedImage. 

225 """ 

226 try: 

227 import lsst.afw.image 

228 except ImportError: 

229 pytest.skip("afw could not be imported") 

230 mi = make_masked_image() 

231 with RoundtripFits(mi, "MaskedImageV2") as roundtrip: 

232 legacy_masked_image = roundtrip.get(storageClass="MaskedImage") 

233 assert isinstance(legacy_masked_image, lsst.afw.image.MaskedImage) 

234 compare_masked_image_to_legacy(mi, legacy_masked_image, expect_view=False) 

235 

236 

237def test_fits_roundtrip_lossy(tmp_path: Path) -> None: 

238 """Verify MaskedImage round-trips correctly through FITS with lossy 

239 compression. 

240 """ 

241 mi = make_masked_image() 

242 subbox = Box.factory[11:20, 25:30] 

243 subslices = (slice(6, 15), slice(17, 22)) 

244 np.testing.assert_array_equal(mi.image.array[subslices], mi.image[subbox].array) 

245 path = tmp_path / "lossy.fits" 

246 mi.write( 

247 path, 

248 compression_options={ 

249 "image": FitsCompressionOptions.LOSSY, 

250 "variance": FitsCompressionOptions.LOSSY, 

251 }, 

252 compression_seed=50, 

253 ) 

254 roundtripped = MaskedImage.read(path) 

255 subimage = MaskedImage.read(path, bbox=subbox) 

256 with astropy.io.fits.open(path, disable_image_compression=True) as fits: 

257 assert fits[1].header["ZCMPTYPE"] == "RICE_1" 

258 assert fits[2].header["ZCMPTYPE"] == "GZIP_2" 

259 assert fits[3].header["ZCMPTYPE"] == "RICE_1" 

260 assert_masked_images_equal(roundtripped, mi, expect_view=False, rtol=0.01) 

261 assert_masked_images_equal(subimage, roundtripped[subbox], expect_view=False) 

262 

263 

264@skip_no_h5py 

265def test_round_trip_ndf_compatible_mask() -> None: 

266 """Verify NDF round-trip for a MaskedImage with ≤8 mask planes.""" 

267 mi = make_masked_image() 

268 with RoundtripNdf(mi, "MaskedImageV2") as roundtrip: 

269 assert_masked_images_equal(roundtrip.result, mi, expect_view=False) 

270 

271 

272@skip_no_h5py 

273def test_round_trip_ndf_incompatible_mask() -> None: 

274 """Verify NDF round-trip for a MaskedImage with more than 8 mask planes.""" 

275 rng = np.random.default_rng(7) 

276 planes = [MaskPlane(f"P{i}", f"plane {i}") for i in range(12)] 

277 wide = MaskedImage( 

278 Image( 

279 rng.normal(100.0, 8.0, size=(50, 60)), 

280 dtype=np.float64, 

281 unit=u.nJy, 

282 yx0=(0, 0), 

283 ), 

284 mask_schema=MaskSchema(planes), 

285 ) 

286 wide.variance.array = rng.normal(64.0, 0.5, size=wide.bbox.shape) 

287 with RoundtripNdf(wide, "MaskedImageV2") as roundtrip: 

288 assert_masked_images_equal(roundtrip.result, wide, expect_view=False) 

289 

290 

291@skip_no_h5py 

292def test_round_trip_ndf_many_plane_mask() -> None: 

293 """Verify NDF round-trip for a mask that needs more than one int32 

294 chunk. 

295 """ 

296 rng = np.random.default_rng(11) 

297 planes = [MaskPlane(f"P{i}", f"plane {i}") for i in range(40)] 

298 wide = MaskedImage( 

299 Image( 

300 rng.normal(100.0, 8.0, size=(10, 12)), 

301 dtype=np.float64, 

302 unit=u.nJy, 

303 yx0=(0, 0), 

304 ), 

305 mask_schema=MaskSchema(planes), 

306 ) 

307 wide.mask.set("P0", wide.image.array > 100.0) 

308 wide.mask.set("P17", wide.image.array < 95.0) 

309 wide.mask.set("P39", wide.image.array > 110.0) 

310 wide.variance.array = rng.normal(64.0, 0.5, size=wide.bbox.shape) 

311 with RoundtripNdf(wide, "MaskedImageV2") as roundtrip: 

312 assert_masked_images_equal(roundtrip.result, wide, expect_view=False) 

313 

314 

315@skip_no_h5py 

316def test_fits_ndf_consistency() -> None: 

317 """Verify FITS and NDF backends produce equal MaskedImages on 

318 round-trip. 

319 """ 

320 mi = make_masked_image() 

321 with ( 

322 RoundtripFits(mi) as fits_rt, 

323 RoundtripNdf(mi) as ndf_rt, 

324 ): 

325 assert_masked_images_equal(mi, fits_rt.result, expect_view=False) 

326 assert_masked_images_equal(mi, ndf_rt.result, expect_view=False) 

327 assert_masked_images_equal(fits_rt.result, ndf_rt.result, expect_view=False) 

328 

329 

330def test_fits_json_consistency() -> None: 

331 """Verify FITS and JSON backends produce equal MaskedImages on 

332 round-trip. 

333 """ 

334 mi = make_masked_image() 

335 with ( 

336 RoundtripFits(mi) as fits_rt, 

337 RoundtripJson(mi) as json_rt, 

338 ): 

339 assert_masked_images_equal(mi, fits_rt.result, expect_view=False) 

340 assert_masked_images_equal(mi, json_rt.result, expect_view=False) 

341 assert_masked_images_equal(fits_rt.result, json_rt.result, expect_view=False) 

342 

343 

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

345 """Test MaskedImage.read_legacy, MaskedImage.to_legacy, and 

346 MaskedImage.from_legacy. 

347 """ 

348 legacy_masked_image = legacy_test_data.reader.read() 

349 compare_masked_image_to_legacy( 

350 legacy_test_data.masked_image, 

351 legacy_masked_image, 

352 plane_map=legacy_test_data.plane_map, 

353 expect_view=False, 

354 ) 

355 compare_masked_image_to_legacy( 

356 legacy_test_data.masked_image, 

357 legacy_test_data.masked_image.to_legacy(plane_map=legacy_test_data.plane_map), 

358 plane_map=legacy_test_data.plane_map, 

359 expect_view=True, 

360 ) 

361 compare_masked_image_to_legacy( 

362 MaskedImage.from_legacy(legacy_masked_image, plane_map=legacy_test_data.plane_map), 

363 legacy_masked_image, 

364 expect_view=True, 

365 plane_map=legacy_test_data.plane_map, 

366 )