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

104 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-09 02:27 -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 read_archive, 

33 write_archive, 

34) 

35 

36if TYPE_CHECKING: 

37 from lsst.daf.butler import DatasetProvenance, SerializedDatasetRef 

38 

39 

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

41 

42 

43class GeneralizedImage(ABC): 

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

45 with the same pixel grid and sky projection. 

46 

47 Parameters 

48 ---------- 

49 metadata 

50 Arbitrary flexible metadata to associate with the image. 

51 """ 

52 

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

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

55 self._opaque_metadata: OpaqueArchiveMetadata | None = None 

56 self._butler_info: ButlerInfo | None = None 

57 

58 @property 

59 @abstractmethod 

60 def bbox(self) -> Box: 

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

62 raise NotImplementedError() 

63 

64 @property 

65 def yx0(self) -> YX[int]: 

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

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

68 """ 

69 return self.bbox.start 

70 

71 @property 

72 @abstractmethod 

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

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

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

76 

77 Notes 

78 ----- 

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

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

81 """ 

82 raise NotImplementedError() 

83 

84 @property 

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

86 """An Astropy 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 `sky_projection`. 

93 

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

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

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

97 """ 

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

99 

100 @cached_property 

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

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

103 

104 Notes 

105 ----- 

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

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

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

109 

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

111 naturally representable as a FITS WCS. 

112 """ 

113 return ( 

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

115 if self.sky_projection is not None 

116 else None 

117 ) 

118 

119 @property 

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

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

122 "array" pixel coordinates. 

123 

124 Notes 

125 ----- 

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

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

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

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

130 the parent image. 

131 

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

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

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

135 and their parents. 

136 

137 See Also 

138 -------- 

139 lsst.images.BoxSliceFactory 

140 lsst.images.IntervalSliceFactory 

141 """ 

142 return LocalSliceProxy(self) 

143 

144 @property 

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

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

147 coordinates. 

148 

149 Notes 

150 ----- 

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

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

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

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

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

156 system. 

157 

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

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

160 `local` slicing for indices obtained from those. 

161 

162 See Also 

163 -------- 

164 lsst.images.BoxSliceFactory 

165 lsst.images.IntervalSliceFactory 

166 """ 

167 return AbsoluteSliceProxy(self) 

168 

169 @property 

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

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

172 

173 Notes 

174 ----- 

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

176 disconnected by reassigning to a copy explicitly: 

177 

178 image.metadata = image.metadata.copy() 

179 """ 

180 return self._metadata 

181 

182 @metadata.setter 

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

184 self._metadata = value 

185 

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

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

188 @abstractmethod 

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

190 if not isinstance(bbox, Box): 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true

191 raise TypeError( 

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

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

194 ) 

195 return self 

196 

197 @abstractmethod 

198 def copy(self) -> Self: 

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

200 

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

202 instances) are not copied. 

203 """ 

204 raise NotImplementedError() 

205 

206 @classmethod 

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

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

209 

210 A thin convenience wrapper around 

211 `lsst.images.serialization.read_archive` that fixes the expected 

212 in-memory type to this class. The container format is inferred 

213 from ``path``'s extension. 

214 

215 Parameters 

216 ---------- 

217 path 

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

219 **kwargs 

220 Forwarded to `~lsst.images.serialization.read_archive` 

221 (e.g. ``bbox`` to read a subimage). 

222 """ 

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

224 

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

226 """Write this object to a file. 

227 

228 A thin convenience wrapper around 

229 `lsst.images.serialization.write_archive`. 

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

231 

232 Parameters 

233 ---------- 

234 path 

235 Destination file path. Must not already exist. 

236 **kwargs 

237 Forwarded to `~lsst.images.serialization.write_archive` (e.g. 

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

239 """ 

240 write_archive(self, path, **kwargs) 

241 

242 @property 

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

244 """The butler dataset reference for this image 

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

246 """ 

247 if self._butler_info is None: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true

248 return None 

249 from lsst.daf.butler import SerializedDatasetRef 

250 

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

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

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

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

255 

256 @property 

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

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

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

260 """ 

261 if self._butler_info is None: 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true

262 return None 

263 

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

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

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

267 from lsst.daf.butler import DatasetProvenance 

268 

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

270 

271 def _transfer_metadata[T: GeneralizedImage]( 

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

273 ) -> T: 

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

275 

276 Parameters 

277 ---------- 

278 new 

279 New instance to modify and return. 

280 copy 

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

282 bbox 

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

284 

285 Returns 

286 ------- 

287 GeneralizedImage 

288 The new object passed in, modified in place. 

289 

290 Notes 

291 ----- 

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

293 construction of a new one. 

294 """ 

295 if bbox is not None: 

296 opaque_metadata = ( 

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

298 ) 

299 else: 

300 opaque_metadata = self._opaque_metadata 

301 metadata = self._metadata 

302 if copy: 

303 metadata = metadata.copy() 

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

305 new._metadata = metadata 

306 new._opaque_metadata = opaque_metadata 

307 new._butler_info = self._butler_info 

308 return new 

309 

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

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

312 at the end of deserialization. 

313 """ 

314 self._metadata = model.metadata 

315 self._butler_info = model.butler_info 

316 return self 

317 

318 

319class LocalSliceProxy[T: GeneralizedImage]: 

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

321 slicing. 

322 

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

324 

325 Parameters 

326 ---------- 

327 parent 

328 Image the slice proxy operates on. 

329 """ 

330 

331 def __init__(self, parent: T) -> None: 

332 self._parent = parent 

333 

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

335 try: 

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

337 except TypeError as err: 

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

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

340 raise 

341 

342 

343class AbsoluteSliceProxy[T: GeneralizedImage]: 

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

345 slicing. 

346 

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

348 

349 Parameters 

350 ---------- 

351 parent 

352 Image the slice proxy operates on. 

353 """ 

354 

355 def __init__(self, parent: T) -> None: 

356 self._parent = parent 

357 

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

359 try: 

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

361 except TypeError as err: 

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

363 err.add_note( 

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

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

366 ) 

367 raise