Coverage for python / lsst / images / _visit_image.py: 28%

204 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-27 01:55 -0800

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 

14import warnings 

15 

16__all__ = ("VisitImage", "VisitImageSerializationModel") 

17 

18import functools 

19from collections.abc import Callable, Mapping 

20from typing import Any, Literal, cast, overload 

21 

22import astropy.io.fits 

23import astropy.units 

24import astropy.wcs 

25import pydantic 

26 

27from ._geom import Box 

28from ._image import Image, ImageSerializationModel 

29from ._mask import Mask, MaskPlane, MaskSchema, MaskSerializationModel, get_legacy_visit_image_mask_planes 

30from ._masked_image import MaskedImage, MaskedImageSerializationModel 

31from ._transforms import DetectorFrame, Projection, ProjectionAstropyView, ProjectionSerializationModel 

32from .fits import FitsOpaqueMetadata 

33from .psfs import ( 

34 PiffSerializationModel, 

35 PiffWrapper, 

36 PointSpreadFunction, 

37 PSFExSerializationModel, 

38 PSFExWrapper, 

39) 

40from .serialization import ArchiveReadError, InputArchive, OutputArchive 

41 

42 

43class VisitImage(MaskedImage): 

44 """A calibrated single-visit image. 

45 

46 Parameters 

47 ---------- 

48 image 

49 The main image plane. If this has a `Projection`, it will be used 

50 for all planes unless a ``projection`` is passed separately. 

51 mask 

52 A bitmask image that annotates the main image plane. Must have the 

53 same bounding box as ``image`` if provided. Any attached projection 

54 is replaced (possibly by `None`). 

55 variance 

56 The per-pixel uncertainty of the main image as an image of variance 

57 values. Must have the same bounding box as ``image`` if provided, and 

58 its units must be the square of ``image.unit`` or `None`. 

59 Values default to ``1.0``. Any attached projection is replaced 

60 (possibly by `None`). 

61 mask_schema 

62 Schema for the mask plane. Must be provided if and only if ``mask`` is 

63 not provided. 

64 opaque_metadata 

65 Opaque metadata obtained from reading this object from storage. It may 

66 be provided when writing to storage to propagate that metadata and/or 

67 preserve file-format-specific options (e.g. compression parameters). 

68 projection 

69 Projection that maps the pixel grid to the sky. Can only be `None` if 

70 a projection is already attached to ``image``. 

71 psf 

72 Point-spread function model for this image, or an exception explaining 

73 why it could not be read (to be raised if the PSF is requested later). 

74 """ 

75 

76 def __init__( 

77 self, 

78 image: Image, 

79 *, 

80 mask: Mask | None = None, 

81 variance: Image | None = None, 

82 mask_schema: MaskSchema | None = None, 

83 projection: Projection[DetectorFrame] | None = None, 

84 psf: PointSpreadFunction | ArchiveReadError, 

85 ): 

86 super().__init__( 

87 image, 

88 mask=mask, 

89 variance=variance, 

90 mask_schema=mask_schema, 

91 projection=projection, 

92 ) 

93 if self.image.unit is None: 

94 raise TypeError("The image component of a VisitImage must have units.") 

95 if self.image.projection is None: 

96 raise TypeError("The projection component of a VisitImage cannot be None.") 

97 if not isinstance(self.image.projection.pixel_frame, DetectorFrame): 

98 raise TypeError("The projection's pixel frame must be a DetectorFrame for VisitImage.") 

99 self._psf = psf 

100 

101 @property 

102 def unit(self) -> astropy.units.UnitBase: 

103 """The units of the image plane (`astropy.units.Unit`).""" 

104 return cast(astropy.units.UnitBase, super().unit) 

105 

106 @property 

107 def projection(self) -> Projection[DetectorFrame]: 

108 """The projection that maps the pixel grid to the sky 

109 (`Projection` [`DetectorFrame`]). 

110 """ 

111 return cast(Projection[DetectorFrame], super().projection) 

112 

113 @property 

114 def astropy_wcs(self) -> ProjectionAstropyView: 

115 """An Astropy WCS for the pixel arrays (`ProjectionAstropyView`). 

116 

117 Notes 

118 ----- 

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

120 such that the first row and column in the arrays are ``(0, 0)``, not 

121 ``bbox.start``, as is the case for `projection`. 

122 

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

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

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

126 """ 

