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

413 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-10 09:31 +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__ = ("VisitImage", "VisitImageSerializationModel") 

15 

16import functools 

17import logging 

18from collections.abc import Mapping 

19from types import EllipsisType 

20from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast 

21 

22import astropy.io.fits 

23import astropy.units 

24import numpy as np 

25import pydantic 

26from astro_metadata_translator import ObservationInfo 

27 

28from ._backgrounds import BackgroundMap, BackgroundMapSerializationModel 

29from ._concrete_bounds import BoundsSerializationModel 

30from ._geom import Bounds, Box 

31from ._image import Image, ImageSerializationModel 

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

33from ._masked_image import MaskedImage, MaskedImageSerializationModel 

34from ._obs_info_from_legacy import obs_info_from_legacy 

35from ._observation_summary_stats import ObservationSummaryStats 

36from ._polygon import Polygon 

37from ._transforms import ( 

38 DetectorFrame, 

39 SkyProjection, 

40 SkyProjectionAstropyView, 

41 SkyProjectionSerializationModel, 

42) 

43from .aperture_corrections import ( 

44 ApertureCorrectionMap, 

45 ApertureCorrectionMapSerializationModel, 

46 aperture_corrections_from_legacy, 

47 aperture_corrections_to_legacy, 

48) 

49from .cameras import Detector, DetectorSerializationModel 

50from .describe import DescribeOptions, FieldRole, Report, ReportField 

51from .fields import BaseField, Field, FieldSerializationModel, field_from_legacy_photo_calib 

52from .fits import FitsOpaqueMetadata, parse_legacy_bunit 

53from .psfs import ( 

54 GaussianPointSpreadFunction, 

55 GaussianPSFSerializationModel, 

56 LegacyPointSpreadFunction, 

57 PiffSerializationModel, 

58 PiffWrapper, 

59 PointSpreadFunction, 

60 PSFExSerializationModel, 

61 PSFExWrapper, 

62) 

63from .serialization import ArchiveReadError, InputArchive, InvalidParameterError, MetadataValue, OutputArchive 

64from .utils import is_none 

65 

66if TYPE_CHECKING: 

67 try: 

68 from lsst.afw.cameraGeom import Detector as LegacyDetector 

69 from lsst.afw.image import Exposure as LegacyExposure 

70 from lsst.afw.image import FilterLabel as LegacyFilterLabel 

71 from lsst.afw.image import VisitInfo as LegacyVisitInfo 

72 from lsst.daf.butler import DimensionRecord 

73 except ImportError: 

74 type LegacyDetector = Any # type: ignore[no-redef] 

75 type LegacyExposure = Any # type: ignore[no-redef] 

76 type LegacyFilterLabel = Any # type: ignore[no-redef] 

77 type LegacyVisitInfo = Any # type: ignore[no-redef] 

78 type DimensionRecord = Any # type: ignore[no-redef] 

79 

80_LOG = logging.getLogger("lsst.images") 

81 

82 

83class VisitImage(MaskedImage): 

84 """A calibrated single-visit image. 

85 

86 Parameters 

87 ---------- 

88 image 

89 The main image plane. If this has a `SkyProjection`, it will be used 

90 for all planes unless a ``sky_projection`` is passed separately. 

91 mask 

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

93 same bounding box as ``image`` if provided. Any attached 

94 ``sky_projection`` is replaced (possibly by `None`). 

95 variance 

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

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

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

99 Values default to ``1.0``. Any attached ``sky_projection`` is replaced 

100 (possibly by `None`). 

101 mask_schema 

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

103 not provided. 

104 sky_projection 

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

106 a ``sky_projection`` is already attached to ``image``. 

107 bounds 

108 The region where this image's pixels and other properties are valid. 

109 If not provided, the bounding box of the image is used. Other 

110 components (``psf``, ``sky_projection``, ``aperture_corrections``, 

111 etc.) are assumed to have their own bounds which may or may not be the 

112 same as the image bounds. If ``bounds`` extends beyond the image 

113 bounding box, the intersection between ``bounds`` and the image 

114 bounding box is used instead. 

115 obs_info 

116 General information about this visit in standardized form. 

117 summary_stats 

118 Summary statistics associated with this visit. Initialized to default 

119 values if not provided. 

120 photometric_scaling 

121 Field that can be used to multiply a post-ISR image units to yield 

122 calibrated image units. This may be a scaling that was already 

123 applied (so dividing by it will recover the post-ISR units) or a 

124 scaling that has not been applied, depending on ``image.unit``. 

125 psf 

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

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

128 detector 

129 Geometry and electronic information for the detector attached to this 

130 image. 

131 aperture_corrections : `dict` [`str`, `~fields.BaseField`] 

132 Mapping from photometry algorithm name to the aperture correction for 

133 that algorithm. 

134 backgrounds 

135 Background models associated with this image. 

136 band 

137 Name of the passband the image was observed with (this is a shorter, 

138 less specific version of ``obs_info.physical_filter``). 

139 metadata 

140 Arbitrary flexible metadata to associate with the image. 

141 """ 

142 

143 def __init__( 

144 self, 

145 image: Image, 

146 *, 

147 mask: Mask | None = None, 

148 variance: Image | None = None, 

149 mask_schema: MaskSchema | None = None, 

150 sky_projection: SkyProjection[DetectorFrame] | None = None, 

151 bounds: Bounds | None = None, 

152 obs_info: ObservationInfo | None = None, 

153 summary_stats: ObservationSummaryStats | None = None, 

154 photometric_scaling: Field | None = None, 

155 psf: PointSpreadFunction | ArchiveReadError, 

156 detector: Detector, 

157 aperture_corrections: ApertureCorrectionMap | None = None, 

158 backgrounds: BackgroundMap | None = None, 

159 band: str, 

160 metadata: dict[str, MetadataValue] | None = None, 

161 ) -> None: 

162 super().__init__( 

163 image, 

164 mask=mask, 

165 variance=variance, 

166 mask_schema=mask_schema, 

167 sky_projection=sky_projection, 

168 metadata=metadata, 

169 ) 

170 if self.image.unit is None: 

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

172 if self.image.sky_projection is None: 

173 raise TypeError("The sky_projection component of a VisitImage cannot be None.") 

