Coverage for python / lsst / images / _transforms / _camera_frame_set.py: 25%

98 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-27 01:31 -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 

14__all__ = ("CameraFrameSet", "CameraFrameSetSerializationModel") 

15 

16from typing import Any 

17 

18import astropy.units as u 

19import pydantic 

20 

21from .._geom import Bounds, Box 

22from ..serialization import ArchiveTree, InputArchive, InvalidParameterError, OutputArchive 

23from . import _ast as astshim 

24from . import _frames # use this import style to facilitate pattern matching 

25from ._frame_set import FrameLookupError, FrameSet 

26from ._transform import Transform 

27 

28 

29class CameraFrameSet(FrameSet): 

30 """A `FrameSet` that manages the coordinate systems of a camera. 

31 

32 The `CameraFrameSet` class constructor is considered a private 

33 implementation detail. At present, instances can only be obtained by 

34 loading them from storage (via 

35 `~CameraFrameSetSerializationModel.deserialize`) or converting a legacy 

36 `lsst.afw.cameraGeom` object (`from_legacy`). 

37 """ 

38 

39 # This constructor is kept private while we support both the astshim 

40 # and starlink-pyast AST wrappers. For now: 

41 # 'instrument': the short (butler dimension) name. 

42 # 'ast': an astshim.FrameSet as returned by 

43 # lsst.afw.cameraGeom.TransformMap.makeFrameSet. 

44 # Should have frames with Ident values FOCAL_PLANE, FIELD_ANGLE 

45 # and DETECTOR_${ID}, and the focal plane frame must know its 

46 # units. 

47 def __init__(self, instrument: str, ast: astshim.FrameSet): 

48 self._ast = ast 

49 self._focal_plane_frame_id: int = 0 

50 self._field_angle_frame_id: int = 0 

51 self._detector_frame_ids: dict[int, int] = {} 

52 for frame_id in range(1, self._ast.nFrame + 1): 

53 ast_frame = self._ast.getFrame(frame_id, copy=False) 

54 match ast_frame.ident: 

55 case "FOCAL_PLANE": 

56 self._focal_plane_frame_id = frame_id 

57 case "FIELD_ANGLE": 

58 self._field_angle_frame_id = frame_id 

59 case str(s) if s.startswith("DETECTOR_"): 

60 detector_id = int(s.removeprefix("DETECTOR_")) 

61 self._detector_frame_ids[detector_id] = frame_id 

62 case _: 

63 raise ValueError(f"Unexpected frame in camera AST FrameSet:\n{ast_frame.show()}.") 

64 if self._focal_plane_frame_id == 0: 

65 raise ValueError("No FOCAL_PLANE frame in camera AST FrameSet.") 

66 self._focal_plane_frame = _frames.FocalPlaneFrame( 

67 instrument=instrument, 

68 unit=u.Unit(self._ast.getFrame(self._focal_plane_frame_id, copy=False).getUnit(1)), 

69 ) 

70 self._field_angle_frame = _frames.FieldAngleFrame(instrument=instrument) 

71 if self._field_angle_frame_id == 0: 

72 raise ValueError("No FIELD_ANGLE frame in camera AST FrameSet.") 

73 

74 @property 

75 def instrument(self) -> str: 

76 """Name of the instrument (`str`).""" 

77 return self._focal_plane_frame.instrument 

78 

79 def focal_plane(self, visit: int | None = None) -> _frames.FocalPlaneFrame: 

80 """Return a focal plane frame for this instrument. 

81 

82 Parameters 

83 ---------- 

84 visit 

85 ID for the visit this frame will correspond to. This only needs 

86 to be provided in contexts where camera frames will be related to 

87 the sky via a `Projection`. 

88 """ 

89 if visit is None: 

90 return self._focal_plane_frame 

91 else: 

92 return self._focal_plane_frame.model_copy(update={"visit": visit}) 

93 

94 def field_angle(self, visit: int | None = None) -> _frames.FieldAngleFrame: 

95 """Return a field angle frame for this instrument. 

96 

97 Parameters 

98 ---------- 

99 visit 

100 ID for the visit this frame will correspond to. This only needs 

101 to be provided in contexts where camera frames will be related to 

102 the sky via a `Projection`. 

103 """ 

104 if visit is None: 

105 return self._field_angle_frame 

106 else: 

107 return self._field_angle_frame.model_copy(update={"visit": visit}) 

108 

109 def detector(self, detector: int, *, visit: int | None = None) -> _frames.DetectorFrame: 

110 """Return a detector pixel-coordinate frame for this instrument. 

111 

112 Parameters 

113 ---------- 

114 detector 

115 ID of the detector. 

116 visit 

117 ID for the visit this frame will correspond to. This only needs 

118 to be provided in contexts where camera frames will be related to 

119 the sky via a `Projection`. 

120 """ 

