Coverage for tests/test_visit_image.py: 16%

419 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-08 08:43 +0000

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 os 

15import unittest 

16import warnings 

17from typing import Any, ClassVar 

18 

19import astropy.io.fits 

20import astropy.units as u 

21import astropy.wcs 

22import numpy as np 

23from astro_metadata_translator import ObservationInfo 

24 

25from lsst.images import ( 

26 BackgroundMap, 

27 Box, 

28 DetectorFrame, 

29 DifferenceImage, 

30 Image, 

31 MaskPlane, 

32 MaskSchema, 

33 ObservationSummaryStats, 

34 Polygon, 

35 ProjectionAstropyView, 

36 TractFrame, 

37 VisitImage, 

38 get_legacy_difference_image_mask_planes, 

39 get_legacy_visit_image_mask_planes, 

40) 

41from lsst.images.aperture_corrections import ApertureCorrectionMap, aperture_corrections_to_legacy 

42from lsst.images.cameras import Detector 

43from lsst.images.fields import ChebyshevField, field_from_legacy_photo_calib 

44from lsst.images.fits import ExtensionKey, FitsOpaqueMetadata 

45from lsst.images.psfs import GaussianPointSpreadFunction, PointSpreadFunction 

46from lsst.images.serialization import read 

47from lsst.images.tests import ( 

48 DP2_VISIT_DETECTOR_DATA_ID, 

49 RoundtripFits, 

50 RoundtripJson, 

51 RoundtripNdf, 

52 TemporaryButler, 

53 assert_close, 

54 assert_masked_images_equal, 

55 assert_projections_equal, 

56 assert_visit_images_equal, 

57 compare_aperture_corrections_to_legacy, 

58 compare_detector_to_legacy, 

59 compare_photo_calib_to_legacy, 

60 compare_visit_image_to_legacy, 

61 make_random_projection, 

62) 

63 

64try: 

65 import h5py # noqa: F401 

66 

67 HAVE_H5PY = True 

68except ImportError: 

69 HAVE_H5PY = False 

70 

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

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

73 

74 

75class VisitImageTestCase(unittest.TestCase): 

76 """Basic Tests for VisitImage.""" 

77 

78 @classmethod 

79 def setUpClass(cls) -> None: 

80 cls.rng = np.random.default_rng(500) 

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

82 cls.mask_schema = MaskSchema([MaskPlane("M1", "D1")]) 

83 cls.obs_info = ObservationInfo(instrument="LSSTCam", detector_num=4, physical_filter="r1") 

84 cls.summary_stats = ObservationSummaryStats(psfSigma=2.5, zeroPoint=31.4) 

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

86 cls.aperture_corrections: ApertureCorrectionMap = { 

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

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

89 } 

90 cls.detector = read(os.path.join(LOCAL_DATA_DIR, "detector.json"), Detector) 

91 

92 opaque = FitsOpaqueMetadata() 

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

94 with warnings.catch_warnings(): 

95 # Silence warnings about long keys becoming HIERARCH. 

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

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

98 opaque.extract_legacy_primary_header(hdr) 

99 

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

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

102 # polygon is the lower triangle of the image. 

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

104 cls.projection = make_random_projection(cls.rng, det_frame, Box.factory[1:4096, 1:4096]) 

105 # API signature suggests projection and obs_info can be None but they 

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

107 cls.visit_image = VisitImage( 

108 cls.image, 

109 variance=cls.variance, 

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

111 mask_schema=cls.mask_schema, 

112 projection=cls.projection, 

113 obs_info=cls.obs_info, 

114 summary_stats=cls.summary_stats, 

115 detector=cls.detector, 

116 bounds=cls.polygon, 

117 aperture_corrections=cls.aperture_corrections, 

118 band="r", 

119 ) 

120 cls.visit_image.backgrounds.add( 

121 "standard", 

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

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

124 is_subtracted=True, 

125 ) 

126 cls.visit_image._opaque_metadata = opaque 

127 cls.simplest_visit_image = VisitImage( 

128 cls.image, 

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

130 mask_schema=cls.mask_schema, 

131 projection=cls.projection, 

132 detector=cls.detector, 

133 obs_info=cls.obs_info, 

134 band="r", 

135 ) 

136 

137 def test_basics(self) -> None: 

138 """Test basic constructor patterns.""" 

139 # Test default fill of variance. 

140 visit = self.simplest_visit_image 

141 self.assertEqual(visit.variance.array[0, 0], 1.0) 

142 self.assertIs(visit[...], visit) 

143 self.assertEqual(str(visit), "VisitImage(Image([y=0:1024, x=0:1024], int64), ['M1'])") 

144 self.assertEqual( 

145 repr(visit), 

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

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

148 " dtype=dtype('uint8')))", 

149 ) 

150 

151 astropy_wcs = visit.astropy_wcs 

152 self.assertIsInstance(astropy_wcs, ProjectionAstropyView) 

153 approx_wcs = visit.fits_wcs 

154 self.assertIsInstance(approx_wcs, astropy.wcs.WCS) 

155 

156 with self.assertRaises(TypeError): 

157 # Requires a PSF. 

158 VisitImage( 

159 self.image, 

160 mask_schema=self.mask_schema, 

161 projection=self.projection, 

162 obs_info=self.obs_info, 

163 detector=self.detector, 

164 band="r", 

165 ) 

