Coverage for tests/test_cameras.py: 71%
140 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-24 02:01 -0700
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-24 02:01 -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.
12from __future__ import annotations
14import os
15from typing import Any
17import pytest
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)
31LOCAL_DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
33EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None)
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
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}
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)
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)
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)
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)
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
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
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
169def _make_legacy_amplifier(*, has_prescan: bool) -> Any:
170 """Return a legacy amplifier with a simple raw geometry.
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.
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
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()
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)
222def test_amplifier_without_prescan(reset_afw_mask_planes: None) -> None: # noqa: F811
223 """Test converting a legacy amplifier whose prescan region is empty.
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)
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)