Coverage for python/lsst/images/_mask.py: 69%

496 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 08:38 +0000

1# This file is part of lsst-images. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

11 

12from __future__ import annotations 

13 

14__all__ = ( 

15 "Mask", 

16 "MaskPlane", 

17 "MaskPlaneBit", 

18 "MaskSchema", 

19 "MaskSerializationModel", 

20 "get_legacy_deep_coadd_mask_planes", 

21 "get_legacy_difference_image_mask_planes", 

22 "get_legacy_non_cell_coadd_mask_planes", 

23 "get_legacy_visit_image_mask_planes", 

24) 

25 

26import dataclasses 

27import math 

28from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence, Set 

29from types import EllipsisType 

30from typing import TYPE_CHECKING, Any, ClassVar, cast 

31 

32import astropy.io.fits 

33import astropy.wcs 

34import numpy as np 

35import numpy.typing as npt 

36import pydantic 

37 

38from lsst.resources import ResourcePath, ResourcePathExpression 

39 

40from . import fits 

41from ._generalized_image import GeneralizedImage 

42from ._geom import YX, Box, NoOverlapError 

43from ._transforms import Frame, SkyProjection, SkyProjectionSerializationModel 

44from .describe import DescribableMixin, DescribeOptions, FieldRole, Report, ReportField, ReportTable 

45from .serialization import ( 

46 ArchiveReadError, 

47 ArchiveTree, 

48 ArrayReferenceModel, 

49 InlineArrayModel, 

50 InputArchive, 

51 IntegerType, 

52 InvalidParameterError, 

53 MetadataValue, 

54 NumberType, 

55 OutputArchive, 

56 is_integer, 

57 no_header_updates, 

58) 

59from .utils import is_none 

60 

61if TYPE_CHECKING: 

62 try: 

63 from lsst.afw.image import Mask as LegacyMask 

64 except ImportError: 

65 type LegacyMask = Any # type: ignore[no-redef] 

66 

67 

68@dataclasses.dataclass(frozen=True) 

69class MaskPlane: 

70 """Name and description of a single plane in a mask array.""" 

71 

72 name: str 

73 """Unique name for the mask plane (`str`).""" 

74 

75 description: str 

76 """Human-readable documentation for the mask plane (`str`).""" 

77 

78 @classmethod 

79 def read_legacy(cls, header: astropy.io.fits.Header, *, strip: bool = True) -> dict[str, int]: 

80 """Read mask plane descriptions written by 

81 `lsst.afw.image.Mask.writeFits`. 

82 

83 Parameters 

84 ---------- 

85 header 

86 FITS header. 

87 strip 

88 If `True` (default), delete the ``MP_`` cards from the header after 

89 reading them, as appropriate when the mask is being reinterpreted 

90 for new code only. If `False`, leave them in place so they can be 

91 propagated for backwards compatibility (re-indexed to the new 

92 schema by the caller). 

93 

94 Returns 

95 ------- 

96 `dict` [`str`, `int`] 

97 A dictionary mapping mask plane name to integer bit index. 

98 """ 

99 result: dict[str, int] = {} 

100 for card in list(header.cards): 

101 if card.keyword.startswith("MP_"): 

102 result[card.keyword.removeprefix("MP_")] = card.value 

103 if strip: 

104 del header[card.keyword] 

105 return result 

106 

107 

108@dataclasses.dataclass(frozen=True) 

109class MaskPlaneBit: 

110 """The nested array index and mask value associated with a single mask 

111 plane. 

112 """ 

113 

114 index: int 

115 """Index into the last dimension of the mask array where this plane's bit 

116 is stored. 

117 """ 

118 

119 mask: np.integer 

120 """Bitmask that selects just this plane's bit from a mask array value 

121 (`numpy.integer`). 

122 """ 

123 

124 @classmethod 

125 def compute(cls, overall_index: int, stride: int, mask_type: type[np.integer]) -> MaskPlaneBit: 

126 """Construct a `MaskPlaneBit` from the overall index of a plane in a 

127 `MaskSchema` and the stride (number of bits per mask array element). 

128 

129 Parameters 

130 ---------- 

131 overall_index 

132 Index of the plane across the whole schema. 

133 stride 

134 Number of mask bits per array element. 

135 mask_type 

136 Integer dtype of the mask array elements. 

137 """ 

138 index, bit = divmod(overall_index, stride) 

139 return cls(index, mask_type(1 << bit)) 

140 

141 def check(self, value: np.ndarray) -> bool: 

142 """Test if this bit is set on a single `Mask` pixel value. 

143 

144 Parameters 

145 ---------- 

146 value 

147 A 1-d array of length `MaskSchema.mask_size`, representing a 

148 single pixel in a `Mask`. 

149 """ 

150 return bool(value[self.index] & self.mask) 

151 

152 

153class MaskSchema(DescribableMixin): 

154 """A schema for a bit-packed mask array. 

155 

156 Parameters 

157 ---------- 

158 planes 

159 Iterable of `MaskPlane` instances that define the schema. `None` 

160 values may be included to reserve bits for future use. 

161 dtype 

162 The numpy data type of the mask arrays that use this schema. 

163 

164 Notes 

165 ----- 

166 A `MaskSchema` is a collection of mask planes, which each correspond to a 

167 single bit in a mask array. Mask schemas are immutable and associated with 

168 a particular array data type, allowing them to safely precompute the index 

169 and bitmask for each plane. 

170 

171 `MaskSchema` indexing is by integer (the overall index of a plane in the 

172 schema). The `descriptions` attribute may be indexed by plane name to get 

173 the description for that plane, and the `bitmask` method can be used to 

174 obtain an array that can be used to select one or more planes by name in 

175 a mask array that uses this schema. 

176 

177 If no mask planes are provided, a `None` placeholder is automatically 

178 added. 

179 """ 

180 

181 def __init__(self, planes: Iterable[MaskPlane | None], dtype: npt.DTypeLike = np.uint8) -> None: 

182 self._planes: tuple[MaskPlane | None, ...] = tuple(planes) or (None,) 

183 self._dtype = cast(np.dtype[np.integer], np.dtype(dtype)) 

184 stride = self.bits_per_element(self._dtype) 

185 self._descriptions = {plane.name: plane.description for plane in self._planes if plane is not None} 

186 self._mask_size = math.ceil(len(self._planes) / stride) 

187 self._bits: dict[str, MaskPlaneBit] = { 

188 plane.name: MaskPlaneBit.compute(n, stride, self._dtype.type) 

189 for n, plane in enumerate(self._planes) 

190 if plane is not None 

191 } 

192 

193 @staticmethod 

194 def bits_per_element(dtype: npt.DTypeLike) -> int: 

195 """Return the number of mask bits per array element for the given 

196 data type. 

197 

198 Parameters 

199 ---------- 

200 dtype 

201 Data type of the mask array elements. 

202 """ 

203 dtype = np.dtype(dtype) 

204 match dtype.kind: 

205 case "u": 

206 return dtype.itemsize * 8 

207 case "i": 

208 return dtype.itemsize * 8 - 1 

209 case _: 

210 raise TypeError(f"dtype for masks must be an integer; got {dtype} with kind={dtype.kind}.") 

211 

212 def __iter__(self) -> Iterator[MaskPlane | None]: 

213 return iter(self._planes) 

214 

215 def __len__(self) -> int: 

216 return len(self._planes) 

217 

218 def __contains__(self, plane: str | MaskPlane) -> bool: 

219 return getattr(plane, "name", plane) in self.names 

220 

221 def __getitem__(self, i: int) -> MaskPlane | None: 

