Coverage for python / lsst / images / _generalized_image.py: 43%

95 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-28 09:01 +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 

14__all__ = ("AbsoluteSliceProxy", "GeneralizedImage", "LocalSliceProxy") 

15 

16from abc import ABC, abstractmethod 

17from functools import cached_property 

18from types import EllipsisType 

19from typing import TYPE_CHECKING, Any, Self, TypeVar 

20 

21import astropy.wcs 

22 

23from ._geom import Box 

24from ._transforms import Projection, ProjectionAstropyView 

25from .serialization import ArchiveTree, ButlerInfo, MetadataValue, OpaqueArchiveMetadata 

26 

27if TYPE_CHECKING: 

28 from lsst.daf.butler import DatasetProvenance, SerializedDatasetRef 

29 

30 

31T = TypeVar("T", bound="GeneralizedImage") # for sphinx 

32 

33 

34class GeneralizedImage(ABC): 

35 """A base class for types that represent one or more 2-d image-like arrays 

36 with the same pixel grid and projection. 

37 

38 Parameters 

39 ---------- 

40 metadata 

41 Arbitrary flexible metadata to associate with the image. 

42 """ 

43 

44 def __init__(self, metadata: dict[str, MetadataValue] | None = None): 

45 self._metadata = metadata if metadata is not None else {} 

46 self._opaque_metadata: OpaqueArchiveMetadata | None = None 

47 self._butler_info: ButlerInfo | None = None 

48 

49 @property 

50 @abstractmethod 

51 def bbox(self) -> Box: 

52 """Bounding box for the image (`Box`).""" 

53 raise NotImplementedError() 

54 

55 @property 

56 @abstractmethod 

57 def projection(self) -> Projection[Any] | None: 

58 """The projection that maps this image's pixel grid to the sky 

59 (`Projection` | `None`). 

60 

61 Notes 

62 ----- 

63 The pixel coordinates used by this projection account for the bounding 

64 box ``start``; they are not just array indices. 

65 """ 

66 raise NotImplementedError() 

67 

68 @property 

69 def astropy_wcs(self) -> ProjectionAstropyView | None: 

70 """An Astropy WCS for this image's pixel array. 

71 

72 Notes 

73 ----- 

74 As expected for Astropy WCS objects, this defines pixel coordinates 

75 such that the first row and column in any associated arrays are 

76 ``(0, 0)``, not ``bbox.start``, as is the case for `projection`. 

77 

78 This object satisfies the `astropy.wcs.wcsapi.BaseHighLevelWCS` and 

79 `astropy.wcs.wcsapi.BaseLowLevelWCS` interfaces, but it is not an 

80 `astropy.wcs.WCS` (use `fits_wcs` for that). 

81 """ 

82 return self.projection.as_astropy(self.bbox) if self.projection is not None else None 

83 

84 @cached_property 

85 def fits_wcs(self) -> astropy.wcs.WCS | None: 

86 """An Astropy FITS WCS for this image's pixel array. 

87 

88 Notes 

89 ----- 

90 As expected for Astropy WCS objects, this defines pixel coordinates 

91 such that the first row and column in any associated arrays are 

92 ``(0, 0)``, not ``bbox.start``, as is the case for `projection`. 

93 

94 This may be an approximation or absent if `projection` is not 

95 naturally representable as a FITS WCS. 

96 """ 

97 return ( 

98 self.projection.as_fits_wcs(self.bbox, allow_approximation=True) 

99 if self.projection is not None 

100 else None 

101 ) 

102 

103 @property 

104 def local(self) -> LocalSliceProxy[Self]: 

105 """A proxy object for slicing a generalized image using "local" or 

106 "array" pixel coordinates. 

107 

108 Notes 

109 ----- 

110 In this convention, the first row and column of the pixel grid is 

111 always at ``(0, 0)``. This is also the convention used by 

112 `astropy.wcs` objects. When a subimage is created from a parent image, 

113 its "local" coordinate system is offset from the coordinate systems of 

114 the parent image. 

115 

116 Note that most `lsst.images` types (e.g. `~lsst.images.Box`, 

117 `~lsst.images.Projection`, `~lsst.images.psfs.PointSpreadFunction`) 

118 operate instead in "absolute" coordinates, which is shared by subimage 

119 and their parents. 

120 

121 See Also 

122 -------- 

123 lsst.images.BoxSliceFactory 

124 lsst.images.IntervalSliceFactory 

125 """ 

126 return LocalSliceProxy(self) 

127 

128 @property 

129 def absolute(self) -> AbsoluteSliceProxy[Self]: 

130 """A proxy object for slicing a generalized image using absolute pixel 

131 coordinates. 

132 

133 Notes 

134 ----- 

135 In this convention, the first row and column of the pixel grid is 

136 ``bbox.start``. A subimage and its parent image share the same 

137 absolute pixel coordinate system, and most `lsst.images` types (e.g. 

138 `~lsst.images.Box`, `~lsst.images.Projection`, 

139 `~lsst.images.psfs.PointSpreadFunction`) operate exclusively in this 

140 system. 

141 

142 Note that `astropy.wcs` and `numpy.ndarray` are not aware of the 

143 ``bbox.start`` offset that defines tihs coordinates system; use 

144 `local` slicing for indices obtained from those. 

145 

146 See Also 

147 -------- 

148 lsst.images.BoxSliceFactory 

149 lsst.images.IntervalSliceFactory 

150 """ 

151 return AbsoluteSliceProxy(self) 

