Coverage for python / lsst / images / _masked_image.py: 33%

185 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-03 00:49 -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 

14__all__ = ("MaskedImage", "MaskedImageSerializationModel") 

15 

16import functools 

17from collections.abc import Mapping 

18from contextlib import ExitStack 

19from types import EllipsisType 

20from typing import Any, Literal, overload 

21 

22import astropy.io.fits 

23import astropy.units 

24import astropy.wcs 

25import numpy as np 

26import pydantic 

27from astro_metadata_translator import ObservationInfo 

28 

29from lsst.resources import ResourcePath, ResourcePathExpression 

30 

31from . import fits 

32from ._generalized_image import GeneralizedImage 

33from ._geom import Box 

34from ._image import Image, ImageSerializationModel 

35from ._mask import Mask, MaskPlane, MaskSchema, MaskSerializationModel 

36from ._transforms import Frame, Projection, ProjectionSerializationModel 

37from .serialization import ( 

38 ArchiveTree, 

39 InputArchive, 

40 OpaqueArchiveMetadata, 

41 OutputArchive, 

42) 

43from .utils import is_none 

44 

45 

46class MaskedImage(GeneralizedImage): 

47 """A multi-plane image with data (image), mask, and variance planes. 

48 

49 Parameters 

50 ---------- 

51 image 

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

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

54 mask 

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

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

57 is replaced (possibly by `None`). 

58 variance 

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

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

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

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

63 (possibly by `None`). 

64 mask_schema 

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

66 not provided. 

67 projection 

68 Projection that maps the pixel grid to the sky. 

69 obs_info 

70 General information about the associated observation in standardized 

71 form. 

72 """ 

73 

74 def __init__( 

75 self, 

76 image: Image, 

77 *, 

78 mask: Mask | None = None, 

79 variance: Image | None = None, 

80 mask_schema: MaskSchema | None = None, 

81 projection: Projection | None = None, 

82 obs_info: ObservationInfo | None = None, 

83 ): 

84 if projection is None: 

85 projection = image.projection 

86 else: 

87 image = image.view(projection=projection) 

88 if obs_info is None: 

89 obs_info = image.obs_info 

90 else: 

91 image = image.view(obs_info=obs_info) 

92 if mask is None: 

93 if mask_schema is None: 

94 raise TypeError("'mask_schema' must be provided if 'mask' is not.") 

95 mask = Mask(schema=mask_schema, bbox=image.bbox, projection=projection, obs_info=obs_info) 

96 elif mask_schema is not None: 

97 raise TypeError("'mask_schema' may not be provided if 'mask' is.") 

98 else: 

99 if image.bbox != mask.bbox: 

100 raise ValueError(f"Image ({image.bbox}) and mask ({mask.bbox}) bboxes do not agree.") 

101 mask = mask.view(projection=projection) 

102 if variance is None: 

103 variance = Image( 

104 1.0, 

105 dtype=np.float32, 

106 bbox=image.bbox, 

107 unit=None if image.unit is None else image.unit**2, 

108 projection=projection, 

109 obs_info=obs_info, 

110 ) 

111 else: 

112 if image.bbox != variance.bbox: 

113 raise ValueError(f"Image ({image.bbox}) and variance ({variance.bbox}) bboxes do not agree.") 

114 variance = variance.view(projection=projection, obs_info=obs_info) 

115 if image.unit is None: 

116 if variance.unit is not None: 

117 raise ValueError(f"Image has no units but variance does ({variance.unit}).") 

118 elif variance.unit is None: 

119 variance = variance.view(unit=image.unit**2) 

120 elif variance.unit != image.unit**2: 

121 raise ValueError( 

122 f"Variance unit ({variance.unit}) should be the square of the image unit ({image.unit})." 

123 ) 

124 self._image = image 

125 self._mask = mask 

126 self._variance = variance 

127 self._opaque_metadata: OpaqueArchiveMetadata | None = None 

128 

129 @property 

130 def image(self) -> Image: 

131 """The main image plane (`Image`).""" 

132 return self._image 

133 

134 @property 

135 def mask(self) -> Mask: 

136 """The mask plane (`Mask`).""" 

137 return self._mask 

138 

139 @property 

140 def variance(self) -> Image: 