222 return self._planes[i] 

223 

224 def _describe( 

225 self, 

226 options: DescribeOptions = DescribeOptions(), 

227 /, 

228 *, 

229 counts: Mapping[str, int] | None = None, 

230 ) -> Report: 

231 """Return a `Report` describing this mask schema. 

232 

233 Parameters 

234 ---------- 

235 options : `DescribeOptions`, optional 

236 Rendering options. 

237 counts : `~collections.abc.Mapping` [`str`, `int`], optional 

238 Number of set pixels per plane name. When provided, the mask 

239 planes table gains a ``"Set pixels"`` column. 

240 """ 

241 # The table lists every plane, so the plane count is REPR_ONLY: repr 

242 # needs it to round-trip, but next to the table it would just be noise. 

243 fields = [ 

244 ReportField( 

245 label="planes", 

246 value=f"<{len(self._planes)} planes>", 

247 repr_value=repr(list(self._planes)), 

248 positional=True, 

249 role=FieldRole.REPR_ONLY, 

250 ), 

251 ReportField(label="dtype", value=str(self._dtype), repr_value=repr(self._dtype)), 

252 ] 

253 if options.brief: 

254 return Report(type_name="MaskSchema", fields=fields) 

255 columns = ["Bit", "Index", "Mask", "Name", "Description"] 

256 if counts is not None: 

257 columns.append("Set pixels") 

258 rows = [] 

259 for n, plane in enumerate(self._planes): 

260 if plane is None: 

261 continue 

262 row: list[Any] = [ 

263 n, 

264 self._bits[plane.name].index, 

265 hex(self._bits[plane.name].mask), 

266 plane.name, 

267 plane.description, 

268 ] 

269 if counts is not None: 

270 row.append(counts.get(plane.name, 0)) 

271 rows.append(row) 

272 return Report( 

273 type_name="MaskSchema", 

274 fields=fields, 

275 tables=[ 

276 ReportTable( 

277 title="Mask planes", 

278 columns=columns, 

279 rows=rows, 

280 ) 

281 ], 

282 ) 

283 

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

285 if isinstance(other, MaskSchema): 285 ↛ 287line 285 didn't jump to line 287 because the condition on line 285 was always true

286 return self._planes == other._planes and self._dtype == other._dtype 

287 return False 

288 

289 @property 

290 def dtype(self) -> np.dtype: 

291 """The numpy data type of the mask arrays that use this schema.""" 

292 return self._dtype 

293 

294 @property 

295 def mask_size(self) -> int: 

296 """The number of elements in the last dimension of any mask array that 

297 uses this schema. 

298 """ 

299 return self._mask_size 

300 

301 @property 

302 def names(self) -> Set[str]: 

303 """The names of the mask planes, in bit order.""" 

304 return self._bits.keys() 

305 

306 @property 

307 def descriptions(self) -> Mapping[str, str]: 

308 """A mapping from plane name to description.""" 

309 return self._descriptions 

310 

311 def bit(self, plane: str) -> MaskPlaneBit: 

312 """Return the last array index and mask for the given mask plane. 

313 

314 Parameters 

315 ---------- 

316 plane 

317 Name of the mask plane. 

318 """ 

319 return self._bits[plane] 

320 

321 def bitmask(self, *planes: str) -> np.ndarray: 

322 """Return a 1-d mask array that represents the union (i.e. bitwise OR) 

323 of the planes with the given names. 

324 

325 Parameters 

326 ---------- 

327 *planes 

328 Mask plane names. 

329 

330 Returns 

331 ------- 

332 numpy.ndarray 

333 A 1-d array with shape ``(mask_size,)``. 

334 """ 

335 result = np.zeros(self.mask_size, dtype=self._dtype) 

336 for plane in planes: 

337 bit = self._bits[plane] 

338 result[bit.index] |= bit.mask 

339 return result 

340 

341 def split(self, dtype: npt.DTypeLike) -> list[MaskSchema]: 

342 """Split the schema into an equivalent series of schemas that each 

343 have a `mask_size` of ``1``, dropping all `None` placeholders. 

344 

345 Parameters 

346 ---------- 

347 dtype 

348 Data type of the new mask pixels. 

349 

350 Returns 

351 ------- 

352 `list` [`MaskSchema`] 

353 A list of mask schemas that together include all planes in 

354 ``self`` and have `mask_size` equal to ``1``. If there are no 

355 mask planes (only `None` placeholders) in ``self``, a single mask 

356 schema with a `None` placeholder is returned; otherwise `None` 

357 placeholders are returned. 

358 """ 

359 dtype = np.dtype(dtype) 

360 planes: list[MaskPlane] = [] 

361 schemas: list[MaskSchema] = [] 

362 n_planes_per_schema = self.bits_per_element(dtype) 

363 for plane in self._planes: 

364 if plane is not None: 

365 planes.append(plane) 

366 if len(planes) == n_planes_per_schema: 

367 schemas.append(MaskSchema(planes, dtype=dtype)) 

368 planes.clear() 

369 if planes: 369 ↛ 371line 369 didn't jump to line 371 because the condition on line 369 was always true

370 schemas.append(MaskSchema(planes, dtype=dtype)) 

371 if not schemas: 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true

372 schemas.append(MaskSchema([None], dtype=dtype)) 

373 return schemas 

374 

375 def update_header(self, header: astropy.io.fits.Header) -> None: 

376 """Add a description of this mask schema to a FITS header. 

377 

378 Parameters 

379 ---------- 

380 header 

381 FITS header to add the mask schema description to. 

382 """ 

383 for n, plane in enumerate(self): 

384 if plane is not None: 

385 bit = self.bit(plane.name) 

386 if bit.index != 0: 386 ↛ 387line 386 didn't jump to line 387 because the condition on line 386 was never true

387 raise TypeError("Only mask schemas with mask_size==1 can be described in FITS.") 

388 header.set(f"MSKN{n:04d}", plane.name, f"Name for mask plane {n}.") 

389 header.set(f"MSKM{n:04d}", bit.mask, f"Bitmask for plane n={n}; always 1<<n.") 

390 # We don't add a comment to the description card, because it's 

391 # likely to overrun a single card and get the CONTINUE 

392 # treatment. That will cause Astropy to warn about the comment 

393 # being truncated and that's worse than just leaving it 

394 # unexplained; it's pretty obvious from context what it is. 

395 header.set(f"MSKD{n:04d}", plane.description) 

396 

397 def strip_header(self, header: astropy.io.fits.Header) -> None: 

398 """Remove all header cards added by `update_header`. 

399 

400 Parameters 

401 ---------- 

402 header 

403 FITS header to remove the mask schema cards from. 

404 """ 

405 for n, plane in enumerate(self): 

406 if plane is not None: 406 ↛ 405line 406 didn't jump to line 405 because the condition on line 406 was always true

407 header.remove(f"MSKN{n:04d}", ignore_missing=True) 

408 header.remove(f"MSKM{n:04d}", ignore_missing=True) 

409 header.remove(f"MSKD{n:04d}", ignore_missing=True) 

410 

411 @classmethod 

412 def from_fits_header(cls, header: astropy.io.fits.Header, dtype: npt.DTypeLike = np.uint8) -> MaskSchema: 