174 if obs_info is None: 

175 raise TypeError("The observation info component of a VisitImage cannot be None.") 

176 if obs_info.physical_filter is None: 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true

177 raise ValueError("The obs_info.physical_filter attribute of a VisitImage cannot be None.") 

178 self._obs_info = obs_info 

179 if not isinstance(self.image.sky_projection.pixel_frame, DetectorFrame): 

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

181 if summary_stats is None: 

182 summary_stats = ObservationSummaryStats() 

183 self._summary_stats = summary_stats 

184 if photometric_scaling is not None and photometric_scaling.unit is None: 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true

185 raise TypeError("If a photometric_scaling is provided, it must have units.") 

186 self._photometric_scaling = photometric_scaling 

187 self._psf = psf 

188 self._detector = detector 

189 self._aperture_corrections = aperture_corrections if aperture_corrections is not None else {} 

190 self._bounds = bounds if bounds is not None else self.bbox 

191 if not self.bbox.contains(self._bounds.bbox): 

192 self._bounds = self._bounds.intersection(self.bbox) 

193 self._backgrounds = backgrounds if backgrounds is not None else BackgroundMap() 

194 self._band = band 

195 

196 @property 

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

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

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

200 

201 @property 

202 def sky_projection(self) -> SkyProjection[DetectorFrame]: 

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

204 (`SkyProjection` [`DetectorFrame`]). 

205 """ 

206 return cast(SkyProjection[DetectorFrame], super().sky_projection) 

207 

208 @property 

209 def bounds(self) -> Bounds: 

210 """The region where pixels are valid (`Bounds`).""" 

211 return self._bounds 

212 

213 @property 

214 def obs_info(self) -> ObservationInfo: 

215 """General information about this observation in standard form. 

216 (`~astro_metadata_translator.ObservationInfo`). 

217 """ 

218 return self._obs_info 

219 

220 @property 

221 def physical_filter(self) -> str: 

222 """Full name of the physical bandpass filter (`str`).""" 

223 assert self._obs_info.physical_filter is not None, "Guaranteed at construction." 

224 return self._obs_info.physical_filter 

225 

226 @property 

227 def band(self) -> str: 

228 """Short name of the bandpass filter (`str`).""" 

229 return self._band 

230 

231 @property 

232 def astropy_wcs(self) -> SkyProjectionAstropyView: 

233 """An Astropy WCS for the pixel arrays (`SkyProjectionAstropyView`). 

234 

235 Notes 

236 ----- 

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

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

239 ``bbox.start``, as is the case for `sky_projection`. 

240 

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

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

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

244 """ 

245 return cast(SkyProjectionAstropyView, super().astropy_wcs) 

246 

247 @property 

248 def summary_stats(self) -> ObservationSummaryStats: 

249 """Optional summary statistics for this observation 

250 (`ObservationSummaryStats`). 

251 """ 

252 return self._summary_stats 

253 

254 @property 

255 def photometric_scaling(self) -> Field | None: 

256 """Field that multiplies a post-ISR image to yield the calibrated 

257 image (~`fields.BaseField`). 

258 """ 

259 return self._photometric_scaling 

260 

261 @photometric_scaling.setter 

262 def photometric_scaling(self, value: Field) -> None: 

263 if value.unit is None: 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true

264 raise TypeError("The photometric_scaling for a VisitImage must have units.") 

265 self._photometric_scaling = value 

266 

267 @property 

268 def psf(self) -> PointSpreadFunction: 

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

270 (`.psfs.PointSpreadFunction`). 

271 """ 

272 if isinstance(self._psf, ArchiveReadError): 

273 raise self._psf 

274 return self._psf 

275 

276 @property 

277 def detector(self) -> Detector: 

278 """Geometry and electronic information about the detector 

279 (`.cameras.Detector`). 

280 """ 

281 return self._detector 

282 

283 @property 

284 def aperture_corrections(self) -> ApertureCorrectionMap: 

285 """A mapping from photometry algorithm name to the aperture correction 

286 field for that algorithm (`dict` [`str`, `~.fields.BaseField`]). 

287 """ 

288 return self._aperture_corrections 

289 

290 @property 

291 def backgrounds(self) -> BackgroundMap: 

292 """A mapping of backgrounds associated with this image 

293 (`BackgroundMap`). 

294 """ 

295 return self._backgrounds 

296 

297 def __getitem__(self, bbox: Box | EllipsisType) -> VisitImage: 

298 bbox, _ = self._handle_getitem_args(bbox) 

299 return self._transfer_metadata( 

300 VisitImage( 

301 self.image[bbox], 

302 mask=self.mask[bbox], 

303 variance=self.variance[bbox], 

304 sky_projection=self.sky_projection, 

305 psf=self._psf, 

306 obs_info=self.obs_info, 

307 bounds=self._bounds, # don't need to intersect here, because __init__ will do that. 

308 summary_stats=self.summary_stats, 

309 detector=self._detector, 

310 photometric_scaling=self._photometric_scaling, 

311 aperture_corrections=self.aperture_corrections, 

312 backgrounds=self._backgrounds, 

313 band=self._band, 

314 ), 

315 bbox=bbox, 

316 ) 

317 

318 def _summary_stats_describe_options(self, options: DescribeOptions) -> DescribeOptions: 

319 """Return the options the summary-statistics child report is built 

320 with. 

321 

322 Parameters 

323 ---------- 

324 options : `DescribeOptions` 

325 Options this image's own report is being built with. 

326 

327 Returns 

328 ------- 

329 options : `DescribeOptions` 

330 Options for the child report. 

331 

332 Notes 

333 ----- 

334 `ObservationSummaryStats.raCorners` and 

335 `~ObservationSummaryStats.decCorners` hold the sky corners of the 

336 image the statistics were measured on, which the sky projection 

337 tabulates more readably. Taking a cutout does not recompute the 

338 statistics, so those corners still describe the original image and 

339 are worth keeping wherever they no longer match the pixels on hand. 

340 """ 

341 if self.bbox == self.detector.bbox: 

342 return options.for_child("corners") 

343 return options.for_child() 

344 

345 def _describe_psf(self, options: DescribeOptions) -> Report: 