141 """The variance plane (`Image`).""" 

142 return self._variance 

143 

144 @property 

145 def bbox(self) -> Box: 

146 """The bounding box shared by all three image planes (`Box`).""" 

147 return self._image.bbox 

148 

149 @property 

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

151 """The units of the image plane (`astropy.units.Unit` | `None`).""" 

152 return self._image.unit 

153 

154 @property 

155 def projection(self) -> Projection[Any] | None: 

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

157 (`Projection` | `None`). 

158 """ 

159 return self._image.projection 

160 

161 @property 

162 def obs_info(self) -> ObservationInfo | None: 

163 """General information about the associated observation in standard 

164 form. (`~astro_metadata_translator.ObservationInfo` | `None`). 

165 """ 

166 return self._image.obs_info 

167 

168 def __getitem__(self, bbox: Box | EllipsisType) -> MaskedImage: 

169 super().__getitem__(bbox) 

170 if bbox is ...: 

171 return self 

172 result = MaskedImage( 

173 # Projection and obs_info propagate from the image. 

174 self.image[bbox], 

175 mask=self.mask[bbox], 

176 variance=self.variance[bbox], 

177 ) 

178 if self._opaque_metadata is not None: 

179 result._opaque_metadata = self._opaque_metadata.subset(bbox) 

180 return result 

181 

182 def __setitem__(self, bbox: Box | EllipsisType, value: MaskedImage) -> None: 

183 self._image[bbox] = value.image 

184 self._mask[bbox] = value.mask 

185 self._variance[bbox] = value.variance 

186 

187 def __str__(self) -> str: 

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

189 

190 def __repr__(self) -> str: 

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

192 

193 def copy(self) -> MaskedImage: 

194 """Deep-copy the masked image.""" 

195 result = MaskedImage(image=self._image.copy(), mask=self._mask.copy(), variance=self._variance.copy()) 

196 if self._opaque_metadata is not None: 

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

198 return result 

199 

200 def serialize(self, archive: OutputArchive[Any]) -> MaskedImageSerializationModel: 

201 """Serialize the masked image to an output archive. 

202 

203 Parameters 

204 ---------- 

205 archive 

206 Archive to write to. 

207 """ 

208 serialized_image = archive.serialize_direct( 

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

210 ) 

211 serialized_mask = archive.serialize_direct( 

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

213 ) 

214 serialized_variance = archive.serialize_direct( 

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

216 ) 

217 serialized_projection = ( 

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

219 if self.projection is not None 

220 else None 

221 ) 

222 return MaskedImageSerializationModel( 

223 image=serialized_image, 

224 mask=serialized_mask, 

225 variance=serialized_variance, 

226 projection=serialized_projection, 

227 ) 

228 

229 @staticmethod 

230 def deserialize( 

231 model: MaskedImageSerializationModel[Any], archive: InputArchive[Any], *, bbox: Box | None = None 

232 ) -> MaskedImage: 

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

234 

235 Parameters 

236 ---------- 

237 model 

238 A Pydantic model representation of the image, holding references 

239 to data stored in the archive. 

240 archive 

241 Archive to read from. 

242 bbox 

243 Bounding box of a subimage to read instead. 

244 """ 

245 image = Image.deserialize(model.image, archive, bbox=bbox) 

246 mask = Mask.deserialize(model.mask, archive, bbox=bbox) 

247 variance = Image.deserialize(model.variance, archive, bbox=bbox) 

248 projection = ( 

249 Projection.deserialize(model.projection, archive) if model.projection is not None else None 

250 ) 

251 return MaskedImage(image, mask=mask, variance=variance, projection=projection) 

252 

253 @staticmethod 

254 def _get_archive_tree_type[P: pydantic.BaseModel]( 

255 pointer_type: type[P], 

256 ) -> type[MaskedImageSerializationModel[P]]: 

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

258 type that uses the given pointer type. 

259 """ 

260 return MaskedImageSerializationModel[pointer_type] # type: ignore 

261 

262 def write_fits( 

263 self, 

264 filename: str, 

265 *, 

266 image_compression: fits.FitsCompressionOptions | None = fits.FitsCompressionOptions.DEFAULT, 

267 mask_compression: fits.FitsCompressionOptions | None = fits.FitsCompressionOptions.DEFAULT, 

268 variance_compression: fits.FitsCompressionOptions | None = fits.FitsCompressionOptions.DEFAULT, 

269 compression_seed: int | None = None, 

270 ) -> None: 

271 """Write the image to a FITS file. 