121 try: 

122 frame_id = self._detector_frame_ids[detector] 

123 except KeyError: 

124 raise FrameLookupError( 

125 f"No frame for detector {detector!r} in camera for {self.instrument!r}." 

126 ) from None 

127 ast_frame = self._ast.getFrame(frame_id, copy=False) 

128 bbox = Box.factory[ 

129 int(ast_frame.getBottom(2)) : int(ast_frame.getTop(2)), 

130 int(ast_frame.getBottom(1)) : int(ast_frame.getTop(1)), 

131 ] 

132 return _frames.DetectorFrame(instrument=self.instrument, detector=detector, visit=visit, bbox=bbox) 

133 

134 def __contains__(self, frame: _frames.Frame) -> bool: 

135 try: 

136 self._parse_frame_arg(frame) 

137 return True 

138 except FrameLookupError: 

139 return False 

140 

141 def __getitem__[I: _frames.Frame, O: _frames.Frame](self, key: tuple[I, O]) -> Transform[I, O]: 

142 in_frame, out_frame = key 

143 in_frame_id, in_bounds = self._parse_frame_arg(in_frame) 

144 out_frame_id, out_bounds = self._parse_frame_arg(out_frame) 

145 return Transform( 

146 in_frame, 

147 out_frame, 

148 self._ast.getMapping(in_frame_id, out_frame_id), 

149 in_bounds=in_bounds, 

150 out_bounds=out_bounds, 

151 ) 

152 

153 def _parse_frame_arg(self, frame: _frames.Frame) -> tuple[int, Bounds | None]: 

154 bounds: Bounds | None = None 

155 match frame: 

156 case _frames.DetectorFrame(instrument=self.instrument, detector=detector_id): 

157 try: 

158 frame_id = self._detector_frame_ids[detector_id] 

159 except KeyError: 

160 raise FrameLookupError( 

161 f"No frame for detector {detector_id!r} in camera for {self.instrument!r}." 

162 ) from None 

163 bounds = frame.bbox 

164 case _frames.FocalPlaneFrame(instrument=self.instrument): 

165 frame_id = self._focal_plane_frame_id 

166 case _frames.FieldAngleFrame(instrument=self.instrument): 

167 frame_id = self._field_angle_frame_id 

168 case _: 

169 raise FrameLookupError(f"Invalid frame for camera {self.instrument}: {frame!r}.") 

170 return frame_id, bounds 

171 

172 def serialize(self, archive: OutputArchive[Any]) -> CameraFrameSetSerializationModel: 

173 """Serialize the frame set to an archive. 

174 

175 Parameters 

176 ---------- 

177 archive 

178 Archive to serialize to. 

179 

180 Returns 

181 ------- 

182 `CameraFrameSetSerializationModel` 

183 Serialized form of the frame set. 

184 """ 

185 return CameraFrameSetSerializationModel(instrument=self.instrument, ast=self._ast.show()) 

186 

187 @staticmethod 

188 def _get_archive_tree_type( 

189 pointer_type: type[pydantic.BaseModel], 

190 ) -> type[CameraFrameSetSerializationModel]: 

191 """Return the serialization model type for this object for an archive 

192 type that uses the given pointer type. 

193 """ 

194 return CameraFrameSetSerializationModel 

195 

196 @classmethod 

197 def from_legacy(cls, camera: Any) -> CameraFrameSet: 

198 """Construct a transform from a legacy `lsst.afw.cameraGeom.Camera`. 

199 

200 Parameters 

201 ---------- 

202 camera 

203 An `lsst.afw.cameraGeom.Camera` instance to convert. 

204 """ 

205 transform_map = camera.getTransformMap() 

206 ast_frame_set = transform_map.makeFrameSet(list(camera)) 

207 return CameraFrameSet("HSC", ast_frame_set) 

208 

209 

210class CameraFrameSetSerializationModel(ArchiveTree): 

211 """Serialization model for `CameraFrameSet`.""" 

212 

213 instrument: str = pydantic.Field(description="Name of the instrument.") 

214 ast: str = pydantic.Field( 

215 description="A serialized Starlink AST FrameSet, using the AST native encoding." 

216 ) 

217 

218 def deserialize(self, archive: InputArchive[Any], **kwargs: Any) -> CameraFrameSet: 

219 """Deserialize a frame set from an archive. 

220 

221 Parameters 

222 ---------- 

223 archive 

224 Archive to read from. 

225 **kwargs 

226 Unsupported keyword arguments are accepted only to provide better 

227 error messages (raising `serialization.InvalidParameterError`). 

228 """ 

229 if kwargs: 

230 raise InvalidParameterError(f"Unrecognized parameters for CameraFrameSet: {set(kwargs.keys())}.") 

231 return CameraFrameSet(self.instrument, astshim.FrameSet.fromString(self.ast))