Coverage for tests/test_visit_image.py: 57%

495 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 math 

16import os 

17import warnings 

18from typing import Any, Literal 

19 

20import astropy.io.fits 

21import astropy.units as u 

22import astropy.wcs 

23import numpy as np 

24import pytest 

25from astro_metadata_translator import ObservationInfo 

26 

27from lsst.images import ( 

28 BackgroundMap, 

29 Box, 

30 DetectorFrame, 

31 DifferenceImage, 

32 Image, 

33 MaskPlane, 

34 MaskSchema, 

35 ObservationSummaryStats, 

36 Polygon, 

37 SkyProjectionAstropyView, 

38 TractFrame, 

39 VisitImage, 

40 get_legacy_difference_image_mask_planes, 

41 get_legacy_visit_image_mask_planes, 

42) 

43from lsst.images.aperture_corrections import ApertureCorrectionMap, aperture_corrections_to_legacy 

44from lsst.images.cameras import Detector 

45from lsst.images.fields import ChebyshevField, SplineField, SumField, field_from_legacy_photo_calib 

46from lsst.images.fits import ExtensionKey, FitsOpaqueMetadata 

47from lsst.images.psfs import GaussianPointSpreadFunction, PointSpreadFunction 

48from lsst.images.serialization import read_archive 

49from lsst.images.tests import ( 

50 DP2_VISIT_DETECTOR_DATA_ID, 

51 RoundtripFits, 

52 RoundtripJson, 

53 RoundtripNdf, 

54 TemporaryButler, 

55 assert_close, 

56 assert_masked_images_equal, 

57 assert_sky_projections_equal, 

58 assert_visit_images_equal, 

59 compare_aperture_corrections_to_legacy, 

60 compare_detector_to_legacy, 

61 compare_photo_calib_to_legacy, 

62 compare_visit_image_to_legacy, 

63 make_random_sky_projection, 

64) 

65 

66try: 

67 import h5py # noqa: F401 

68 

69 HAVE_H5PY = True 

70except ImportError: 

71 HAVE_H5PY = False 

72 

73try: 

74 from lsst.afw.image import Exposure as LegacyExposure 

75 from lsst.afw.image import VisitInfo as LegacyVisitInfo 

76except ImportError: 

77 type LegacyExposure = Any # type: ignore[no-redef] 

78 type LegacyVisitInfo = Any # type: ignore[no-redef] 

79 

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

81LOCAL_DATA_DIR = os.path.join(os.path.dirname(__file__), "data") 

82 

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

84 

85 

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

87def visit_image_components() -> dict[str, Any]: 

88 """Return a dictionary of VisitImage components.""" 

89 rng = np.random.default_rng(500) 

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

91 mask_schema = MaskSchema([MaskPlane("M1", "D1")]) 

92 obs_info = ObservationInfo(instrument="LSSTCam", detector_num=4, physical_filter="r1") 

93 summary_stats = ObservationSummaryStats(psfSigma=2.5, zeroPoint=31.4) 

94 gaussian_psf = GaussianPointSpreadFunction(2.5, stamp_size=33, bounds=Box.factory[-10:10, -12:13]) 

95 aperture_corrections: ApertureCorrectionMap = { 

96 "flux1": ChebyshevField(det_frame.bbox, np.array([0.75])), 

97 "flux2": ChebyshevField(det_frame.bbox, np.array([0.625])), 

98 } 

99 detector = read_archive(os.path.join(LOCAL_DATA_DIR, "detector.json"), Detector) 

100 image = Image(42, shape=(1024, 1024), unit=u.nJy) 

101 variance = Image(5.0, shape=(1024, 1024), unit=u.nJy * u.nJy) 

102 # polygon is the lower triangle of the image. 

103 polygon = Polygon(x_vertices=[-0.5, 1023.5, -0.5], y_vertices=[-0.5, -0.5, 1023.5]) 

104 sky_projection = make_random_sky_projection(rng, det_frame, det_frame.bbox) 

105 return { 

106 "mask_schema": mask_schema, 

107 "obs_info": obs_info, 

108 "summary_stats": summary_stats, 

109 "gaussian_psf": gaussian_psf, 

110 "aperture_corrections": aperture_corrections, 

111 "detector": detector, 

112 "image": image, 

113 "variance": variance, 

114 "polygon": polygon, 

115 "sky_projection": sky_projection, 

116 } 

117 

118 

119def make_visit_image(components: dict[str, Any]) -> VisitImage: 

120 """Construct a new VisitImage with most components populated.""" 

121 det_frame = components["sky_projection"].pixel_frame 

122 opaque = FitsOpaqueMetadata() 

123 hdr = astropy.io.fits.Header() 

124 with warnings.catch_warnings(): 

125 # Silence warnings about long keys becoming HIERARCH. 

126 warnings.simplefilter("ignore", category=astropy.io.fits.verify.VerifyWarning) 

127 hdr.update({"PLATFORM": "lsstcam", "LSST BUTLER ID": "123456789"}) 

128 opaque.extract_legacy_primary_header(hdr) 

129 # API signature suggests sky_projection and obs_info can be None but 

130 # they are required (unless you pass them in via the image plane). 

131 vi = VisitImage( 

132 components["image"], 

133 variance=components["variance"], 

134 psf=GaussianPointSpreadFunction(2.5, stamp_size=33, bounds=Box.factory[-10:10, -12:13]), 

135 mask_schema=components["mask_schema"], 

136 sky_projection=components["sky_projection"], 

137 obs_info=components["obs_info"], 

138 summary_stats=components["summary_stats"], 

139 detector=components["detector"], 

140 bounds=components["polygon"], 

141 aperture_corrections=components["aperture_corrections"], 

142 band="r", 

143 ) 

144 vi.backgrounds.add( 

145 "standard", 

146 ChebyshevField(det_frame.bbox, np.array([[2.0]])), 

147 description="Background subtracted from the image.", 

148 is_subtracted=True, 

149 ) 

150 vi._opaque_metadata = opaque 

151 return vi 

152 

153 

154def make_simplest_visit_image(components: dict[str, Any]) -> VisitImage: 

155 """Construct a VisitImage with the minimal set of components populated.""" 

