Coverage for tests/test_cameras.py: 71%

140 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-25 15:36 -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 os 

15from typing import Any 

16 

17import pytest 

18 

19from lsst.images import YX 

20from lsst.images.cameras import Amplifier, AmplifierRawGeometry, Detector, ReadoutCorner 

21from lsst.images.describe import DescribableMixin, Report 

22from lsst.images.serialization import read_archive 

23from lsst.images.tests import ( 

24 DP2_VISIT_DETECTOR_DATA_ID, 

25 RoundtripFits, 

26 compare_amplifier_to_legacy, 

27 compare_detector_to_legacy, 

28 reset_afw_mask_planes, # noqa: F401 

29) 

30 

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

32 

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

34 

35 

36@pytest.fixture 

37def legacy_camera_data(reset_afw_mask_planes: None) -> dict[str, Any]: # noqa: F811 

38 """Return a dict with legacy_camera and legacy_detector loaded from the 

39 test data directory. 

40 """ 

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

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

43 # reset_afw_mask_planes will have already skipped if afw is not available. 

44 from lsst.afw.cameraGeom import Camera as LegacyCamera 

45 from lsst.afw.image import ExposureFitsReader 

46 

47 legacy_camera = LegacyCamera.readFits(os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "camera.fits")) 

48 legacy_detector = ExposureFitsReader( 

49 os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "visit_image.fits") 

50 ).readDetector() 

51 return {"legacy_camera": legacy_camera, "legacy_detector": legacy_detector} 

52 

53 

54def test_visit_image_detector_legacy_conversions(legacy_camera_data: dict[str, Any]) -> None: 

55 """Test converting a visit-image detector from legacy and back with 

56 serialization in between. 

57 """ 

58 legacy_detector = legacy_camera_data["legacy_detector"] 

59 detector = Detector.from_legacy( 

60 legacy_detector, 

61 instrument=DP2_VISIT_DETECTOR_DATA_ID["instrument"], 

62 visit=DP2_VISIT_DETECTOR_DATA_ID["visit"], 

63 ) 

64 compare_detector_to_legacy(detector, legacy_detector, is_raw_assembled=True) 

65 with RoundtripFits(detector) as roundtrip: 

66 pass 

67 compare_detector_to_legacy(roundtrip.result, legacy_detector, is_raw_assembled=True) 

68 compare_detector_to_legacy(detector, roundtrip.result.to_legacy(), is_raw_assembled=True) 

69 

70 

71def test_camera_detector_legacy_conversions(legacy_camera_data: dict[str, Any]) -> None: 

72 """Test converting one detector of each type from a legacy Camera and 

73 back. 

74 """ 

75 legacy_camera = legacy_camera_data["legacy_camera"] 

76 detector_types_seen = set() 

77 for legacy_detector_1 in legacy_camera: 

78 if legacy_detector_1.getType() in detector_types_seen: 

79 continue 

80 detector_types_seen.add(legacy_detector_1.getType()) 

81 detector = Detector.from_legacy( 

82 legacy_detector_1, 

83 instrument=DP2_VISIT_DETECTOR_DATA_ID["instrument"], 

84 ) 

85 compare_detector_to_legacy(detector, legacy_detector_1, is_raw_assembled=False) 

86 with RoundtripFits(detector) as roundtrip: 

87 pass 

88 compare_detector_to_legacy(roundtrip.result, legacy_detector_1, is_raw_assembled=False) 

89 legacy_detector_2 = roundtrip.result.to_legacy() 

90 compare_detector_to_legacy(detector, legacy_detector_2, is_raw_assembled=False) 

91 

92 

93def test_expanded_detector_roundtrip(legacy_camera_data: dict[str, Any]) -> None: 

94 """Test round-tripping a detector that holds both assembled and 

95 unassembled raw amplifier geometry. 

96 """ 

97 legacy_camera = legacy_camera_data["legacy_camera"] 

98 legacy_detector = legacy_camera_data["legacy_detector"] 

99 legacy_camera_detector = legacy_camera[DP2_VISIT_DETECTOR_DATA_ID["detector"]] 

100 detector = Detector.from_legacy( 

101 legacy_camera_detector, 

102 instrument=DP2_VISIT_DETECTOR_DATA_ID["instrument"], 

103 visit=DP2_VISIT_DETECTOR_DATA_ID["visit"], 

104 is_raw_assembled=False, 

105 ) 