413 """Reconstruct a schema from the ``MSKN``/``MSKD`` cards written by 

414 `update_header`. 

415 

416 Parameters 

417 ---------- 

418 header 

419 FITS header containing ``MSKN{n:04d}`` plane-name cards and 

420 ``MSKD{n:04d}`` description cards. 

421 dtype 

422 Data type of the mask arrays that will use this schema. The cards 

423 describe a ``mask_size==1`` serialized form and do not record the 

424 in-memory dtype, so the caller must supply it; it defaults to the 

425 same ``uint8`` used by the `Mask` constructor. 

426 

427 Returns 

428 ------- 

429 `MaskSchema` 

430 Schema whose planes are ordered by their ``MSKN`` index, with 

431 `None` placeholders inserted for any gaps in that numbering. 

432 

433 Raises 

434 ------ 

435 ValueError 

436 Raised if the header contains no ``MSKN`` cards. 

437 """ 

438 planes_by_index: dict[int, MaskPlane] = {} 

439 for card in header.cards: 

440 if card.keyword.startswith("MSKN"): 

441 n = int(card.keyword.removeprefix("MSKN")) 

442 planes_by_index[n] = MaskPlane(card.value, header.get(f"MSKD{n:04d}", "")) 

443 if not planes_by_index: 

444 raise ValueError("Header has no MSKN cards describing a mask schema.") 

445 planes = [planes_by_index.get(n) for n in range(max(planes_by_index) + 1)] 

446 return cls(planes, dtype=dtype) 

447 

448 def interpret(self, value: np.ndarray) -> list[str]: 

449 """Return the names of the mask planes that are set in the given 

450 pixel value. 

451 

452 Parameters 

453 ---------- 

454 value 

455 A 1-d array of length `mask_size`, representing a single pixel in 

456 a `Mask`. 

457 """ 

458 return [name for name, bit in self._bits.items() if bit.check(value)] 

459 

460 

461class Mask(GeneralizedImage): 

462 """A 2-d bitmask image backed by a 3-d byte array. 

463 

464 Parameters 

465 ---------- 

466 array_or_fill 

467 Array or fill value for the mask. If a fill value, ``bbox`` or 

468 ``shape`` must be provided. 

469 schema 

470 Schema that defines the planes and their bit assignments. 

471 bbox 

472 Bounding box for the mask. This sets the shape of the first two 

473 dimensions of the array. 

474 yx0 

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

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

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

478 shape 

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

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

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

482 include the last dimension of the array. 

483 sky_projection 

484 Projection that maps the pixel grid to the sky. 

485 metadata 

486 Arbitrary flexible metadata to associate with the mask. 

487 

488 Notes 

489 ----- 

490 Indexing the `array` attribute of a `Mask` does not take into account its 

491 ``yx0`` offset, but accessing a subimage mask by indexing a `Mask` with 

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

493 within the original mask. 

494 

495 A mask's ``bbox`` corresponds to the leading dimensions of its backing 

496 `numpy.ndarray`, while the last dimension's size is always equal to the 

497 `~MaskSchema.mask_size` of its schema, since a schema can in general 

498 require multiple array elements to represent all of its planes. 

499 """ 

500 

501 def __init__( 

502 self, 

503 array_or_fill: np.ndarray | int = 0, 

504 /, 

505 *, 

506 schema: MaskSchema, 

507 bbox: Box | None = None, 

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

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

510 sky_projection: SkyProjection | None = None, 

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

512 ) -> None: 

513 super().__init__(metadata) 

514 if shape is not None: 

515 shape = tuple(shape) 

516 if isinstance(array_or_fill, np.ndarray): 

517 array = np.array(array_or_fill, dtype=schema.dtype, copy=None) 

518 if array.ndim != 3: 

519 raise ValueError("Mask array must be 3-d.") 

520 if bbox is None: 

521 bbox = Box.from_shape(array.shape[:-1], start=yx0) 

522 elif bbox.shape + (schema.mask_size,) != array.shape: 

523 raise ValueError( 

524 f"Explicit bbox shape {bbox.shape} and schema of size {schema.mask_size} do not " 

525 f"match array with shape {array.shape}." 

526 ) 

527 if shape is not None and shape + (schema.mask_size,) != array.shape: 

528 raise ValueError( 

529 f"Explicit shape {shape} and schema of size {schema.mask_size} do " 

530 f"not match array with shape {array.shape}." 

531 ) 

532 

533 else: 

534 if bbox is None: 

535 if shape is None: 

536 raise TypeError("No bbox, size, or array provided.") 

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

538 array = np.full(bbox.shape + (schema.mask_size,), array_or_fill, dtype=schema.dtype) 

539 self._array = array 

540 self._bbox: Box = bbox 

541 self._schema: MaskSchema = schema 

542 self._sky_projection = sky_projection 

543 

544 @property 

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

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

547 

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

549 bounding box and underlying data pointer are never changed. 

550 """ 

551 return self._array 

552 

553 @array.setter 

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

555 self._array[:, :] = value 

556 

557 @property 

558 def schema(self) -> MaskSchema: 

559 """Schema that defines the planes and their bit assignments 

560 (`MaskSchema`). 

561 """ 

562 return self._schema 

563 

564 @property 

565 def bbox(self) -> Box: 

566 """2-d bounding box of the mask (`Box`). 

567 

568 This sets the shape of the first two dimensions of the array. 

569 """ 

570 return self._bbox 

571 

572 @property 

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

574 """The projection that maps this mask's pixel grid to the sky 

575 (`SkyProjection` | `None`). 

576 

577 Notes 

578 ----- 

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

580 box ``start`` (i.e. ``yx0``); they are not just array indices. 

581 """ 

582 return self._sky_projection 

583 

584 def __getitem__(self, bbox: Box | EllipsisType) -> Mask: 

585 bbox, indices = self._handle_getitem_args(bbox) 

586 return self._transfer_metadata( 

587 Mask( 

588 self.array[indices + (slice(None),)], 

589 bbox=bbox, 

590 schema=self.schema, 

591 sky_projection=self._sky_projection, 

592 ), 

593 bbox=bbox, 

594 ) 

595 

596 def __setitem__(self, bbox: Box | EllipsisType, value: Mask) -> None: 

597 subview = self[bbox] 

598 subview.clear() 

599 subview.update(value) 

600 

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

602 """Return a `Report` describing this mask. 

603 

604 Parameters 

605 ---------- 

606 options : `DescribeOptions`, optional 

607 Rendering options. ``"bbox"`` and ``"sky_projection"`` are 

608 recognized in `DescribeOptions.exclude`, and 

609 `DescribeOptions.detail` adds per-plane set-pixel counts to the 

610 schema table, which scans the pixel data. 

611 """ 

612 summary = f"Mask({self.bbox!s}, {list(self.schema.names)})" 

613 fields = [ 

614 ReportField( 

615 label="array", 

616 value="<array>", 

617 repr_value="...", 

618 positional=True, 

619 role=FieldRole.REPR_ONLY, 

620 ), 

621 ] 

622 if "bbox" not in options.exclude: 

623 fields.append(ReportField(label="bbox", value=self.bbox, repr_value=repr(self.bbox))) 

624 # The schema is rendered as a child below, so repr is the only place 

625 # this field needs to appear. 

626 fields.append( 

627 ReportField( 

628 label="schema", 

629 value=self.schema, 

630 repr_value=repr(self.schema), 

631 role=FieldRole.REPR_ONLY, 

632 ) 

633 ) 

634 if options.brief: 

635 return Report(type_name="Mask", summary=summary, fields=fields) 

636 child = options.for_child() 

637 if options.detail: 

638 counts = {name: int(np.count_nonzero(self.get(name))) for name in self.schema.names} 

639 schema_report = self.schema._describe(child, counts=counts) 

640 else: 

641 schema_report = self.schema._describe(child) 

642 children: dict[str, Report] = {"schema": schema_report} 

643 if "sky_projection" not in options.exclude and self._sky_projection is not None: 643 ↛ 644line 643 didn't jump to line 644 because the condition on line 643 was never true

644 children["sky_projection"] = self._sky_projection._describe(child, bbox=self._bbox) 

645 return Report( 

646 type_name="Mask", 

647 summary=summary, 

648 fields=fields, 

649 children=children, 

650 ) 

651 

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

653 if not isinstance(other, Mask): 

654 return NotImplemented 

655 return ( 

656 self._bbox == other._bbox 

657 and self._schema == other._schema 

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

659 ) 

660 

661 def copy(self) -> Mask: 

662 """Deep-copy the mask and metadata.""" 

663 return self._transfer_metadata( 

664 Mask( 

665 self._array.copy(), bbox=self._bbox, schema=self._schema, sky_projection=self._sky_projection 

666 ), 

667 copy=True, 

668 ) 

669 

670 def view( 

671 self, 

672 *, 

673 schema: MaskSchema | EllipsisType = ..., 

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

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

676 ) -> Mask: 

677 """Make a view of the mask, with optional updates. 

