Coverage for tests/test_from_hdu_list.py: 99%

246 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 

12"""Tests for reconstructing Image/MaskedImage from cut-down HDU lists such as 

13those written by ``dax_images_cutout``. 

14""" 

15 

16from __future__ import annotations 

17 

18from pathlib import Path 

19 

20import astropy.io.fits 

21import astropy.units as u 

22import numpy as np 

23import pytest 

24 

25from lsst.images import Box, GeneralFrame, Image, Mask, MaskedImage, MaskPlane, MaskSchema 

26from lsst.images import fits as images_fits 

27from lsst.images.fits import ExtensionKey, FitsOpaqueMetadata 

28from lsst.images.tests import ( 

29 assert_images_equal, 

30 assert_masked_images_equal, 

31 make_random_sky_projection, 

32) 

33 

34 

35def make_hdu_list( 

36 tmp_path: Path, 

37 *, 

38 projection: bool = False, 

39 planes: int = 3, 

40 rng: np.random.Generator | None = None, 

41) -> tuple[MaskedImage, astropy.io.fits.HDUList]: 

42 """Return a (MaskedImage, cut-down HDUList) pair for use in tests.""" 

43 if rng is None: 

44 rng = np.random.default_rng(7) 

45 shape = (20, 25) 

46 yx0 = (5, 8) 

47 bbox = Box.from_shape(shape, start=yx0) 

48 proj = make_random_sky_projection(rng, GeneralFrame(unit=u.pix), bbox) if projection else None 

49 image = Image(rng.normal(100.0, 8.0, shape).astype("float32"), unit=u.nJy, yx0=yx0, sky_projection=proj) 

50 schema = MaskSchema([MaskPlane(f"P{i}", f"description {i}") for i in range(planes)]) 

51 masked_image = MaskedImage(image, mask_schema=schema) 

52 for i in range(planes): 

53 masked_image.mask.set(f"P{i}", rng.random(shape) > 0.5) 

54 masked_image.variance.array = rng.normal(64.0, 0.5, shape) 

55 

56 path = str(tmp_path / "x.fits") 

57 masked_image.write(path) 

58 with astropy.io.fits.open(path) as hdul: 

59 hdus: list[astropy.io.fits.hdu.base._BaseHDU] = [ 

60 astropy.io.fits.PrimaryHDU(header=hdul[0].header.copy()) 

61 ] 

62 for name in ["IMAGE", "MASK", "VARIANCE"]: 

63 src = hdul[name] 

64 hdus.append( 

65 astropy.io.fits.ImageHDU(data=np.asarray(src.data), header=src.header.copy(), name=name) 

66 ) 

67 hdu_list = astropy.io.fits.HDUList(hdus) 

68 return masked_image, hdu_list 

69 

70 

71def _cutdown( 

72 obj: object, names: list[str], tmp_path: Path, **write_kwargs: object 

73) -> astropy.io.fits.HDUList: 

74 """Return an in-memory cut-down HDUList with the requested extension 

75 names. 

76 """ 

77 path = str(tmp_path / "cutdown.fits") 

78 obj.write(path, **write_kwargs) # type: ignore[attr-defined] 

79 with astropy.io.fits.open(path) as hdul: 

80 hdus: list[astropy.io.fits.hdu.base._BaseHDU] = [ 

81 astropy.io.fits.PrimaryHDU(header=hdul[0].header.copy()) 

82 ] 

83 for name in names: 

84 src = hdul[name] 

85 hdus.append( 

86 astropy.io.fits.ImageHDU(data=np.asarray(src.data), header=src.header.copy(), name=name) 

87 ) 

88 return astropy.io.fits.HDUList(hdus) 

89 

90 

91def make_legacy_masked_image_hdu_list( 

92 planes: dict[str, int], 

93 set_pixels: dict[str, tuple[int, int]], 

94 *, 

95 shape: tuple[int, int] = (6, 7), 

96 yx0: tuple[int, int] = (5, 8), 

97 rng: np.random.Generator | None = None, 

98) -> astropy.io.fits.HDUList: 

99 """Return an afw-style legacy cut-down HDUList with MP_ cards in the MASK 

100 HDU. 

101 """ 

102 if rng is None: 102 ↛ 104line 102 didn't jump to line 104 because the condition on line 102 was always true

103 rng = np.random.default_rng(7) 

104 mask_data = np.zeros(shape, dtype=np.int32) 