166 

167 with self.assertRaises(TypeError): 

168 # Requires ObservationInfo. 

169 VisitImage( 

170 self.image, 

171 psf=self.gaussian_psf, 

172 mask_schema=self.mask_schema, 

173 projection=self.projection, 

174 detector=self.detector, 

175 band="r", 

176 ) 

177 

178 with self.assertRaises(TypeError): 

179 # Requires a projection. 

180 VisitImage( 

181 self.image, 

182 psf=self.gaussian_psf, 

183 mask_schema=self.mask_schema, 

184 obs_info=self.obs_info, 

185 detector=self.detector, 

186 band="r", 

187 ) 

188 

189 with self.assertRaises(TypeError): 

190 # Requires a detector. 

191 VisitImage( 

192 self.image, 

193 psf=self.gaussian_psf, 

194 mask_schema=self.mask_schema, 

195 projection=self.projection, 

196 obs_info=self.obs_info, 

197 band="r", 

198 ) 

199 

200 with self.assertRaises(TypeError): 

201 # Requires some form of mask. 

202 VisitImage( 

203 self.image, 

204 psf=self.gaussian_psf, 

205 projection=self.projection, 

206 obs_info=self.obs_info, 

207 detector=self.detector, 

208 band="r", 

209 ) 

210 

211 with self.assertRaises(TypeError): 

212 VisitImage( 

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

214 psf=self.gaussian_psf, 

215 mask_schema=self.mask_schema, 

216 projection=self.projection, 

217 obs_info=self.obs_info, 

218 detector=self.detector, 

219 band="r", 

220 ) 

221 

222 # Requires a DetectorFrame. 

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

224 tract_proj = make_random_projection(self.rng, tract_frame, Box.factory[1:4096, 1:4096]) 

225 with self.assertRaises(TypeError): 

226 VisitImage( 

227 self.image, 

228 projection=tract_proj, 

229 psf=self.gaussian_psf, 

230 mask_schema=self.mask_schema, 

231 obs_info=self.obs_info, 

232 detector=self.detector, 

233 band="r", 

234 ) 

235 

236 # Variance unit mismatch. 

237 with self.assertRaises(ValueError): 

238 VisitImage( 

239 self.image, 

240 variance=self.image, 

241 psf=self.gaussian_psf, 

242 mask_schema=self.mask_schema, 

243 projection=self.projection, 

244 obs_info=self.obs_info, 

245 detector=self.detector, 

246 band="r", 

247 ) 

248 

249 def test_copy_and_slice(self) -> None: 

250 """Test that arrays and components are copied (when not immutable) by 

251 'copy' and referenced by 'slice'. 

252 """ 

253 visit = self.visit_image 

254 copy = visit.copy() 

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

256 self.assertEqual(visit.image.array[0, 0], 42.0) 

257 self.assertEqual(copy.image.array[0, 0], 30.0) 

258 subvisit = visit[Box.factory[0:5, 0:5]] 

259 # Check summary stats. 

260 self.assertEqual(copy.summary_stats, visit.summary_stats) 

261 self.assertIsNot(copy.summary_stats, visit.summary_stats) 

262 self.assertEqual(subvisit.summary_stats, visit.summary_stats) 

263 self.assertIs(subvisit.summary_stats, visit.summary_stats) 

264 # Check aperture corrections. 

265 self.assertEqual(copy.aperture_corrections.keys(), visit.aperture_corrections.keys()) 

266 self.assertIsNot(copy.aperture_corrections, visit.aperture_corrections) 

267 self.assertEqual(subvisit.aperture_corrections.keys(), visit.aperture_corrections.keys()) 

268 self.assertIs(subvisit.aperture_corrections, visit.aperture_corrections) 

269 # Check backgrounds. 

270 self.assertEqual(copy.backgrounds.keys(), visit.backgrounds.keys()) 

271 self.assertIsNot(copy.backgrounds, visit.backgrounds) 

272 self.assertEqual(subvisit.backgrounds.keys(), visit.backgrounds.keys()) 

273 self.assertIs(subvisit.backgrounds, visit.backgrounds) 

274 # Check bounds. 

275 self.assertIs(copy.bounds, self.polygon) 

276 self.assertEqual(subvisit.bounds, subvisit.bbox) # original polygon wholly encloses subvisit.bbox 

277 

278 def test_obs_info(self) -> None: 

279 """Check that ObservationInfo has been constructed.""" 

280 visit = self.visit_image 

281 self.assertIsNotNone(visit.obs_info) 

282 self.maxDiff = None 

283 assert visit.obs_info is not None # for mypy. 

284 self.assertEqual(visit.obs_info.instrument, "LSSTCam") 

285 

286 def test_summary_stats(self) -> None: 

287 """Test the comparisons and attributes of ObservationSummaryStats.""" 

288 self.assertEqual(self.summary_stats, ObservationSummaryStats(psfSigma=2.5, zeroPoint=31.4)) 

289 self.assertNotEqual(self.summary_stats, ObservationSummaryStats(psfSigma=2.5)) 