678 

679 Parameters 

680 ---------- 

681 schema 

682 Replacement schema; defaults to the current schema. 

683 sky_projection 

684 Replacement sky projection; defaults to the current one. 

685 yx0 

686 Replacement origin of the mask; defaults to the current origin. 

687 

688 Notes 

689 ----- 

690 This can only be used to make changes to schema descriptions; plane 

691 names must remain the same (in the same order). 

692 """ 

693 if schema is ...: 693 ↛ 696line 693 didn't jump to line 696 because the condition on line 693 was always true

694 schema = self._schema 

695 else: 

696 if list(schema.names) != list(self.schema.names): 

697 raise ValueError("Cannot create a mask view with a schema with different names.") 

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

699 sky_projection = self._sky_projection 

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

701 yx0 = self._bbox.start 

702 return self._transfer_metadata( 

703 Mask(self._array, yx0=yx0, schema=schema, sky_projection=sky_projection) 

704 ) 

705 

706 def update(self, other: Mask) -> None: 

707 """Update ``self`` to include all common mask values set in ``other``. 

708 

709 Parameters 

710 ---------- 

711 other 

712 Mask whose set bits are merged into ``self``. 

713 

714 Notes 

715 ----- 

716 This only operates on the intersection of the two mask bounding boxes 

717 and the mask planes that are present in both. Mask bits are only set, 

718 not cleared (i.e. this uses ``|=`` updates, not ``=`` assignments). 

719 """ 

720 lhs = self 

721 rhs = other 

722 if other.bbox != self.bbox: 722 ↛ 723line 722 didn't jump to line 723 because the condition on line 722 was never true

723 try: 

724 bbox = self.bbox.intersection(other.bbox) 

725 except NoOverlapError: 

726 return 

727 lhs = self[bbox] 

728 rhs = other[bbox] 

729 for name in self.schema.names & other.schema.names: 

730 lhs.set(name, rhs.get(name)) 

731 

732 def get(self, plane: str) -> np.ndarray: 

733 """Return a 2-d boolean array for the given mask plane. 

734 

735 Parameters 

736 ---------- 

737 plane 

738 Name of the mask plane. 

739 

740 Returns 

741 ------- 

742 numpy.ndarray 

743 A 2-d boolean array with the same shape as `bbox` that is `True` 

744 where the bit for ``plane`` is set and `False` elsewhere. 

745 """ 

746 bit = self.schema.bit(plane) 

747 return (self._array[..., bit.index] & bit.mask).astype(bool) 

748 

749 def set(self, plane: str, boolean_mask: np.ndarray | EllipsisType = ...) -> None: 

750 """Set a mask plane. 

751 

752 Parameters 

753 ---------- 

754 plane 

755 Name of the mask plane to set. 

756 boolean_mask 

757 A 2-d boolean array with the same shape as `bbox` that is `True` 

758 where the bit for ``plane`` should be set and `False` where it 

759 should be left unchanged (*not* set to zero). May be ``...`` to 

760 set the bit everywhere. 

761 """ 

762 bit = self.schema.bit(plane) 

763 if boolean_mask is not ...: 763 ↛ 765line 763 didn't jump to line 765 because the condition on line 763 was always true

764 boolean_mask = boolean_mask.astype(bool) 

765 self._array[boolean_mask, bit.index] |= bit.mask 

766 

767 def clear(self, plane: str | None = None, boolean_mask: np.ndarray | EllipsisType = ...) -> None: 

768 """Clear one or more mask planes. 

769 

770 Parameters 

771 ---------- 

772 plane 

773 Name of the mask plane to set. If `None` all mask planes are 

774 cleared. 

775 boolean_mask 

776 A 2-d boolean array with the same shape as `bbox` that is `True` 

777 where the bit for ``plane`` should be cleared and `False` where it 

778 should be left unchanged. May be ``...`` to clear the bit 

779 everywhere. 

780 """ 

781 if boolean_mask is not ...: 781 ↛ 782line 781 didn't jump to line 782 because the condition on line 781 was never true

782 boolean_mask = boolean_mask.astype(bool) 

783 if plane is None: 783 ↛ 786line 783 didn't jump to line 786 because the condition on line 783 was always true

784 self._array[boolean_mask, :] = 0 

785 else: 

786 bit = self.schema.bit(plane) 

787 self._array[boolean_mask, bit.index] &= ~bit.mask 

788 

789 def add_plane(self, name: str, description: str) -> Mask: 

790 """Return a new mask with one additional mask plane. 

791 

792 This is a convenience wrapper around `add_planes` for the common case 

793 of adding a single plane. 

794 

795 Parameters 

796 ---------- 

797 name 

798 Unique name for the new mask plane. 

799 description 

800 Human-readable documentation for the new mask plane. 

801 

802 Returns 

803 ------- 

804 `Mask` 

805 A new mask whose schema includes the new plane; see `add_planes` 

806 for the reallocation and view semantics. 

807 

808 Raises 

809 ------ 

810 ValueError 

811 Raised if a plane named ``name`` already exists. 

812 """ 

813 return self.add_planes([MaskPlane(name, description)]) 

814 

815 def add_planes(self, planes: Iterable[MaskPlane | None], *, drop: Iterable[str] = ()) -> Mask: 

816 """Return a new mask with planes added and/or dropped. 

817 

818 Parameters 

819 ---------- 

820 planes 

821 New mask planes to append, in order, after the planes retained 

822 from this mask. `None` entries reserve unused bits (placeholders), 

823 exactly as in `MaskSchema`. 

824 drop 

825 Names of existing planes to remove from the schema. 

826 

827 Returns 

828 ------- 

829 `Mask` 

830 A new mask with the updated schema. Retained planes keep their 

831 pixel values (copied by name); newly added planes start cleared. 

832 

833 Raises 

834 ------ 

835 ValueError 

836 Raised if a name in ``drop`` is not an existing plane, or if a 

837 plane in ``planes`` collides with a retained plane name. 

838 

839 Notes 

840 ----- 

841 Adding or dropping planes always reallocates the backing array and 

842 returns a new `Mask`; this mask is left unchanged and any views or 

843 subimages of it continue to refer to the original array with the 

844 original schema. This is deliberate: there is no way to update the 

845 schema of an existing view, and a stale view must never set bits that 

846 its now-outdated schema regards as unused. Dropping a plane compacts 

847 the schema, so planes after it are reassigned to lower bits and the 

848 pixel values are repacked by plane name to match. 

