Coverage for python/lsst/images/_image.py: 82%

228 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-25 01:29 -0700

1# This file is part of lsst-images. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11 

12from __future__ import annotations 

13 

14__all__ = ("Image", "ImageSerializationModel") 

15 

16from collections.abc import Callable, Sequence 

17from contextlib import ExitStack 

18from types import EllipsisType 

19from typing import TYPE_CHECKING, Any, ClassVar, final 

20 

21import astropy.io.fits 

22import astropy.units 

23import astropy.wcs 

24import numpy as np 

25import numpy.typing as npt 

26import pydantic 

27 

28from lsst.resources import ResourcePath, ResourcePathExpression 

29 

30from . import fits 

31from ._generalized_image import GeneralizedImage 

32from ._geom import YX, Box 

33from ._transforms import Frame, GeneralFrame, SkyProjection, SkyProjectionSerializationModel 

34from .serialization import ( 

35 ArchiveTree, 

36 ArrayReferenceModel, 

37 ArrayReferenceQuantityModel, 

38 InlineArrayModel, 

39 InlineArrayQuantityModel, 

40 InputArchive, 

41 InvalidParameterError, 

42 MetadataValue, 

43 OutputArchive, 

44 no_header_updates, 

45) 

46from .utils import is_none 

47 

48if TYPE_CHECKING: 

49 try: 

50 from lsst.afw.image import Image as LegacyImage 

51 except ImportError: 

52 type LegacyImage = Any # type: ignore[no-redef] 

53 

54 

55DEFAULT_PIXEL_FRAME = GeneralFrame(unit=astropy.units.pix) 

56"""The pixel-grid `Frame` assumed when reconstructing a `SkyProjection` from a 

57FITS header that does not otherwise identify its pixel frame, consistent with 

58the FITS standard's notion of a plain pixel axis. 

59""" 

60 

61 

62@final 

63class Image(GeneralizedImage): 

64 """A 2-d array that may be augmented with units and a nonzero origin. 

65 

66 Parameters 

67 ---------- 

68 array_or_fill 

69 Array or fill value for the image. If a fill value, ``bbox`` or 

70 ``shape`` must be provided. 

71 bbox 

72 Bounding box for the image. 

73 yx0 

74 Logical coordinates of the first pixel in the array, ordered ``y``, 

75 ``x`` (unless an `XY` instance is passed). Ignored if 

76 ``bbox`` is provided. Defaults to zeros. 

77 shape 

78 Leading dimensions of the array, ordered ``y``, ``x`` (unless an `XY` 

79 instance is passed). Only needed if ``array_or_fill`` is not an 

80 array and ``bbox`` is not provided. Like the bbox, this does not 

81 include the last dimension of the array. 

82 dtype 

83 Pixel data type override. 

84 unit 

85 Units for the image's pixel values. 

86 sky_projection 

87 Projection that maps the pixel grid to the sky. 

88 metadata 

89 Arbitrary flexible metadata to associate with the image. 

90 

91 Notes 

92 ----- 

93 Indexing the `array` attribute of an `Image` does not take into account its 

94 ``yx0`` offset, but accessing a subimage by indexing an `Image` with a 

95 `Box` does, and the `bbox` of the subimage is set to match its location 

96 within the original image. 

97 

98 Indexed assignment to a subimage requires consistency between the 

99 coordinate systems and units of both operands, but it will automatically 

100 select a subimage of the right-hand side and convert compatible units when 

101 possible. In other words:: 

102 

103 a[box] = b 

104 

105 is a shortcut for 

106 

107 a[box].quantity = b[box].quantity 

108 

109 An ellipsis (``...``) can be used instead of a `Box` to assign to the full 

110 image. 

111 """ 

112 

113 def __init__( 

114 self, 

115 array_or_fill: np.ndarray | int | float = 0, 

116 /, 

117 *, 

118 bbox: Box | None = None, 

119 yx0: Sequence[int] | None = None, 

120 shape: Sequence[int] | None = None, 

121 dtype: npt.DTypeLike | None = None, 

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

123 sky_projection: SkyProjection[Any] | None = None, 

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

125 ) -> None: 