290 self.assertNotEqual( 

291 self.summary_stats, ObservationSummaryStats(psfSigma=2.5, raCorners=(5.2, 5.4, 5.4, 5.2)) 

292 ) 

293 

294 @unittest.skipUnless(HAVE_H5PY, "h5py is not installed") 

295 def test_round_trip_ndf(self): 

296 """NDF round-trip for VisitImage.""" 

297 with RoundtripNdf(self, self.visit_image, "VisitImage") as roundtrip: 

298 assert_visit_images_equal(self, roundtrip.result, self.visit_image, expect_view=False) 

299 

300 @unittest.skipUnless(HAVE_H5PY, "h5py is not installed") 

301 def test_fits_ndf_consistency(self): 

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

303 with RoundtripFits(self, self.visit_image) as fits_rt, RoundtripNdf(self, self.visit_image) as ndf_rt: 

304 assert_visit_images_equal(self, self.visit_image, fits_rt.result, expect_view=False) 

305 assert_visit_images_equal(self, self.visit_image, ndf_rt.result, expect_view=False) 

306 assert_visit_images_equal(self, fits_rt.result, ndf_rt.result, expect_view=False) 

307 

308 def test_fits_json_consistency(self): 

309 """FITS and JSON backends produce equal VisitImages on round-trip.""" 

310 with ( 

311 RoundtripFits(self, self.visit_image) as fits_rt, 

312 RoundtripJson(self, self.visit_image) as json_rt, 

313 ): 

314 assert_visit_images_equal(self, self.visit_image, fits_rt.result, expect_view=False) 

315 assert_visit_images_equal(self, self.visit_image, json_rt.result, expect_view=False) 

316 assert_visit_images_equal(self, fits_rt.result, json_rt.result, expect_view=False) 

317 

318 def test_read_write(self) -> None: 

319 """Test that a visit can round trip through a FITS file.""" 

320 with RoundtripFits(self, self.visit_image, "VisitImage") as roundtrip: 

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

322 # wrote WCSs. 

323 fits = roundtrip.inspect() 

324 self.assertEqual(fits[1].header["ZCMPTYPE"], "GZIP_2") 

325 self.assertEqual(fits[1].header["CTYPE1"], "RA---TAN") 

326 self.assertEqual(fits[2].header["ZCMPTYPE"], "GZIP_2") 

327 self.assertEqual(fits[2].header["CTYPE1"], "RA---TAN") 

328 self.assertEqual(fits[3].header["ZCMPTYPE"], "GZIP_2") 

329 self.assertEqual(fits[3].header["CTYPE1"], "RA---TAN") 

330 # Check a subimage read. 

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

332 subimage = roundtrip.get(bbox=subbox) 

333 assert_masked_images_equal(self, subimage, self.visit_image[subbox], expect_view=False) 

334 with self.subTest(): 

335 self.assertEqual(roundtrip.get("bbox"), self.visit_image.bbox) 

336 with self.subTest(): 

337 obs_info = roundtrip.get("obs_info") 

338 self.assertIsInstance(obs_info, ObservationInfo) 

339 self.assertEqual(obs_info, self.visit_image.obs_info) 

340 with self.subTest(): 

341 summary_stats = roundtrip.get("summary_stats") 

342 self.assertIsInstance(summary_stats, ObservationSummaryStats) 

343 self.assertEqual(summary_stats, self.visit_image.summary_stats) 

344 with self.subTest(): 

345 psf = roundtrip.get("psf") 

346 self.assertIsInstance(psf, GaussianPointSpreadFunction) 

347 self.assertEqual(psf.kernel_bbox, self.gaussian_psf.kernel_bbox) 

348 with self.subTest(): 

349 backgrounds = roundtrip.get("backgrounds") 

350 self.assertIsInstance(backgrounds, BackgroundMap) 

351 self.assertEqual(backgrounds.keys(), {"standard"}) 

352 self.assertIsInstance(backgrounds["standard"].field, ChebyshevField) 

353 self.assertEqual(backgrounds.subtracted.name, "standard") 

354 self.assertEqual( 

355 roundtrip.result.backgrounds.subtracted.description, 

356 "Background subtracted from the image.", 

357 ) 

358 

359 assert_visit_images_equal(self, roundtrip.result, self.visit_image, expect_view=False) 

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

361 self.assertEqual(len(roundtrip.result._opaque_metadata.headers[ExtensionKey()]), 1) 

362 self.assertEqual( 

363 dict(self.visit_image._opaque_metadata.headers[ExtensionKey()]), 

364 dict(roundtrip.result._opaque_metadata.headers[ExtensionKey()]), 

365 ) 

366 self.assertFalse(roundtrip.result._opaque_metadata.headers[ExtensionKey("IMAGE")]) 

367 self.assertFalse(roundtrip.result._opaque_metadata.headers[ExtensionKey("MASK")]) 

368 self.assertFalse(roundtrip.result._opaque_metadata.headers[ExtensionKey("VARIANCE")]) 

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

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

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

372 self.assertIsInstance(roundtrip.result.backgrounds, BackgroundMap) 

373 self.assertEqual(roundtrip.result.backgrounds.keys(), {"standard"}) 

374 self.assertIsInstance(roundtrip.result.backgrounds["standard"].field, ChebyshevField) 

375 self.assertEqual(roundtrip.result.backgrounds.subtracted.name, "standard") 