156 return VisitImage( 

157 components["image"], 

158 psf=GaussianPointSpreadFunction(2.5, stamp_size=33, bounds=Box.factory[-10:10, -12:13]), 

159 mask_schema=components["mask_schema"], 

160 sky_projection=components["sky_projection"], 

161 detector=components["detector"], 

162 obs_info=components["obs_info"], 

163 band="r", 

164 ) 

165 

166 

167def _make_sum_background_visit_image(components: dict[str, Any], visit_image: VisitImage) -> VisitImage: 

168 """Return a VisitImage whose subtracted background is a SumField.""" 

169 rng = np.random.default_rng(42) 

170 bbox = visit_image.image.sky_projection.pixel_frame.bbox 

171 bin_y = bbox.y.linspace(6) 

172 bin_x = bbox.x.linspace(7) 

173 spline_a = SplineField( 

174 bbox, 

175 rng.standard_normal(size=(bin_y.size, bin_x.size)), 

176 y=bin_y, 

177 x=bin_x, 

178 ) 

179 spline_b = SplineField( 

180 bbox, 

181 rng.standard_normal(size=(bin_y.size, bin_x.size)), 

182 y=bin_y, 

183 x=bin_x, 

184 ) 

185 sum_field = SumField([spline_a, spline_b]) 

186 bg_map = BackgroundMap() 

187 bg_map.add( 

188 "stacked", 

189 sum_field, 

190 description="Two-operand SumField subtracted background.", 

191 is_subtracted=True, 

192 ) 

193 return VisitImage( 

194 components["image"], 

195 variance=components["variance"], 

196 psf=components["gaussian_psf"], 

197 mask_schema=components["mask_schema"], 

198 sky_projection=components["sky_projection"], 

199 obs_info=components["obs_info"], 

200 summary_stats=components["summary_stats"], 

201 detector=components["detector"], 

202 band="r", 

203 backgrounds=bg_map, 

204 ) 

205 

206 

207def _check_sum_background_round_trip(result: VisitImage, original: VisitImage) -> None: 

208 """Assert that a round-tripped SumField background matches the original.""" 

209 subtracted = result.backgrounds.subtracted 

210 assert subtracted is not None 

211 assert isinstance(subtracted.field, SumField) 

212 original_subtracted = original.backgrounds.subtracted 

213 assert original_subtracted is not None 

214 original_field = original_subtracted.field 

215 assert isinstance(original_field, SumField) 

216 round_field = subtracted.field 

217 assert isinstance(round_field, SumField) 

218 assert len(round_field.operands) == len(original_field.operands) 

219 for round_op, orig_op in zip(round_field.operands, original_field.operands, strict=True): 

220 assert round_op == orig_op 

221 

222 

223def test_basics(visit_image_components: dict[str, Any]) -> None: 

224 """Verify VisitImage constructor patterns and required-argument checks.""" 

225 c = visit_image_components 

226 # Test default fill of variance. 

227 visit = make_simplest_visit_image(c) 

228 assert visit.variance.array[0, 0] == 1.0 

229 assert visit[...] is not visit 

230 assert str(visit) == "VisitImage(Image([y=0:1024, x=0:1024], int64), ['M1'])" 

231 assert repr(visit) == ( 

232 "VisitImage(Image(..., bbox=Box(y=Interval(start=0, stop=1024), x=Interval(start=0, stop=1024))," 

233 " dtype=dtype('int64')), mask_schema=MaskSchema([MaskPlane(name='M1', description='D1')]," 

234 " dtype=dtype('uint8')))" 

235 ) 

236 

237 astropy_wcs = visit.astropy_wcs 

238 assert isinstance(astropy_wcs, SkyProjectionAstropyView) 

239 approx_wcs = visit.fits_wcs 

240 assert isinstance(approx_wcs, astropy.wcs.WCS) 

241 

242 with pytest.raises(TypeError): 

243 # Requires a PSF. 

244 VisitImage( 

245 c["image"], 

246 mask_schema=c["mask_schema"], 

247 sky_projection=c["sky_projection"], 

248 obs_info=c["obs_info"], 

249 detector=c["detector"], 

250 band="r", 

251 ) 

252 

253 with pytest.raises(TypeError): 

254 # Requires ObservationInfo. 

255 VisitImage( 

256 c["image"], 

257 psf=c["gaussian_psf"], 

258 mask_schema=c["mask_schema"], 

259 sky_projection=c["sky_projection"], 

260 detector=c["detector"], 

261 band="r", 

262 ) 

263 

264 with pytest.raises(TypeError): 

265 # Requires a sky_projection. 

266 VisitImage( 

267 c["image"], 

268 psf=c["gaussian_psf"], 

269 mask_schema=c["mask_schema"], 

270 obs_info=c["obs_info"], 

271 detector=c["detector"], 

272 band="r", 

273 ) 

274 

275 with pytest.raises(TypeError): 

276 # Requires a detector. 

277 VisitImage( 

278 c["image"], 

279 psf=c["gaussian_psf"], 

280 mask_schema=c["mask_schema"], 

281 sky_projection=c["sky_projection"], 

282 obs_info=c["obs_info"], 

283 band="r", 

284 ) 

285 

286 with pytest.raises(TypeError): 

287 # Requires some form of mask. 

288 VisitImage( 

289 c["image"], 

290 psf=c["gaussian_psf"], 

291 sky_projection=c["sky_projection"], 

292 obs_info=c["obs_info"], 

293 detector=c["detector"], 

294 band="r", 

295 ) 

296 

297 with pytest.raises(TypeError): 

298 VisitImage( 

299 Image(42, shape=(5, 5)), 

300 psf=c["gaussian_psf"], 

301 mask_schema=c["mask_schema"], 

302 sky_projection=c["sky_projection"], 

303 obs_info=c["obs_info"], 

304 detector=c["detector"], 

305 band="r", 

306 ) 

307 

308 # Requires a DetectorFrame. 

309 rng = np.random.default_rng(501) 

310 tract_frame = TractFrame(skymap="Skymap", tract=1, bbox=Box.factory[1:10, 1:10]) 