106 for amplifier, legacy_assembled_amplifier in zip( 

107 detector.amplifiers, legacy_detector.getAmplifiers(), strict=True 

108 ): 

109 assert amplifier.unassembled_raw_geometry is not None 

110 assert amplifier.assembled_raw_geometry is None 

111 amplifier.assembled_raw_geometry = AmplifierRawGeometry.from_legacy_amplifier( 

112 legacy_assembled_amplifier 

113 ) 

114 with RoundtripFits(detector) as roundtrip: 

115 pass 

116 compare_detector_to_legacy(roundtrip.result, legacy_camera_detector, is_raw_assembled=False) 

117 compare_detector_to_legacy(roundtrip.result, legacy_detector, is_raw_assembled=True) 

118 

119 

120def test_as_flips() -> None: 

121 """Tes that ReadoutCorner.as_flips returns the correct YX flip flags for 

122 each corner. 

123 """ 

124 assert ReadoutCorner.LL.as_flips() == YX(y=False, x=False) 

125 assert ReadoutCorner.LR.as_flips() == YX(y=False, x=True) 

126 assert ReadoutCorner.UL.as_flips() == YX(y=True, x=False) 

127 assert ReadoutCorner.UR.as_flips() == YX(y=True, x=True) 

128 

129 

130def test_flips_roundtrip() -> None: 

131 """Test that ReadoutCorner.from_flips is the inverse of as_flips for all 

132 corners. 

133 """ 

134 for corner in ReadoutCorner: 

135 flips = corner.as_flips() 

136 assert ReadoutCorner.from_flips(y=flips.y, x=flips.x) is corner 

137 

138 

139def test_apply_flips() -> None: 

140 """Test that ReadoutCorner.apply_flips with identity returns the same 

141 corner, and a few specific flips are correct. 

142 """ 

143 for corner in ReadoutCorner: 

144 assert corner.apply_flips(y=False, x=False) is corner 

145 assert ReadoutCorner.LL.apply_flips(y=True, x=True) is ReadoutCorner.UR 

146 assert ReadoutCorner.LR.apply_flips(y=False, x=True) is ReadoutCorner.LL 

147 assert ReadoutCorner.UL.apply_flips(y=True, x=False) is ReadoutCorner.LL 

148 assert ReadoutCorner.UR.apply_flips(y=True, x=True) is ReadoutCorner.LL 

149 

150 

151def test_detector_describe() -> None: 

152 """Detector._describe returns a Report with the expected fields.""" 

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

154 assert isinstance(detector, DescribableMixin) 

155 report = detector._describe() 

156 assert isinstance(report, Report) 

157 assert report.type_name == "Detector" 

158 labels = {f.label for f in report.fields} 

159 assert "instrument" in labels 

160 assert "name" in labels 

161 assert "id" in labels 

162 assert "type" in labels 

163 assert "serial" in labels 

164 assert "bbox" in labels 

165 name_field = next(f for f in report.fields if f.label == "name") 

166 assert name_field.value == detector.name 

167 

168 

169def _make_legacy_amplifier(*, has_prescan: bool) -> Any: 

170 """Return a legacy amplifier with a simple raw geometry. 

171 

172 The raw regions tile the raw bounding box: 4 columns of prescan, 100 

173 columns of data and 10 columns of serial overscan make up its 114 

174 columns, and 50 rows of data plus 6 rows of parallel overscan make up 

175 its 56 rows. 

176 

177 Parameters 

178 ---------- 

179 has_prescan 

180 Whether to give the amplifier a prescan region. DECam amplifiers 

181 have none, and afw reports a missing region as an empty box. 

182 """ 

183 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier 

184 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner 

185 from lsst.geom import Box2I, Extent2I, Point2I 

186 

187 builder = LegacyAmplifier.Builder() 

188 builder.setName("C00") 

189 builder.setBBox(Box2I(Point2I(0, 0), Extent2I(100, 50))) 

190 builder.setRawBBox(Box2I(Point2I(0, 0), Extent2I(114, 56))) 

191 builder.setRawDataBBox(Box2I(Point2I(4, 0), Extent2I(100, 50))) 

192 builder.setRawSerialOverscanBBox(Box2I(Point2I(104, 0), Extent2I(10, 50))) 

193 builder.setRawParallelOverscanBBox(Box2I(Point2I(4, 50), Extent2I(100, 6))) 