849 """ 

850 drop_set = set(drop) 

851 if unknown := drop_set - set(self._schema.names): 

852 raise ValueError(f"Cannot drop mask planes that do not exist: {sorted(unknown)}.") 

853 retained = [plane for plane in self._schema if plane is None or plane.name not in drop_set] 

854 names = {plane.name for plane in retained if plane is not None} 

855 new_planes = list(planes) 

856 for plane in new_planes: 

857 if plane is None: 

858 continue 

859 if plane.name in names: 

860 raise ValueError(f"Mask plane {plane.name!r} already exists.") 

861 names.add(plane.name) 

862 new_schema = MaskSchema([*retained, *new_planes], dtype=self._schema.dtype) 

863 result = Mask(0, schema=new_schema, bbox=self._bbox, sky_projection=self._sky_projection) 

864 # The retained planes are exactly the names common to both schemas, and 

865 # ``result`` starts cleared and shares this mask's bbox, so ``update`` 

866 # transfers their pixel values (and nothing else) by name. 

867 result.update(self) 

868 return self._transfer_metadata(result, copy=True) 

869 

870 def serialize[P: pydantic.BaseModel]( 

871 self, 

872 archive: OutputArchive[P], 

873 *, 

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

875 save_projection: bool = True, 

876 add_offset_wcs: str | None = "A", 

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

878 options_name: str | None = None, 

879 ) -> MaskSerializationModel[P]: 

880 """Serialize the mask to an output archive. 

881 

882 Parameters 

883 ---------- 

884 archive 

885 Archive to write to. 

886 update_header 

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

888 containing this mask in order to add keys to it. This callback 

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

890 FITS. As multiple HDUs may be added, this function may be called 

891 multiple times. 

892 save_projection 

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

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

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

896 ``add_offset_wcs`` is not ``" "``). 

897 add_offset_wcs 

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

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

900 defined by ``bbox.start`` / ``yx0``. Set to `None` to not write 

901 this WCS. If this is set to ``" "``, it will prevent the 

902 `SkyProjection` from being saved as a FITS WCS. 

903 tile_shape 

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

905 the array in distinct tiles for faster subarray retrieval. 

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

907 options_name 

908 Use this name to look up archive options. 

909 """ 

910 if _archive_prefers_native_mask_arrays(archive): 

911 # HDS presents array dimensions in Fortran order, which is the 

912 # reverse of the h5py dataset shape. Store the in-memory trailing 

913 # mask-byte axis first in HDF5 so Starlink tools see HDS axes 

914 # (x, y, byte), without changing the bit packing within a pixel. 

915 array_model = archive.add_array( 

916 np.moveaxis(self._array, -1, 0), 

917 update_header=update_header, 

918 tile_shape=tile_shape, 

919 options_name=options_name, 

920 ) 

921 if not isinstance(array_model, ArrayReferenceModel): 921 ↛ 922line 921 didn't jump to line 922 because the condition on line 921 was never true

922 raise RuntimeError("Native mask arrays require reference array storage.") 

923 array_model.shape = list(self._array.shape) 

924 data: list[ArrayReferenceModel | InlineArrayModel] = [array_model] 

925 else: 

926 data = [] 

927 for schema_2d in self.schema.split(np.int32): 

928 mask_2d = Mask(0, bbox=self.bbox, schema=schema_2d, sky_projection=self._sky_projection) 

929 mask_2d.update(self) 

930 data.append( 

931 mask_2d._serialize_2d( 

932 archive, 

933 update_header=update_header, 

934 add_offset_wcs=add_offset_wcs, 

935 tile_shape=tile_shape, 

936 options_name=options_name, 

937 ) 

938 ) 

939 serialized_projection: SkyProjectionSerializationModel[P] | None = None 

940 if save_projection and self.sky_projection is not None: 940 ↛ 941line 940 didn't jump to line 941 because the condition on line 940 was never true

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

942 serialized_dtype = NumberType.from_numpy(self.schema.dtype) 

943 assert is_integer(serialized_dtype), "Mask dtypes should always be integers." 

944 return MaskSerializationModel.model_construct( 

945 data=data, 

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

947 planes=list(self.schema), 

948 dtype=serialized_dtype, 

949 sky_projection=serialized_projection, 

950 metadata=self.metadata, 

951 ) 

952 

953 def _serialize_2d[P: pydantic.BaseModel]( 

954 self, 

955 archive: OutputArchive[P], 

956 *, 

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

958 add_offset_wcs: str | None = "A", 

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

960 options_name: str | None = None, 

961 ) -> ArrayReferenceModel | InlineArrayModel: 

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

963 update_header(header) 

964 self.schema.update_header(header) 

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

966 if self.fits_wcs: 

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

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

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

970 

971 assert self.array.shape[2] == 1, "Mask should be split before calling this method." 

972 return archive.add_array( 

973 self._array[:, :, 0], 

974 update_header=_update_header, 

975 tile_shape=tile_shape, 

976 options_name=options_name, 

977 ) 

978 

979 @staticmethod 

980 def _get_archive_tree_type[P: pydantic.BaseModel]( 

981 pointer_type: type[P], 

982 ) -> type[MaskSerializationModel[P]]: 

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

984 type that uses the given pointer type. 

985 """ 

986 return MaskSerializationModel[pointer_type] # type: ignore 

987 

988 _archive_default_name: ClassVar[str] = "mask" 

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

990 top-level object. 

991 """ 

992 

993 @staticmethod 

994 def from_legacy( 

995 legacy: Any, 

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

997 *, 

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

999 ) -> Mask: 

1000 """Convert from an `lsst.afw.image.Mask` instance. 

1001 

1002 Parameters 

1003 ---------- 

1004 legacy 

1005 An `lsst.afw.image.Mask` instance. This will not share pixel 

1006 data with the new object. 

1007 plane_map 

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

1009 description. If not provided, the right legacy mask plane will be 

1010 guessed, but this can depend on which mask planes the legacy 

1011 mask actually has set. 

1012 sky_projection 

1013 Projection from pixels to xky. 

1014 """ 

1015 return Mask._from_legacy_array( 

1016 legacy.array, 

1017 legacy.getMaskPlaneDict(), 

1018 yx0=YX(y=legacy.getY0(), x=legacy.getX0()), 

1019 plane_map=plane_map, 

1020 sky_projection=sky_projection, 

1021 ) 

1022 

1023 def to_legacy(self, plane_map: Mapping[str, MaskPlane] | None = None) -> Any: 

1024 """Convert to an `lsst.afw.image.Mask` instance. 

1025 

1026 The pixel data will not be shared between the two objects. 

1027 

1028 Parameters 

1029 ---------- 

1030 plane_map 

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

1032 description. 