346 """Return the report for this image's PSF, or for the error that 

347 stopped it being read. 

348 

349 Parameters 

350 ---------- 

351 options : `DescribeOptions` 

352 Options the child report is built with. 

353 

354 Returns 

355 ------- 

356 report : `Report` 

357 Report describing the PSF, or naming it unreadable. 

358 

359 Notes 

360 ----- 

361 A PSF that could not be read is a supported state of a visit image, 

362 not a failure of it, and it is often the only unreadable part: a PSF 

363 model can depend on a package the reader does not have installed. 

364 Describing it therefore has to say what is missing and why, rather 

365 than raise and take the report of everything else with it. 

366 """ 

367 if isinstance(self._psf, ArchiveReadError): 

368 return Report( 

369 type_name="PointSpreadFunction", 

370 summary=f"unreadable ({self._psf})", 

371 inline=True, 

372 ) 

373 return self._psf._describe(options) 

374 

375 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report: 

376 """Return a `Report` describing this visit image. 

377 

378 Parameters 

379 ---------- 

380 options : `DescribeOptions`, optional 

381 Rendering options; forwarded to all children. Child construction 

382 can be expensive, so `DescribeOptions.brief` skips it. 

383 """ 

384 # The image and mask schema are rendered as children below, and the 

385 # image renders as ``Image(bbox, dtype)``, which would restate the 

386 # shared bbox. Both are REPR_ONLY so only repr sees them. 

387 fields = [ 

388 ReportField( 

389 label="image", 

390 value=self.image, 

391 repr_value=repr(self.image), 

392 positional=True, 

393 role=FieldRole.REPR_ONLY, 

394 ), 

395 ReportField( 

396 label="mask_schema", 

397 value=self.mask.schema, 

398 repr_value=repr(self.mask.schema), 

399 role=FieldRole.REPR_ONLY, 

400 ), 

401 ReportField(label="band", value=self.band, role=FieldRole.DERIVED), 

402 ReportField(label="physical_filter", value=self.physical_filter, role=FieldRole.DERIVED), 

403 ReportField(label="bbox", value=self.bbox, repr_value=repr(self.bbox), role=FieldRole.DERIVED), 

404 ] 

405 summary = f"VisitImage({self.image!s}, {list(self.mask.schema.names)})" 

406 if options.brief: 

407 return Report(type_name="VisitImage", summary=summary, fields=fields) 

408 child = options.for_child() 

409 plane = options.for_child("sky_projection", "bbox") 

410 children: dict[str, Report] = { 

411 "image": self.image._describe(plane), 

412 "mask": self.mask._describe(plane), 

413 "variance": self.variance._describe(plane), 

414 "sky_projection": self.sky_projection._describe(child, bbox=self.bbox), 

415 "psf": self._describe_psf(child), 

416 "detector": self.detector._describe(child), 

417 "summary_stats": self.summary_stats._describe(self._summary_stats_describe_options(options)), 

418 "backgrounds": self.backgrounds._describe(child), 

419 } 

420 if self.photometric_scaling is not None: 

421 children["photometric_scaling"] = self.photometric_scaling._describe(child) 

422 return Report( 

423 type_name="VisitImage", 

424 summary=summary, 

425 fields=fields, 

426 children=children, 

427 ) 

428 

429 def copy(self, *, copy_detector: bool = False) -> VisitImage: 

430 """Deep-copy the visit image. 

431 

432 Parameters 

433 ---------- 

434 copy_detector 

435 Whether to deep-copy the `detector` attribute. 

436 """ 

437 return self._transfer_metadata( 

438 VisitImage( 

439 image=self._image.copy(), 

440 mask=self._mask.copy(), 

441 variance=self._variance.copy(), 

442 psf=self._psf, 

443 obs_info=self.obs_info, 

444 bounds=self._bounds, 

445 summary_stats=self.summary_stats.model_copy(), 

446 detector=self._detector.copy() if copy_detector else self._detector, 

447 photometric_scaling=self._photometric_scaling, 

448 aperture_corrections=self.aperture_corrections.copy(), 

449 backgrounds=self._backgrounds.copy(), 

450 band=self.band, 

451 ), 

452 copy=True, 

453 ) 

454 

455 def convert_unit( 

456 self, 

457 unit: astropy.units.UnitBase = astropy.units.nJy, 

458 copy: Literal["as-needed"] | bool = True, 

459 copy_detector: bool = False, 

460 ) -> VisitImage: 

461 """Return an equivalent image with different pixel units. 

462 

463 Parameters 

464 ---------- 

465 unit 

466 The unit to transform to. This may be any of the following: 

467 

468 - any unit directly relatable to the current units via Astropy; 

469 - any unit relatable to the product of the current units with the 

470 `photometric_scaling` (i.e. if the current image is in 

471 instrumental units but we know how to calibrate them) 

472 - any unit relatable to the quotient of the current units with the 

473 `photometric_scaling` (i.e. if the current image is in 

474 calibrated units and we want to revert back to instrumental 

475 units). 

476 copy 

477 Whether to copy the images and other components. If `True`, all 

478 components that aren't controlled by some other argument will 

479 always be deep-copied. If `False`, the operation will fail if the 

480 image is not already in the right units. If ``as-needed``, only 

481 the image and variance will be copied, and only if they are not 

482 already in the right units. 

483 copy_detector 

484 Whether to deep-copy the `detector` attribute. 

485 

486 Returns 

487 ------- 

488 `VisitImage` 

489 An image with the given units. 

490 """ 

491 if copy not in (True, False, "as-needed"): 491 ↛ 492line 491 didn't jump to line 492 because the condition on line 491 was never true

492 raise TypeError(f"Invalid value for 'copy' parameter: {copy!r}.") 

493 if (factor := _get_unit_conversion_factor(self.unit, unit)) is not None: 493 ↛ 494line 493 didn't jump to line 494 because the condition on line 493 was never true

494 if factor == 1.0: 

495 if copy is True: # not "as-needed" 

496 return self.copy() 

497 else: 

498 return self[...] 

499 elif copy is False: 

500 raise astropy.units.UnitConversionError( 

501 f"Units must be converted ({self.unit} -> {unit}), but copy=False." 

502 ) 

503 image = Image( 

504 self._image.array * factor, bbox=self.bbox, sky_projection=self.sky_projection, unit=unit 

505 ) 