105 for name, (y, x) in set_pixels.items(): 

106 mask_data[y, x] |= 1 << planes[name] 

107 hdus: list[astropy.io.fits.hdu.base._BaseHDU] = [astropy.io.fits.PrimaryHDU()] 

108 for name, data in [ 

109 ("IMAGE", rng.normal(0.0, 1.0, shape).astype("float32")), 

110 ("MASK", mask_data), 

111 ("VARIANCE", rng.normal(1.0, 0.1, shape).astype("float32")), 

112 ]: 

113 hdu = astropy.io.fits.ImageHDU(data=data, name=name) 

114 hdu.header["LTV1"] = -yx0[1] 

115 hdu.header["LTV2"] = -yx0[0] 

116 hdus.append(hdu) 

117 with images_fits.suppress_fits_card_warnings(): 

118 for name, bit in planes.items(): 

119 hdus[2].header[f"MP_{name}"] = bit 

120 return astropy.io.fits.HDUList(hdus) 

121 

122 

123def _schema_index(mask: Mask, name: str) -> int: 

124 """Return the zero-based plane index of the named plane in the mask 

125 schema. 

126 """ 

127 return next(n for n, plane in enumerate(mask.schema) if plane is not None and plane.name == name) 

128 

129 

130AFW_VISIT_BITS = { 

131 "BAD": 0, 

132 "SAT": 1, 

133 "INTRP": 2, 

134 "CR": 3, 

135 "EDGE": 4, 

136 "DETECTED": 5, 

137 "DETECTED_NEGATIVE": 6, 

138 "SUSPECT": 7, 

139 "NO_DATA": 8, 

140} 

141 

142 

143def _legacy_mask_hdu( 

144 set_pixels: dict[str, tuple[int, int]], *, shape: tuple[int, int] = (6, 7) 

145) -> astropy.io.fits.ImageHDU: 

146 """Return a standalone afw-style MASK ImageHDU with MP_ cards.""" 

147 data = np.zeros(shape, dtype=np.int32) 

148 for name, (y, x) in set_pixels.items(): 

149 data[y, x] |= 1 << AFW_VISIT_BITS[name] 

150 hdu = astropy.io.fits.ImageHDU(data=data, name="MASK") 

151 hdu.header["LTV1"] = -8 

152 hdu.header["LTV2"] = -5 

153 with images_fits.suppress_fits_card_warnings(): 

154 for name, bit in AFW_VISIT_BITS.items(): 

155 hdu.header[f"MP_{name}"] = bit 

156 return hdu 

157 

158 

159def _legacy_full_hdu_list( 

160 set_pixels: dict[str, tuple[int, int]], *, shape: tuple[int, int] = (6, 7) 

161) -> astropy.io.fits.HDUList: 

162 """Return an afw-style full HDUList (PRIMARY+IMAGE+MASK+VARIANCE) with MP_ 

163 cards. 

164 """ 

165 hdus: list[astropy.io.fits.hdu.base._BaseHDU] = [astropy.io.fits.PrimaryHDU()] 

166 for name, data in [ 

167 ("IMAGE", np.zeros(shape, dtype=np.float32)), 

168 ("MASK", None), 

169 ("VARIANCE", np.ones(shape, dtype=np.float32)), 

170 ]: 

171 if name == "MASK": 

172 hdus.append(_legacy_mask_hdu(set_pixels, shape=shape)) 

173 else: 

174 hdu = astropy.io.fits.ImageHDU(data=data, name=name) 

175 hdu.header["LTV1"] = -8 

176 hdu.header["LTV2"] = -5 

177 hdus.append(hdu) 

178 return astropy.io.fits.HDUList(hdus) 

179 

180 

181def test_offset_wcs_round_trip() -> None: 

182 """Verify add_offset_wcs and read_offset_wcs are inverses of each other.""" 

183 header = astropy.io.fits.Header() 

184 images_fits.add_offset_wcs(header, x=19190, y=22580, key="A") 

185 assert images_fits.read_offset_wcs(header, key="A") == (19190, 22580) 

186 

187 

188def test_offset_wcs_absent_returns_none() -> None: 

189 """Verify read_offset_wcs returns None when no offset WCS key is 

190 present. 

191 """ 

192 assert images_fits.read_offset_wcs(astropy.io.fits.Header(), key="A") is None 

193 

194 

195def test_offset_wcs_other_key_ignored() -> None: 

196 """Verify read_offset_wcs ignores WCS keys written under a different 

197 letter. 

198 """ 