272 

273 Parameters 

274 ---------- 

275 filename 

276 Name of the file to write to. Must be a local file. 

277 image_compression 

278 Compression options for the `image` plane. 

279 mask_compression 

280 Compression options for the `mask` plane. 

281 variance_compression 

282 Compression options for the `variance` plane. 

283 compression_seed 

284 A FITS tile compression seed to use whenever the configured 

285 compression seed is `None` or (for backwards compatibility) ``0``. 

286 This value is then incremented every time it is used. 

287 """ 

288 compression_options = {} 

289 if image_compression is not fits.FitsCompressionOptions.DEFAULT: 

290 compression_options["image"] = image_compression 

291 if mask_compression is not fits.FitsCompressionOptions.DEFAULT: 

292 compression_options["mask"] = mask_compression 

293 if variance_compression is not fits.FitsCompressionOptions.DEFAULT: 

294 compression_options["variance"] = variance_compression 

295 fits.write(self, filename, compression_options=compression_options, compression_seed=compression_seed) 

296 

297 @classmethod 

298 def read_fits(cls, url: ResourcePathExpression, *, bbox: Box | None = None) -> MaskedImage: 

299 """Read an image from a FITS file. 

300 

301 Parameters 

302 ---------- 

303 url 

304 URL of the file to read; may be any type supported by 

305 `lsst.resources.ResourcePath`. 

306 bbox 

307 Bounding box of a subimage to read instead. 

308 """ 

309 return fits.read(cls, url, bbox=bbox) 

310 

311 @staticmethod 

312 def from_legacy( 

313 legacy: Any, 

314 *, 

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

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

317 ) -> MaskedImage: 

318 """Convert from an `lsst.afw.image.MaskedImage` instance. 

319 

320 Parameters 

321 ---------- 

322 legacy 

323 An `lsst.afw.image.MaskedImage` instance that will share image and 

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

325 unit 

326 Units of the image. 

327 plane_map 

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

329 description. 

330 """ 

331 return MaskedImage( 

332 image=Image.from_legacy(legacy.getImage(), unit), 

333 mask=Mask.from_legacy(legacy.getMask(), plane_map), 

334 variance=Image.from_legacy(legacy.getVariance()), 

335 ) 

336 

337 def to_legacy(self, *, copy: bool | None = None, plane_map: Mapping[str, MaskPlane] | None = None) -> Any: 

338 """Convert to an `lsst.afw.image.MaskedImage` instance. 

339 

340 Parameters 

341 ---------- 

342 copy 

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

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

345 is read-only (this is not supported by afw). If `None`, onyl if 

346 the pixel data is read-only. Mask pixel data is always copied. 

347 plane_map 

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

349 description. 

350 """ 

351 import lsst.afw.image 

352 

353 return lsst.afw.image.MaskedImage( 

354 self.image.to_legacy(copy=copy), 

355 mask=self.mask.to_legacy(plane_map), 

356 variance=self.variance.to_legacy(copy=copy), 

357 dtype=self.image.array.dtype, 

358 ) 

359 

360 @overload 

361 @staticmethod 

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

363 uri: ResourcePathExpression, 

364 *, 

365 preserve_quantization: bool = False, 

366 component: Literal["image"], 

367 fits_wcs_frame: Frame | None = None, 

368 ) -> Image: ... 

369 

370 @overload 

371 @staticmethod 

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

373 uri: ResourcePathExpression, 

374 *, 

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

376 component: Literal["mask"], 

377 fits_wcs_frame: Frame | None = None, 

378 ) -> Mask: ... 

379 

380 @overload 

381 @staticmethod 

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

383 uri: ResourcePathExpression, 

384 *, 

385 preserve_quantization: bool = False, 

386 component: Literal["variance"], 

387 fits_wcs_frame: Frame | None = None, 

388 ) -> Image: ... 

389 

390 @overload 

391 @staticmethod 

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

393 uri: ResourcePathExpression, 

394 *, 

395 preserve_quantization: bool = False, 

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

397 component: None = None, 

398 fits_wcs_frame: Frame | None = None, 

399 ) -> MaskedImage: ... 

400 

401 @staticmethod 

402 def read_legacy( 

403 uri: ResourcePathExpression, 

404 *, 

405 preserve_quantization: bool = False, 

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

407 component: Literal["image", "mask", "variance"] | None = None, 

408 fits_wcs_frame: Frame | None = None, 

409 ) -> Any: 

410 """Read a FITS file written by `lsst.afw.image.MaskedImage.writeFits`. 