1033 """ 

1034 import lsst.afw.image 

1035 import lsst.geom 

1036 

1037 result = lsst.afw.image.Mask(self.bbox.to_legacy()) 

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

1039 plane_map = {plane.name: plane for plane in self.schema if plane is not None} 

1040 for old_name, new_plane in plane_map.items(): 

1041 old_bit = result.addMaskPlane(old_name) 

1042 old_bitmask = 1 << old_bit 

1043 if old_bitmask == 2147483648: 1043 ↛ 1046line 1043 didn't jump to line 1046 because the condition on line 1043 was never true

1044 # afw uses int32 masks, but relies on overflow wrapping, which 

1045 # numpy doesn't like. 

1046 old_bitmask = -2147483648 

1047 if new_plane in self.schema: 1047 ↛ 1040line 1047 didn't jump to line 1040 because the condition on line 1047 was always true

1048 result.array[self.get(new_plane.name)] |= old_bitmask 

1049 return result 

1050 

1051 @staticmethod 

1052 def _from_legacy_array( 

1053 array2d: np.ndarray, 

1054 old_planes: Mapping[str, int], 

1055 *, 

1056 yx0: YX[int], 

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

1058 sky_projection: SkyProjection | None = None, 

1059 ) -> Mask: 

1060 if plane_map is None: 1060 ↛ 1061line 1060 didn't jump to line 1061 because the condition on line 1060 was never true

1061 plane_map = _guess_legacy_plane_map(old_planes) 

1062 planes: list[MaskPlane] = list(plane_map.values()) if plane_map is not None else [] 

1063 new_name_to_old_bitmask: dict[str, int] = {} 

1064 for old_name, old_bit in old_planes.items(): 

1065 old_bitmask = 1 << old_bit 

1066 if old_bitmask == 2147483648: 1066 ↛ 1069line 1066 didn't jump to line 1069 because the condition on line 1066 was never true

1067 # afw uses int32 masks, but relies on overflow wrapping, which 

1068 # numpy doesn't like. 

1069 old_bitmask = -2147483648 

1070 if new_plane := plane_map.get(old_name): 

1071 # Already added to 'planes' at initialization. 

1072 new_name_to_old_bitmask[new_plane.name] = old_bitmask 

1073 else: 

1074 if n_orphaned := np.count_nonzero(array2d & old_bitmask): 1074 ↛ 1075line 1074 didn't jump to line 1075 because the condition on line 1074 was never true

1075 raise RuntimeError( 

1076 f"Legacy mask plane {old_name!r} is not remapped, " 

1077 f"but {n_orphaned} pixels have this bit set." 

1078 ) 

1079 schema = MaskSchema(planes) 

1080 mask = Mask(0, schema=schema, yx0=yx0, shape=array2d.shape, sky_projection=sky_projection) 

1081 for new_name, old_bitmask in new_name_to_old_bitmask.items(): 

1082 mask.set(new_name, array2d & old_bitmask) 

1083 return mask 

1084 

1085 @staticmethod 

1086 def read_legacy( 

1087 uri: ResourcePathExpression, 

1088 *, 

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

1090 ext: str | int = 1, 

1091 fits_wcs_frame: Frame | None = None, 

1092 ) -> Mask: 

1093 """Read a FITS file written by `lsst.afw.image.Mask.writeFits`. 

1094 

1095 Parameters 

1096 ---------- 

1097 uri 

1098 URI or file name. 

1099 plane_map 

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

1101 description. If not provided, the right legacy mask plane will be 

1102 guessed, but this can depend on which mask planes the legacy 

1103 mask actually has set. 

1104 ext 

1105 Name or index of the FITS HDU to read. 

1106 fits_wcs_frame 

1107 If not `None` and the HDU containing the mask has a FITS WCS, 

1108 attach a `SkyProjection` to the returned mask by converting that 

1109 WCS. 

1110 """ 

1111 opaque_metadata = fits.FitsOpaqueMetadata() 

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

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

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

1115 result = Mask._read_legacy_hdu( 

1116 hdu_list[ext], opaque_metadata, plane_map=plane_map, fits_wcs_frame=fits_wcs_frame 

1117 ) 

1118 result._opaque_metadata = opaque_metadata 

1119 return result 

1120 

1121 @staticmethod 

1122 def _read_legacy_hdu( 

1123 hdu: astropy.io.fits.ImageHDU | astropy.io.fits.CompImageHDU | astropy.io.fits.BinTableHDU, 

1124 opaque_metadata: fits.FitsOpaqueMetadata, 

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

1126 fits_wcs_frame: Frame | None = None, 

1127 strip_legacy_planes: bool = True, 

1128 ) -> Mask: 

1129 if isinstance(hdu, astropy.io.fits.BinTableHDU): 1129 ↛ 1130line 1129 didn't jump to line 1130 because the condition on line 1129 was never true

1130 hdu = astropy.io.fits.CompImageHDU(bintable=hdu) 

1131 yx0 = fits.read_yx0(hdu.header) 

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

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

1134 sky_projection: SkyProjection | None = None 

1135 if fits_wcs_frame is not None: 1135 ↛ 1136line 1135 didn't jump to line 1136 because the condition on line 1135 was never true

1136 try: 

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

1138 except KeyError: 

1139 pass 

1140 else: 

1141 sky_projection = SkyProjection.from_fits_wcs( 

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

1143 ) 

1144 if any(card.keyword.startswith("MSKN") for card in hdu.header.cards): 

1145 # New ``lsst.images`` form: plane definitions are self-describing 

1146 # via MSKN/MSKM/MSKD cards, so no plane_map is needed. The on-disk 

1147 # array packs every plane into one element; ``set`` repacks each 

1148 # plane into the (default uint8) in-memory layout by name. 

1149 schema = MaskSchema.from_fits_header(hdu.header) 

1150 mask = Mask(0, schema=schema, yx0=yx0, shape=hdu.data.shape, sky_projection=sky_projection) 

1151 for n, plane in enumerate(schema): 

1152 if plane is not None: 1152 ↛ 1151line 1152 didn't jump to line 1151 because the condition on line 1152 was always true

1153 mask.set(plane.name, hdu.data & hdu.header.get(f"MSKM{n:04d}", 1 << n)) 

1154 schema.strip_header(hdu.header) 

1155 else: 

1156 # Legacy ``lsst.afw.image`` form: bit indices in MP_* cards are 

1157 # mapped to new planes via ``plane_map``. 

1158 old_planes = MaskPlane.read_legacy(hdu.header, strip=strip_legacy_planes) 

1159 resolved_map = plane_map if plane_map is not None else _guess_legacy_plane_map(old_planes) 

1160 mask = Mask._from_legacy_array( 

1161 hdu.data, old_planes, yx0=yx0, plane_map=resolved_map, sky_projection=sky_projection 

1162 ) 

1163 if not strip_legacy_planes: 

1164 # Keep the MP_ cards for backwards compatibility, but re-index 

1165 # them to the (reshuffled) positions of the new schema so a 

1166 # legacy reader sees each plane at the bit it is actually 

1167 # packed into on disk. 

1168 _reindex_legacy_plane_cards(hdu.header, old_planes, resolved_map, mask.schema) 

1169 fits.strip_wcs_cards(hdu.header) 

1170 hdu.header.strip() 

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

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

1173 # afw set BUNIT on masks because of limitations in how FITS 

1174 # metadata is handled there. 

1175 hdu.header.remove("BUNIT", ignore_missing=True) 

1176 opaque_metadata.add_header(hdu.header) 

1177 return mask 

1178 

1179 

1180class MaskSerializationModel[P: pydantic.BaseModel](ArchiveTree): 

1181 """Pydantic model used to represent the serialized form of a `.Mask`.""" 

1182 

1183 SCHEMA_NAME: ClassVar[str] = "mask" 

1184 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

1185 MIN_READ_VERSION: ClassVar[int] = 1 

1186 PUBLIC_TYPE: ClassVar[type] = Mask 

1187 

1188 data: list[ArrayReferenceModel | InlineArrayModel] = pydantic.Field( 

1189 description="References to pixel data." 

1190 ) 

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

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

1193 ) 

1194 planes: list[MaskPlane | None] = pydantic.Field(description="Definitions of the bitplanes in the mask.") 

1195 dtype: IntegerType = pydantic.Field(description="Data type of the in-memory mask.") 

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

1197 default=None, 

1198 exclude_if=is_none, 

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

1200 ) 

1201 

1202 @property 

1203 def bbox(self) -> Box: 

1204 """The 2-d bounding box of the mask.""" 

1205 shape = self.data[0].shape 

1206 if len(shape) == 3: 

1207 shape = shape[:2] 

1208 return Box.from_shape(shape, start=self.yx0) 

1209 

1210 def deserialize( 

1211 self, 

1212 archive: InputArchive[Any], 

1213 *, 

1214 bbox: Box | None = None, 

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

1216 **kwargs: Any, 

1217 ) -> Mask: 

1218 """Deserialize a mask from an input archive. 