199 header = astropy.io.fits.Header() 

200 images_fits.add_offset_wcs(header, x=3, y=4, key="A") 

201 assert images_fits.read_offset_wcs(header, key="B") is None 

202 

203 

204def test_read_yx0_from_offset_wcs() -> None: 

205 """Verify read_yx0 recovers yx0 from an offset WCS header.""" 

206 header = astropy.io.fits.Header() 

207 images_fits.add_offset_wcs(header, x=19190, y=22580) 

208 yx0 = images_fits.read_yx0(header) 

209 assert (yx0.y, yx0.x) == (22580, 19190) 

210 

211 

212def test_read_yx0_from_ltv() -> None: 

213 """Verify read_yx0 recovers yx0 from LTV1/LTV2 header cards.""" 

214 header = astropy.io.fits.Header() 

215 header["LTV1"] = -19190 

216 header["LTV2"] = -22580 

217 yx0 = images_fits.read_yx0(header) 

218 assert (yx0.y, yx0.x) == (22580, 19190) 

219 

220 

221def test_read_yx0_missing_raises() -> None: 

222 """Verify read_yx0 raises ValueError when no offset information is 

223 present. 

224 """ 

225 with pytest.raises(ValueError): 

226 images_fits.read_yx0(astropy.io.fits.Header()) 

227 

228 

229def test_masked_image_round_trip(tmp_path: Path) -> None: 

230 """Verify a cut-down MaskedImage reconstructs to an equal MaskedImage.""" 

231 masked_image, cutdown = make_hdu_list(tmp_path) 

232 result = MaskedImage.from_hdu_list(cutdown) 

233 assert_masked_images_equal(result, masked_image) 

234 

235 

236def test_legacy_non_cell_coadd_from_hdu_list() -> None: 

237 """Verify a legacy afw-style cut-down HDUList reconstructs a MaskedImage 

238 with MP_ planes. 

239 """ 

240 planes = {"BAD": 0, "DETECTED": 5, "INEXACT_PSF": 11, "SENSOR_EDGE": 14} 

241 set_pixels = {"DETECTED": (1, 2), "INEXACT_PSF": (3, 4), "SENSOR_EDGE": (5, 6)} 

242 hdul = make_legacy_masked_image_hdu_list(planes, set_pixels) 

243 result = MaskedImage.from_hdu_list(hdul) 

244 assert "SENSOR_EDGE" in result.mask.schema.names 

245 assert result.mask.get("SENSOR_EDGE")[5, 6] 

246 assert result.mask.get("INEXACT_PSF")[3, 4] 

247 assert result.mask.bbox.y.start == 5 

248 assert result.mask.bbox.x.start == 8 

249 

250 

251def test_masked_image_round_trip_with_projection(tmp_path: Path) -> None: 

252 """Verify the sky projection is recovered from the FITS WCS in the cut-down 

253 HDUList. 

254 """ 

255 masked_image, cutdown = make_hdu_list(tmp_path, projection=True) 

256 result = MaskedImage.from_hdu_list(cutdown) 

257 assert result.sky_projection is not None 

258 center = (masked_image.bbox.x.size / 2, masked_image.bbox.y.size / 2) 

259 expected_wcs = masked_image.fits_wcs 

260 actual_wcs = result.fits_wcs 

261 assert expected_wcs is not None and actual_wcs is not None 

262 expected = expected_wcs.pixel_to_world(*center) 

263 actual = actual_wcs.pixel_to_world(*center) 

264 assert expected.separation(actual).arcsec < 1e-3 

265 

266 

267def test_mask_planes_repacked_across_byte_boundary(tmp_path: Path) -> None: 

268 """Verify nine planes stored in an int32 HDU are repacked into the two-byte 

269 uint8 layout. 

270 """ 

271 rng = np.random.default_rng(7) 

272 masked_image, _ = make_hdu_list(tmp_path, planes=9, rng=rng) 

273 assert masked_image.mask.schema.mask_size == 2 

274 cutdown = _cutdown(masked_image, ["IMAGE", "MASK", "VARIANCE"], tmp_path) 

275 assert np.asarray(cutdown["MASK"].data).dtype.kind == "i" 

276 result = MaskedImage.from_hdu_list(cutdown) 

277 assert result.mask.schema.mask_size == 2 

278 assert_masked_images_equal(result, masked_image) 

279 

280 