126 super().__init__(metadata) 

127 if isinstance(array_or_fill, np.ndarray): 

128 if dtype is not None: 

129 array = np.array(array_or_fill, dtype=dtype, copy=None) 

130 else: 

131 array = array_or_fill 

132 if bbox is None: 

133 bbox = Box.from_shape(array.shape, start=yx0) 

134 elif bbox.shape != array.shape: 

135 raise ValueError( 

136 f"Explicit bbox shape {bbox.shape} does not match array with shape {array.shape}." 

137 ) 

138 if shape is not None and shape != array.shape: 

139 raise ValueError(f"Explicit shape {shape} does not match array with shape {array.shape}.") 

140 else: 

141 if bbox is None: 

142 if shape is None: 

143 raise TypeError("No bbox, shape, or array provided.") 

144 bbox = Box.from_shape(shape, start=yx0) 

145 elif shape is not None and shape != bbox.shape: 

146 raise ValueError(f"Explicit shape {shape} does not match bbox shape {bbox.shape}.") 

147 array = np.full(bbox.shape, array_or_fill, dtype=dtype) 

148 self._array: np.ndarray = array 

149 self._bbox: Box = bbox 

150 self._unit = unit 

151 self._sky_projection = sky_projection 

152 

153 @property 

154 def array(self) -> np.ndarray: 

155 """The low-level array (`numpy.ndarray`). 

156 

157 Assigning to this attribute modifies the existing array in place; the 

158 bounding box and underlying data pointer are never changed. 

159 """ 

160 return self._array 

161 

162 @array.setter 

163 def array(self, value: np.ndarray | int | float) -> None: 

164 self._array[...] = value 

165 

166 @property 

167 def quantity(self) -> astropy.units.Quantity: 

168 """The low-level array with units (`astropy.units.Quantity`). 

169 

170 Assigning to this attribute modifies the existing array in place; the 

171 bounding box and underlying data pointer are never changed. 

172 """ 

173 return astropy.units.Quantity(self._array, self._unit, copy=False) 

174 

175 @quantity.setter 

176 def quantity(self, value: astropy.units.Quantity) -> None: 

177 self.quantity[...] = value 

178 

179 @property 

180 def bbox(self) -> Box: 

181 """Bounding box for the image (`Box`).""" 

182 return self._bbox 

183 

184 @property 

185 def unit(self) -> astropy.units.UnitBase | None: 

186 """Units for the image's pixel values (`astropy.units.Unit` or 

187 `None`). 

188 """ 

189 return self._unit 

190 

191 @property 

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

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

194 (`SkyProjection` | `None`). 

195 

196 Notes 

197 ----- 

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

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

200 """ 

201 return self._sky_projection 

202 

203 def __getitem__(self, bbox: Box | EllipsisType) -> Image: 

204 if bbox is ...: 

205 return self 

206 super().__getitem__(bbox) 

207 indices = bbox.slice_within(self._bbox) 

208 return self._transfer_metadata( 

209 Image(self._array[indices], bbox=bbox, unit=self._unit, sky_projection=self._sky_projection), 

210 bbox=bbox, 

211 ) 

212 

213 def __setitem__(self, bbox: Box | EllipsisType, value: Image) -> None: 

214 self[bbox].quantity[...] = value.quantity 

215 

216 def __str__(self) -> str: 

217 return f"Image({self.bbox!s}, {self.array.dtype.type.__name__})" 

218 

219 def __repr__(self) -> str: 

220 return f"Image(..., bbox={self.bbox!r}, dtype={self.array.dtype!r})" 

221 

222 def __eq__(self, other: object) -> bool: 

223 if not isinstance(other, Image): 

224 return NotImplemented 

225 return ( 

226 self._bbox == other._bbox 

227 and self._unit == other._unit 

228 and np.array_equal(self._array, other._array, equal_nan=True) 

229 ) 

230 

231 def copy(self) -> Image: 

232 return self._transfer_metadata( 

233 Image(self._array.copy(), bbox=self._bbox, unit=self._unit, sky_projection=self._sky_projection), 

234 copy=True, 

235 ) 

236 

237 def view( 

238 self, 

239 *, 

240 unit: astropy.units.UnitBase | None | EllipsisType = ..., 

241 sky_projection: SkyProjection | None | EllipsisType = ..., 

242 yx0: Sequence[int] | EllipsisType = ..., 

243 ) -> Image: 

244 """Make a view of the image, with optional updates.""" 

245 if unit is ...: 245 ↛ 247line 245 didn't jump to line 247 because the condition on line 245 was always true

246 unit = self._unit 

247 if sky_projection is ...: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true

248 sky_projection = self._sky_projection 

249 if yx0 is ...: 249 ↛ 251line 249 didn't jump to line 251 because the condition on line 249 was always true

250 yx0 = self._bbox.start 

251 return self._transfer_metadata(Image(self._array, yx0=yx0, unit=unit, sky_projection=sky_projection)) 

252 

253 def serialize[P: pydantic.BaseModel]( 

254 self, 

255 archive: OutputArchive[P], 

256 *, 

257 update_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

258 save_projection: bool = True, 

259 add_offset_wcs: str | None = "A", 

260 tile_shape: tuple[int, ...] | None = None, 

261 options_name: str | None = None, 

262 ) -> ImageSerializationModel[P]: 

263 """Serialize the image to an output archive. 