506 variance = Image( 

507 self._variance.array * factor**2, 

508 bbox=self.bbox, 

509 unit=unit**2, 

510 ) 

511 elif self._photometric_scaling is None: 511 ↛ 512line 511 didn't jump to line 512 because the condition on line 511 was never true

512 raise astropy.units.UnitConversionError( 

513 "VisitImage.photometric_scaling is None, and there " 

514 f"is no constant conversion from {self.unit} to {unit}." 

515 ) 

516 else: 

517 if copy is False: 517 ↛ 518line 517 didn't jump to line 518 because the condition on line 517 was never true

518 raise astropy.units.UnitConversionError( 

519 f"Photometric scaling must be applied to go from ={self.unit} to {unit}, but copy=False." 

520 ) 

521 scaling = self._photometric_scaling 

522 assert scaling.unit is not None, "Checked at construction." 

523 if (constant_factor := _get_unit_conversion_factor(self.unit * scaling.unit, unit)) is not None: 

524 if constant_factor != 1.0: 524 ↛ 525line 524 didn't jump to line 525 because the condition on line 524 was never true

525 scaling = scaling * constant_factor 

526 scaling_array = scaling.render(self.bbox, dtype=self.image.array.dtype).array 

527 elif (constant_factor := _get_unit_conversion_factor(self.unit / scaling.unit, unit)) is not None: 527 ↛ 533line 527 didn't jump to line 533 because the condition on line 527 was always true

528 if constant_factor != 1.0: 528 ↛ 529line 528 didn't jump to line 529 because the condition on line 528 was never true

529 scaling = scaling / constant_factor 

530 scaling_array = scaling.render(self.bbox, dtype=self.image.array.dtype).array 

531 np.true_divide(1.0, scaling_array, out=scaling_array) 

532 else: 

533 raise astropy.units.UnitConversionError( 

534 f"photometric_scaling with units {scaling.unit} does not " 

535 f"provide a path from {self.unit} to {unit}." 

536 ) 

537 # We needed to allocate a new array to evaluate the scaling field, 

538 # and then we need to allocate another to hold its square for the 

539 # variance scaling. But then we can multiply those arrays in-place 

540 # to get the output image and variance to avoid yet more 

541 # allocations (note we can't instead multiply the visit image's 

542 # image and variance arrays in place because they might have other 

543 # references that are still associated with the old units). 

544 image = Image(scaling_array, bbox=self.bbox, unit=unit) 

545 variance = Image(np.square(scaling_array), bbox=self.bbox, unit=unit**2) 

546 image.array *= self._image.array 

547 variance.array *= self._variance.array 

548 copy_components = copy is True 

549 return self._transfer_metadata( 

550 VisitImage( 

551 image=image, 

552 mask=self._mask if not copy_components else self._mask.copy(), 

553 variance=variance, 

554 sky_projection=self.sky_projection, # never copied; immutable 

555 obs_info=self.obs_info if not copy_components else self.obs_info.model_copy(), 

556 psf=self._psf, # never copied; immutable 

557 bounds=self._bounds, # never copied; immutable 

558 summary_stats=self.summary_stats if not copy_components else self.summary_stats.model_copy(), 

559 detector=self._detector if not copy_detector else self._detector.copy(), 

560 photometric_scaling=self._photometric_scaling, # never copied; immutable 

561 aperture_corrections=( 

562 self.aperture_corrections if not copy_components else self.aperture_corrections.copy() 

563 ), 

564 backgrounds=self.backgrounds if not copy_components else self.backgrounds.copy(), 

565 band=self.band, 

566 ) 

567 ) 

568 

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

570 return self._serialize_impl(VisitImageSerializationModel, archive) 

571 

572 # This is slightly bad Liskov substitution - we're demanding M be a 

573 # VisitImageSerializationModel, not just a MaskedImageSerializationModel, 

574 # but that's because we know only `serialize` will call it. 

575 def _serialize_impl[M: VisitImageSerializationModel[Any]]( # type: ignore[override] 

576 self, model_type: type[M], archive: OutputArchive[Any] 

577 ) -> M: 

578 result = super()._serialize_impl(model_type, archive) 

579 match self._psf: 

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

581 # but not a single isinstance check on the three types. 

582 case PiffWrapper(): 582 ↛ 583line 582 didn't jump to line 583 because the pattern on line 582 never matched

583 result.psf = archive.serialize_direct("psf", self._psf.serialize) 

584 case PSFExWrapper(): 584 ↛ 585line 584 didn't jump to line 585 because the pattern on line 584 never matched

585 result.psf = archive.serialize_direct("psf", self._psf.serialize) 

586 case GaussianPointSpreadFunction(): 586 ↛ 588line 586 didn't jump to line 588 because the pattern on line 586 always matched

587 result.psf = archive.serialize_direct("psf", self._psf.serialize) 

588 case _: 

589 raise TypeError( 

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

591 ) 

592 assert result.sky_projection is not None, "VisitImage always has a sky_projection." 

593 result.obs_info = self.obs_info 

594 result.summary_stats = self.summary_stats 

595 result.bounds = self._bounds.serialize() if self._bounds != self.bbox else None 

596 result.detector = archive.serialize_direct("detector", self._detector.serialize) 

597 result.band = self.band 

598 result.photometric_scaling = ( 

599 # MyPy can't quite follow the type union through the serialize 

600 # method return types. 

601 archive.serialize_direct( 

602 "photometric_scaling", 

603 self._photometric_scaling.serialize, 

604 ) # type: ignore[assignment] 

605 if self._photometric_scaling is not None 

606 else None 

607 ) 

608 result.aperture_corrections = archive.serialize_direct( 

609 "aperture_corrections", 

610 functools.partial(ApertureCorrectionMapSerializationModel.serialize, self.aperture_corrections), 

611 ) 

612 result.backgrounds = archive.serialize_direct("backgrounds", self._backgrounds.serialize) 

613 return result 

614 

615 @staticmethod 

