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

106 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-15 03:25 +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 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): 194 ↛ 195line 194 didn't jump to line 195 because the condition on line 194 was never true

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: 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true

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: 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true

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