376 self.assertEqual( 

377 roundtrip.result.backgrounds.subtracted.description, "Background subtracted from the image." 

378 ) 

379 

380 

381class VisitImageLegacyTestMixin: 

382 """Tests for the VisitImage class and the basics of the archive, to be 

383 specialized for a particular test image. 

384 

385 `setUp` or `setUpClass` must be implemented to set the attributes declared 

386 in the class. 

387 """ 

388 

389 filename: str 

390 legacy_exposure: Any 

391 plane_map: dict[str, MaskPlane] 

392 visit_image: VisitImage 

393 unit: u.UnitBase 

394 storage_class: ClassVar[str] = "VisitImage" 

395 

396 def test_legacy_errors(self) -> None: 

397 """Legacy read failure modes.""" 

398 with self.assertRaises(ValueError): 

399 VisitImage.from_legacy(self.legacy_exposure, instrument="HSC") 

400 with self.assertRaises(ValueError): 

401 VisitImage.from_legacy(self.legacy_exposure, visit=123456) 

402 with self.assertRaises(ValueError): 

403 VisitImage.from_legacy(self.legacy_exposure, unit=u.mJy) 

404 visit = VisitImage.from_legacy( 

405 self.legacy_exposure, instrument="LSSTCam", unit=self.unit, visit=2025052000177 

406 ) 

407 self.assertEqual(visit.unit, self.unit) 

408 

409 with self.assertRaises(ValueError): 

410 VisitImage.read_legacy(self.filename, instrument="HSC") 

411 with self.assertRaises(ValueError): 

412 VisitImage.read_legacy(self.filename, visit=123456) 

413 

414 def test_component_reads(self) -> None: 

415 """Test reads of components from legacy file.""" 

416 visit = VisitImage.read_legacy(self.filename) 

417 proj = VisitImage.read_legacy(self.filename, component="projection") 

418 assert_projections_equal(self, proj, visit.projection, expect_identity=False) 

419 image = VisitImage.read_legacy(self.filename, component="image") 

420 self.assertEqual(image, visit.image) 

421 assert_projections_equal(self, proj, image.projection, expect_identity=False) 

422 variance = VisitImage.read_legacy(self.filename, component="variance") 

423 self.assertEqual(variance, visit.variance) 

424 assert_projections_equal(self, proj, variance.projection, expect_identity=False) 

425 mask = VisitImage.read_legacy(self.filename, component="mask") 

426 self.assertEqual(mask, visit.mask) 

427 assert_projections_equal(self, proj, mask.projection, expect_identity=False) 

428 psf = VisitImage.read_legacy(self.filename, component="psf") 

429 self.assertIsInstance(psf, PointSpreadFunction) 

430 obs_info = VisitImage.read_legacy(self.filename, component="obs_info") 

431 self.check_legacy_obs_info(obs_info) 

432 summary_stats = VisitImage.read_legacy(self.filename, component="summary_stats") 

433 self.assertIsInstance(summary_stats, ObservationSummaryStats) 

434 self.assertEqual(summary_stats.nPsfStar, self.legacy_exposure.info.getSummaryStats().nPsfStar) 

435 compare_aperture_corrections_to_legacy( 

436 self, 

437 VisitImage.read_legacy(self.filename, component="aperture_corrections"), 

438 self.legacy_exposure.info.getApCorrMap(), 

439 visit.bbox, 

440 ) 

441 detector = VisitImage.read_legacy(self.filename, component="detector") 

442 compare_detector_to_legacy(self, detector, self.legacy_exposure.getDetector(), is_raw_assembled=True) 

443 photometric_scaling = VisitImage.read_legacy(self.filename, component="photometric_scaling") 

444 compare_photo_calib_to_legacy( 

445 self, 

446 photometric_scaling, 

447 self.legacy_exposure.getPhotoCalib(), 

448 subimage_bbox=visit.bbox, 

449 ) 

450 

451 def check_legacy_obs_info(self, obs_info: ObservationInfo | None) -> None: 

452 """Check that an `ObservationInfo` instance is not `None`, and that it 

453 matches the one in the legacy test data file. 

454 """ 

455 self.assertIsInstance(obs_info, ObservationInfo) 

456 self.assertEqual(obs_info.instrument, "LSSTCam") 

457 self.assertEqual(obs_info.detector_num, 85, obs_info) 

458 self.assertEqual(obs_info.detector_unique_name, "R21_S11", obs_info) 

459 self.assertEqual(obs_info.physical_filter, "r_57", obs_info) 

460 

461 def test_obs_info(self) -> None: 

462 """Check that ObservationInfo has been constructed.""" 

463 legacy = VisitImage.from_legacy(self.legacy_exposure, plane_map=self.plane_map) 

464 self.assertIsNotNone(legacy.obs_info) 

465 self.maxDiff = None 

466 self.assertEqual(legacy.obs_info, self.visit_image.obs_info) 

467 assert legacy.obs_info is not None # for mypy. 

468 self.assertEqual(legacy.obs_info.instrument, "LSSTCam") 

469 self.assertEqual(legacy.obs_info.detector_num, 85, legacy.obs_info) 

470 self.assertEqual(legacy.obs_info.detector_unique_name, "R21_S11", legacy.obs_info) 