616 def _get_archive_tree_type[P: pydantic.BaseModel]( 

617 pointer_type: type[P], 

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

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

620 type that uses the given pointer type. 

621 """ 

622 return VisitImageSerializationModel[pointer_type] # type: ignore 

623 

624 @staticmethod 

625 def from_legacy( # type: ignore[override] 

626 legacy: LegacyExposure, 

627 *, 

628 exposure_record: DimensionRecord | None, 

629 unit: astropy.units.UnitBase | None = None, 

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

631 ) -> VisitImage: 

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

633 

634 Parameters 

635 ---------- 

636 legacy 

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

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

639 exposure_record 

640 The ``exposure`` dimension record for this observation, as a 

641 source of additional required metadata. Pass `None` explicitly to 

642 convert without some `~astro_metadata_translator.ObservationInfo` 

643 fields (with a warning). 

644 unit 

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

646 key will be used, if available. 

647 plane_map 

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

649 description. If `None` (default) 

650 `get_legacy_visit_image_mask_planes` is used. 

651 """ 

652 from lsst.afw.image import setVisitInfoMetadata 

653 from lsst.daf.base import PropertyList 

654 

655 if plane_map is None: 655 ↛ 657line 655 didn't jump to line 657 because the condition on line 655 was always true

656 plane_map = get_legacy_visit_image_mask_planes() 

657 legacy_wcs = legacy.getWcs() 

658 if legacy_wcs is None: 658 ↛ 659line 658 didn't jump to line 659 because the condition on line 658 was never true

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

660 legacy_detector = legacy.getDetector() 

661 if legacy_detector is None: 661 ↛ 662line 661 didn't jump to line 662 because the condition on line 661 was never true

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

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

664 visit_info = legacy.info.getVisitInfo() 

665 if visit_info is None: 665 ↛ 666line 665 didn't jump to line 666 because the condition on line 665 was never true

666 raise ValueError("Exposure does not have a VisitInfo.") 

667 md = legacy.getMetadata() 

668 primary_header = md.to_astropy_header() 

669 # afw's ExposureReader strips the VisitInfo's header cards from its 

670 # metadata, but we want to include them in the FITS header we 

671 # attach. 

672 pl = PropertyList() 

673 setVisitInfoMetadata(pl, visit_info) 

674 primary_header.update(pl.to_astropy_header()) 

675 telescope = primary_header.get("TELESCOP") 

676 obs_info = obs_info_from_legacy( 

677 visit_info, 

678 exposure_record, 

679 legacy_detector, 

680 detector_exposure_id=legacy.info.getId(), 

681 telescope=telescope, 

682 physical_filter=legacy.info.getFilter().physicalLabel or None, 

683 ) 

684 if exposure_record is not None: 684 ↛ 688line 684 didn't jump to line 688 because the condition on line 684 was always true

685 instrument = exposure_record.instrument 

686 visit = exposure_record.id # assume visit ID == exposure ID; always true in practice 

687 else: 

688 instrument = visit_info.getInstrumentLabel() 

689 visit = visit_info.getId() 

690 opaque_fits_metadata = FitsOpaqueMetadata() 

691 metadata = opaque_fits_metadata.extract_legacy_primary_header(primary_header) 

692 instrumental_unit = opaque_fits_metadata.get_instrumental_unit() or astropy.units.electron 

693 hdr_unit: astropy.units.UnitBase | None = None 

694 if hdr_unit_str := md.get("BUNIT"): 694 ↛ 696line 694 didn't jump to line 696 because the condition on line 694 was always true

695 hdr_unit = parse_legacy_bunit(hdr_unit_str, instrumental_unit=instrumental_unit) 

696 if unit is None: 696 ↛ 698line 696 didn't jump to line 698 because the condition on line 696 was always true

697 unit = hdr_unit 

698 elif hdr_unit is not None and hdr_unit != unit: 

699 raise ValueError(f"BUNIT value {hdr_unit} disagrees with given unit {unit}.") 

700 sky_projection = SkyProjection.from_legacy( 

701 legacy_wcs, 

702 DetectorFrame( 

703 instrument=instrument, 

704 visit=visit, 

705 detector=legacy_detector.getId(), 

706 bbox=detector_bbox, 

707 ), 

708 ) 

709 legacy_psf = legacy.getPsf() 

710 if legacy_psf is None: 710 ↛ 711line 710 didn't jump to line 711 because the condition on line 710 was never true

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

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

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

714 legacy_summary_stats = legacy.info.getSummaryStats() 

715 legacy_ap_corr_map = legacy.info.getApCorrMap() 

716 legacy_polygon = legacy.info.getValidPolygon() 

717 legacy_photo_calib = legacy.info.getPhotoCalib() 

718 detector = Detector.from_legacy( 

719 legacy_detector, instrument=instrument, visit=visit, is_raw_assembled=True 

720 ) 

721 if instrument == "LSSTCam": 

722 obs_info = _fix_lsstcam_detector_serial(obs_info, detector) 

723 result = VisitImage( 

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

725 mask=masked_image.mask, 

726 variance=masked_image.variance, 

727 sky_projection=sky_projection, 

728 psf=psf, 

729 obs_info=obs_info, 

730 summary_stats=( 

731 ObservationSummaryStats.from_legacy(legacy_summary_stats) 

732 if legacy_summary_stats is not None 

733 else None 

734 ), 

735 detector=detector, 

736 aperture_corrections=( 

737 aperture_corrections_from_legacy(legacy_ap_corr_map) 

738 if legacy_ap_corr_map is not None 

739 else None 

740 ), 

741 bounds=Polygon.from_legacy(legacy_polygon) if legacy_polygon is not None else None, 

742 photometric_scaling=( 

743 field_from_legacy_photo_calib( 

744 legacy_photo_calib, bounds=detector_bbox, instrumental_unit=instrumental_unit 

745 ) 

746 if legacy_photo_calib is not None 

747 else None 

748 ), 

749 band=legacy.info.getFilter().bandLabel, 

750 metadata=metadata, 

751 ) 

752 result._metadata["id"] = legacy.info.getId() 

753 result._opaque_metadata = opaque_fits_metadata 

754 return result 

755 

756 def to_legacy( 

757 self, *, copy: bool | None = None, plane_map: Mapping[str, MaskPlane] | None = None 

758 ) -> LegacyExposure: 

759 """Convert to an `lsst.afw.image.Exposure` instance. 

760 

761 Parameters 

762 ---------- 

763 copy 

764 If `True`, always copy the image and variance pixel data. 

765 If `False`, return a view, and raise `TypeError` if the pixel data 

766 is read-only (this is not supported by afw). If `None`, only copy 

767 if the pixel data is read-only. Mask pixel data is always copied. 

768 plane_map 

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

770 description. If `None` (default), 

771 `get_legacy_visit_image_mask_planes` is used. 

772 """ 

773 from lsst.afw.image import Exposure as LegacyExposure 

774 from lsst.afw.image import FilterLabel as LegacyFilterLabel 

775 from lsst.obs.base.makeRawVisitInfoViaObsInfo import MakeRawVisitInfoViaObsInfo 

776 

777 if plane_map is None: 777 ↛ 779line 777 didn't jump to line 779 because the condition on line 777 was always true

778 plane_map = get_legacy_visit_image_mask_planes() 

779 legacy_masked_image = super().to_legacy(copy=copy, plane_map=plane_map) 

780 result = LegacyExposure(legacy_masked_image, dtype=self.image.array.dtype) 

781 result_info = result.info 

782 result_info.setId(self._metadata.get("id")) 

783 result_info.setWcs(self.sky_projection.to_legacy()) 

784 result_info.setDetector(self.detector.to_legacy()) 

785 result_info.setFilter(LegacyFilterLabel.fromBandPhysical(self.band, self.obs_info.physical_filter)) 

786 if self._photometric_scaling is not None: 786 ↛ 787line 786 didn't jump to line 787 because the condition on line 786 was never true

787 result_info.setPhotoCalib(self._photometric_scaling.to_legacy_photo_calib(self.unit)) 

788 else: 

789 result_info.setPhotoCalib(BaseField.make_legacy_photo_calib(self.unit)) 

790 self._fill_legacy_metadata(result_info.getMetadata()) 

791 if isinstance(self._psf, LegacyPointSpreadFunction): 791 ↛ 793line 791 didn't jump to line 793 because the condition on line 791 was always true

792 result_info.setPsf(self._psf.legacy_psf) 

793 elif isinstance(self._psf, PiffWrapper): 

794 result_info.setPsf(self._psf.to_legacy()) 

795 if isinstance(self.bounds, Polygon): 795 ↛ 796line 795 didn't jump to line 796 because the condition on line 795 was never true

796 result_info.setValidPolygon(self.bounds.to_legacy()) 

797 if self.aperture_corrections: 797 ↛ 798line 797 didn't jump to line 798 because the condition on line 797 was never true

798 result_info.setApCorrMap(aperture_corrections_to_legacy(self.aperture_corrections)) 

799 result_info.setVisitInfo(MakeRawVisitInfoViaObsInfo.observationInfo2visitInfo(self.obs_info)) 

800 result_info.setSummaryStats(self.summary_stats.to_legacy()) 

801 return result 

802 

803 @staticmethod 

804 def read_legacy( # type: ignore[override] 

805 filename: str, 

806 *, 

807 exposure_record: DimensionRecord | None, 

808 preserve_quantization: bool = False, 

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

810 component: Literal[ 

811 "bbox", 

812 "image", 

813 "mask", 

814 "variance", 

815 "sky_projection", 

816 "psf", 

817 "detector", 

818 "photometric_scaling", 

819 "obs_info", 

820 "summary_stats", 

821 "aperture_corrections", 

822 ] 

823 | None = None, 

824 ) -> Any: 

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

826 

827 Parameters 

828 ---------- 

829 filename 

830 Full name of the file. 

831 exposure_record 

832 The ``exposure`` dimension record for this observation, as a 

833 source of additional required metadata. Pass `None` explicitly to 

834 convert without some `~astro_metadata_translator.ObservationInfo` 

835 fields (with a warning). 

836 preserve_quantization 

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

838 exactly preserve quantization-compressed pixel values. This causes 

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

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

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

842 not transferred to the copy. 

843 plane_map 

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

845 description. If `None` (default) 

846 `get_legacy_visit_image_mask_planes` is used. 

847 component 

848 A component to read instead of the full image. 

849 """ 

850 from lsst.afw.image import ExposureFitsReader 

851 

852 reader = ExposureFitsReader(filename) 

853 if component == "bbox": 853 ↛ 854line 853 didn't jump to line 854 because the condition on line 853 was never true

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

855 legacy_detector = reader.readDetector() 

856 if legacy_detector is None: 856 ↛ 857line 856 didn't jump to line 857 because the condition on line 856 was never true

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

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

859 legacy_wcs = None 

860 if component in (None, "image", "mask", "variance", "sky_projection"): 860 ↛ 864line 860 didn't jump to line 864 because the condition on line 860 was always true

861 legacy_wcs = reader.readWcs() 

862 if legacy_wcs is None: 862 ↛ 863line 862 didn't jump to line 863 because the condition on line 862 was never true

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

864 legacy_exposure_info = reader.readExposureInfo() 

865 summary_stats = None 

866 if component in (None, "summary_stats"): 866 ↛ 872line 866 didn't jump to line 872 because the condition on line 866 was always true

867 legacy_stats = legacy_exposure_info.getSummaryStats() 

868 if legacy_stats is not None: 

869 summary_stats = ObservationSummaryStats.from_legacy(legacy_stats) 

870 if component == "summary_stats": 870 ↛ 871line 870 didn't jump to line 871 because the condition on line 870 was never true

871 return summary_stats 

872 if component in (None, "psf"): 872 ↛ 879line 872 didn't jump to line 879 because the condition on line 872 was always true

873 legacy_psf = reader.readPsf() 

874 if legacy_psf is None: 874 ↛ 875line 874 didn't jump to line 875 because the condition on line 874 was never true

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

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

877 if component == "psf": 877 ↛ 878line 877 didn't jump to line 878 because the condition on line 877 was never true

878 return psf 

879 aperture_corrections: ApertureCorrectionMap = {} 

880 if component in (None, "aperture_corrections"): 880 ↛ 886line 880 didn't jump to line 886 because the condition on line 880 was always true

881 legacy_ap_corr_map = reader.readApCorrMap() 

882 if legacy_ap_corr_map is not None: 882 ↛ 883line 882 didn't jump to line 883 because the condition on line 882 was never true

883 aperture_corrections = aperture_corrections_from_legacy(legacy_ap_corr_map) 

884 if component == "aperture_corrections": 884 ↛ 885line 884 didn't jump to line 885 because the condition on line 884 was never true

885 return aperture_corrections 

886 assert component in ( 

887 None, 

888 "image", 

889 "mask", 

890 "variance", 

891 "sky_projection", 

892 "obs_info", 

893 "detector", 

894 "photometric_scaling", 

895 ), component # for MyPy 

896 visit_info = legacy_exposure_info.getVisitInfo() 

897 if visit_info is None: 897 ↛ 898line 897 didn't jump to line 898 because the condition on line 897 was never true

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

899 filter_label = reader.readFilter() 

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

901 primary_header = hdu_list[0].header 

902 telescope = primary_header.get("TELESCOP") 

903 obs_info = obs_info_from_legacy( 

904 visit_info, 

905 exposure_record, 

906 legacy_detector, 

907 detector_exposure_id=legacy_exposure_info.getId(), 

908 telescope=str(telescope) if telescope is not None else None, 

909 physical_filter=filter_label.physicalLabel or None, 

910 ) 

911 if exposure_record is not None: 911 ↛ 915line 911 didn't jump to line 915 because the condition on line 911 was always true

912 instrument = exposure_record.instrument 

913 visit = exposure_record.id 

914 else: 

915 instrument = visit_info.getInstrumentLabel() 

916 visit = visit_info.getId() 

917 if component == "obs_info": 917 ↛ 918line 917 didn't jump to line 918 because the condition on line 917 was never true

918 if instrument == "LSSTCam": 

919 obs_info = _fix_lsstcam_detector_serial(obs_info) 

920 return obs_info 

921 opaque_metadata = FitsOpaqueMetadata() 

922 # This extraction is destructive, so we need to be sure to pass 

923 # this opaque_metadata down to MaskedImage._read_legacy_hdus 

924 # so it doesn't try to extract it again. 

925 metadata = opaque_metadata.extract_legacy_primary_header(primary_header) 

926 if (instrumental_unit := opaque_metadata.get_instrumental_unit()) is None: 926 ↛ 928line 926 didn't jump to line 928 because the condition on line 926 was always true

927 instrumental_unit = astropy.units.electron 

928 photometric_scaling: Field | None = None 

929 if component in (None, "photometric_scaling"): 929 ↛ 935line 929 didn't jump to line 935 because the condition on line 929 was always true

930 legacy_photo_calib = reader.readPhotoCalib() 

931 if legacy_photo_calib is not None: 

932 photometric_scaling = field_from_legacy_photo_calib( 

933 legacy_photo_calib, bounds=detector_bbox, instrumental_unit=instrumental_unit 

934 ) 

935 if component == "photometric_scaling": 935 ↛ 936line 935 didn't jump to line 936 because the condition on line 935 was never true

936 return photometric_scaling 

937 if component in ("detector", None): 937 ↛ 945line 937 didn't jump to line 945 because the condition on line 937 was always true

938 detector = Detector.from_legacy( 

939 legacy_detector, instrument=instrument, visit=visit, is_raw_assembled=True 

940 ) 

941 if instrument == "LSSTCam": 

942 obs_info = _fix_lsstcam_detector_serial(obs_info, detector) 

943 if component == "detector": 943 ↛ 944line 943 didn't jump to line 944 because the condition on line 943 was never true

944 return detector 

945 assert component != "detector", "MyPy can't work this out from the above." 

946 sky_projection = SkyProjection.from_legacy( 

947 legacy_wcs, 

948 DetectorFrame( 

949 instrument=instrument, 

950 visit=visit, 

951 detector=legacy_detector.getId(), 

952 bbox=detector_bbox, 

953 ), 

954 ) 

955 if component == "sky_projection": 955 ↛ 956line 955 didn't jump to line 956 because the condition on line 955 was never true

956 return sky_projection 

957 if plane_map is None: 957 ↛ 959line 957 didn't jump to line 959 because the condition on line 957 was always true

958 plane_map = get_legacy_visit_image_mask_planes() 

959 from_masked_image = MaskedImage._read_legacy_hdus( 

960 hdu_list, 

961 filename, 

962 opaque_metadata=opaque_metadata, 

963 preserve_quantization=preserve_quantization, 

964 plane_map=plane_map, 

965 component=component, 

966 ) 

967 if component is not None: 967 ↛ 970line 967 didn't jump to line 970 because the condition on line 967 was never true

968 # This is the image, mask, or variance; attach the sky_projection 

969 # and obs_info and return 

970 return from_masked_image.view(sky_projection=sky_projection) 

971 legacy_polygon = reader.readValidPolygon() 

972 result = VisitImage( 

973 from_masked_image.image, 

974 mask=from_masked_image.mask, 

975 variance=from_masked_image.variance, 

976 sky_projection=sky_projection, 

977 psf=psf, 

978 detector=detector, 

979 obs_info=obs_info, 

980 summary_stats=summary_stats, 

981 aperture_corrections=aperture_corrections, 

982 bounds=Polygon.from_legacy(legacy_polygon) if legacy_polygon is not None else None, 

983 photometric_scaling=photometric_scaling, 

984 band=filter_label.bandLabel, 

985 metadata=metadata, 

986 ) 

987 result._opaque_metadata = from_masked_image._opaque_metadata 

988 result._metadata["id"] = reader.readExposureId() 

989 return result 

990 

991 

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

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

994 

995 SCHEMA_NAME: ClassVar[str] = "visit_image" 

996 SCHEMA_VERSION: ClassVar[str] = "1.1.0" 

997 MIN_READ_VERSION: ClassVar[int] = 1 

998 PUBLIC_TYPE: ClassVar[type] = VisitImage 

999 

1000 # Inherited attributes are duplicated because that improves the docs 

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

1002 # important docs. 

1003 

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

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

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

1007 ) 

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

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

1010 ) 

1011 sky_projection: SkyProjectionSerializationModel[P] = pydantic.Field( 

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

1013 ) 

1014 psf: PiffSerializationModel | PSFExSerializationModel | GaussianPSFSerializationModel | Any = ( 

1015 pydantic.Field(union_mode="left_to_right", description="PSF model for the image.") 

1016 ) 

1017 obs_info: ObservationInfo = pydantic.Field( 

1018 description="Standardized description of visit metadata", 

1019 ) 

1020 photometric_scaling: FieldSerializationModel | None = pydantic.Field( 

1021 default=None, 

1022 description="Scaling that can be used to multiply a post-ISR image to yield calibrated pixel values.", 

1023 ) 

1024 summary_stats: ObservationSummaryStats = pydantic.Field( 

1025 description="Summary statistics for the observation." 

1026 ) 

1027 detector: DetectorSerializationModel = pydantic.Field( 

1028 description="Geometry and electronic information for the detector." 

1029 ) 

1030 aperture_corrections: ApertureCorrectionMapSerializationModel = pydantic.Field( 

1031 default_factory=ApertureCorrectionMapSerializationModel, 

1032 description="Aperture corrections, keyed by flux algorithm.", 

1033 ) 

1034 bounds: BoundsSerializationModel | None = pydantic.Field( 

1035 default=None, 

1036 description="Pixel validity region, if different from the image bounding box.", 

1037 exclude_if=is_none, 

1038 ) 

1039 backgrounds: BackgroundMapSerializationModel = pydantic.Field( 

1040 default_factory=BackgroundMapSerializationModel, 

1041 description="Background models associated with this image.", 

1042 ) 

1043 band: str = pydantic.Field(description="Short name of the bandpass filter.") 

1044 

1045 def deserialize( 

1046 self, archive: InputArchive[Any], *, bbox: Box | None = None, **kwargs: Any 

1047 ) -> VisitImage: 

1048 if kwargs: 1048 ↛ 1049line 1048 didn't jump to line 1049 because the condition on line 1048 was never true

1049 raise InvalidParameterError(f"Unrecognized parameters for VisitImage: {set(kwargs.keys())}.") 

1050 masked_image = super().deserialize(archive, bbox=bbox) 

1051 try: 

1052 psf = self.psf.deserialize(archive) 

1053 except ArchiveReadError as err: 

1054 # Defer this until/unless somebody actually asks for the PSF. 

1055 psf = err 

1056 detector = self.detector.deserialize(archive) 

1057 aperture_corrections = self.aperture_corrections.deserialize(archive) 

1058 photometric_scaling = ( 

1059 self.photometric_scaling.deserialize(archive) if self.photometric_scaling is not None else None 

1060 ) 

1061 return VisitImage( 

1062 masked_image.image, 

1063 mask=masked_image.mask, 

1064 variance=masked_image.variance, 

1065 psf=psf, 

1066 sky_projection=masked_image.sky_projection, 

1067 obs_info=self.obs_info, 

1068 summary_stats=self.summary_stats, 

1069 detector=detector, 

1070 aperture_corrections=aperture_corrections, 

1071 photometric_scaling=photometric_scaling, 

1072 bounds=self.bounds.deserialize() if self.bounds is not None else None, 

1073 backgrounds=self.backgrounds.deserialize(archive), 

1074 band=self.band, 

1075 )._finish_deserialize(self) 

1076 

1077 def deserialize_component(self, component: str, archive: InputArchive[Any], **kwargs: Any) -> Any: 

1078 if kwargs and component not in ("image", "mask", "variance", "masked_image"): 1078 ↛ 1079line 1078 didn't jump to line 1079 because the condition on line 1078 was never true

1079 raise InvalidParameterError( 

1080 f"Unsupported parameters for VisitImage component {component}: {set(kwargs.keys())}." 

1081 ) 

1082 if component == "masked_image": 

1083 return super().deserialize(archive, **kwargs) 

1084 return super().deserialize_component(component, archive, **kwargs) 

1085 

1086 

1087# Serial numbers that were wrong in LSSTCam camera definitions before the 

1088# DM-55080 fix: the R24 and R34 serial blocks were swapped, and one ITL sensor 

1089# had a stale serial. Keys are detector IDs, values are correct serial. 

1090_DM55080_DETECTOR_SERIALS: dict[int, str] = { 

1091 108: "E2V-CCD250-140", 

1092 109: "E2V-CCD250-314", 

1093 110: "E2V-CCD250-302", 

1094 111: "E2V-CCD250-298", 

1095 112: "E2V-CCD250-305", 

1096 113: "E2V-CCD250-318", 

1097 114: "E2V-CCD250-304", 

1098 115: "E2V-CCD250-301", 

1099 116: "E2V-CCD250-385", 

1100 153: "E2V-CCD250-160", 

1101 154: "E2V-CCD250-411", 

1102 155: "E2V-CCD250-256", 

1103 156: "E2V-CCD250-253", 

1104 157: "E2V-CCD250-194", 

1105 158: "E2V-CCD250-231", 

1106 159: "E2V-CCD250-224", 

1107 160: "E2V-CCD250-189", 

1108 161: "E2V-CCD250-134", 

1109 173: "ITL-3800C-327", 

1110} 

1111 

1112 

1113def _fix_lsstcam_detector_serial( 

1114 obs_info: ObservationInfo, detector: Detector | None = None 

1115) -> ObservationInfo: 

1116 assert obs_info.detector_num is not None 

1117 expected = _DM55080_DETECTOR_SERIALS.get(obs_info.detector_num) 

1118 if expected is None: 1118 ↛ 1120line 1118 didn't jump to line 1120 because the condition on line 1118 was always true

1119 return obs_info 

1120 if detector is not None and detector.serial != expected: 

1121 _LOG.warning( 

1122 "Detector %d has camera-geometry serial %s, which predates the" 

1123 " DM-55080 camera-definition fix; using %s.", 

1124 detector.id, 

1125 detector.serial, 

1126 expected, 

1127 ) 

1128 detector._attributes.serial = expected 

1129 if obs_info.detector_serial != expected: 

1130 with obs_info.edit_copy() as obs_info: 

1131 obs_info.detector_serial = expected 

1132 return obs_info 

1133 

1134 

1135def _get_unit_conversion_factor( 

1136 original: astropy.units.UnitBase, new: astropy.units.UnitBase 

1137) -> float | None: 

1138 try: 

1139 return original.to(new) 

1140 except astropy.units.UnitConversionError: 

1141 return None