281def test_image_from_hdu_list_reads_first_two_hdus(tmp_path: Path) -> None: 

282 """Verify Image.from_hdu_list reads PRIMARY+IMAGE and ignores later 

283 HDUs. 

284 """ 

285 masked_image, cutdown = make_hdu_list(tmp_path) 

286 result = Image.from_hdu_list(cutdown) 

287 assert_images_equal(result, masked_image.image) 

288 two = _cutdown(masked_image, ["IMAGE"], tmp_path) 

289 assert_images_equal(Image.from_hdu_list(two), masked_image.image) 

290 

291 

292def test_missing_mask_schema_raises(tmp_path: Path) -> None: 

293 """Verify a MASK HDU without MSK* cards raises ValueError.""" 

294 _, cutdown = make_hdu_list(tmp_path) 

295 for key in [k for k in cutdown["MASK"].header if k.startswith(("MSKN", "MSKM", "MSKD"))]: 

296 del cutdown["MASK"].header[key] 

297 with pytest.raises(ValueError): 

298 MaskedImage.from_hdu_list(cutdown) 

299 

300 

301def test_multiple_mask_hdus_raises(tmp_path: Path) -> None: 

302 """Verify two MASK HDUs raise ValueError rather than silently dropping 

303 one. 

304 """ 

305 _, cutdown = make_hdu_list(tmp_path) 

306 extra_mask = astropy.io.fits.ImageHDU( 

307 data=np.asarray(cutdown["MASK"].data), header=cutdown["MASK"].header.copy(), name="MASK" 

308 ) 

309 extra_mask.header["EXTVER"] = 2 

310 cutdown.append(extra_mask) 

311 with pytest.raises(ValueError): 

312 MaskedImage.from_hdu_list(cutdown) 

313 

314 

315def test_primary_header_preserved(tmp_path: Path) -> None: 

316 """Verify confusing container cards are dropped and other primary cards 

317 survive as opaque metadata. 

318 """ 

319 masked_image, _ = make_hdu_list(tmp_path) 

320 

321 def add_card(header: astropy.io.fits.Header) -> None: 

322 header["MYCARD"] = "hello" 

323 

324 cutdown = _cutdown(masked_image, ["IMAGE", "MASK", "VARIANCE"], tmp_path, update_header=add_card) 

325 result = MaskedImage.from_hdu_list(cutdown) 

326 assert isinstance(result._opaque_metadata, FitsOpaqueMetadata) 

327 primary = result._opaque_metadata.headers[ExtensionKey()] 

328 assert primary["MYCARD"] == "hello" 

329 assert "DATAMODL" not in primary 

330 assert "INDXADDR" not in primary 

331 assert "JSONADDR" not in primary 

332 

333 

334def test_legacy_mp_ltv_path() -> None: 

335 """Verify the legacy MP_/LTV branch reads a synthetic afw-style MASK HDU 

336 correctly. 

337 """ 

338 data = np.zeros((6, 7), dtype=np.int32) 

339 data[1, 2] = 0b01 # BAD 

340 data[3, 4] = 0b10 # SAT 

341 hdu = astropy.io.fits.ImageHDU(data=data, name="MASK") 

342 hdu.header["LTV1"] = -8 

343 hdu.header["LTV2"] = -5 

344 hdu.header["MP_BAD"] = 0 

345 hdu.header["MP_SAT"] = 1 

346 plane_map = {"BAD": MaskPlane("BAD", "bad"), "SAT": MaskPlane("SATURATED", "saturated")} 

347 mask = Mask._read_legacy_hdu(hdu, FitsOpaqueMetadata(), plane_map=plane_map) 

348 assert mask.bbox.y.start == 5 

349 assert mask.bbox.x.start == 8 

350 assert set(mask.schema.names) == {"BAD", "SATURATED"} 

351 assert mask.get("BAD")[1, 2] 

352 assert mask.get("SATURATED")[3, 4] 

353 assert not mask.get("BAD")[3, 4] 

354 

355 

356def test_read_legacy_strip_false_keeps_cards() -> None: 

357 """Verify MaskPlane.read_legacy with strip=False reads planes but leaves 

358 MP_ cards in the header. 

359 """ 

360 header = astropy.io.fits.Header() 

361 header["MP_BAD"] = 0 

362 header["MP_SAT"] = 1 

363 planes = MaskPlane.read_legacy(header, strip=False) 

364 assert planes == {"BAD": 0, "SAT": 1} 