471 self.assertEqual(legacy.obs_info.physical_filter, "r_57", legacy.obs_info) 

472 

473 def test_aperture_corrections_to_legacy(self) -> None: 

474 """Test that we can convert an aperture correction map back to a 

475 legacy `lsst.afw.image.ApCorrMap`. 

476 """ 

477 legacy_ap_corr_map = aperture_corrections_to_legacy(self.visit_image.aperture_corrections) 

478 compare_aperture_corrections_to_legacy( 

479 self, self.visit_image.aperture_corrections, legacy_ap_corr_map, self.visit_image.bbox 

480 ) 

481 

482 def test_read_legacy_headers(self) -> None: 

483 """Test that headers were correctly stripped and interpreted in 

484 `VisitImage.read_legacy`. 

485 """ 

486 # Check that we read the units from BUNIT. 

487 self.assertEqual(self.visit_image.unit, self.unit) 

488 # Check that the primary header has the keys we want, and none of the 

489 # keys we don't want. 

490 header = self.visit_image._opaque_metadata.headers[ExtensionKey()] 

491 self.assertIn("EXPTIME", header) 

492 self.assertEqual(header["PLATFORM"], "lsstcam") 

493 self.assertNotIn("LSST BUTLER ID", header) 

494 self.assertNotIn("AR HDU", header) 

495 self.assertNotIn("A_ORDER", header) 

496 # Check that the extension HDUs do not have any custom headers. 

497 self.assertFalse(self.visit_image._opaque_metadata.headers[ExtensionKey("IMAGE")]) 

498 self.assertFalse(self.visit_image._opaque_metadata.headers[ExtensionKey("MASK")]) 

499 self.assertFalse(self.visit_image._opaque_metadata.headers[ExtensionKey("VARIANCE")]) 

500 

501 def test_from_legacy_headers(self) -> None: 

502 """Test that from_legacy handles headers properly.""" 

503 legacy = VisitImage.from_legacy(self.legacy_exposure, plane_map=self.plane_map) 

504 header = legacy._opaque_metadata.headers[ExtensionKey()] 

505 self.assertIn("EXPTIME", header) 

506 self.assertEqual(header["PLATFORM"], "lsstcam") 

507 self.assertNotIn("LSST BUTLER ID", header) 

508 self.assertNotIn("AR HDU", header) 

509 self.assertNotIn("A_ORDER", header) 

510 # Check that the extension HDUs do not have any custom headers. 

511 self.assertFalse(self.visit_image._opaque_metadata.headers[ExtensionKey("IMAGE")]) 

512 self.assertFalse(self.visit_image._opaque_metadata.headers[ExtensionKey("MASK")]) 

513 self.assertFalse(self.visit_image._opaque_metadata.headers[ExtensionKey("VARIANCE")]) 

514 

515 def test_rewrite(self) -> None: 

516 """Test that we can rewrite the visit image and preserve both 

517 lossy-compressed pixel values and components exactly. 

518 """ 

519 import lsst.afw.image 

520 

521 with RoundtripFits(self, self.visit_image, self.storage_class) as roundtrip: 

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

523 # wrote WCSs. 

524 fits = roundtrip.inspect() 

525 self.assertEqual(fits[1].header["ZCMPTYPE"], "RICE_1") 

526 self.assertEqual(fits[1].header["CTYPE1"], "RA---TAN-SIP") 

527 self.assertEqual(fits[2].header["ZCMPTYPE"], "GZIP_2") 

528 self.assertEqual(fits[2].header["CTYPE1"], "RA---TAN-SIP") 

529 self.assertEqual(fits[3].header["ZCMPTYPE"], "RICE_1") 

530 self.assertEqual(fits[3].header["CTYPE1"], "RA---TAN-SIP") 

531 # Check a subimage read. 

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

533 subimage = roundtrip.get(bbox=subbox) 

534 assert_masked_images_equal(self, subimage, self.visit_image[subbox], expect_view=False) 

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

536 with self.subTest(): 

537 self.assertEqual(roundtrip.get("bbox"), self.visit_image.bbox) 

538 alternates = { 

539 k: roundtrip.get(k) 

540 for k in [ 

541 "projection", 

542 "image", 

543 "mask", 

544 "variance", 

545 "psf", 

546 "obs_info", 

547 "summary_stats", 

548 "aperture_corrections", 

549 "detector", 

550 "photometric_scaling", 

551 ] 

552 } 

553 # Test reading back in as an Exposure. 

554 with self.subTest(): 

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

556 self.assertIsInstance(legacy_exposure, lsst.afw.image.Exposure) 

557 # This covers most of the compnents, which have clean 1-1 

558 # mappings from legacy to new: 

559 compare_visit_image_to_legacy( 

560 self, 

561 self.visit_image, 

562 legacy_exposure, 

563 expect_view=False, 

564 plane_map=self.plane_map, 

565 **DP2_VISIT_DETECTOR_DATA_ID, 

566 ) 

567 # A few components are different enough to merit extra 

568 # attention: 

569 if self.visit_image.unit == u.nJy: 

570 self.assertTrue(legacy_exposure.getPhotoCalib()._isConstant) 

