Coverage for tests/test_imageCutoutsBackend.py: 23%

99 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-09 10:01 +0000

1# This file is part of dax_images_cutout. 

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# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22import os.path 

23import tempfile 

24import unittest 

25 

26import astropy.io.fits 

27import numpy as np 

28 

29import lsst.daf.butler 

30import lsst.images 

31import lsst.sphgeom 

32import lsst.utils.tests 

33from lsst.dax.images.cutout import CutoutMode, ImageCutoutFactory, projection_finders, stencils 

34 

35try: 

36 import lsst.afw.image 

37 import lsst.geom 

38 

39 HAVE_AFW = True 

40except ImportError: 

41 HAVE_AFW = False 

42 

43 

44@unittest.skipUnless(HAVE_AFW, "lsst.afw not available") 

45class TestImageCutoutsBackend(lsst.utils.tests.TestCase): 

46 """Tests for ImageCutoutsBackend.""" 

47 

48 @classmethod 

49 def setUpClass(cls): 

50 try: 

51 cls.data_dir = lsst.utils.getPackageDir("testdata_image_cutouts") 

52 except LookupError: 

53 raise unittest.SkipTest("testdata_image_cutouts not setup.") from None 

54 

55 def setUp(self): 

56 collection = "2.2i/runs/test-med-1/w_2022_03/DM-33223/20220118T193330Z" 

57 self.butler = lsst.daf.butler.Butler(os.path.join(self.data_dir, "repo"), collections=collection) 

58 

59 # Centered on a galaxy 

60 point = lsst.sphgeom.LonLat.fromDegrees(56.6400770, -36.4492250) 

61 radius = lsst.sphgeom.Angle((10 * lsst.geom.arcseconds).asRadians()) 

62 self.stencil = stencils.SkyCircle(point, radius) 

63 

64 self.projectionFinders = ( 

65 projection_finders.ReadComponents(), 

66 projection_finders.ReadComponentsAstropyFits(), 

67 ) 

68 

69 self.dataId = {"patch": 24, "tract": 3828, "band": "r", "skymap": "DC2"} 

70 self.ref = self.butler.find_dataset("deepCoadd_calexp", data_id=self.dataId) 

71 

72 def test_extract_ref(self): 

73 """Test that extract_ref produces a reasonable cutout.""" 

74 with tempfile.TemporaryDirectory() as tempdir: 

75 for cutout_mode in CutoutMode: 

76 # Try each available projection finder at least once. 

77 proj_finder = self.projectionFinders[cutout_mode.value % len(self.projectionFinders)] 

78 cutoutBackend = ImageCutoutFactory(self.butler, proj_finder, tempdir) 

79 result = cutoutBackend.extract_ref(self.stencil, self.ref, cutout_mode=cutout_mode) 

80 # The galaxy should be near the center of the image. 

81 match result.cutout: 

82 case lsst.afw.image.Exposure() | lsst.afw.image.MaskedImage(): 

83 box = result.cutout.getBBox() 

84 array = result.cutout.image.array 

85 case lsst.afw.image.Image(): 

86 array = result.cutout.array 

87 box = result.cutout.getBBox() 

88 case lsst.images.MaskedImage(): 

89 box = result.cutout.bbox.to_legacy() 

90 array = result.cutout.image.array 

91 case lsst.images.Image(): 

92 array = result.cutout.array 

93 box = result.cutout.bbox.to_legacy() 

94 case _: 

95 raise RuntimeError(f"Unexpected cutout type: {type(result.cutout)}") 

96 self.assertEqual(box.width, 101) 

97 self.assertEqual(box.height, 101) 

98 self.assertFloatsAlmostEqual(array[50, 49], 2.083247661590576) 

99 self.assertFloatsAlmostEqual(array[1, 1], -0.14575983583927155) 

100 self.assertFloatsAlmostEqual(array[100, 100], 0.08674515783786774) 

101 

102 output = cutoutBackend.process_ref(self.stencil, self.ref, cutout_mode=cutout_mode) 

103 self.assertTrue(output.exists()) 

104 

105 output = cutoutBackend.process_uuid(self.stencil, self.ref.id, cutout_mode=cutout_mode) 

106 self.assertTrue(output.exists()) 

107 

108 def test_provenance_in_primary_header(self): 

109 """Cutout provenance must land in the primary FITS header for every 

110 cutout mode, including the native ``lsst.images`` container modes. 

111 """ 