264 

265 Parameters 

266 ---------- 

267 archive 

268 Archive to write to. 

269 update_header 

270 A callback that will be given the FITS header for the HDU 

271 containing this image in order to add keys to it. This callback 

272 may be provided but will not be called if the output format is not 

273 FITS. 

274 save_projection 

275 If `True`, save the `SkyProjection` attached to the image, if there 

276 is one. This does not affect whether a FITS WCS corresponding to 

277 the projection is written (it always is, if available, and if 

278 ``add_offset_wcs`` is not ``" "``). 

279 add_offset_wcs 

280 A FITS WCS single-character suffix to use when adding a linear 

281 WCS that maps the FITS array to the logical pixel coordinates 

282 defined by ``bbox.start``. Set to `None` to not write this WCS. 

283 If this is set to ``" "``, it will prevent the `SkyProjection` from 

284 being saved as a FITS WCS. 

285 tile_shape 

286 The recommended shape of each tile, if the archive will save 

287 the array in distinct tiles for faster subarray retrieval. 

288 This is a hint; archives are not required to use this value. 

289 options_name 

290 Use this name to look up archive options. 

291 """ 

292 

293 def _update_header(header: astropy.io.fits.Header) -> None: 

294 update_header(header) 

295 if self.unit is not None: 

296 try: 

297 header["BUNIT"] = self.unit.to_string(format="fits") 

298 except ValueError: 

299 # Units not supported by FITS; write it anyway because 

300 # the accepted units are just a recommendation in the 

301 # standard. 

302 header["BUNIT"] = self.unit.to_string() 

303 if self.sky_projection is not None and add_offset_wcs != " ": 

304 if self.fits_wcs: 

305 header.update(self.fits_wcs.to_header(relax=True)) 

306 if add_offset_wcs is not None: 306 ↛ exitline 306 didn't return from function '_update_header' because the condition on line 306 was always true

307 fits.add_offset_wcs(header, x=self.bbox.x.start, y=self.bbox.y.start, key=add_offset_wcs) 

308 

309 array_model = archive.add_array( 

310 self.array, update_header=_update_header, tile_shape=tile_shape, options_name=options_name 

311 ) 

312 serialized_projection: SkyProjectionSerializationModel[P] | None = None 

313 if save_projection and self.sky_projection is not None: 

314 serialized_projection = archive.serialize_direct("sky_projection", self.sky_projection.serialize) 

315 data = array_model if self.unit is None else array_model.with_units(self.unit) 

316 return ImageSerializationModel.model_construct( 

317 data=data, 

318 yx0=list(self.bbox.start), 

319 sky_projection=serialized_projection, 

320 metadata=self.metadata, 

321 ) 

322 

323 @staticmethod 

324 def _get_archive_tree_type[P: pydantic.BaseModel]( 

325 pointer_type: type[P], 

326 ) -> type[ImageSerializationModel[P]]: 

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

328 type that uses the given pointer type. 

329 """ 