571 self.assertEqual(legacy_exposure.getPhotoCalib().getCalibrationMean(), 1.0) 

572 else: 

573 compare_photo_calib_to_legacy( 

574 self, 

575 self.visit_image.photometric_scaling, 

576 legacy_exposure.getPhotoCalib(), 

577 subimage_bbox=subbox, 

578 ) 

579 self.assertEqual(legacy_exposure.info.getId(), self.legacy_exposure.info.getId()) 

580 # Try to do a butler get of a component with storage class 

581 # override. 

582 with self.subTest(): 

583 # We have VisitInfo available. 

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

585 self.assertIsInstance(visit_info, lsst.afw.image.VisitInfo) 

586 self.assertEqual(visit_info.getInstrumentLabel(), "LSSTCam") 

587 

588 assert_visit_images_equal(self, roundtrip.result, self.visit_image, expect_view=False) 

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

590 self.assertEqual( 

591 dict(self.visit_image._opaque_metadata.headers[ExtensionKey()]), 

592 dict(roundtrip.result._opaque_metadata.headers[ExtensionKey()]), 

593 ) 

594 self.assertFalse(roundtrip.result._opaque_metadata.headers[ExtensionKey("IMAGE")]) 

595 self.assertFalse(roundtrip.result._opaque_metadata.headers[ExtensionKey("MASK")]) 

596 self.assertFalse(roundtrip.result._opaque_metadata.headers[ExtensionKey("VARIANCE")]) 

597 self.assertEqual(roundtrip.result._opaque_metadata.headers[ExtensionKey()]["PLATFORM"], "lsstcam") 

598 compare_visit_image_to_legacy( 

599 self, 

600 roundtrip.result, 

601 self.legacy_exposure, 

602 expect_view=False, 

603 plane_map=self.plane_map, 

604 **DP2_VISIT_DETECTOR_DATA_ID, 

605 alternates=alternates, 

606 ) 

607 # Check converting from the legacy object in-memory. 

608 compare_visit_image_to_legacy( 

609 self, 

610 VisitImage.from_legacy(self.legacy_exposure, plane_map=self.plane_map), 

611 self.legacy_exposure, 

612 expect_view=True, 

613 plane_map=self.plane_map, 

614 **DP2_VISIT_DETECTOR_DATA_ID, 

615 ) 

616 

617 def test_butler_converters(self) -> None: 

618 """Test that we can read a VisitImage and its components from a butler 

619 dataset written as an `lsst.afw.image.Exposure`. 

620 """ 

621 if self.legacy_exposure is None: 

622 raise unittest.SkipTest("lsst.afw.image.afw could not be imported.") 

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

624 from lsst.daf.butler import FileDataset 

625 

626 helper.butler.ingest(FileDataset(path=self.filename, refs=[helper.legacy]), transfer="symlink") 

627 visit_image_ref = helper.legacy.overrideStorageClass(self.storage_class) 

628 with warnings.catch_warnings(): 

629 # Silence warnings about data ID and filter label disagreeing. 

630 warnings.simplefilter("ignore", category=UserWarning) 

631 visit_image = helper.butler.get(visit_image_ref) 

632 # We didn't ask for the quantization to be preserved, so it 

633 # shouldn't be. 

634 self.assertEqual(visit_image._opaque_metadata.precompressed.keys(), set()) 

635 # This time preserve the quantization 

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

637 self.assertEqual(visit_image._opaque_metadata.precompressed.keys(), {"IMAGE", "VARIANCE"}) 

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

639 self.assertEqual(bbox, visit_image.bbox) 

640 alternates = { 

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

642 # TODO: including "projection" or "obs_info" here fails because 

643 # there's code in daf_butler that expects any component to be 

644 # valid for the *internal* storage class, not the requested 

645 # one, and that's difficult to fix because it's tied up with 

646 # the data ID standardization logic. 

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

648 } 

649 compare_visit_image_to_legacy( 

650 self, 

651 visit_image, 

652 self.legacy_exposure, 

653 expect_view=False, 

654 plane_map=self.plane_map, 

655 alternates=alternates, 

656 **DP2_VISIT_DETECTOR_DATA_ID, 

657 ) 

658 # Add some metadata to the new VisitImage and then do a converting 

659 # `put` that should write to the old format (we have to delete the 

660 # old one first, which just deletes a symlink). 

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

662 visit_image.metadata["MixedCaseKey"] = 52 

663 helper.butler.put(visit_image, visit_image_ref) 

664 # Check that we can read *that* back in as a legacy exposure. 

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

666 compare_visit_image_to_legacy( 

667 self, 

668 visit_image, 

669 legacy_exposure, 

670 expect_view=False, 

671 plane_map=self.plane_map, 

672 alternates=alternates, 

673 **DP2_VISIT_DETECTOR_DATA_ID, 

674 ) 

675 # Check that we can read it back in as a VisitImage, and that the 

676 # new metadata is preserved. 

677 visit_image_2 = helper.butler.get(visit_image_ref) 

678 compare_visit_image_to_legacy( 

679 self, 

680 visit_image_2, 

681 legacy_exposure, 

682 expect_view=False, 

683 plane_map=self.plane_map, 

684 alternates=alternates, 

685 **DP2_VISIT_DETECTOR_DATA_ID, 

686 ) 

