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

121 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 08:38 +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, NoOverlapError, NotContainedError 

26from ._transforms import SkyProjection, SkyProjectionAstropyView 

27from .describe import DescribableMixin 

28from .serialization import ( 

29 ArchiveTree, 

30 ButlerInfo, 

31 MetadataValue, 

32 OpaqueArchiveMetadata, 

33 read_archive, 

34 write_archive, 

35) 

36 

37if TYPE_CHECKING: 

38 from lsst.daf.butler import DatasetProvenance, SerializedDatasetRef 

39 

40 

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

42 

43 

44class GeneralizedImage(DescribableMixin, ABC): 

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

46 with the same pixel grid and sky projection. 

47 

48 Parameters 

49 ---------- 

50 metadata 

51 Arbitrary flexible metadata to associate with the image. 

52 """ 

53 

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

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

56 self._opaque_metadata: OpaqueArchiveMetadata | None = None 

57 self._butler_info: ButlerInfo | None = None 

58 

59 @property 

60 @abstractmethod 

61 def bbox(self) -> Box: 

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

63 raise NotImplementedError() 

64 

65 @property 

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

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

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

69 """ 

70 return self.bbox.start 

71 

72 @property 

73 @abstractmethod 

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

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

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

77 

78 Notes 

79 ----- 

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

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

82 """ 

83 raise NotImplementedError() 

84 

85 @property 

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

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

88 

89 Notes 

90 ----- 

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

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

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

94 

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

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

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

98 """ 

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

100 

101 @cached_property 

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

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

104 

105 Notes 

106 ----- 

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

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

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

110 

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

112 naturally representable as a FITS WCS. 

113 """ 

114 return ( 

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

116 if self.sky_projection is not None 

117 else None 

118 ) 

119 

120 @property 

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

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

123 "array" pixel coordinates. 

124 

125 Notes 

126 ----- 

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

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

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

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

131 the parent image. 

132 

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

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

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

136 and their parents. 

137 

138 See Also 

139 -------- 

140 lsst.images.BoxSliceFactory 

141 lsst.images.IntervalSliceFactory 

142 """ 

143 return LocalSliceProxy(self) 

144 

145 @property 

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

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

148 coordinates. 

149 

150 Notes 

151 ----- 

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

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

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

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

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

157 system. 

158 

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

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

161 `local` slicing for indices obtained from those. 

162 

163 See Also 

164 -------- 

165 lsst.images.BoxSliceFactory 

166 lsst.images.IntervalSliceFactory 

167 """ 

168 return AbsoluteSliceProxy(self) 

169 

170 @property 

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

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

173 

174 Notes 

175 ----- 

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

177 disconnected by reassigning to a copy explicitly: 

178 

179 image.metadata = image.metadata.copy() 

180 """ 

181 return self._metadata 

182 

183 @metadata.setter 

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

185 self._metadata = value 

186 

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

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

189 @abstractmethod 

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

191 raise NotImplementedError() 

192 

193 @abstractmethod 

194 def copy(self) -> Self: 

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

196 

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

198 instances) are not copied. 

199 """ 

200 raise NotImplementedError() 

201 

202 @classmethod 

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

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

205 

206 A thin convenience wrapper around 

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

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

209 from ``path``'s extension. 

210 

211 Parameters 

212 ---------- 

213 path 

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

215 **kwargs 

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

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

218 """ 

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

220 

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

222 """Write this object to a file. 

223 

224 A thin convenience wrapper around 

225 `lsst.images.serialization.write_archive`. 

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

227 

228 Parameters 

229 ---------- 

230 path 

231 Destination file path. Must not already exist. 

232 **kwargs 

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

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

235 """ 

236 write_archive(self, path, **kwargs) 

237 

238 @property 

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

240 """The butler dataset reference for this image 

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

242 """ 

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

244 return None 

245 from lsst.daf.butler import SerializedDatasetRef 

246 

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

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

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

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

251 

252 @property 

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

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

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

256 """ 

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

258 return None 

259 

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

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

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

263 from lsst.daf.butler import DatasetProvenance 

264 

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

266 

267 def _transfer_metadata[T: GeneralizedImage]( 

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

269 ) -> T: 

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

271 

272 Parameters 

273 ---------- 

274 new 

275 New instance to modify and return. 

276 copy 

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

278 bbox 

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

280 

281 Returns 

282 ------- 

283 GeneralizedImage 

284 The new object passed in, modified in place. 

285 

286 Notes 

287 ----- 

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

289 construction of a new one. 

290 """ 

291 if bbox is not None: 

292 opaque_metadata = ( 

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

294 ) 

295 else: 

296 opaque_metadata = self._opaque_metadata 

297 metadata = self._metadata 

298 if copy: 

299 metadata = metadata.copy() 

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

301 new._metadata = metadata 

302 new._opaque_metadata = opaque_metadata 

303 new._butler_info = self._butler_info 

304 return new 

305 

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

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

308 at the end of deserialization. 

309 """ 

310 self._metadata = model.metadata 

311 self._butler_info = model.butler_info 

312 return self 

313 

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

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

316 if bbox is ...: 

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

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

319 raise TypeError( 

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

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

322 ) 

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

324 

325 def bbox_from_sky_circle( 

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

327 ) -> Box: 

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

329 region on the sky. 

330 

331 Parameters 

332 ---------- 

333 center 

334 The center of the circle, as a scalar 

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

336 radius 

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

338 clip 

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

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

341 any part of the circle is off the image. 

342 

343 Returns 

344 ------- 

345 Box 

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

347 coordinates. 

348 

349 Raises 

350 ------ 

351 NotContainedError 

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

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

354 ValueError 

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

356 image has no sky projection. 

357 """ 

358 # Get the relevant mapping. 

359 sky_projection = self.sky_projection 

360 if sky_projection is None: 

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

362 

363 # Calculate the Box around the region. 

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

365 

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

367 if clip: 

368 try: 

369 region_box = region_box.intersection(self.bbox) 

370 except NoOverlapError as e: 

371 raise NotContainedError( 

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

373 ) from e 

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

375 raise NotContainedError( 

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

377 ) 

378 

379 return region_box 

380 

381 

382class LocalSliceProxy[T: GeneralizedImage]: 

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

384 slicing. 

385 

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

387 

388 Parameters 

389 ---------- 

390 parent 

391 Image the slice proxy operates on. 

392 """ 

393 

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

395 self._parent = parent 

396 

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

398 try: 

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

400 except TypeError as err: 

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

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

403 raise 

404 

405 

406class AbsoluteSliceProxy[T: GeneralizedImage]: 

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

408 slicing. 

409 

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

411 

412 Parameters 

413 ---------- 

414 parent 

415 Image the slice proxy operates on. 

416 """ 

417 

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

419 self._parent = parent 

420 

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

422 try: 

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

424 except TypeError as err: 

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

426 err.add_note( 

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

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

429 ) 

430 raise