1219 

1220 Parameters 

1221 ---------- 

1222 archive 

1223 Archive to read from. 

1224 bbox 

1225 Bounding box of a subimage to read instead. 

1226 strip_header 

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

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

1229 `Mask.serialize`. 

1230 **kwargs 

1231 Unsupported keyword arguments are accepted only to provide better 

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

1233 """ 

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

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

1236 

1237 def strip_header_and_legacy_planes(header: astropy.io.fits.Header) -> None: 

1238 # The authoritative schema comes from the serialized tree, so drop 

1239 # any legacy MP_* cards (written only for afw compatibility in the 

1240 # legacy-cutout scenario) rather than carrying them as opaque 

1241 # metadata, where they could drift out of sync or be re-propagated. 

1242 strip_header(header) 

1243 _strip_legacy_plane_cards(header) 

1244 

1245 slices: tuple[slice, ...] | EllipsisType = ... 

1246 if bbox is not None: 

1247 slices = bbox.slice_within(self.bbox) 

1248 else: 

1249 bbox = self.bbox 

1250 if not is_integer(self.dtype): 1250 ↛ 1251line 1250 didn't jump to line 1251 because the condition on line 1250 was never true

1251 raise ArchiveReadError(f"Mask array has a non-integer dtype: {self.dtype}.") 

1252 schema = MaskSchema(self.planes, dtype=self.dtype.to_numpy()) 

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

1254 if len(self.data) == 1 and tuple(self.data[0].shape) == tuple(self.bbox.shape) + (schema.mask_size,): 

1255 storage_slices = slices if slices is ... else (slice(None),) + slices 

1256 array = archive.get_array( 

1257 self.data[0], strip_header=strip_header_and_legacy_planes, slices=storage_slices 

1258 ) 

1259 array = np.moveaxis(array, 0, -1) 

1260 return Mask(array, schema=schema, bbox=bbox, sky_projection=sky_projection)._finish_deserialize( 

1261 self 

1262 ) 

1263 result = Mask(0, schema=schema, bbox=bbox, sky_projection=sky_projection) 

1264 schemas_2d = schema.split(np.int32) 

1265 if len(schemas_2d) != len(self.data): 1265 ↛ 1266line 1265 didn't jump to line 1266 because the condition on line 1265 was never true

1266 raise ArchiveReadError( 

1267 f"Number of mask arrays ({len(self.data)}) does not match expectation ({len(schemas_2d)})." 

1268 ) 

1269 for array_model, schema_2d in zip(self.data, schemas_2d): 

1270 mask_2d = self._deserialize_2d( 

1271 array_model, 

1272 schema_2d, 

1273 bbox.start, 

1274 archive, 

1275 strip_header=strip_header_and_legacy_planes, 

1276 slices=slices, 

1277 ) 

1278 result.update(mask_2d) 

1279 return result._finish_deserialize(self) 

1280 

1281 @staticmethod 

1282 def _deserialize_2d( 

1283 ref: ArrayReferenceModel | InlineArrayModel, 

1284 schema_2d: MaskSchema, 

1285 yx0: Sequence[int], 

1286 archive: InputArchive[Any], 

1287 *, 

1288 slices: tuple[slice, ...] | EllipsisType = ..., 

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

1290 ) -> Mask: 

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

1292 strip_header(header) 

1293 schema_2d.strip_header(header) 

1294 fits.strip_wcs_cards(header) 

1295 

1296 array_2d = archive.get_array(ref, strip_header=_strip_header, slices=slices) 

1297 return Mask(array_2d[:, :, np.newaxis], schema=schema_2d, yx0=yx0) 

1298 

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

1300 if kwargs: 

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

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

1303 

1304 

1305def _archive_prefers_native_mask_arrays(archive: OutputArchive[Any]) -> bool: 

1306 """Return whether an archive wants masks in their native 3-D layout.""" 

1307 current: Any = archive 

1308 while current is not None: 

1309 if getattr(current, "_prefer_native_mask_arrays", False): 

1310 return True 

1311 current = getattr(current, "_parent", None) 

1312 return False 

1313 

1314 

1315def get_legacy_visit_image_mask_planes() -> dict[str, MaskPlane]: 

1316 """Return a mapping from legacy mask plane name to `MaskPlane` instance 

1317 for LSST visit images, c. DP2. 

1318 """ 

1319 return { 

1320 "BAD": MaskPlane("BAD", "Bad pixel in the instrument, including bad amplifiers."), 

1321 "SAT": MaskPlane( 

1322 "SATURATED", "Pixel was saturated or affected by saturation in a neighboring pixel." 

1323 ), 

1324 "INTRP": MaskPlane("INTERPOLATED", "Original pixel value was interpolated."), 

1325 "CR": MaskPlane("COSMIC_RAY", "A cosmic ray affected this pixel."), 

1326 "EDGE": MaskPlane( 

1327 "DETECTION_EDGE", 

1328 "Pixel was too close to the edge to be considered for detection, " 

1329 "due to the finite size of the detection kernel.", 

1330 ), 

1331 "DETECTED": MaskPlane("DETECTED", "Pixel was part of a detected source."), 

1332 "SUSPECT": MaskPlane("SUSPECT", "Pixel was close to the saturation level. "), 

1333 "NO_DATA": MaskPlane("NO_DATA", "No data was available for this pixel."), 

1334 "VIGNETTED": MaskPlane("VIGNETTED", "Pixel was vignetted by the optics."), 

1335 "PARTLY_VIGNETTED": MaskPlane("PARTLY_VIGNETTED", "Pixel was partly vignetted by the optics."), 

1336 "CROSSTALK": MaskPlane("CROSSTALK", "Pixel was affected by crosstalk and corrected accordingly."), 

1337 "ITL_DIP": MaskPlane( 

1338 "ITL_DIP", "Pixel was affected by a dark vertical trail from a bright source, on an ITL CCD." 

1339 ), 

1340 "NOT_DEBLENDED": MaskPlane( 

1341 "NOT_DEBLENDED", 

1342 "Pixel belonged to a detection that was not deblended, usually due to size limits.", 

1343 ), 

1344 "SPIKE": MaskPlane( 

1345 "SPIKE", "Pixel is in the neighborhood of a diffraction spike from a bright star." 

1346 ), 

1347 "UNMASKEDNAN": MaskPlane("UNMASKED_NAN", "Pixel was found to be NaN unexpectedly."), 

1348 } 

1349 

1350 

1351def get_legacy_difference_image_mask_planes() -> dict[str, MaskPlane]: 

1352 """Return a mapping from legacy mask plane name to `MaskPlane` instance 

1353 for LSST difference images, c. DP2. 