687 self.assertEqual(visit_image_2.metadata["MixedCaseKey"], 52) 

688 

689 

690@unittest.skipUnless(EXTERNAL_DATA_DIR is not None, "TESTDATA_IMAGES_DIR is not in the environment.") 

691class VisitImageLegacyTestCase(unittest.TestCase, VisitImageLegacyTestMixin): 

692 """Tests for the VisitImage class using a DRP-final visit_image dataset. 

693 

694 Requires legacy code. 

695 """ 

696 

697 @classmethod 

698 def setUpClass(cls) -> None: 

699 assert EXTERNAL_DATA_DIR is not None, "Guaranteed by decorator." 

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

701 try: 

702 from lsst.afw.image import ExposureFitsReader 

703 

704 cls.legacy_exposure = ExposureFitsReader(cls.filename).read() 

705 except ImportError: 

706 raise unittest.SkipTest("afw not available; cannot read legacy visit images") from None 

707 cls.plane_map = get_legacy_visit_image_mask_planes() 

708 cls.visit_image = VisitImage.read_legacy( 

709 cls.filename, preserve_quantization=True, plane_map=cls.plane_map 

710 ) 

711 cls.unit = u.nJy 

712 

713 def test_convert_unit(self) -> None: 

714 """Test using the ``photometric_scaling`` to swap between 

715 calibrated and instrumental units. 

716 

717 This includes tests of the `VisitImage.to_legacy` logic for units that 

718 don't map directly to `lsst.afw.image.PhotoCalib` conventions. 

719 """ 

720 from lsst.afw.table import ExposureCatalog 

721 

722 assert EXTERNAL_DATA_DIR is not None, "Guaranteed by decorator." 

723 # Make a copy of class state so we can modify it without breaking 

724 # other tests. 

725 original = self.visit_image.copy() 

726 # We should not be able to convert to instrumental units when there is 

727 # no photometric scaling. 

728 with self.assertRaises(u.UnitConversionError): 

729 original.convert_unit(u.electron) 

730 # Converting to the current unit should be a no-op that does not need 

731 # to copy. 

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

733 self.assertTrue(np.may_share_memory(visit_image_nJy.image.array, original.image.array)) 

734 self.assertTrue(np.may_share_memory(visit_image_nJy.variance.array, original.variance.array)) 

735 # Even without a photometric_scaling attached, we should be able to 

736 # convert to a compatible unit, but only if we allow copies. 

737 with self.assertRaises(u.UnitConversionError): 

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

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

740 self.assertEqual(visit_image_mJy.unit, u.mJy) 

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

742 self.assertTrue(np.may_share_memory(visit_image_nJy.mask.array, original.mask.array)) 

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

744 # Converting a mJy image to legacy should make a PhotoCalib that maps 

745 # mJy to nJy. 

746 legacy_exposure_mJy = visit_image_mJy.to_legacy() 

747 assert_close(self, legacy_exposure_mJy.getPhotoCalib().getCalibrationMean(), 1e6) 

748 legacy_masked_image_nJy = legacy_exposure_mJy.getPhotoCalib().calibrateImage( 

749 legacy_exposure_mJy.maskedImage 

750 ) 

751 assert_close(self, visit_image_nJy.image.array, legacy_masked_image_nJy.image.array) 

752 assert_close(self, visit_image_nJy.variance.array, legacy_masked_image_nJy.variance.array) 

753 # Test that we haven't dropped any component objects along the way, 

754 # and that they're all still the same objects or thin views. 

755 self.assertTrue(np.may_share_memory(visit_image_mJy.mask.array, original.mask.array)) 

756 self.assertIs(visit_image_mJy.projection, original.projection) 

757 self.assertIs(visit_image_mJy.obs_info, original.obs_info) 

758 self.assertIs(visit_image_mJy.summary_stats, original.summary_stats) 

759 self.assertIs(visit_image_mJy.psf, original.psf) 

760 self.assertIs(visit_image_mJy.detector, original.detector) 

761 self.assertIs(visit_image_mJy.bounds, original.bounds) 

762 self.assertIs(visit_image_mJy.aperture_corrections, original.aperture_corrections) 

763 self.assertIs(visit_image_mJy.photometric_scaling, original.photometric_scaling) 

764 # Attach the final PhotoCalib (this isn't stored with the legacy file 

765 # because that is the mapping to nJy, which is trivial). 

766 visit_summary = ExposureCatalog.readFits( 

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

768 ) 

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

770 visit_image_nJy.photometric_scaling = field_from_legacy_photo_calib( 

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

772 ) 

773 compare_photo_calib_to_legacy( 

774 self, 

775 visit_image_nJy.photometric_scaling, 

776 self.legacy_exposure.getPhotoCalib(), 

777 applied_legacy_photo_calib=legacy_photo_calib, 

778 subimage_bbox=visit_image_nJy.bbox, 

779 ) 

780 # We still can't convert to completely unrelated units. 

781 with self.assertRaises(u.UnitConversionError): 

782 visit_image_nJy.convert_unit(u.mm) 

783 # Uncalibrating via the photometric_scaling matches what legacy code 

784 # does, and by default it copies everything. 

785 with self.assertRaises(u.UnitConversionError): 

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