127 return cast(ProjectionAstropyView, super().astropy_wcs) 

128 

129 @property 

130 def psf(self) -> PointSpreadFunction: 

131 """The point-spread function model for this image 

132 (`.psfs.PointSpreadFunction`). 

133 """ 

134 if isinstance(self._psf, ArchiveReadError): 

135 raise self._psf 

136 return self._psf 

137 

138 def __getitem__(self, bbox: Box) -> VisitImage: 

139 result = VisitImage( 

140 self.image[bbox], 

141 mask=self.mask[bbox], 

142 variance=self.variance[bbox], 

143 projection=self.projection, 

144 psf=self.psf, 

145 ) 

146 if opaque_metadata := getattr(self, "_opaque_metadata", None): 

147 result._opaque_metadata = opaque_metadata.subset(bbox) 

148 return result 

149 

150 def __str__(self) -> str: 

151 return f"VisitImage({self.image!s}, {list(self.mask.schema.names)})" 

152 

153 def __repr__(self) -> str: 

154 return f"VisitImage({self.image!r}, mask_schema={self.mask.schema!r})" 

155 

156 def copy(self) -> VisitImage: 

157 """Deep-copy the visit image.""" 

158 result = VisitImage( 

159 image=self._image.copy(), mask=self._mask.copy(), variance=self._variance.copy(), psf=self._psf 

160 ) 

161 if self._opaque_metadata is not None: 

162 result._opaque_metadata = self._opaque_metadata.copy() 

163 return result 

164 

165 def serialize(self, archive: OutputArchive[Any]) -> VisitImageSerializationModel: 

166 serialized_image = archive.serialize_direct( 

167 "image", functools.partial(self.image.serialize, save_projection=False) 

168 ) 

169 serialized_mask = archive.serialize_direct( 

170 "mask", functools.partial(self.mask.serialize, save_projection=False) 

171 ) 

172 serialized_variance = archive.serialize_direct( 

173 "variance", functools.partial(self.variance.serialize, save_projection=False) 

174 ) 

175 serialized_projection = ( 

176 archive.serialize_direct("projection", self.projection.serialize) 

177 if self.projection is not None 

178 else None 

179 ) 

180 serialized_psf: PiffSerializationModel | PSFExSerializationModel 

181 match self._psf: 

182 # MyPy is able to figure things out here with this match statement, 

183 # but not a single isinstance check on both types. 

184 case PiffWrapper(): 

185 serialized_psf = archive.serialize_direct("psf", self._psf.serialize) 

186 case PSFExWrapper(): 

187 serialized_psf = archive.serialize_direct("psf", self._psf.serialize) 

188 case _: 

189 raise TypeError( 

190 f"Cannot serialize VisitImage with unrecognized PSF type {type(self._psf).__name__}." 

191 ) 

192 return VisitImageSerializationModel( 

193 image=serialized_image, 

194 mask=serialized_mask, 

195 variance=serialized_variance, 

196 projection=serialized_projection, 

197 psf=serialized_psf, 

198 ) 

199 

200 # Type-checkers want the model argument to only require 

201 # MaskedImageSerializationModel[Any], and they'd be absolutely right if 

202 # this were a regular instance method. But whether Liskov substitution 

203 # applies to classmethods and staticmethods is sort of context-dependent, 

204 # and here we do not want it to. 

205 @staticmethod 

206 def deserialize( 

207 model: VisitImageSerializationModel[Any], # type: ignore[override] 

208 archive: InputArchive[Any], 

209 *, 

210 bbox: Box | None = None, 

211 ) -> VisitImage: 

212 masked_image = MaskedImage.deserialize(model, archive, bbox=bbox) 

213 psf = model.deserialize_psf(archive) 

214 projection = Projection.deserialize(model.projection, archive) 

215 result = VisitImage( 

216 masked_image.image, 

217 mask=masked_image.mask, 

218 variance=masked_image.variance, 

219 psf=psf, 

220 projection=projection, 

221 ) 

222 return result 

223 

224 @staticmethod 

225 def _get_archive_tree_type[P: pydantic.BaseModel]( 

226 pointer_type: type[P], 

227 ) -> type[VisitImageSerializationModel[P]]: 

