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

107 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-16 15:24 -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 _handle_getitem_args for some user-friendly 

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

188 @abstractmethod 

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

190 raise NotImplementedError() 

191 

192 @abstractmethod 

193 def copy(self) -> Self: 

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

195 

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

197 instances) are not copied. 

198 """ 

199 raise NotImplementedError() 

200 

201 @classmethod 

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

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

204 

205 A thin convenience wrapper around 

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

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

208 from ``path``'s extension. 

209 

210 Parameters 

211 ---------- 

212 path 

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

214 **kwargs 

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

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

217 """ 

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

219 

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

221 """Write this object to a file. 

222 

223 A thin convenience wrapper around 

224 `lsst.images.serialization.write_archive`. 

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_archive` (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: 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true

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

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 def _handle_getitem_args(self, bbox: Box | EllipsisType) -> tuple[Box, YX[slice]]: 

314 """Interpret the standard arguments to __getitem__.""" 

315 if bbox is ...: 

316 return self.bbox, YX(y=slice(None), x=slice(None)) 

317 elif not isinstance(bbox, Box): 317 ↛ 318line 317 didn't jump to line 318 because the condition on line 317 was never true

318 raise TypeError( 

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

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

321 ) 

322 return bbox, bbox.slice_within(self.bbox) 

323 

324 

325class LocalSliceProxy[T: GeneralizedImage]: 

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

327 slicing. 

328 

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

330 

331 Parameters 

332 ---------- 

333 parent 

334 Image the slice proxy operates on. 

335 """ 

336 

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

338 self._parent = parent 

339 

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

341 try: 

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

343 except TypeError as err: 

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

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

346 raise 

347 

348 

349class AbsoluteSliceProxy[T: GeneralizedImage]: 

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

351 slicing. 

352 

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

354 

355 Parameters 

356 ---------- 

357 parent 

358 Image the slice proxy operates on. 

359 """ 

360 

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

362 self._parent = parent 

363 

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

365 try: 

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

367 except TypeError as err: 

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

369 err.add_note( 

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

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

372 ) 

373 raise