311 tract_proj = make_random_sky_projection(rng, tract_frame, Box.factory[1:4096, 1:4096]) 

312 with pytest.raises(TypeError): 

313 VisitImage( 

314 c["image"], 

315 sky_projection=tract_proj, 

316 psf=c["gaussian_psf"], 

317 mask_schema=c["mask_schema"], 

318 obs_info=c["obs_info"], 

319 detector=c["detector"], 

320 band="r", 

321 ) 

322 

323 # Variance unit mismatch. 

324 with pytest.raises(ValueError): 

325 VisitImage( 

326 c["image"], 

327 variance=c["image"], 

328 psf=c["gaussian_psf"], 

329 mask_schema=c["mask_schema"], 

330 sky_projection=c["sky_projection"], 

331 obs_info=c["obs_info"], 

332 detector=c["detector"], 

333 band="r", 

334 ) 

335 

336 

337def test_copy_and_slice(visit_image_components: dict[str, Any]) -> None: 

338 """Verify that copy deep-copies arrays and components while slice shares 

339 them. 

340 """ 

341 c = visit_image_components 

342 visit_image = make_visit_image(c) 

343 copy = visit_image.copy() 

344 copy.image.array[0, 0] = 30.0 

345 assert visit_image.image.array[0, 0] == 42.0 

346 assert copy.image.array[0, 0] == 30.0 

347 subvisit = visit_image[Box.factory[0:5, 0:5]] 

348 # Check summary stats. 

349 assert copy.summary_stats == visit_image.summary_stats 

350 assert copy.summary_stats is not visit_image.summary_stats 

351 assert subvisit.summary_stats == visit_image.summary_stats 

352 assert subvisit.summary_stats is visit_image.summary_stats 

353 # Check aperture corrections. 

354 assert copy.aperture_corrections.keys() == visit_image.aperture_corrections.keys() 

355 assert copy.aperture_corrections is not visit_image.aperture_corrections 

356 assert subvisit.aperture_corrections.keys() == visit_image.aperture_corrections.keys() 

357 assert subvisit.aperture_corrections is visit_image.aperture_corrections 

358 # Check backgrounds. 

359 assert copy.backgrounds.keys() == visit_image.backgrounds.keys() 

360 assert copy.backgrounds is not visit_image.backgrounds 

361 assert subvisit.backgrounds.keys() == visit_image.backgrounds.keys() 

362 assert subvisit.backgrounds is visit_image.backgrounds 

363 # Check bounds. 

364 assert copy.bounds is c["polygon"] 

365 assert subvisit.bounds == subvisit.bbox # original polygon wholly encloses subvisit.bbox 

366 

367 

368def test_obs_info(visit_image_components: dict[str, Any]) -> None: 

369 """Verify that ObservationInfo is present and carries the expected 

370 instrument. 

371 """ 

372 visit_image = make_visit_image(visit_image_components) 

373 assert visit_image.obs_info is not None 

374 assert visit_image.obs_info.instrument == "LSSTCam" 

375 

376 

377def test_summary_stats(visit_image_components: dict[str, Any]) -> None: 

378 """Verify ObservationSummaryStats equality and inequality comparisons.""" 

379 summary_stats = visit_image_components["summary_stats"] 

380 assert summary_stats == ObservationSummaryStats(psfSigma=2.5, zeroPoint=31.4) 

381 assert summary_stats != ObservationSummaryStats(psfSigma=2.5) 

382 assert summary_stats != ObservationSummaryStats(psfSigma=2.5, raCorners=(5.2, 5.4, 5.4, 5.2)) 

383 

384 

385def test_summary_stats_to_legacy() -> None: 

386 """Verify ObservationSummaryStats round-trips through the legacy 

387 ExposureSummaryStats even when this package defines fields that the 

388 installed afw does not. 

389 """ 

390 try: 

391 from lsst.afw.image import ExposureSummaryStats 

392 except ImportError: 

393 pytest.skip("lsst.afw.image is not available") 

394 

395 summary_stats = ObservationSummaryStats(psfSigma=2.5, zeroPoint=31.4) 

396 legacy = summary_stats.to_legacy() 

397 assert isinstance(legacy, ExposureSummaryStats) 

398 assert legacy.psfSigma == 2.5 

399 assert legacy.zeroPoint == 31.4 

400 # Empty (NaN) fields unknown to the legacy struct are dropped, so the 

401 # round trip reproduces the original. 

402 assert ObservationSummaryStats.from_legacy(legacy) == summary_stats 

403 

404 # A real value in a field unknown to the legacy struct cannot be 

405 # represented and must raise rather than be silently dropped. 

406 legacy_fields = {field.name for field in dataclasses.fields(ExposureSummaryStats)} 

407 extra_float_fields = [ 

408 name 

409 for name, info in ObservationSummaryStats.model_fields.items() 

410 if name not in legacy_fields and info.annotation is float 

411 ] 

412 if extra_float_fields: 412 ↛ exitline 412 didn't return from function 'test_summary_stats_to_legacy' because the condition on line 412 was always true

413 with pytest.raises(ValueError): 

414 ObservationSummaryStats(**{extra_float_fields[0]: 1.0}).to_legacy() 

415 

416 

417def test_summary_stats_from_legacy_unknown_field() -> None: 

418 """Verify from_legacy drops empty unknown fields but errors on set ones.""" 

419 

420 @dataclasses.dataclass 

421 class FakeLegacy: 

422 psfSigma: float = 2.5 

423 notARealField: float = math.nan 

424 

425 # An unknown field that is empty is dropped. 

426 stats = ObservationSummaryStats.from_legacy(FakeLegacy()) 

427 assert stats.psfSigma == 2.5 

428 

429 # An unknown field that holds a real value cannot be represented. 

430 with pytest.raises(ValueError): 

431 ObservationSummaryStats.from_legacy(FakeLegacy(notARealField=1.0)) 

432 

433 

434@skip_no_h5py 

435def test_round_trip_ndf(visit_image_components: dict[str, Any]) -> None: 

436 """Verify NDF round-trip produces a VisitImage equal to the original.""" 

437 visit_image = make_visit_image(visit_image_components) 

