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

131 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-09-30 11:30 +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 collections.abc import Mapping 

18from functools import cached_property 

19from types import EllipsisType 

20from typing import TYPE_CHECKING, Any, Self, TypeVar 

21 

22import astropy.wcs 

23 

24from lsst.resources import ResourcePathExpression 

25 

26from ._geom import YX, Box, NoOverlapError, NotContainedError 

27from ._metadata import MetadataView, _NativeMetadata 

28from ._transforms import SkyProjection, SkyProjectionAstropyView 

29from .describe import DescribableMixin 

30from .serialization import ( 

31 ArchiveTree, 

32 ButlerInfo, 

33 EmptyExternalMetadata, 

34 MetadataValue, 

35 OpaqueArchiveMetadata, 

36 read_archive, 

37 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(DescribableMixin, 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. A `dict`, 

55 or the native metadata of a `~lsst.images.MetadataView`, is shared 

56 by reference rather than copied, unlike assignment to `metadata`; 

57 any other mapping is copied. 

58 """ 

59 

60 def __init__(self, metadata: Mapping[str, MetadataValue] | MetadataView | None = None) -> None: 

61 self._metadata = self._to_native_dict(metadata) if metadata is not None else {} 

62 self._opaque_metadata: OpaqueArchiveMetadata | None = None 

63 self._butler_info: ButlerInfo | None = None 

64 

65 @property 

66 @abstractmethod 

67 def bbox(self) -> Box: 

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

69 raise NotImplementedError() 

70 

71 @property 

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

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

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

75 """ 

76 return self.bbox.start 

77 

78 @property 

79 @abstractmethod 

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

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

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

83 

84 Notes 

85 ----- 

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

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

88 """ 

89 raise NotImplementedError() 

90 

91 @property 

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

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

94 

95 Notes 

96 ----- 

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

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

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

100 

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

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

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

104 """ 

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

106 

107 @cached_property 

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

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

110 

111 Notes 

112 ----- 

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

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

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

116 

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

118 naturally representable as a FITS WCS. 

119 """ 

120 return ( 

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

122 if self.sky_projection is not None 

123 else None 

124 ) 

125 

126 @property 

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

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

129 "array" pixel coordinates. 

130 

131 Notes 

132 ----- 

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

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

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

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

137 the parent image. 

138 

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

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

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

142 and their parents. 

143 

144 See Also 

145 -------- 

146 lsst.images.BoxSliceFactory 

147 lsst.images.IntervalSliceFactory 

148 """ 

149 return LocalSliceProxy(self) 

150 

151 @property 

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

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

154 coordinates. 

155 

156 Notes 

157 ----- 

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

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

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

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

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

163 system. 

164 

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

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

167 `local` slicing for indices obtained from those. 

168 

169 See Also 

170 -------- 

171 lsst.images.BoxSliceFactory 

172 lsst.images.IntervalSliceFactory 

173 """ 

174 return AbsoluteSliceProxy(self) 

175 

176 @property 

177 def metadata(self) -> MetadataView: 

178 """Flexible metadata associated with the image 

179 (`~lsst.images.MetadataView`). 

180 

181 Notes 

182 ----- 

183 The view combines two sources: 

184 

185 - ``metadata.native``: metadata that is part of the image's data 

186 model and is saved with it. Keys are case-sensitive. 

187 - ``metadata.external``: read-only metadata carried in from the file 

188 the image was read from, such as the primary FITS header. Keys are 

189 case-insensitive, and ``COMMENT`` and ``HISTORY`` cards are not 

190 included. 

191 

192 Lookups check native metadata first, matching case exactly, and then 

193 external metadata. Writes go to native metadata; adding a new key 

194 that case-insensitively matches an external key raises `KeyError`. 

195 When an external key is repeated, which of its values is returned is 

196 unspecified; use `~lsst.images.MetadataView.get_all` to obtain every 

197 value. 

198 

199 Native metadata is shared with subimages and other views. Assigning 

200 a mapping replaces the native metadata with a shallow copy of it (of 

201 just its native metadata, for a `~lsst.images.MetadataView`), 

202 disconnecting this image from any others it was shared with while 

203 leaving external metadata readable. To disconnect while keeping the 

204 current contents, or to start empty:: 

205 

206 image.metadata = image.metadata 

207 image.metadata = {} 

208 

209 Assignment does not check for keys that shadow external metadata. 

210 """ 

211 external = ( 

212 self._opaque_metadata.external_metadata() 

213 if self._opaque_metadata is not None 

214 else EmptyExternalMetadata() 

215 ) 

216 return MetadataView(_NativeMetadata(self._metadata, external), external) 

217 

218 @metadata.setter 

219 def metadata(self, value: Mapping[str, MetadataValue] | MetadataView) -> None: 

220 self._metadata = dict(value._native if isinstance(value, MetadataView) else value) 

221 

222 @staticmethod 

223 def _to_native_dict(value: Mapping[str, MetadataValue] | MetadataView) -> dict[str, MetadataValue]: 

224 """Return the dict to hold as native metadata, sharing it with 

225 ``value`` where possible. 

226 """ 

227 if isinstance(value, MetadataView): 

228 return value._native._data 

229 if isinstance(value, dict): 229 ↛ 231line 229 didn't jump to line 231 because the condition on line 229 was always true

230 return value 

231 return dict(value) 

232 

233 # Subclasses should delegate to _handle_getitem_args for some user-friendly 

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

235 @abstractmethod 

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

237 raise NotImplementedError() 

238 

239 @abstractmethod 

240 def copy(self) -> Self: 

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

242 

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

244 instances) are not copied. 

245 """ 

246 raise NotImplementedError() 

247 

248 @classmethod 

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

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

251 

252 A thin convenience wrapper around 

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

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

255 from ``path``'s extension. 

256 

257 Parameters 

258 ---------- 

259 path 

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

261 **kwargs 

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

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

264 """ 

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

266 

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

268 """Write this object to a file. 

269 

270 A thin convenience wrapper around 

271 `lsst.images.serialization.write_archive`. 

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

273 

274 Parameters 

275 ---------- 

276 path 

277 Destination file path. Must not already exist. 

278 **kwargs 

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

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

281 """ 

282 write_archive(self, path, **kwargs) 

283 

284 @property 

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

286 """The butler dataset reference for this image 

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

288 """ 

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

290 return None 

291 from lsst.daf.butler import SerializedDatasetRef 

292 

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

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

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

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

297 

298 @property 

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

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

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

302 """ 

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

304 return None 

305 

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

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

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

309 from lsst.daf.butler import DatasetProvenance 

310 

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

312 

313 def _transfer_metadata[T: GeneralizedImage]( 

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

315 ) -> T: 

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

317 

318 Parameters 

319 ---------- 

320 new 

321 New instance to modify and return. 

322 copy 

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

324 bbox 

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

326 

327 Returns 

328 ------- 

329 GeneralizedImage 

330 The new object passed in, modified in place. 

331 

332 Notes 

333 ----- 

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

335 construction of a new one. 

336 """ 

337 if bbox is not None: 

338 opaque_metadata = ( 

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

340 ) 

341 else: 

342 opaque_metadata = self._opaque_metadata 

343 metadata = self._metadata 

344 if copy: 

345 metadata = metadata.copy() 

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

347 new._metadata = metadata 

348 new._opaque_metadata = opaque_metadata 

349 new._butler_info = self._butler_info 

350 return new 

351 

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

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

354 at the end of deserialization. 

355 """ 

356 self._metadata = model.metadata 

357 self._butler_info = model.butler_info 

358 return self 

359 

360 def _handle_getitem_args(self, bbox: Box | EllipsisType) -> tuple[Box, YX[slice]]: 

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

362 if bbox is ...: 

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

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

365 raise TypeError( 

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

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

368 ) 

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

370 

371 def bbox_from_sky_circle( 

372 self, center: astropy.coordinates.SkyCoord, radius: astropy.coordinates.Angle, clip: bool = False 

373 ) -> Box: 

374 """Calculate the bounding box on this image corresponding to a circular 

375 region on the sky. 

376 

377 Parameters 

378 ---------- 

379 center 

380 The center of the circle, as a scalar 

381 `astropy.coordinates.SkyCoord` in any frame. 

382 radius 

383 Radius of the circle, as a scalar `astropy.coordinates.Angle`. 

384 clip 

385 If `True` (`False` is default), clip pixel bounds when the circle 

386 extends outside the image. If `False` an exception is raised if 

387 any part of the circle is off the image. 

388 

389 Returns 

390 ------- 

391 Box 

392 Bounding box enclosing the circle in this image's pixel 

393 coordinates. 

394 

395 Raises 

396 ------ 

397 NotContainedError 

398 Raised if the requested region is entirely off the image or if 

399 any part of the region is off the image and clipping is `False`. 

400 ValueError 

401 Raised if ``center`` or ``radius`` is not scalar, or if this 

402 image has no sky projection. 

403 """ 

404 # Get the relevant mapping. 

405 sky_projection = self.sky_projection 

406 if sky_projection is None: 

407 raise ValueError("A sky projection is required to calculate a bounding box from a sky region.") 

408 

409 # Calculate the Box around the region. 

410 region_box = Box.from_sky_circle(sky_projection, center, radius) 

411 

412 # Determine the box within the image itself, clipping if requested. 

413 if clip: 

414 try: 

415 region_box = region_box.intersection(self.bbox) 

416 except NoOverlapError as e: 

417 raise NotContainedError( 

418 f"Requested sky circle has pixel bbox {region_box} which does not overlap {self.bbox}" 

419 ) from e 

420 if not self.bbox.contains(region_box): 

421 raise NotContainedError( 

422 f"Requested sky circle has pixel bbox {region_box}, which is not within {self.bbox}" 

423 ) 

424 

425 return region_box 

426 

427 

428class LocalSliceProxy[T: GeneralizedImage]: 

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

430 slicing. 

431 

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

433 

434 Parameters 

435 ---------- 

436 parent 

437 Image the slice proxy operates on. 

438 """ 

439 

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

441 self._parent = parent 

442 

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

444 try: 

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

446 except TypeError as err: 

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

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

449 raise 

450 

451 

452class AbsoluteSliceProxy[T: GeneralizedImage]: 

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

454 slicing. 

455 

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

457 

458 Parameters 

459 ---------- 

460 parent 

461 Image the slice proxy operates on. 

462 """ 

463 

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

465 self._parent = parent 

466 

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

468 try: 

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

470 except TypeError as err: 

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

472 err.add_note( 

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

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

475 ) 

476 raise