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

103 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-08 08:45 +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 lsst.resources import ResourcePathExpression 

24 

25from ._geom import Box 

26from ._transforms import Projection, ProjectionAstropyView 

27from .serialization import ( 

28 ArchiveTree, 

29 ButlerInfo, 

30 MetadataValue, 

31 OpaqueArchiveMetadata, 

32) 

33from .serialization import ( 

34 read as read_archive, 

35) 

36from .serialization import ( 

37 write as write_archive, 

38) 

39 

40if TYPE_CHECKING: 

41 from lsst.daf.butler import DatasetProvenance, SerializedDatasetRef 

42 

43 

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

45 

46 

47class GeneralizedImage(ABC): 

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

49 with the same pixel grid and projection. 

50 

51 Parameters 

52 ---------- 

53 metadata 

54 Arbitrary flexible metadata to associate with the image. 

55 """ 

56 

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

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

59 self._opaque_metadata: OpaqueArchiveMetadata | None = None 

60 self._butler_info: ButlerInfo | None = None 

61 

62 @property 

63 @abstractmethod 

64 def bbox(self) -> Box: 

65 """Bounding box for the image (`~lsst.images.Box`).""" 

66 raise NotImplementedError() 

67 

68 @property 

69 @abstractmethod 

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

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

72 (`~lsst.images.Projection` | `None`). 

73 

74 Notes 

75 ----- 

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

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

78 """ 

79 raise NotImplementedError() 

80 

81 @property 

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

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

84 

85 Notes 

86 ----- 

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

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

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

90 

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

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

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

94 """ 

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

96 

97 @cached_property 

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

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

100 

101 Notes 

102 ----- 

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

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

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

106 

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

108 naturally representable as a FITS WCS. 

109 """ 

110 return ( 

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

112 if self.projection is not None 

113 else None 

114 ) 

115 

116 @property 

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

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

119 "array" pixel coordinates. 

120 

121 Notes 

122 ----- 

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

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

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

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

127 the parent image. 

128 

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

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

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

132 and their parents. 

133 

134 See Also 

135 -------- 

136 lsst.images.BoxSliceFactory 

137 lsst.images.IntervalSliceFactory 

138 """ 

139 return LocalSliceProxy(self) 

140 

141 @property 

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

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

144 coordinates. 

145 

146 Notes 

147 ----- 

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

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

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

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

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

153 system. 

154 

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

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

157 `local` slicing for indices obtained from those. 

158 

159 See Also 

160 -------- 

161 lsst.images.BoxSliceFactory 

162 lsst.images.IntervalSliceFactory 

163 """ 

164 return AbsoluteSliceProxy(self) 

165 

166 @property 

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

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

169 

170 Notes 

171 ----- 

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

173 disconnected by reassigning to a copy explicitly: 

174 

175 image.metadata = image.metadata.copy() 

176 """ 

177 return self._metadata 

178 

179 @metadata.setter 

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

181 self._metadata = value 

182 

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

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

185 @abstractmethod 

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

187 if not isinstance(bbox, Box): 

188 raise TypeError( 

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

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

191 ) 

192 return self 

193 

194 @abstractmethod 

195 def copy(self) -> Self: 

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

197 

198 Attached immutable objects (like `~lsst.images.Projection` instances) 

199 are not copied. 

200 """ 

201 raise NotImplementedError() 

202 

203 @classmethod 

204 def read(cls, path: ResourcePathExpression, **kwargs: Any) -> Self: 

205 """Read an instance of this class from a file. 

206 

207 A thin convenience wrapper around `lsst.images.serialization.read` 

208 that fixes the expected in-memory type to this class. The container 

209 format is inferred from ``path``'s extension. 

210 

211 Parameters 

212 ---------- 

213 path 

214 File to read; convertible to `lsst.resources.ResourcePath`. 

215 **kwargs 

216 Forwarded to `~lsst.images.serialization.read` (e.g. ``bbox`` to 

217 read a subimage). 

218 """ 

219 return read_archive(path, cls, **kwargs) 

220 

221 def write(self, path: str, **kwargs: Any) -> None: 

222 """Write this object to a file. 

223 

224 A thin convenience wrapper around `lsst.images.serialization.write`. 

225 The container format is chosen from ``path``'s extension. 

226 

227 Parameters 

228 ---------- 

229 path 

230 Destination file path. Must not already exist. 

231 **kwargs 

232 Forwarded to `~lsst.images.serialization.write` (e.g. 

233 ``compression_options`` and ``compression_seed`` for FITS). 

234 """ 

235 write_archive(self, path, **kwargs) 

236 

237 @property 

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

239 """The butler dataset reference for this image 

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

241 """ 

242 if self._butler_info is None: 

243 return None 

244 from lsst.daf.butler import SerializedDatasetRef 

245 

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

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

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

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

250 

251 @property 

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

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

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

255 """ 

256 if self._butler_info is None: 

257 return None 

258 

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

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

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

262 from lsst.daf.butler import DatasetProvenance 

263 

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

265 

266 def _transfer_metadata[T: GeneralizedImage]( 

267 self, new: T, copy: bool = False, bbox: Box | None = None 

268 ) -> T: 

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

270 

271 Parameters 

272 ---------- 

273 new 

274 New instance to modify and return. 

275 copy 

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

277 bbox 

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

279 

280 Returns 

281 ------- 

282 GeneralizedImage 

283 The new object passed in, modified in place. 

284 

285 Notes 

286 ----- 

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

288 construction of a new one. 

289 """ 

290 if bbox is not None: 

291 opaque_metadata = ( 

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

293 ) 

294 else: 

295 opaque_metadata = self._opaque_metadata 

296 metadata = self._metadata 

297 if copy: 

298 metadata = metadata.copy() 

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

300 new._metadata = metadata 

301 new._opaque_metadata = opaque_metadata 

302 new._butler_info = self._butler_info 

303 return new 

304 

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

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

307 at the end of deserialization. 

308 """ 

309 self._metadata = model.metadata 

310 self._butler_info = model.butler_info 

311 return self 

312 

313 

314class LocalSliceProxy[T: GeneralizedImage]: 

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

316 slicing. 

317 

318 See `~lsst.images.GeneralizedImage.local` for more information. 

319 """ 

320 

321 def __init__(self, parent: T): 

322 self._parent = parent 

323 

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

325 try: 

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

327 except TypeError as err: 

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

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

330 raise 

331 

332 

333class AbsoluteSliceProxy[T: GeneralizedImage]: 

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

335 slicing. 

336 

337 See `~lsst.images.GeneralizedImage.absolute` for more information. 

338 """ 

339 

340 def __init__(self, parent: T): 

341 self._parent = parent 

342 

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

344 try: 

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

346 except TypeError as err: 

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

348 err.add_note( 

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

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

351 ) 

352 raise