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

106 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-10 03:25 -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__ = ("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 YX, Box 

26from ._transforms import SkyProjection, SkyProjectionAstropyView 

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 sky 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 def yx0(self) -> YX[int]: 

70 """The coordinates of the first pixel in the array 

71 (`~lsst.geom.YX` [`int`]). 

72 """ 

73 return self.bbox.start 

74 

75 @property 

76 @abstractmethod 

77 def sky_projection(self) -> SkyProjection[Any] | None: 

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

79 (`~lsst.images.SkyProjection` | `None`). 

80 

81 Notes 

82 ----- 

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

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

85 """ 

86 raise NotImplementedError() 

87 

88 @property 

89 def astropy_wcs(self) -> SkyProjectionAstropyView | None: 

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

91 

92 Notes 

93 ----- 

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

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

96 ``(0, 0)``, not ``bbox.start``, as is the case for `sky_projection`. 

97 

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

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

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

101 """ 

102 return self.sky_projection.as_astropy(self.bbox) if self.sky_projection is not None else None 

103 

104 @cached_property 

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

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

107 

108 Notes 

109 ----- 

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

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

112 ``(0, 0)``, not ``bbox.start``, as is the case for `sky_projection`. 

113 

114 This may be an approximation or absent if `sky_projection` is not 

115 naturally representable as a FITS WCS. 

116 """ 

117 return ( 

118 self.sky_projection.as_fits_wcs(self.bbox, allow_approximation=True) 

119 if self.sky_projection is not None 

120 else None 

121 ) 

122 

123 @property 

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

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

126 "array" pixel coordinates. 

127 

128 Notes 

129 ----- 

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

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

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

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

134 the parent image. 

135 

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

137 `~lsst.images.SkyProjection`, `~lsst.images.psfs.PointSpreadFunction`) 

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

139 and their parents. 

140 

141 See Also 

142 -------- 

143 lsst.images.BoxSliceFactory 

144 lsst.images.IntervalSliceFactory 

145 """ 

146 return LocalSliceProxy(self) 

147 

148 @property 

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

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

151 coordinates. 

152 

153 Notes 

154 ----- 

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

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

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

158 `~lsst.images.Box`, `~lsst.images.SkyProjection`, 

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

160 system. 

161 

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

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

164 `local` slicing for indices obtained from those. 

165 

166 See Also 

167 -------- 

168 lsst.images.BoxSliceFactory 

169 lsst.images.IntervalSliceFactory 

170 """ 

171 return AbsoluteSliceProxy(self) 

172 

173 @property 

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

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

176 

177 Notes 

178 ----- 

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

180 disconnected by reassigning to a copy explicitly: 

181 

182 image.metadata = image.metadata.copy() 

183 """ 

184 return self._metadata 

185 

186 @metadata.setter 

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

188 self._metadata = value 

189 

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

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

192 @abstractmethod 

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

194 if not isinstance(bbox, Box): 

195 raise TypeError( 

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

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

198 ) 

199 return self 

200 

201 @abstractmethod 

202 def copy(self) -> Self: 

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

204 

205 Attached immutable objects (like `~lsst.images.SkyProjection` 

206 instances) are not copied. 

207 """ 

208 raise NotImplementedError() 

209 

210 @classmethod 

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

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

213 

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

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

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

217 

218 Parameters 

219 ---------- 

220 path 

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

222 **kwargs 

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

224 read a subimage). 

225 """ 

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

227 

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

229 """Write this object to a file. 

230 

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

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

233 

234 Parameters 

235 ---------- 

236 path 

237 Destination file path. Must not already exist. 

238 **kwargs 

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

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

241 """ 

242 write_archive(self, path, **kwargs) 

243 

244 @property 

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

246 """The butler dataset reference for this image 

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

248 """ 

249 if self._butler_info is None: 

250 return None 

251 from lsst.daf.butler import SerializedDatasetRef 

252 

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

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

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

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

257 

258 @property 

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

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

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

262 """ 

263 if self._butler_info is None: 

264 return None 

265 

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

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

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

269 from lsst.daf.butler import DatasetProvenance 

270 

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

272 

273 def _transfer_metadata[T: GeneralizedImage]( 

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

275 ) -> T: 

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

277 

278 Parameters 

279 ---------- 

280 new 

281 New instance to modify and return. 

282 copy 

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

284 bbox 

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

286 

287 Returns 

288 ------- 

289 GeneralizedImage 

290 The new object passed in, modified in place. 

291 

292 Notes 

293 ----- 

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

295 construction of a new one. 

296 """ 

297 if bbox is not None: 

298 opaque_metadata = ( 

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

300 ) 

301 else: 

302 opaque_metadata = self._opaque_metadata 

303 metadata = self._metadata 

304 if copy: 

305 metadata = metadata.copy() 

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

307 new._metadata = metadata 

308 new._opaque_metadata = opaque_metadata 

309 new._butler_info = self._butler_info 

310 return new 

311 

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

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

314 at the end of deserialization. 

315 """ 

316 self._metadata = model.metadata 

317 self._butler_info = model.butler_info 

318 return self 

319 

320 

321class LocalSliceProxy[T: GeneralizedImage]: 

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

323 slicing. 

324 

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

326 """ 

327 

328 def __init__(self, parent: T): 

329 self._parent = parent 

330 

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

332 try: 

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

334 except TypeError as err: 

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

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

337 raise 

338 

339 

340class AbsoluteSliceProxy[T: GeneralizedImage]: 

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

342 slicing. 

343 

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

345 """ 

346 

347 def __init__(self, parent: T): 

348 self._parent = parent 

349 

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

351 try: 

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

353 except TypeError as err: 

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

355 err.add_note( 

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

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

358 ) 

359 raise