438 with RoundtripNdf(visit_image, "VisitImage") as roundtrip: 

439 assert_visit_images_equal(roundtrip.result, visit_image, expect_view=False) 

440 

441 

442@skip_no_h5py 

443def test_fits_ndf_consistency(visit_image_components: dict[str, Any]) -> None: 

444 """Verify FITS and NDF backends produce equal VisitImages on round-trip.""" 

445 visit_image = make_visit_image(visit_image_components) 

446 with RoundtripFits(visit_image) as fits_rt, RoundtripNdf(visit_image) as ndf_rt: 

447 assert_visit_images_equal(visit_image, fits_rt.result, expect_view=False) 

448 assert_visit_images_equal(visit_image, ndf_rt.result, expect_view=False) 

449 assert_visit_images_equal(fits_rt.result, ndf_rt.result, expect_view=False) 

450 

451 

452def test_fits_json_consistency(visit_image_components: dict[str, Any]) -> None: 

453 """Verify FITS and JSON backends produce equal VisitImages.""" 

454 visit_image = make_visit_image(visit_image_components) 

455 with ( 

456 RoundtripFits(visit_image) as fits_rt, 

457 RoundtripJson(visit_image) as json_rt, 

458 ): 

459 assert_visit_images_equal(visit_image, fits_rt.result, expect_view=False) 

460 assert_visit_images_equal(visit_image, json_rt.result, expect_view=False) 

461 assert_visit_images_equal(fits_rt.result, json_rt.result, expect_view=False) 

462 

463 

464def test_read_write(visit_image_components: dict[str, Any]) -> None: 

465 """Verify a VisitImage round-trips through FITS with correct compression. 

466 

467 Checks compression headers, subimage reads, equality, and opaque 

468 metadata. Contains only butler-free assertions; component reads live 

469 in `test_read_write_components`. 

470 """ 

471 visit_image = make_visit_image(visit_image_components) 

472 with RoundtripFits(visit_image, "VisitImage") as roundtrip: 

473 # Check that we're still using the right compression, and that we 

474 # wrote WCSs. 

475 fits = roundtrip.inspect() 

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

477 assert fits[1].header["CTYPE1"] == "RA---TAN" 

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

479 assert fits[2].header["CTYPE1"] == "RA---TAN" 

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

481 assert fits[3].header["CTYPE1"] == "RA---TAN" 

482 # Check a subimage read (no component arg — does not trigger a skip). 

483 subbox = Box.factory[8:13, 9:30] 

484 subimage = roundtrip.get(bbox=subbox) 

485 assert_masked_images_equal(subimage, visit_image[subbox], expect_view=False) 

486 

487 assert_visit_images_equal(roundtrip.result, visit_image, expect_view=False) 

488 # Check that the round-tripped headers are the same (up to card order). 

489 assert len(roundtrip.result._opaque_metadata.headers[ExtensionKey()]) == 1 

490 assert dict(visit_image._opaque_metadata.headers[ExtensionKey()]) == dict( 

491 roundtrip.result._opaque_metadata.headers[ExtensionKey()] 

492 ) 

493 assert not roundtrip.result._opaque_metadata.headers[ExtensionKey("IMAGE")] 

494 assert not roundtrip.result._opaque_metadata.headers[ExtensionKey("MASK")] 

495 assert not roundtrip.result._opaque_metadata.headers[ExtensionKey("VARIANCE")] 

496 # Spot-check the concrete background contents (names, field types, 

497 # subtracted entry) against the known fixture, so the equality check 

498 # above is not vacuously satisfied by empty background maps. 

499 assert isinstance(roundtrip.result.backgrounds, BackgroundMap) 

500 assert roundtrip.result.backgrounds.keys() == {"standard"} 

501 assert isinstance(roundtrip.result.backgrounds["standard"].field, ChebyshevField) 

502 assert roundtrip.result.backgrounds.subtracted.name == "standard" 

503 assert roundtrip.result.backgrounds.subtracted.description == "Background subtracted from the image." 

504 

505 

506def test_read_write_components(visit_image_components: dict[str, Any]) -> None: 

507 """Verify component reads and storage-class overrides round-trip correctly. 

508 

509 Requires a butler; skips when `lsst.daf.butler` is absent. 

510 Butler-free assertions live in `test_read_write`. 

511 """ 

512 c = visit_image_components 

513 visit_image = make_visit_image(c) 

514 with RoundtripFits(visit_image, "VisitImage") as roundtrip: 

515 subbox = Box.factory[8:13, 9:30] 

516 subimage = roundtrip.get(bbox=subbox) 

517 

518 # Get an explicit masked image to compare with the subimage. 

519 subimage_masked = roundtrip.get("masked_image", bbox=subbox) 

520 assert_masked_images_equal(subimage_masked, subimage, expect_view=False) 

521 

522 # Get the same masked image in a multi-component get and ensure 

523 # it is the same thing. 

524 components = roundtrip.get("components", components=["masked_image", "psf"], bbox=subbox) 

525 assert set(components) == {"masked_image", "psf"} 

526 assert_masked_images_equal(components["masked_image"], subimage_masked, expect_view=False) 

527 

528 assert roundtrip.get("bbox") == visit_image.bbox 

529 

530 obs_info = roundtrip.get("obs_info") 

531 assert isinstance(obs_info, ObservationInfo) 

532 assert obs_info == visit_image.obs_info 

533 

534 summary_stats = roundtrip.get("summary_stats") 

535 assert isinstance(summary_stats, ObservationSummaryStats) 

536 assert summary_stats == visit_image.summary_stats 

537 

538 psf = roundtrip.get("psf") 

539 assert isinstance(psf, GaussianPointSpreadFunction) 

540 assert psf.kernel_bbox == c["gaussian_psf"].kernel_bbox 

541 

542 backgrounds = roundtrip.get("backgrounds") 

543 assert isinstance(backgrounds, BackgroundMap) 

544 assert backgrounds.keys() == {"standard"} 

545 assert isinstance(backgrounds["standard"].field, ChebyshevField) 

546 assert backgrounds.subtracted.name == "standard" 

547 assert roundtrip.result.backgrounds.subtracted.description == "Background subtracted from the image." 