152 

153 @property 

154 def metadata(self) -> dict[str, MetadataValue]: 

155 """Arbitrary flexible metadata associated with the image (`dict`). 

156 

157 Notes 

158 ----- 

159 Metadata is shared with subimages and other views. It can be 

160 disconnected by reassigning to a copy explicitly: 

161 

162 image.metadata = image.metadata.copy() 

163 """ 

164 return self._metadata 

165 

166 @metadata.setter 

167 def metadata(self, value: dict[str, MetadataValue]) -> None: 

168 self._metadata = value 

169 

170 # Subclasses should delegate to super().__getitem__ for some user-friendly 

171 # argument type-checking before providing their own implementation. 

172 @abstractmethod 

173 def __getitem__(self, bbox: Box | EllipsisType) -> Self: 

174 if not isinstance(bbox, Box): 

175 raise TypeError( 

176 "Only Box objects can be used to subset image objects directly; " 

177 "use .local[y, x] or .absolute[y, x] proxies for slice-based subsets." 

178 ) 

179 return self 

180 

181 @abstractmethod 

182 def copy(self) -> Self: 

183 """Deep-copy the image and metadata. 

184 

185 Attached immutable objects (like `Projection` instances) are not 

186 copied. 

187 """ 

188 raise NotImplementedError() 

189 

190 @property 

191 def butler_dataset(self) -> SerializedDatasetRef | None: 

192 """The butler dataset reference for this image 

193 (`lsst.daf.butler.SerializedDatasetRef` | `None`). 

194 """ 

195 if self._butler_info is None: 

196 return None 

197 from lsst.daf.butler import SerializedDatasetRef 

198 

199 # Guard against the unlikely case where the dataset was deserialized as 

200 # Any because `lsst.daf.butler` couldn't be imported before, but can be 

201 # imported now (*anything* can happen in Jupyter). 

202 return SerializedDatasetRef.model_validate(self._butler_info.dataset) 

203 

204 @property 

205 def butler_provenance(self) -> DatasetProvenance | None: 

206 """The butler inputs and ID of the task quantum that produced this 

207 dataset (`lsst.daf.butler.DatasetProvenance` | `None`) 

208 """ 

209 if self._butler_info is None: 

210 return None 

211 

212 # Guard against the unlikely case where the provenance was deserialized 

213 # as Any because `lsst.daf.butler` couldn't be imported before, but can 

214 # be imported now (*anything* can happen in Jupyter). 

215 from lsst.daf.butler import DatasetProvenance 

216 

217 return DatasetProvenance.model_validate(self._butler_info.provenance) 

218 

219 def _transfer_metadata(self, new: Self, copy: bool = False, bbox: Box | None = None) -> Self: 

220 """Transfer metadata held by this base class to a new instance. 

221 

222 Parameters 

223 ---------- 

224 new 

225 New instance to modify and return. 

226 copy 

227 Whether the new instance is a deep-copy of ``self``. 

228 bbox 

229 Bounding box used to construct ``new`` as a subset of ``self``. 

230 

231 Returns 

232 ------- 

233 GeneralizedImage 

234 The new object passed in, modified in place. 

235 

236 Notes 

237 ----- 

238 This is a utility method for subclasses to use when finishing 

239 construction of a new one. 

240 """ 

241 if bbox is not None: 

242 opaque_metadata = ( 

243 self._opaque_metadata.subset(bbox) if self._opaque_metadata is not None else None 

244 ) 

245 else: 

246 opaque_metadata = self._opaque_metadata 

247 metadata = self._metadata 

248 if copy: 

249 metadata = metadata.copy() 

250 opaque_metadata = opaque_metadata.copy() if opaque_metadata is not None else None 

251 new._metadata = metadata 

252 new._opaque_metadata = opaque_metadata 

253 new._butler_info = self._butler_info 

254 return new 

255 

256 def _finish_deserialize(self, model: ArchiveTree) -> Self: 

257 """Attach generic information from `ArchiveTree` to this instance 

258 at the end of deserialization. 

259 """ 

260 self._metadata = model.metadata 

261 self._butler_info = model.butler_info 

262 return self 

263 

264 

265class LocalSliceProxy[T: GeneralizedImage]: 

266 """A proxy object for obtaining a generalized image subset using local 

267 slicing. 

268 

269 See `GeneralizedImage.local` for more information. 

270 """ 

271 

272 def __init__(self, parent: T): 

273 self._parent = parent 

274 

275 def __getitem__(self, slices: tuple[slice, slice]) -> T: 

276 try: 

277 return self._parent[self._parent.bbox.local[slices]] 

278 except TypeError as err: 

279 if hasattr(self._parent, "array"): 

280 err.add_note("The .array attribute may provide more slicing flexibility.") 

281 raise 

282 

283 

284class AbsoluteSliceProxy[T: GeneralizedImage]: 

285 """A proxy object for obtaining a generalized image subset using absolute 

286 slicing. 

287 

288 See `GeneralizedImage.absolute` for more information. 

289 """ 

290 

291 def __init__(self, parent: T): 

292 self._parent = parent 

293 

294 def __getitem__(self, slices: tuple[slice, slice]) -> T: 

295 try: 

296 return self._parent[self._parent.bbox.absolute[slices]] 

297 except TypeError as err: 

298 if hasattr(self._parent, "array"): 

299 err.add_note( 

300 "The .array attribute may provide more slicing flexibility " 

301 "(but only works in local coordinates)." 

302 ) 

303 raise