1354 """ 

1355 result = get_legacy_visit_image_mask_planes() 

1356 result["DETECTED_NEGATIVE"] = MaskPlane( 

1357 "DETECTED_NEGATIVE", "Pixel was part of a detected source with negative flux." 

1358 ) 

1359 result["SAT_TEMPLATE"] = MaskPlane("SAT_TEMPLATE", "Template pixel was saturated.") 

1360 result["HIGH_VARIANCE"] = MaskPlane( 

1361 "HIGH_VARIANCE", "Template pixel had fewer-than-usual input epochs and hence high noise." 

1362 ) 

1363 result["STREAK"] = MaskPlane( 

1364 "STREAK", "An extended streak (probably an artificial satellite) affected this pixel." 

1365 ) 

1366 return result 

1367 

1368 

1369def get_legacy_deep_coadd_mask_planes() -> dict[str, MaskPlane]: 

1370 """Return a mapping from legacy mask plane name to `MaskPlane` instance 

1371 for LSST deep coadds, c. DP2. 

1372 """ 

1373 return { 

1374 "NO_DATA": MaskPlane("NO_DATA", "No data was available for this pixel."), 

1375 "INTRP": MaskPlane("INTERPOLATED", "Pixel value is the result of interpolating nearby good pixels."), 

1376 "CR": MaskPlane( 

1377 "COSMIC_RAY", 

1378 "A cosmic ray affected this pixel on at least one input image (and was interpolated).", 

1379 ), 

1380 "SAT": MaskPlane( 

1381 "SATURATED", 

1382 "More than 10% of the potential input visits had a saturated pixel at this location " 

1383 "('potential' because saturated pixel values are not actually propagated to the coadd). " 

1384 "SATURATED always implies REJECTED, and is often a reason for NO_DATA.", 

1385 ), 

1386 "EDGE": MaskPlane( 

1387 "DETECTION_EDGE", 

1388 "Pixel was too close to the edge of the patch to be considered for detection, " 

1389 "due to the finite size of the detection kernel.", 

1390 ), 

1391 "CLIPPED": MaskPlane( 

1392 "CLIPPED", 

1393 "Region was identified as a probable artifact when comparing multiple single-visit warps. " 

1394 "CLIPPED always implies REJECTED.", 

1395 ), 

1396 "REJECTED": MaskPlane( 

1397 "REJECTED", 

1398 "At least one input visit was left out of the coadd for this pixel due to masking. " 

1399 "REJECTED always implies INEXACT_PSF.", 

1400 ), 

1401 "DETECTED": MaskPlane("DETECTED", "Pixel was part of a detected source."), 

1402 "INEXACT_PSF": MaskPlane( 

1403 "INEXACT_PSF", 

1404 "The set of visits contributing to this pixel differs from the set of visits " 

1405 "contributing to the PSF model for its cell.", 

1406 ), 

1407 } 

1408 

1409 

1410def get_legacy_non_cell_coadd_mask_planes() -> dict[str, MaskPlane]: 

1411 """Return a mapping from legacy mask plane name to `MaskPlane` instance 

1412 for LSST non-cell coadds such as ``template_coadd`` in DP2, and all 

1413 DP1 coadds. 

1414 

1415 These coadds carry the visit-level planes propagated from their input 

1416 warps in addition to the coadd-specific planes, and flag chip edges with 

1417 ``SENSOR_EDGE`` (cell coadds use ``CELL_EDGE`` instead). 

1418 """ 

1419 result = get_legacy_deep_coadd_mask_planes() 

1420 result["BAD"] = MaskPlane("BAD", "Bad pixel in the instrument, including bad amplifiers.") 

1421 result["SUSPECT"] = MaskPlane("SUSPECT", "Pixel was close to the saturation level.") 

1422 result["CROSSTALK"] = MaskPlane("CROSSTALK", "Pixel was affected by crosstalk and corrected accordingly.") 

1423 result["DETECTED_NEGATIVE"] = MaskPlane( 

1424 "DETECTED_NEGATIVE", "Pixel was part of a detected source with negative flux." 

1425 ) 

1426 result["NOT_DEBLENDED"] = MaskPlane( 

1427 "NOT_DEBLENDED", 

1428 "Pixel belonged to a detection that was not deblended, usually due to size limits.", 

1429 ) 

1430 result["UNMASKEDNAN"] = MaskPlane("UNMASKED_NAN", "Pixel was found to be NaN unexpectedly.") 

1431 result["SENSOR_EDGE"] = MaskPlane( 

1432 "SENSOR_EDGE", 

1433 "Pixel is near the edge of a contributing sensor/chip, so the coadd PSF is discontinuous there.", 

1434 ) 

1435 return result 

1436 

1437 

1438def _guess_legacy_plane_map(old_planes: Mapping[str, int]) -> dict[str, MaskPlane]: 

1439 """Guess which of the ``get_legacy_*_plane_map`` created the given mask 

1440 plane dictionary and call it. 

1441 """ 

1442 if "SAT_TEMPLATE" in old_planes: 1442 ↛ 1443line 1442 didn't jump to line 1443 because the condition on line 1442 was never true

1443 return get_legacy_difference_image_mask_planes() 

1444 if "INEXACT_PSF" in old_planes: 

1445 # Both cell and non-cell coadds have INEXACT_PSF, but only non-cell 

1446 # (assemble_coadd) coadds flag chip edges with SENSOR_EDGE; cell coadds 

1447 # use CELL_EDGE. 

1448 if "SENSOR_EDGE" in old_planes: 

1449 return get_legacy_non_cell_coadd_mask_planes() 

1450 return get_legacy_deep_coadd_mask_planes() 

1451 return get_legacy_visit_image_mask_planes() 

1452 

1453 

1454def _reindex_legacy_plane_cards( 

1455 header: astropy.io.fits.Header, 

1456 old_planes: Mapping[str, int], 

1457 plane_map: Mapping[str, MaskPlane], 

1458 schema: MaskSchema, 

1459) -> None: 

1460 """Rewrite retained legacy ``MP_`` cards in place to match a reshuffled 

1461 schema. 

1462 

1463 Parameters 

1464 ---------- 

1465 header 

1466 Header whose ``MP_`` cards are updated in place. 

1467 old_planes 

1468 Mapping from legacy mask plane name to its original (on-disk) bit 

1469 index, as returned by `MaskPlane.read_legacy`. 

1470 plane_map 

1471 Mapping from legacy mask plane name to the `MaskPlane` it was remapped 

1472 to in ``schema``. 

1473 schema 

1474 The reconstructed schema that defines the new bit positions. 

1475 

1476 Notes 

1477 ----- 

1478 Each ``MP_<legacy name>`` card is set to the index that its remapped plane 

1479 occupies in ``schema`` (equivalently, the ``MSKN`` index written on 

1480 serialization). Cards for legacy planes that are not represented in the 

1481 new schema are removed, since they no longer correspond to any stored bit. 

1482 Legacy masks have at most 31 planes, so every plane maps to a single bit in 

1483 one on-disk element and the index is unambiguous. 

1484 """ 

1485 new_index = {plane.name: n for n, plane in enumerate(schema) if plane is not None} 

1486 for old_name in old_planes: 

1487 keyword = f"MP_{old_name}" 

1488 new_plane = plane_map.get(old_name) 

1489 if new_plane is not None and (index := new_index.get(new_plane.name)) is not None: 

1490 header[keyword] = index 

1491 else: 

1492 del header[keyword] 

1493 

1494 

1495def _strip_legacy_plane_cards(header: astropy.io.fits.Header) -> None: 

1496 """Remove all legacy ``MP_*`` mask-plane cards from a FITS header. 

1497 

1498 These are written only so that legacy tooling can read masks reconstructed 

1499 from legacy cutouts; the ``lsst.images`` reader uses the serialized schema 

1500 instead, so it strips them rather than carrying them as opaque metadata. 

1501 """ 

1502 for keyword in [card.keyword for card in header.cards if card.keyword.startswith("MP_")]: 

1503 del header[keyword]