548 

549 # Test some components get edge cases. 

550 components = roundtrip.get("components", components="image") 

551 assert isinstance(components["image"], Image) 

552 

553 components = roundtrip.get("components") 

554 assert set(components) == { 

555 "image", 

556 "variance", 

557 "psf", 

558 "bbox", 

559 "mask", 

560 "obs_info", 

561 "backgrounds", 

562 "detector", 

563 "aperture_corrections", 

564 "sky_projection", 

565 "summary_stats", 

566 "photometric_scaling", 

567 } 

568 

569 # Butler morphs RuntimeError to ValueError. 

570 with pytest.raises(ValueError): 

571 roundtrip.get("components", components=["image", "nonexistent"]) 

572 

573 with pytest.raises(ValueError): 

574 roundtrip.get("components", components=["image", "components"]) 

575 

576 with pytest.raises(ValueError): 

577 roundtrip.get("components", components=[]) 

578 

579 with pytest.raises(ValueError): 

580 # PSF does not know how to use bbox so this fails. 

581 roundtrip.get("components", components="psf", bbox=subbox) 

582 

583 

584def test_sum_background_round_trip_fits(visit_image_components: dict[str, Any]) -> None: 

585 """Verify FITS backend keeps two same-named SumField operands as distinct 

586 EXTVERs. 

587 """ 

588 visit_image = make_visit_image(visit_image_components) 

589 visit = _make_sum_background_visit_image(visit_image_components, visit_image) 

590 with RoundtripFits(visit) as roundtrip: 

591 _check_sum_background_round_trip(roundtrip.result, visit) 

592 

593 

594@skip_no_h5py 

595def test_sum_background_round_trip_ndf(visit_image_components: dict[str, Any]) -> None: 

596 """Verify NDF backend disambiguates the repeated ``data`` leaf, just as 

597 the FITS backend does. 

598 """ 

599 visit_image = make_visit_image(visit_image_components) 

600 visit = _make_sum_background_visit_image(visit_image_components, visit_image) 

601 with RoundtripNdf(visit) as roundtrip: 

602 _check_sum_background_round_trip(roundtrip.result, visit) 

603 

604 

605@dataclasses.dataclass 

606class _LegacyTestData: 

607 filename: str 

608 plane_map: dict[str, MaskPlane] = dataclasses.field(default_factory=get_legacy_visit_image_mask_planes) 

609 unit: u.Unit = u.nJy 

610 storage_class: str = "VisitImage" 

611 read_cls: type[VisitImage] = VisitImage 

612 legacy_exposure: LegacyExposure = dataclasses.field(init=False) 

613 

614 @classmethod 

615 def get( 

616 cls, which: Literal["visit_image", "preliminary_visit_image", "difference_image"] 

617 ) -> _LegacyTestData: 

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

619 pytest.skip("TESTDATA_IMAGES is not set up.") 

620 result = cls( 

621 os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", f"{which}.fits"), 

622 ) 

623 match which: 

624 case "preliminary_visit_image": 

625 result.unit = u.electron 

626 case "difference_image": 

627 result.storage_class = "DifferenceImage" 

628 result.read_cls = DifferenceImage 

629 result.plane_map = get_legacy_difference_image_mask_planes() 

630 case "visit_image": 

631 pass 

632 try: 

633 from lsst.afw.image import ExposureFitsReader 

634 

635 result.legacy_exposure = ExposureFitsReader(result.filename).read() 

636 except ImportError: 

637 pytest.skip("lsst.afw.image is not available; cannot read legacy exposures") 

638 result.visit_image = result.read_cls.read_legacy( 

639 result.filename, preserve_quantization=True, plane_map=result.plane_map 

640 ) 

641 return result 

642 

643 

644@pytest.fixture(scope="session", params=["visit_image", "preliminary_visit_image", "difference_image"]) 

645def legacy_test_data(request: pytest.FixtureRequest) -> _LegacyTestData: 

646 """Return legacy test data. 

647 

648 Tests that depend on this parameterized fixture run on all of the legacy 

649 test images. 

650 """ 

651 return _LegacyTestData.get(request.param) 

652 

653 

654@pytest.fixture(scope="session", params=["visit_image", "difference_image"]) 

655def legacy_test_data_calibrated(request: pytest.FixtureRequest) -> _LegacyTestData: 

656 """Return legacy test data for calibrated images only. 

657 

658 Tests that depend on this parameterized fixture do not run on 

659 preliminary_visit_image, since that has 'electron' pixel units 

660 """ 

661 return _LegacyTestData.get(request.param) 

662 

663 

664def _check_legacy_obs_info(obs_info: ObservationInfo | None) -> None: 

665 """Assert obs_info carries expected LSSTCam/DP2 field values.""" 

666 assert isinstance(obs_info, ObservationInfo) 

667 assert obs_info.instrument == "LSSTCam" 

668 assert obs_info.detector_num == 85, obs_info 

669 assert obs_info.detector_unique_name == "R21_S11", obs_info 

670 assert obs_info.physical_filter == "r_57", obs_info 

671 

672 

673def test_legacy_errors(legacy_test_data: _LegacyTestData) -> None: 

674 """Verify that from_legacy and read_legacy raise ValueError on 

675 conflicting arguments. 

676 """ 

677 with pytest.raises(ValueError): 

678 VisitImage.from_legacy(legacy_test_data.legacy_exposure, instrument="HSC") 

679 with pytest.raises(ValueError): 

680 VisitImage.from_legacy(legacy_test_data.legacy_exposure, visit=123456) 

681 with pytest.raises(ValueError): 

682 VisitImage.from_legacy(legacy_test_data.legacy_exposure, unit=u.mJy) 

683 visit = VisitImage.from_legacy( 

684 legacy_test_data.legacy_exposure, 

685 instrument="LSSTCam", 

686 unit=legacy_test_data.unit, 

687 visit=2025052000177, 

688 ) 

689 assert visit.unit == legacy_test_data.unit 

690 

691 with pytest.raises(ValueError): 

692 legacy_test_data.read_cls.read_legacy(legacy_test_data.filename, instrument="HSC") 

693 with pytest.raises(ValueError): 