365 assert "MP_BAD" in header 

366 assert "MP_SAT" in header 

367 

368 

369def test_read_legacy_default_strips_cards() -> None: 

370 """Verify MaskPlane.read_legacy default strips MP_ cards from the 

371 header. 

372 """ 

373 header = astropy.io.fits.Header() 

374 header["MP_BAD"] = 0 

375 header["MP_SAT"] = 1 

376 planes = MaskPlane.read_legacy(header) 

377 assert planes == {"BAD": 0, "SAT": 1} 

378 assert "MP_BAD" not in header 

379 assert "MP_SAT" not in header 

380 

381 

382def test_read_legacy_hdu_reindexes_retained_cards() -> None: 

383 """Verify _read_legacy_hdu with strip_legacy_planes=False rewrites MP_ 

384 cards to new bit positions. 

385 """ 

386 hdu = _legacy_mask_hdu({"SUSPECT": (1, 2), "NO_DATA": (3, 4), "BAD": (0, 0)}) 

387 mask = Mask._read_legacy_hdu(hdu, FitsOpaqueMetadata(), strip_legacy_planes=False) 

388 assert mask.get("SUSPECT")[1, 2] 

389 assert mask.get("NO_DATA")[3, 4] 

390 assert _schema_index(mask, "SUSPECT") == 6 

391 assert _schema_index(mask, "NO_DATA") == 7 

392 assert hdu.header["MP_SUSPECT"] == 6 

393 assert hdu.header["MP_NO_DATA"] == 7 

394 assert hdu.header["MP_BAD"] == 0 

395 assert hdu.header["MP_DETECTED"] == 5 

396 assert "MP_DETECTED_NEGATIVE" not in hdu.header 

397 

398 

399def test_read_legacy_hdu_default_strips() -> None: 

400 """Verify _read_legacy_hdu default behavior strips all MP_ cards.""" 

401 hdu = _legacy_mask_hdu({"BAD": (0, 0)}) 

402 Mask._read_legacy_hdu(hdu, FitsOpaqueMetadata()) 

403 assert not [k for k in hdu.header if k.startswith("MP_")] 

404 

405 

406def test_from_hdu_list_round_trips_reindexed_mp_cards(tmp_path: Path) -> None: 

407 """Verify a reconstructed legacy MaskedImage re-serializes with correct MP_ 

408 bit indices. 

409 """ 

410 hdul = _legacy_full_hdu_list({"SUSPECT": (1, 2), "NO_DATA": (3, 4), "BAD": (0, 0)}) 

411 masked_image = MaskedImage.from_hdu_list(hdul) 

412 path = str(tmp_path / "out.fits") 

413 masked_image.write(path) 

414 with astropy.io.fits.open(path) as out: 

415 header = out["MASK"].header 

416 array = np.asarray(out["MASK"].data) 

417 mskn = {header[k]: int(k.removeprefix("MSKN")) for k in header if k.startswith("MSKN")} 

418 assert header["MP_SUSPECT"] == mskn["SUSPECT"] 

419 assert header["MP_NO_DATA"] == mskn["NO_DATA"] 

420 assert header["MP_SUSPECT"] == 6 

421 assert "MP_DETECTED_NEGATIVE" not in header 

422 assert array[1, 2] & (1 << header["MP_SUSPECT"]) 

423 assert array[3, 4] & (1 << header["MP_NO_DATA"]) 

424 assert array[0, 0] & (1 << header["MP_BAD"]) 

425 

426 

427def test_normal_read_strips_mp_cards(tmp_path: Path) -> None: 

428 """Verify a normal read + rewrite of a legacy-cutout file drops the MP_ 

429 cards. 

430 """ 

431 hdul = _legacy_full_hdu_list({"SUSPECT": (1, 2), "NO_DATA": (3, 4), "BAD": (0, 0)}) 

432 masked_image = MaskedImage.from_hdu_list(hdul) 

433 legacy_cutout = str(tmp_path / "legacy_cutout.fits") 

434 masked_image.write(legacy_cutout) 

435 with astropy.io.fits.open(legacy_cutout) as out: 

436 assert [k for k in out["MASK"].header if k.startswith("MP_")] 

437 rewritten = str(tmp_path / "rewritten.fits") 

438 MaskedImage.read(legacy_cutout).write(rewritten) 

439 with astropy.io.fits.open(rewritten) as out: 

440 assert not [k for k in out["MASK"].header if k.startswith("MP_")]