787 legacy_masked_image_e = legacy_photo_calib.uncalibrateImage(self.legacy_exposure.maskedImage) 

788 visit_image_e = visit_image_nJy.convert_unit(u.electron) 

789 assert_close(self, visit_image_e.image.array, legacy_masked_image_e.image.array) 

790 assert_close(self, visit_image_e.variance.array, legacy_masked_image_e.variance.array) 

791 self.assertFalse(np.may_share_memory(visit_image_e.mask.array, visit_image_nJy.mask.array)) 

792 # We can also uncalibrate if we start with an image that has units 

793 # that are compatible with the photometric_scaling but not identical 

794 # to it. 

795 visit_image_mJy.photometric_scaling = visit_image_nJy.photometric_scaling 

796 visit_image_e = visit_image_mJy.convert_unit(u.electron) 

797 assert_close(self, visit_image_e.image.array, legacy_masked_image_e.image.array) 

798 assert_close(self, visit_image_e.variance.array, legacy_masked_image_e.variance.array) 

799 # We can re-apply the scaling to go back to calibrated units. 

800 visit_image_nJy_2 = visit_image_e.convert_unit(u.nJy) 

801 assert_close(self, visit_image_nJy_2.image.array, visit_image_nJy.image.array) 

802 assert_close(self, visit_image_nJy_2.variance.array, original.variance.array) 

803 # Try calibrating an image with a scaling that has units other than 

804 # nJy in the numerator. 

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

806 visit_image_nJy_3 = visit_image_e.convert_unit(u.nJy) 

807 assert_close(self, visit_image_nJy_3.image.array, visit_image_nJy.image.array) 

808 assert_close(self, visit_image_nJy_3.variance.array, original.variance.array) 

809 # Try converting that uncalibrated image to legacy; the extra mJy/nJy 

810 # factor should get included in the PhotoCalib to recover the original 

811 # PhotoCalib. 

812 legacy_exposure_e = visit_image_e.to_legacy() 

813 assert_close( 

814 self, 

815 legacy_exposure_e.getPhotoCalib().getCalibrationMean(), 

816 legacy_photo_calib.getCalibrationMean(), 

817 ) 

818 legacy_masked_image_nJy = legacy_exposure_e.getPhotoCalib().calibrateImage( 

819 legacy_exposure_e.maskedImage 

820 ) 

821 assert_close(self, visit_image_nJy.image.array, legacy_masked_image_nJy.image.array) 

822 assert_close(self, visit_image_nJy.variance.array, legacy_masked_image_nJy.variance.array) 

823 

824 

825@unittest.skipUnless(EXTERNAL_DATA_DIR is not None, "TESTDATA_IMAGES_DIR is not in the environment.") 

826class PreliminaryVisitImageLegacyTestCase(unittest.TestCase, VisitImageLegacyTestMixin): 

827 """Tests for the VisitImage class using a DRP preliminary_visit_image 

828 dataset. 

829 

830 Requires legacy code. 

831 """ 

832 

833 @classmethod 

834 def setUpClass(cls) -> None: 

835 assert EXTERNAL_DATA_DIR is not None, "Guaranteed by decorator." 

836 cls.filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "preliminary_visit_image.fits") 

837 try: 

838 from lsst.afw.image import ExposureFitsReader 

839 

840 cls.legacy_exposure = ExposureFitsReader(cls.filename).read() 

841 except ImportError: 

842 raise unittest.SkipTest("afw not available; cannot read legacy visit images") from None 

843 cls.plane_map = get_legacy_visit_image_mask_planes() 

844 cls.visit_image = VisitImage.read_legacy( 

845 cls.filename, preserve_quantization=True, plane_map=cls.plane_map 

846 ) 

847 cls.unit = u.electron 

848 

849 

850@unittest.skipUnless(EXTERNAL_DATA_DIR is not None, "TESTDATA_IMAGES_DIR is not in the environment.") 

851class DifferenceImageLegacyTestCase(unittest.TestCase, VisitImageLegacyTestMixin): 

852 """Tests for the DifferenceImage class using a DRP difference_image 

853 dataset. 

854 

855 Because DifferenceImage is a trivial subclass of VisitImage (it may be 

856 extended in the future), we run the VisitImage tests to make sure nothing 

857 has gone wrong in anything that wasn't trivially inherited. 

858 

859 Requires legacy code. 

860 """ 

861 

862 storage_class: ClassVar[str] = "DifferenceImage" 

863 

864 @classmethod 

865 def setUpClass(cls) -> None: 

866 assert EXTERNAL_DATA_DIR is not None, "Guaranteed by decorator." 

867 cls.filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "difference_image.fits") 

868 try: 

869 from lsst.afw.image import ExposureFitsReader 

870 

871 cls.legacy_exposure = ExposureFitsReader(cls.filename).read() 

872 except ImportError: 

873 raise unittest.SkipTest("afw not available; cannot read legacy visit images") from None 

874 cls.plane_map = get_legacy_difference_image_mask_planes() 

875 cls.visit_image = DifferenceImage.read_legacy( 

876 cls.filename, preserve_quantization=True, plane_map=cls.plane_map 

877 ) 

878 cls.unit = u.nJy 

879 

880 

881if __name__ == "__main__": 

882 unittest.main()