694 legacy_test_data.read_cls.read_legacy(legacy_test_data.filename, visit=123456) 

695 

696 

697def test_component_reads(legacy_test_data: _LegacyTestData) -> None: 

698 """Verify that individual components can be read from a legacy FITS 

699 file. 

700 """ 

701 visit = VisitImage.read_legacy(legacy_test_data.filename) 

702 proj = VisitImage.read_legacy(legacy_test_data.filename, component="sky_projection") 

703 assert_sky_projections_equal(proj, visit.sky_projection, expect_identity=False) 

704 image = VisitImage.read_legacy(legacy_test_data.filename, component="image") 

705 assert image == visit.image 

706 assert_sky_projections_equal(proj, image.sky_projection, expect_identity=False) 

707 variance = VisitImage.read_legacy(legacy_test_data.filename, component="variance") 

708 assert variance == visit.variance 

709 assert_sky_projections_equal(proj, variance.sky_projection, expect_identity=False) 

710 mask = VisitImage.read_legacy(legacy_test_data.filename, component="mask") 

711 assert mask == visit.mask 

712 assert_sky_projections_equal(proj, mask.sky_projection, expect_identity=False) 

713 psf = VisitImage.read_legacy(legacy_test_data.filename, component="psf") 

714 assert isinstance(psf, PointSpreadFunction) 

715 obs_info = VisitImage.read_legacy(legacy_test_data.filename, component="obs_info") 

716 _check_legacy_obs_info(obs_info) 

717 summary_stats = VisitImage.read_legacy(legacy_test_data.filename, component="summary_stats") 

718 assert isinstance(summary_stats, ObservationSummaryStats) 

719 assert summary_stats.nPsfStar == legacy_test_data.legacy_exposure.info.getSummaryStats().nPsfStar 

720 compare_aperture_corrections_to_legacy( 

721 VisitImage.read_legacy(legacy_test_data.filename, component="aperture_corrections"), 

722 legacy_test_data.legacy_exposure.info.getApCorrMap(), 

723 visit.bbox, 

724 ) 

725 detector = VisitImage.read_legacy(legacy_test_data.filename, component="detector") 

726 compare_detector_to_legacy( 

727 detector, legacy_test_data.legacy_exposure.getDetector(), is_raw_assembled=True 

728 ) 

729 photometric_scaling = VisitImage.read_legacy(legacy_test_data.filename, component="photometric_scaling") 

730 compare_photo_calib_to_legacy( 

731 photometric_scaling, 

732 legacy_test_data.legacy_exposure.getPhotoCalib(), 

733 subimage_bbox=visit.bbox, 

734 ) 

735 

736 

737def test_legacy_obs_info(legacy_test_data: _LegacyTestData) -> None: 

738 """Verify that ObservationInfo is constructed correctly from a legacy 

739 exposure. 

740 """ 

741 legacy = VisitImage.from_legacy(legacy_test_data.legacy_exposure, plane_map=legacy_test_data.plane_map) 

742 assert legacy.obs_info is not None 

743 assert legacy.obs_info == legacy_test_data.visit_image.obs_info 

744 assert legacy.obs_info is not None # for mypy 

745 assert legacy.obs_info.instrument == "LSSTCam" 

746 assert legacy.obs_info.detector_num == 85, legacy.obs_info 

747 assert legacy.obs_info.detector_unique_name == "R21_S11", legacy.obs_info 

748 assert legacy.obs_info.physical_filter == "r_57", legacy.obs_info 

749 

750 

751def test_aperture_corrections_to_legacy(legacy_test_data: _LegacyTestData) -> None: 

752 """Verify that aperture corrections round-trip through a legacy 

753 ApCorrMap. 

754 """ 

755 ap_corrections = legacy_test_data.visit_image.aperture_corrections 

756 legacy_ap_corr_map = aperture_corrections_to_legacy(ap_corrections) 

757 compare_aperture_corrections_to_legacy( 

758 ap_corrections, 

759 legacy_ap_corr_map, 

760 legacy_test_data.visit_image.bbox, 

761 ) 

762 

763 

764def _check_legacy_headers(visit_image: VisitImage) -> None: 

765 """Assert that primary and extension headers are stripped correctly.""" 

766 header = visit_image._opaque_metadata.headers[ExtensionKey()] 

767 assert "EXPTIME" in header 

768 assert header["PLATFORM"] == "lsstcam" 

769 assert "LSST BUTLER ID" not in header 

770 assert "AR HDU" not in header 

771 assert "A_ORDER" not in header 

772 assert not visit_image._opaque_metadata.headers.get(ExtensionKey("IMAGE"), astropy.io.fits.Header()) 

773 assert not visit_image._opaque_metadata.headers.get(ExtensionKey("MASK"), astropy.io.fits.Header()) 

774 assert not visit_image._opaque_metadata.headers.get(ExtensionKey("VARIANCE"), astropy.io.fits.Header()) 

775 

776 

777def test_read_legacy_headers(legacy_test_data: _LegacyTestData) -> None: 

778 """Verify that headers were stripped and interpreted correctly in 

779 read_legacy. 

780 """ 

781 assert legacy_test_data.visit_image.unit == legacy_test_data.unit 

782 _check_legacy_headers(legacy_test_data.visit_image) 

783 

784 

785def test_from_legacy_headers(legacy_test_data: _LegacyTestData) -> None: 

786 """Verify that from_legacy handles primary and extension headers 

787 correctly. 

788 """ 

789 legacy = VisitImage.from_legacy(legacy_test_data.legacy_exposure, plane_map=legacy_test_data.plane_map) 

790 assert legacy.unit == legacy_test_data.unit 

791 _check_legacy_headers(legacy) 

792 

793 

794def test_rewrite(legacy_test_data: _LegacyTestData) -> None: 

795 """Verify that a legacy VisitImage can be rewritten and round-trips both 

796 pixel values and all components. 

797 """ 

798 with RoundtripFits(legacy_test_data.visit_image, legacy_test_data.storage_class) as roundtrip: 

799 fits = roundtrip.inspect() 

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

801 assert fits[1].header["CTYPE1"] == "RA---TAN-SIP" 

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