228 """Return the serialization model type for this object for an archive 

229 type that uses the given pointer type. 

230 """ 

231 return VisitImageSerializationModel[pointer_type] # type: ignore 

232 

233 # write_fits and read_fits inherited from MaskedImage. 

234 

235 @staticmethod 

236 def from_legacy( 

237 legacy: Any, 

238 *, 

239 unit: astropy.units.Unit | None = None, 

240 plane_map: Mapping[str, MaskPlane] | None = None, 

241 instrument: str | None = None, 

242 visit: int | None = None, 

243 component: str | None = None, 

244 ) -> VisitImage: 

245 """Convert from an `lsst.afw.image.Exposure` instance. 

246 

247 Parameters 

248 ---------- 

249 legacy 

250 An `lsst.afw.image.Exposure` instance that will share image and 

251 variance (but not mask) pixel data with the returned object. 

252 unit 

253 Units of the image. If not provided, the ``BUNIT`` metadata 

254 key will be used, if available. 

255 plane_map 

256 A mapping from legacy mask plane name to the new plane name and 

257 description. If `None` (default) 

258 `get_legacy_visit_image_mask_planes` is used. 

259 instrument 

260 Name of the instrument. Extracted from the metadata if not 

261 provided. 

262 visit 

263 ID of the visit. Extracted from the metadata if not provided. 

264 """ 

265 if plane_map is None: 

266 plane_map = get_legacy_visit_image_mask_planes() 

267 md = legacy.getMetadata() 

268 instrument = _extract_or_check_header("LSST BUTLER DATAID INSTRUMENT", instrument, md, str) 

269 visit = _extract_or_check_header("LSST BUTLER DATAID VISIT", visit, md, int) 

270 unit = _extract_or_check_header("BUNIT", unit, md, lambda x: astropy.units.Unit(x, format="fits")) 

271 legacy_wcs = legacy.getWcs() 

272 if legacy_wcs is None: 

273 raise ValueError("Exposure does not have a SkyWcs.") 

274 legacy_detector = legacy.getDetector() 

275 if legacy_detector is None: 

276 raise ValueError("Exposure does not have a Detector.") 

277 detector_bbox = Box.from_legacy(legacy_detector.getBBox()) 

278 opaque_fits_metadata = FitsOpaqueMetadata() 

279 primary_header = astropy.io.fits.Header() 

280 with warnings.catch_warnings(): 

281 # Silence warnings about long keys becoming HIERARCH. 

282 warnings.simplefilter("ignore", category=astropy.io.fits.verify.VerifyWarning) 

283 primary_header.update(md.toOrderedDict()) 

284 opaque_fits_metadata.extract_legacy_primary_header(primary_header) 

285 projection = Projection.from_legacy( 

286 legacy_wcs, 

287 DetectorFrame( 

288 instrument=instrument, 

289 visit=visit, 

290 detector=legacy_detector.getId(), 

291 bbox=detector_bbox, 

292 ), 

293 ) 

294 legacy_psf = legacy.getPsf() 

295 if legacy_psf is None: 

296 raise ValueError("Exposure file does not have a Psf.") 

297 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds=detector_bbox) 

298 masked_image = MaskedImage.from_legacy(legacy.getMaskedImage(), unit=unit, plane_map=plane_map) 

299 result = VisitImage( 

300 image=masked_image.image.view(unit=unit), 

301 mask=masked_image.mask, 

302 variance=masked_image.variance, 

303 projection=projection, 

304 psf=psf, 

305 ) 

306 result._opaque_metadata = masked_image._opaque_metadata 

307 return result 

308 

309 @overload # type: ignore[override] 

310 @staticmethod 

311 def read_legacy( 311 ↛ exitline 311 didn't return from function 'read_legacy' because

312 filename: str, 

313 *, 

314 component: Literal["bbox"], 

315 ) -> Box: ... 

316 

317 @overload 

318 @staticmethod 

319 def read_legacy( 319 ↛ exitline 319 didn't return from function 'read_legacy' because

320 filename: str, 

321 *, 

322 preserve_quantization: bool = False, 

323 instrument: str | None = None, 

324 visit: int | None = None, 

325 component: Literal["image"], 

326 ) -> Image: ... 

327 