194 if has_prescan: 

195 builder.setRawPrescanBBox(Box2I(Point2I(0, 0), Extent2I(4, 50))) 

196 # LL plus a flip in x puts the readout corner of the trimmed image at 

197 # LR, so the tests can tell whether the corner was converted. 

198 builder.setReadoutCorner(LegacyReadoutCorner.LL) 

199 builder.setRawFlipX(True) 

200 builder.setRawXYOffset(Extent2I(0, 0)) 

201 return builder.finish() 

202 

203 

204def test_amplifier_with_full_raw_geometry(reset_afw_mask_planes: None) -> None: # noqa: F811 

205 """Test converting a legacy amplifier whose raw geometry can be fully 

206 represented. 

207 """ 

208 legacy_amplifier = _make_legacy_amplifier(has_prescan=True) 

209 amplifier = Amplifier.from_legacy(legacy_amplifier, is_raw_assembled=True) 

210 raw_geometry = amplifier.assembled_raw_geometry 

211 assert raw_geometry is not None 

212 assert amplifier.unassembled_raw_geometry is None 

213 # The legacy readout corner belongs to the raw image, which is flipped in 

214 # x relative to the assembled, trimmed image. 

215 assert raw_geometry.readout_corner == ReadoutCorner.LL 

216 assert amplifier.readout_corner == ReadoutCorner.LR 

217 compare_amplifier_to_legacy(amplifier, legacy_amplifier, is_raw_assembled=True) 

218 round_tripped = amplifier.to_legacy_builder(True).finish() 

219 compare_amplifier_to_legacy(amplifier, round_tripped, is_raw_assembled=True) 

220 

221 

222def test_amplifier_without_prescan(reset_afw_mask_planes: None) -> None: # noqa: F811 

223 """Test converting a legacy amplifier whose prescan region is empty. 

224 

225 `Box` requires a positive size, so the prescan box is `None` while the 

226 rest of the raw geometry is kept. 

227 """ 

228 legacy_amplifier = _make_legacy_amplifier(has_prescan=False) 

229 raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(legacy_amplifier) 

230 assert raw_geometry.prescan_bbox is None 

231 assert raw_geometry.serial_prescan_bbox is None 

232 assert raw_geometry.horizontal_prescan_bbox is None 

233 amplifier = Amplifier.from_legacy(legacy_amplifier, is_raw_assembled=True) 

234 assert amplifier.assembled_raw_geometry is not None 

235 assert amplifier.assembled_raw_geometry.prescan_bbox is None 

236 assert amplifier.unassembled_raw_geometry is None 

237 assert amplifier.assembled_raw_geometry.readout_corner == ReadoutCorner.LL 

238 assert amplifier.readout_corner == ReadoutCorner.LR 

239 compare_amplifier_to_legacy(amplifier, legacy_amplifier, is_raw_assembled=True) 

240 # Everything but the prescan survives the round trip, and the prescan 

241 # comes back as the empty box afw uses for an amplifier that has none. 

242 round_tripped = amplifier.to_legacy_builder(True).finish() 

243 assert round_tripped.getRawPrescanBBox().isEmpty() 

244 assert round_tripped.getRawBBox() == legacy_amplifier.getRawBBox() 

245 assert round_tripped.getRawDataBBox() == legacy_amplifier.getRawDataBBox() 

246 assert round_tripped.getRawSerialOverscanBBox() == legacy_amplifier.getRawSerialOverscanBBox() 

247 assert round_tripped.getRawParallelOverscanBBox() == legacy_amplifier.getRawParallelOverscanBBox() 

248 assert round_tripped.getRawFlipX() == legacy_amplifier.getRawFlipX() 

249 assert round_tripped.getReadoutCorner() == legacy_amplifier.getReadoutCorner() 

250 compare_amplifier_to_legacy(amplifier, round_tripped, is_raw_assembled=True) 

251 

252 

253def test_amplifier_wrong_raw_geometry_kind(reset_afw_mask_planes: None) -> None: # noqa: F811 

254 """Test that asking for the raw geometry the amplifier does not have is 

255 still an error. 

256 """ 

257 amplifier = Amplifier.from_legacy(_make_legacy_amplifier(has_prescan=True), is_raw_assembled=True) 

258 with pytest.raises(ValueError, match="unassembled_raw_geometry is None"): 

259 amplifier.to_legacy_builder(False)