803 assert fits[2].header["CTYPE1"] == "RA---TAN-SIP" 

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

805 assert fits[3].header["CTYPE1"] == "RA---TAN-SIP" 

806 subbox = Box.factory[8:13, 9:30] 

807 subimage = roundtrip.get(bbox=subbox) 

808 assert_masked_images_equal(subimage, legacy_test_data.visit_image[subbox], expect_view=False) 

809 alternates: dict[str, Any] = {} 

810 assert roundtrip.get("bbox") == legacy_test_data.visit_image.bbox 

811 alternates = { 

812 k: roundtrip.get(k) 

813 for k in [ 

814 "sky_projection", 

815 "image", 

816 "mask", 

817 "variance", 

818 "psf", 

819 "obs_info", 

820 "summary_stats", 

821 "aperture_corrections", 

822 "detector", 

823 "photometric_scaling", 

824 ] 

825 } 

826 legacy_exposure = roundtrip.get(storageClass="Exposure") 

827 assert isinstance(legacy_exposure, LegacyExposure) 

828 compare_visit_image_to_legacy( 

829 legacy_test_data.visit_image, 

830 legacy_exposure, 

831 expect_view=False, 

832 plane_map=legacy_test_data.plane_map, 

833 **DP2_VISIT_DETECTOR_DATA_ID, 

834 ) 

835 if legacy_test_data.visit_image.unit == u.nJy: 

836 assert legacy_exposure.getPhotoCalib()._isConstant 

837 assert legacy_exposure.getPhotoCalib().getCalibrationMean() == 1.0 

838 else: 

839 compare_photo_calib_to_legacy( 

840 legacy_test_data.visit_image.photometric_scaling, 

841 legacy_exposure.getPhotoCalib(), 

842 subimage_bbox=subbox, 

843 ) 

844 assert legacy_exposure.info.getId() == legacy_test_data.legacy_exposure.info.getId() 

845 visit_info = roundtrip.get("obs_info", storageClass="VisitInfo") 

846 assert isinstance(visit_info, LegacyVisitInfo) 

847 assert visit_info.getInstrumentLabel() == "LSSTCam" 

848 

849 assert_visit_images_equal(roundtrip.result, legacy_test_data.visit_image, expect_view=False) 

850 assert dict(legacy_test_data.visit_image._opaque_metadata.headers[ExtensionKey()]) == dict( 

851 roundtrip.result._opaque_metadata.headers[ExtensionKey()] 

852 ) 

853 assert not roundtrip.result._opaque_metadata.headers[ExtensionKey("IMAGE")] 

854 assert not roundtrip.result._opaque_metadata.headers[ExtensionKey("MASK")] 

855 assert not roundtrip.result._opaque_metadata.headers[ExtensionKey("VARIANCE")] 

856 assert roundtrip.result._opaque_metadata.headers[ExtensionKey()]["PLATFORM"] == "lsstcam" 

857 compare_visit_image_to_legacy( 

858 roundtrip.result, 

859 legacy_test_data.legacy_exposure, 

860 expect_view=False, 

861 plane_map=legacy_test_data.plane_map, 

862 **DP2_VISIT_DETECTOR_DATA_ID, 

863 alternates=alternates, 

864 ) 

865 compare_visit_image_to_legacy( 

866 legacy_test_data.read_cls.from_legacy( 

867 legacy_test_data.legacy_exposure, plane_map=legacy_test_data.plane_map 

868 ), 

869 legacy_test_data.legacy_exposure, 

870 expect_view=True, 

871 plane_map=legacy_test_data.plane_map, 

872 **DP2_VISIT_DETECTOR_DATA_ID, 

873 ) 

874 

875 

876def test_butler_converters(legacy_test_data: _LegacyTestData) -> None: 

877 """Verify that a VisitImage can be read from a Butler dataset written as 

878 an Exposure. 

879 """ 

880 try: 

881 from lsst.daf.butler import FileDataset 

882 except ImportError: 

883 pytest.skip("lsst.daf.butler could not be imported.") 

884 

885 with TemporaryButler(legacy="ExposureF") as helper: 

886 helper.butler.ingest( 

887 FileDataset(path=legacy_test_data.filename, refs=[helper.legacy]), transfer="symlink" 

888 ) 

889 visit_image_ref = helper.legacy.overrideStorageClass(legacy_test_data.storage_class) 

890 with warnings.catch_warnings(): 

891 warnings.filterwarnings("ignore", message=".*filter label mismatch.*", category=UserWarning) 

892 visit_image = helper.butler.get(visit_image_ref) 

893 assert visit_image._opaque_metadata.precompressed.keys() == set() 

894 visit_image = helper.butler.get(visit_image_ref, parameters={"preserve_quantization": True}) 

895 assert visit_image._opaque_metadata.precompressed.keys() == {"IMAGE", "VARIANCE"} 

896 bbox = helper.butler.get(visit_image_ref.makeComponentRef("bbox")) 

897 assert bbox == visit_image.bbox 

898 alternates = { 

899 k: helper.butler.get(visit_image_ref.makeComponentRef(k)) 

900 for k in ["image", "mask", "variance", "bbox", "psf", "detector"] 

901 } 

902 compare_visit_image_to_legacy( 

903 visit_image, 

904 legacy_test_data.legacy_exposure, 

905 expect_view=False, 

906 plane_map=legacy_test_data.plane_map, 

907 alternates=alternates, 

908 **DP2_VISIT_DETECTOR_DATA_ID, 

909 ) 

910 helper.butler.pruneDatasets([helper.legacy], purge=True, unstore=True, disassociate=True) 

911 visit_image.metadata["MixedCaseKey"] = 52 

912 helper.butler.put(visit_image, visit_image_ref) 

913 with warnings.catch_warnings(): 

914 warnings.filterwarnings("ignore", message=".*filter label mismatch.*", category=UserWarning) 

915 legacy_exposure = helper.butler.get(helper.legacy) 

916 compare_visit_image_to_legacy( 

917 visit_image, 

918 legacy_exposure, 

919 expect_view=False, 

920 plane_map=legacy_test_data.plane_map, 

921 alternates=alternates, 

922 **DP2_VISIT_DETECTOR_DATA_ID, 

923 ) 