328 @overload 

329 @staticmethod 

330 def read_legacy( 330 ↛ exitline 330 didn't return from function 'read_legacy' because

331 filename: str, 

332 *, 

333 plane_map: Mapping[str, MaskPlane] | None = None, 

334 instrument: str | None = None, 

335 visit: int | None = None, 

336 component: Literal["mask"], 

337 ) -> Mask: ... 

338 

339 @overload 

340 @staticmethod 

341 def read_legacy( 341 ↛ exitline 341 didn't return from function 'read_legacy' because

342 filename: str, 

343 *, 

344 preserve_quantization: bool = False, 

345 instrument: str | None = None, 

346 visit: int | None = None, 

347 component: Literal["variance"], 

348 ) -> Image: ... 

349 

350 @overload 

351 @staticmethod 

352 def read_legacy( 352 ↛ exitline 352 didn't return from function 'read_legacy' because

353 filename: str, 

354 *, 

355 instrument: str | None = None, 

356 visit: int | None = None, 

357 component: Literal["projection"], 

358 ) -> Projection[DetectorFrame]: ... 

359 

360 @overload 

361 @staticmethod 

362 def read_legacy( 362 ↛ exitline 362 didn't return from function 'read_legacy' because

363 filename: str, 

364 *, 

365 component: Literal["psf"], 

366 ) -> PointSpreadFunction: ... 

367 

368 @overload 

369 @staticmethod 

370 def read_legacy( 370 ↛ exitline 370 didn't return from function 'read_legacy' because

371 filename: str, 

372 *, 

373 preserve_quantization: bool = False, 

374 plane_map: Mapping[str, MaskPlane] | None = None, 

375 instrument: str | None = None, 

376 visit: int | None = None, 

377 component: None = None, 

378 ) -> VisitImage: ... 

379 

380 @staticmethod 

381 def read_legacy( # type: ignore[override] 

382 filename: str, 

383 *, 

384 preserve_quantization: bool = False, 

385 plane_map: Mapping[str, MaskPlane] | None = None, 

386 instrument: str | None = None, 

387 visit: int | None = None, 

388 component: Literal["bbox", "image", "mask", "variance", "projection", "psf"] | None = None, 

389 ) -> Any: 

390 """Read a FITS file written by `lsst.afw.image.Exposure.writeFits`. 

391 

392 Parameters 

393 ---------- 

394 filename 

395 Full name of the file. 

396 preserve_quantization 

397 If `True`, ensure that writing the masked image back out again will 

398 exactly preserve quantization-compressed pixel values. This causes 

399 the image and variance plane arrays to be marked as read-only and 

400 stores the original binary table data for those planes in memory. 

401 If the `MaskedImage` is copied, the precompressed pixel values are 

402 not transferred to the copy. 

403 plane_map 

404 A mapping from legacy mask plane name to the new plane name and 

405 description. If `None` (default) 

406 `get_legacy_visit_image_mask_planes` is used. 

407 instrument 

408 Name of the instrument. Read from the primary header if not 

409 provided. 

410 visit 

411 ID of the visit. Read from the primary header if not 

412 provided. 

413 component 

414 A component to read instead of the full image. 

415 """ 

416 from lsst.afw.image import ExposureFitsReader 

417 

418 reader = ExposureFitsReader(filename) 

419 if component == "bbox": 

420 return Box.from_legacy(reader.readBBox()) 

421 legacy_detector = reader.readDetector() 

422 if legacy_detector is None: 

423 raise ValueError(f"Exposure file {filename!r} does not have a Detector.") 

424 detector_bbox = Box.from_legacy(legacy_detector.getBBox()) 

425 if component in (None, "image", "mask", "variance", "projection"): 

426 legacy_wcs = reader.readWcs() 

427 if legacy_wcs is None: 

428 raise ValueError(f"Exposure file {filename!r} does not have a SkyWcs.") 

429 if component in (None, "psf"): 

430 legacy_psf = reader.readPsf() 

431 if legacy_psf is None: 

432 raise ValueError(f"Exposure file {filename!r} does not have a Psf.") 

433 psf = PointSpreadFunction.from_legacy(legacy_psf, bounds=detector_bbox) 

434 if component == "psf": 

435 return psf 