330 return ImageSerializationModel[pointer_type] # type: ignore 

331 

332 _archive_default_name: ClassVar[str] = "image" 

333 """The name this object should be serialized with when written as the 

334 top-level object. 

335 """ 

336 

337 @staticmethod 

338 def from_legacy(legacy: LegacyImage, unit: astropy.units.UnitBase | None = None) -> Image: 

339 """Convert from an `lsst.afw.image.Image` instance. 

340 

341 Parameters 

342 ---------- 

343 legacy 

344 An `lsst.afw.image.Image` instance that will share pixel data with 

345 the returned object. 

346 unit 

347 Units of the image. 

348 """ 

349 return Image(legacy.array, yx0=YX(y=legacy.getY0(), x=legacy.getX0()), unit=unit) 

350 

351 def to_legacy(self, *, copy: bool | None = None) -> LegacyImage: 

352 """Convert to an `lsst.afw.image.Image` instance. 

353 

354 Parameters 

355 ---------- 

356 copy 

357 If `True`, always copy the pixel data. If `False`, return a view, 

358 and raise `TypeError` if the pixel data is read-only (this is not 

359 supported by afw). If `None`, onyl if the pixel data is 

360 read-only. 

361 """ 

362 import lsst.afw.image 

363 import lsst.geom 

364 

365 array = self._array 

366 if copy: 366 ↛ 367line 366 didn't jump to line 367 because the condition on line 366 was never true

367 array = array.copy() 

368 elif not self._array.flags.writeable: 368 ↛ 369line 368 didn't jump to line 369 because the condition on line 368 was never true

369 if copy is None: 

370 array = array.copy() 

371 else: 

372 raise TypeError("Cannot create a legacy lsst.afw.image.Image view into a read-only array.") 

373 

374 return lsst.afw.image.Image( 

375 array, 

376 deep=False, 

377 dtype=array.dtype.type, 

378 xy0=lsst.geom.Point2I(self._bbox.x.min, self._bbox.y.min), 

379 ) 

380 

381 @classmethod 

382 def from_hdu_list( 

383 cls, 

384 hdu_list: astropy.io.fits.HDUList, 

385 *, 

386 fits_wcs_frame: Frame | None = DEFAULT_PIXEL_FRAME, 

387 ) -> Image: 

388 """Reconstruct an `~lsst.images.Image` from a cut-down ``lsst.images`` 

389 HDU list. 

390 

391 This reads only the first two HDUs (the primary HDU and the image 

392 HDU), as written for the image-only cut-outs produced by 

393 ``dax_images_cutout``: a real ``lsst.images`` file with its JSON-tree, 

394 index, and any nested-archive HDUs dropped. 

395 

396 Parameters 

397 ---------- 

398 hdu_list 

399 HDU list whose first HDU is the primary header and whose second 

400 HDU holds the image pixels. 

401 fits_wcs_frame 

402 Pixel-grid `~lsst.images.Frame` for the 

403 `~lsst.images.SkyProjection` reconstructed from the image HDU's 

404 FITS WCS. Defaults to a plain pixel frame; pass `None` to skip 

405 attaching a projection. 

406 

407 Returns 

408 ------- 

409 `~lsst.images.Image` 

410 The reconstructed image, ready to be re-serialized as a normal 

411 ``lsst.images`` file. 

412 

413 Notes 

414 ----- 

415 The headers of the consumed HDUs are modified in place (WCS and other 

416 interpreted cards are stripped), as in `read_legacy`. 

417 """ 

418 opaque_metadata = fits.FitsOpaqueMetadata() 

419 opaque_metadata.add_cutdown_primary_header(hdu_list[0].header) 

420 result = cls._read_legacy_hdu( 

421 hdu_list[1], opaque_metadata, preserve_bintable=None, fits_wcs_frame=fits_wcs_frame 

422 ) 

423 result._opaque_metadata = opaque_metadata 

424 return result 

425 

426 @staticmethod 

427 def read_legacy( 

428 uri: ResourcePathExpression, 

429 *, 

430 preserve_quantization: bool = False, 

431 ext: str | int = 1, 

432 fits_wcs_frame: Frame | None = None, 

433 ) -> Image: 