112 provenance_keys = ("CUTVERS",) 

113 # Card comments that fit within the 80-column FITS card limit and so 

114 # survive the round trip, covering both the provenance keys (DATE-CUT) 

115 # and the stencil keys (ST_TYPE). Some cards can potentially have 

116 # truncated comments. 

117 provenance_comments = { 

118 "BTLRUUID": "Butler UUID of full image", 

119 "BTLRNAME": "Butler dataset type", 

120 "ST_TYPE": "Type of stencil used to create this cutout", 

121 "ST_RA": "[deg] Circle center Right Ascension", 

122 "DATE-CUT": "Time of cutout extraction", 

123 } 

124 

125 with tempfile.TemporaryDirectory() as tempdir: 

126 cutoutBackend = ImageCutoutFactory(self.butler, self.projectionFinders[0], tempdir) 

127 for cutout_mode in CutoutMode: 

128 with self.subTest(cutout_mode=str(cutout_mode)): 

129 output = cutoutBackend.process_ref(self.stencil, self.ref, cutout_mode=cutout_mode) 

130 with output.open("rb") as fh, astropy.io.fits.open(fh) as hdul: 

131 header = hdul[0].header 

132 for key in provenance_keys: 

133 self.assertIn( 

134 key, 

135 header, 

136 f"{key} missing from primary header for {cutout_mode}", 

137 ) 

138 self.assertEqual(header["BTLRUUID"], self.ref.id.hex) 

139 self.assertEqual(header["BTLRNAME"], self.ref.datasetType.name) 

140 for key, comment in provenance_comments.items(): 

141 self.assertEqual( 

142 header.comments[key], 

143 comment, 

144 f"{key} comment wrong in primary header for {cutout_mode}", 

145 ) 

146 

147 # This test file has a BGMEAN header in the original 

148 # primary header and we need to ensure that it still 

149 # exists in the cutout to indicate that the primary 

150 # header was propagated. IMAGE does not have it since 

151 # IMAGE only reads the second header without merging 

152 if cutout_mode != CutoutMode.IMAGE_ONLY: 

153 self.assertIn("BGMEAN", header) 

154 

155 def test_astropy_masked_image_planes_readable_by_afw(self): 

156 """The ``ASTROPY_MASKED_IMAGE`` backend re-serializes the cutout as an 

157 ``lsst.images`` file but keeps the legacy ``MP_`` mask-plane cards 

158 (re-indexed to the reshuffled schema). ``lsst.afw.image`` can 

159 therefore read the cutout, and each mask plane covers the same pixels 

160 as the afw-only ``MASKED_IMAGE`` backend even though the bit numbering 

161 may differ between the two. 

162 """ 

163 with tempfile.TemporaryDirectory() as tempdir: 

164 cutoutBackend = ImageCutoutFactory(self.butler, self.projectionFinders[0], tempdir) 

165 astropy_uri = cutoutBackend.process_ref( 

166 self.stencil, self.ref, cutout_mode=CutoutMode.ASTROPY_MASKED_IMAGE 

167 ) 

168 afw_uri = cutoutBackend.process_ref(self.stencil, self.ref, cutout_mode=CutoutMode.MASKED_IMAGE) 

169 # Both files are read back through afw; the astropy cutout is an 

170 # lsst.images file, so this exercises afw reading the re-indexed 

171 # MP_ cards. 

172 astropy_mask = lsst.afw.image.MaskedImageF(astropy_uri.ospath).mask 

173 afw_mask = lsst.afw.image.MaskedImageF(afw_uri.ospath).mask 

174 

175 common_planes = set(astropy_mask.getMaskPlaneDict()) & set(afw_mask.getMaskPlaneDict()) 

176 self.assertTrue(common_planes, "afw read no shared mask planes from the astropy cutout") 

177 

178 any_set = False 

179 for plane in sorted(common_planes): 

180 astropy_set = (astropy_mask.array & astropy_mask.getPlaneBitMask(plane)) != 0 

181 afw_set = (afw_mask.array & afw_mask.getPlaneBitMask(plane)) != 0 

182 np.testing.assert_array_equal( 

183 astropy_set, afw_set, err_msg=f"mask plane {plane!r} coverage differs" 

184 ) 

185 any_set = any_set or bool(afw_set.any()) 

186 self.assertTrue(any_set, "expected at least one mask plane to be set in the cutout") 

187 

188 

189if __name__ == "__main__": 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true

190 unittest.main()