436 assert component in (None, "image", "mask", "variance", "projection"), component # for MyPy 

437 with astropy.io.fits.open(filename) as hdu_list: 

438 primary_header = hdu_list[0].header 

439 if instrument is None: 

440 try: 

441 instrument = primary_header["LSST BUTLER DATAID INSTRUMENT"] 

442 except LookupError: 

443 raise ValueError( 

444 "Instrument could not be found in butler data ID FITS headers and must be provided." 

445 ) from None 

446 if visit is None: 

447 try: 

448 visit = primary_header["LSST BUTLER DATAID VISIT"] 

449 except LookupError: 

450 raise ValueError( 

451 "Visit ID could not be found in butler data ID FITS headers and must be provided." 

452 ) from None 

453 projection = Projection.from_legacy( 

454 legacy_wcs, 

455 DetectorFrame( 

456 instrument=instrument, 

457 visit=visit, 

458 detector=legacy_detector.getId(), 

459 bbox=detector_bbox, 

460 ), 

461 ) 

462 if component == "projection": 

463 return projection 

464 if plane_map is None: 

465 plane_map = get_legacy_visit_image_mask_planes() 

466 assert component != "psf", component # for MyPy 

467 from_masked_image = MaskedImage._read_legacy_hdus( 

468 hdu_list, 

469 filename, 

470 preserve_quantization=preserve_quantization, 

471 plane_map=plane_map, 

472 component=component, 

473 ) 

474 if component is not None: 

475 # This is the image, mask, or variance; attach the projection 

476 # and return 

477 return from_masked_image.view(projection=projection) 

478 result = VisitImage( 

479 from_masked_image.image, 

480 mask=from_masked_image.mask, 

481 variance=from_masked_image.variance, 

482 projection=projection, 

483 psf=psf, 

484 ) 

485 result._opaque_metadata = from_masked_image._opaque_metadata 

486 return result 

487 

488 

489class VisitImageSerializationModel[P: pydantic.BaseModel](MaskedImageSerializationModel[P]): 

490 """A Pydantic model used to represent a serialized `VisitImage`.""" 

491 

492 # Inherited attributes are duplicated because that improves the docs 

493 # (some limitation in the sphinx/pydantic integration), and these are 

494 # important docs. 

495 

496 image: ImageSerializationModel[P] = pydantic.Field(description="The main data image.") 

497 mask: MaskSerializationModel[P] = pydantic.Field( 

498 description="Bitmask that annotates the main image's pixels." 

499 ) 

500 variance: ImageSerializationModel[P] = pydantic.Field( 

501 description="Per-pixel variance estimates for the main image." 

502 ) 

503 projection: ProjectionSerializationModel[P] = pydantic.Field( 

504 description="Projection that maps the pixel grid to the sky.", 

505 ) 

506 psf: PiffSerializationModel | PSFExSerializationModel | Any = pydantic.Field( 

507 union_mode="left_to_right", description="PSF model for the image." 

508 ) 

509 

510 def deserialize_psf(self, archive: InputArchive[Any]) -> PointSpreadFunction | ArchiveReadError: 

511 """Finish deserializing the PSF model, or *return* any exception 

512 raised in the attempt. 

513 """ 

514 try: 

515 match self.psf: 

516 case PiffSerializationModel(): 

517 return PiffWrapper.deserialize(self.psf, archive) 

518 case PSFExSerializationModel(): 

519 return PSFExWrapper.deserialize(self.psf, archive) 

520 case _: 

521 raise ArchiveReadError("PSF model type not recognized.") 

522 except ArchiveReadError as err: 

523 return err 

524 

525 

526def _extract_or_check_header[T]( 

527 key: str, given_value: T | None, header: Any, coerce: Callable[[Any], T] 

528) -> T: 

529 hdr_value: T | None = None 

530 if (hdr_raw_value := header.get(key)) is not None: 

531 hdr_value = coerce(hdr_raw_value) 

532 if given_value is not None: 

533 if hdr_value is not None and hdr_value != given_value: 

534 raise ValueError( 

535 f"Given value {given_value!r} does not match {hdr_value!r} from header key {key}." 

536 ) 

537 return given_value 

538 else: 

539 if hdr_value is None: 

540 raise ValueError(f"No FITS header key {key} found.") 

541 return hdr_value