434 """Read a FITS file written by `lsst.afw.image.Image.writeFits`. 

435 

436 Parameters 

437 ---------- 

438 uri 

439 URI or file name. 

440 preserve_quantization 

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

442 exactly preserve quantization-compressed pixel values. This causes 

443 the arrays to be marked as read-only and stores the original binary 

444 table data for those planes in memory. If the `Image` is copied, 

445 the precompressed pixel values are not transferred to the copy. 

446 ext 

447 Name or index of the FITS HDU to read. 

448 fits_wcs_frame 

449 If not `None` and the HDU containing the image has a FITS WCS, 

450 attach a `SkyProjection` to the returned image by converting that 

451 WCS. 

452 """ 

453 opaque_metadata = fits.FitsOpaqueMetadata() 

454 with ExitStack() as exit_stack: 

455 fs, fspath = ResourcePath(uri).to_fsspec() 

456 stream = exit_stack.enter_context(fs.open(fspath)) 

457 hdu_list = exit_stack.enter_context(astropy.io.fits.open(stream)) 

458 opaque_metadata.extract_legacy_primary_header(hdu_list[0].header) 

459 bintable_hdu: astropy.io.fits.BinTableHDU | None = None 

460 if preserve_quantization: 

461 bintable_stream = exit_stack.enter_context(fs.open(fspath)) 

462 bintable_hdu_list = exit_stack.enter_context( 

463 astropy.io.fits.open(bintable_stream, disable_image_compression=True) 

464 ) 

465 bintable_hdu = bintable_hdu_list[ext] 

466 result = Image._read_legacy_hdu( 

467 hdu_list[ext], opaque_metadata, preserve_bintable=bintable_hdu, fits_wcs_frame=fits_wcs_frame 

468 ) 

469 result._opaque_metadata = opaque_metadata 

470 return result 

471 

472 @staticmethod 

473 def _read_legacy_hdu( 

474 hdu: astropy.io.fits.ImageHDU | astropy.io.fits.CompImageHDU, 

475 opaque_metadata: fits.FitsOpaqueMetadata, 

476 *, 

477 preserve_bintable: astropy.io.fits.BinTableHDU | None, 

478 fits_wcs_frame: Frame | None = None, 

479 ) -> Image: 

480 unit: astropy.units.UnitBase | None = None 

481 if (fits_unit := hdu.header.pop("BUNIT", None)) is not None: 

482 try: 

483 unit = astropy.units.Unit(fits_unit, format="fits") 

484 except ValueError: 

485 # Accept non-FITS units by assuming Astropy can still figure 

486 # them out if we don't specify the format. 

487 unit = astropy.units.Unit(fits_unit) 

488 if opaque_metadata.get_instrumental_unit() == astropy.units.electron: 488 ↛ 490line 488 didn't jump to line 490 because the condition on line 488 was never true

489 # Fix incorrect BUNIT='adu' in LSST preliminary_visit_image. 

490 if unit == astropy.units.adu: 

491 unit = astropy.units.electron 

492 if unit == astropy.units.adu**2: 

493 unit = astropy.units.electron**2 

494 yx0 = fits.read_yx0(hdu.header) 

495 hdu.header.remove("LTV1", ignore_missing=True) 

496 hdu.header.remove("LTV2", ignore_missing=True) 

497 read_only: bool = False 

498 if preserve_bintable is not None: 498 ↛ 499line 498 didn't jump to line 499 because the condition on line 498 was never true

499 opaque_metadata.precompressed[hdu.name] = fits.PrecompressedImage.from_bintable(preserve_bintable) 

500 read_only = True 

501 sky_projection: SkyProjection | None = None 

502 if fits_wcs_frame is not None: 

503 try: 

504 fits_wcs = astropy.wcs.WCS(hdu.header) 

505 except KeyError: 

506 pass 

507 else: 

508 sky_projection = SkyProjection.from_fits_wcs( 

509 fits_wcs, pixel_frame=fits_wcs_frame, x0=yx0.x, y0=yx0.y 

510 ) 