411 

412 Parameters 

413 ---------- 

414 uri 

415 URI or file name. 

416 preserve_quantization 

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

418 exactly preserve quantization-compressed pixel values. This causes 

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

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

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

422 not transferred to the copy. 

423 plane_map 

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

425 description. 

426 component 

427 A component to read instead of the full image. 

428 fits_wcs_frame 

429 If not `None` and the HDU containing the image plane has a FITS 

430 WCS, attach a `Projection` to the returned masked image by 

431 converting that WCS. When ``component`` is one of ``"image"``, 

432 ``"mask"``, or ``"variance"``, a FITS WCS from the component HDU 

433 is used instead (all three should have the same WCS). 

434 """ 

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

436 with fs.open(fspath) as stream, astropy.io.fits.open(stream) as hdu_list: 

437 return MaskedImage._read_legacy_hdus( 

438 hdu_list, 

439 uri, 

440 preserve_quantization=preserve_quantization, 

441 plane_map=plane_map, 

442 component=component, 

443 fits_wcs_frame=fits_wcs_frame, 

444 ) 

445 

446 @staticmethod 

447 def _read_legacy_hdus( 

448 hdu_list: astropy.io.fits.HDUList, 

449 uri: ResourcePathExpression, 

450 *, 

451 preserve_quantization: bool = False, 

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

453 component: Literal["image", "mask", "variance"] | None, 

454 fits_wcs_frame: Frame | None = None, 

455 ) -> Any: 

456 opaque_metadata = fits.FitsOpaqueMetadata() 

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

458 image_bintable_hdu: astropy.io.fits.BinTableHDU | None = None 

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

460 result: Any 

461 with ExitStack() as exit_stack: 

462 if preserve_quantization: 

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

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

465 bintable_hdu_list = exit_stack.enter_context( 

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

467 ) 

468 image_bintable_hdu = bintable_hdu_list[1] 

469 variance_bintable_hdu = bintable_hdu_list[3] 

470 if component is None or component == "image": 

471 image = Image._read_legacy_hdu( 

472 hdu_list[1], 

473 opaque_metadata, 

474 preserve_bintable=image_bintable_hdu, 

475 fits_wcs_frame=fits_wcs_frame, 

476 ) 

477 if component == "image": 

478 result = image 

479 if component is None or component == "mask": 

480 mask = Mask._read_legacy_hdu( 

481 hdu_list[2], 

482 opaque_metadata, 

483 plane_map=plane_map, 

484 fits_wcs_frame=fits_wcs_frame if component is not None else None, 

485 ) 

486 if component == "mask": 

487 result = mask 

488 if component is None or component == "variance": 

489 variance = Image._read_legacy_hdu( 

490 hdu_list[3], 

491 opaque_metadata, 

492 preserve_bintable=variance_bintable_hdu, 

493 fits_wcs_frame=fits_wcs_frame if component is not None else None, 

494 ) 

495 if component == "variance": 

496 result = variance 

497 if component is None: 

498 result = MaskedImage(image, mask=mask, variance=variance) 

499 result._opaque_metadata = opaque_metadata 

500 return result 

501 

502 

503class MaskedImageSerializationModel[P: pydantic.BaseModel](ArchiveTree): 

504 """A Pydantic model used to represent a serialized `MaskedImage`.""" 

505 

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

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

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

509 ) 

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

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

512 ) 

513 projection: ProjectionSerializationModel[P] | None = pydantic.Field( 

514 default=None, 

515 exclude_if=is_none, 

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

517 ) 

518 

519 @property 

520 def bbox(self) -> Box: 

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

522 return self.image.bbox