924 visit_image_2 = helper.butler.get(visit_image_ref) 

925 compare_visit_image_to_legacy( 

926 visit_image_2, 

927 legacy_exposure, 

928 expect_view=False, 

929 plane_map=legacy_test_data.plane_map, 

930 alternates=alternates, 

931 **DP2_VISIT_DETECTOR_DATA_ID, 

932 ) 

933 assert visit_image_2.metadata["MixedCaseKey"] == 52 

934 

935 

936def test_convert_unit(legacy_test_data_calibrated: _LegacyTestData) -> None: 

937 """Verify convert_unit round-trips between nJy, mJy, and electron via 

938 photometric_scaling. 

939 """ 

940 from lsst.afw.table import ExposureCatalog 

941 

942 legacy_test_data = legacy_test_data_calibrated 

943 original = legacy_test_data.visit_image.copy() 

944 with pytest.raises(u.UnitConversionError): 

945 original.convert_unit(u.electron) 

946 visit_image_nJy = original.convert_unit(u.nJy, copy=False) 

947 assert np.may_share_memory(visit_image_nJy.image.array, original.image.array) 

948 assert np.may_share_memory(visit_image_nJy.variance.array, original.variance.array) 

949 with pytest.raises(u.UnitConversionError): 

950 original.convert_unit(u.mJy, copy=False) 

951 visit_image_mJy = original.convert_unit(u.mJy, copy="as-needed") 

952 assert visit_image_mJy.unit == u.mJy 

953 assert_close(visit_image_mJy.image.array, original.image.array * 1e-6) 

954 assert np.may_share_memory(visit_image_nJy.mask.array, original.mask.array) 

955 assert_close(visit_image_mJy.variance.array, original.variance.array * 1e-12) 

956 legacy_exposure_mJy = visit_image_mJy.to_legacy() 

957 assert_close(legacy_exposure_mJy.getPhotoCalib().getCalibrationMean(), 1e6) 

958 legacy_masked_image_nJy = legacy_exposure_mJy.getPhotoCalib().calibrateImage( 

959 legacy_exposure_mJy.maskedImage 

960 ) 

961 assert_close(visit_image_nJy.image.array, legacy_masked_image_nJy.image.array) 

962 assert_close(visit_image_nJy.variance.array, legacy_masked_image_nJy.variance.array) 

963 assert np.may_share_memory(visit_image_mJy.mask.array, original.mask.array) 

964 assert visit_image_mJy.sky_projection is original.sky_projection 

965 assert visit_image_mJy.obs_info is original.obs_info 

966 assert visit_image_mJy.summary_stats is original.summary_stats 

967 assert visit_image_mJy.psf is original.psf 

968 assert visit_image_mJy.detector is original.detector 

969 assert visit_image_mJy.bounds is original.bounds 

970 assert visit_image_mJy.aperture_corrections is original.aperture_corrections 

971 assert visit_image_mJy.photometric_scaling is original.photometric_scaling 

972 visit_summary = ExposureCatalog.readFits( 

973 os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "visit_summary.fits") 

974 ) 

975 legacy_photo_calib = visit_summary.find(DP2_VISIT_DETECTOR_DATA_ID["detector"]).getPhotoCalib() 

976 visit_image_nJy.photometric_scaling = field_from_legacy_photo_calib( 

977 legacy_photo_calib, bounds=original.detector.bbox, instrumental_unit=u.electron 

978 ) 

979 compare_photo_calib_to_legacy( 

980 visit_image_nJy.photometric_scaling, 

981 legacy_test_data.legacy_exposure.getPhotoCalib(), 

982 applied_legacy_photo_calib=legacy_photo_calib, 

983 subimage_bbox=visit_image_nJy.bbox, 

984 ) 

985 with pytest.raises(u.UnitConversionError): 

986 visit_image_nJy.convert_unit(u.mm) 

987 with pytest.raises(u.UnitConversionError): 

988 visit_image_nJy.convert_unit(u.electron, copy=False) 

989 legacy_masked_image_e = legacy_photo_calib.uncalibrateImage(legacy_test_data.legacy_exposure.maskedImage) 

990 visit_image_e = visit_image_nJy.convert_unit(u.electron) 

991 assert_close(visit_image_e.image.array, legacy_masked_image_e.image.array) 

992 assert_close(visit_image_e.variance.array, legacy_masked_image_e.variance.array) 

993 assert not np.may_share_memory(visit_image_e.mask.array, visit_image_nJy.mask.array) 

994 visit_image_mJy.photometric_scaling = visit_image_nJy.photometric_scaling 

995 visit_image_e = visit_image_mJy.convert_unit(u.electron) 

996 assert_close(visit_image_e.image.array, legacy_masked_image_e.image.array) 

997 assert_close(visit_image_e.variance.array, legacy_masked_image_e.variance.array) 

998 visit_image_nJy_2 = visit_image_e.convert_unit(u.nJy) 

999 assert_close(visit_image_nJy_2.image.array, visit_image_nJy.image.array) 

1000 assert_close(visit_image_nJy_2.variance.array, original.variance.array) 

1001 visit_image_e.photometric_scaling = visit_image_nJy.photometric_scaling * (1e-6 * u.mJy / u.nJy) 

1002 visit_image_nJy_3 = visit_image_e.convert_unit(u.nJy) 

1003 assert_close(visit_image_nJy_3.image.array, visit_image_nJy.image.array) 

1004 assert_close(visit_image_nJy_3.variance.array, original.variance.array) 

1005 legacy_exposure_e = visit_image_e.to_legacy() 

1006 assert_close( 

1007 legacy_exposure_e.getPhotoCalib().getCalibrationMean(), 

1008 legacy_photo_calib.getCalibrationMean(), 

1009 ) 

1010 legacy_masked_image_nJy = legacy_exposure_e.getPhotoCalib().calibrateImage(legacy_exposure_e.maskedImage) 

1011 assert_close(visit_image_nJy.image.array, legacy_masked_image_nJy.image.array) 

1012 assert_close(visit_image_nJy.variance.array, legacy_masked_image_nJy.variance.array)