511 image = Image(hdu.data, yx0=yx0, unit=unit, sky_projection=sky_projection) 

512 if read_only: 512 ↛ 513line 512 didn't jump to line 513 because the condition on line 512 was never true

513 image._array.flags["WRITEABLE"] = False 

514 fits.strip_wcs_cards(hdu.header) 

515 hdu.header.strip() 

516 hdu.header.remove("EXTTYPE", ignore_missing=True) 

517 hdu.header.remove("INHERIT", ignore_missing=True) 

518 hdu.header.remove("UZSCALE", ignore_missing=True) 

519 opaque_metadata.add_header(hdu.header) 

520 return image 

521 

522 

523class ImageSerializationModel[P: pydantic.BaseModel](ArchiveTree): 

524 """Pydantic model used to represent the serialized form of an `.Image`.""" 

525 

526 SCHEMA_NAME: ClassVar[str] = "image" 

527 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

528 MIN_READ_VERSION: ClassVar[int] = 1 

529 PUBLIC_TYPE: ClassVar[type] = Image 

530 

531 data: ArrayReferenceQuantityModel | ArrayReferenceModel | InlineArrayModel | InlineArrayQuantityModel = ( 

532 pydantic.Field(description="Reference to pixel data.") 

533 ) 

534 yx0: list[int] = pydantic.Field( 

535 description="Coordinate of the first pixels in the array, ordered (y, x)." 

536 ) 

537 sky_projection: SkyProjectionSerializationModel[P] | None = pydantic.Field( 

538 default=None, 

539 exclude_if=is_none, 

540 description="Projection that maps the logical pixel grid onto the sky.", 

541 ) 

542 

543 @property 

544 def bbox(self) -> Box: 

545 """The bounding box of the image.""" 

546 match self.data: 

547 case ArrayReferenceQuantityModel() | InlineArrayQuantityModel(): 

548 shape = self.data.value.shape 

549 case ArrayReferenceModel() | InlineArrayModel(): 549 ↛ 551line 549 didn't jump to line 551 because the pattern on line 549 always matched

550 shape = self.data.shape 

551 return Box.from_shape(shape, self.yx0) 

552 

553 def deserialize( 

554 self, 

555 archive: InputArchive[Any], 

556 *, 

557 bbox: Box | None = None, 

558 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates, 

559 **kwargs: Any, 

560 ) -> Image: 

561 """Deserialize an image from an input archive. 

562 

563 Parameters 

564 ---------- 

565 archive 

566 Archive to read from. 

567 bbox 

568 Bounding box of a subimage to read instead. 

569 strip_header 

570 A callable that strips out any FITS header cards added by the 

571 ``update_header`` argument in the corresponding call to 

572 `Image.serialize`. 

573 **kwargs 

574 Unsupported keyword arguments are accepted only to provide better 

575 error messages (raising `serialization.InvalidParameterError`). 

576 """ 

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

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

579 array_model: ArrayReferenceModel | InlineArrayModel 

580 unit: astropy.units.UnitBase | None = None 

581 if isinstance(self.data, ArrayReferenceQuantityModel | InlineArrayQuantityModel): 

582 array_model = self.data.value 

583 unit = self.data.unit 

584 else: 

585 array_model = self.data 

586 

587 def _strip_header(header: astropy.io.fits.Header) -> None: 

588 if unit is not None: 

589 header.pop("BUNIT", None) 

590 fits.strip_wcs_cards(header) 

591 strip_header(header) 

592 

593 slices = bbox.slice_within(self.bbox) if bbox is not None else ... 

594 array = archive.get_array(array_model, strip_header=_strip_header, slices=slices) 

595 sky_projection = self.sky_projection.deserialize(archive) if self.sky_projection is not None else None 

596 return Image( 

597 array, 

598 yx0=self.yx0 if bbox is None else bbox.start, 

599 unit=unit, 

600 sky_projection=sky_projection, 

601 )._finish_deserialize(self) 

602 

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

604 if kwargs: 

605 raise InvalidParameterError(f"Unsupported parameters for Image components: {set(kwargs.keys())}.") 

606 return super().deserialize_component(component, archive)