Coverage for python/lsst/images/fits/_common.py: 53%

252 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-09-30 11:30 +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 

12 

13from __future__ import annotations 

14 

15__all__ = ( 

16 "FITS_SOURCE_REGEX", 

17 "JSON_COLUMN", 

18 "JSON_EXTNAME", 

19 "ExtensionHDU", 

20 "ExtensionKey", 

21 "FitsCompressionAlgorithm", 

22 "FitsCompressionOptions", 

23 "FitsDitherAlgorithm", 

24 "FitsExternalMetadata", 

25 "FitsOpaqueMetadata", 

26 "FitsQuantizationOptions", 

27 "InvalidFitsArchiveError", 

28 "PointerModel", 

29 "PrecompressedImage", 

30 "add_offset_wcs", 

31 "read_offset_wcs", 

32 "read_yx0", 

33 "strip_butler_cards", 

34 "strip_legacy_exposure_cards", 

35 "strip_wcs_cards", 

36 "suppress_fits_card_warnings", 

37) 

38 

39import contextlib 

40import dataclasses 

41import enum 

42import itertools 

43import re 

44import string 

45import warnings 

46from collections.abc import Iterator 

47from typing import Any, ClassVar, Self, final 

48 

49import astropy.io.fits 

50import astropy.io.fits.verify 

51import numpy as np 

52import pydantic 

53 

54from .._geom import YX, Box 

55from ..serialization import ( 

56 ArchiveReadError, 

57 ExternalMetadata, 

58 ExternalMetadataValue, 

59 OpaqueArchiveMetadata, 

60 TableColumnModel, 

61) 

62 

63type ExtensionHDU = astropy.io.fits.ImageHDU | astropy.io.fits.CompImageHDU | astropy.io.fits.BinTableHDU 

64 

65FITS_SOURCE_REGEX = re.compile(r"fits:(?P<extname>[\w/\-]+)(,(?P<extver>\d+))?(\[\d+\])?") 

66 

67JSON_EXTNAME: str = "JSON" 

68JSON_COLUMN: str = "JSON" 

69 

70 

71@contextlib.contextmanager 

72def suppress_fits_card_warnings() -> Iterator[None]: 

73 """Silence Astropy warnings for FITS cards it fixes up automatically. 

74 

75 When a header is written, Astropy converts keywords longer than eight 

76 characters into ``HIERARCH`` cards and truncates comments that overflow a 

77 card. Both emit a `~astropy.io.fits.verify.VerifyWarning` that carries no 

78 actionable information, so they are silenced around the code that writes 

79 these headers. 

80 """ 

81 with warnings.catch_warnings(): 

82 warnings.filterwarnings( 

83 "ignore", 

84 message="Keyword name .* is greater than 8 characters", 

85 category=astropy.io.fits.verify.VerifyWarning, 

86 ) 

87 warnings.filterwarnings( 

88 "ignore", 

89 message=".*comment will be truncated", 

90 category=astropy.io.fits.verify.VerifyWarning, 

91 ) 

92 yield 

93 

94 

95@dataclasses.dataclass(frozen=True) 

96class ExtensionKey: 

97 """The identifiers for a single FITS extension HDU within a file.""" 

98 

99 name: str = "" 

100 """Value of the EXTNAME keyword, or an empty string for the primary HDU.""" 

101 

102 ver: int = 1 

103 """Value of the EXTVER keyword (which may be absent if it is ``1``).""" 

104 

105 @classmethod 

106 def from_index_row(cls, row: np.ndarray) -> ExtensionKey: 

107 """Construct from a row of the index binary table appended to the 

108 FITS files written by the package. 

109 

110 Parameters 

111 ---------- 

112 row 

113 Structured-array row with ``EXTNAME`` and ``EXTVER`` fields. 

114 """ 

115 return cls(str(row["EXTNAME"]).upper(), int(row["EXTVER"])) 

116 

117 @classmethod 

118 def from_str(cls, source: str) -> ExtensionKey: 

119 """Construct from the `str` coercion of this type, which is used 

120 as the 'source' field in various Pydantic models that serve as 

121 references to other HDUs. 

122 

123 Parameters 

124 ---------- 

125 source 

126 ``fits:<extname>[,<extver>]`` reference string, as produced by 

127 ``str(self)``. 

128 """ 

129 if (m := FITS_SOURCE_REGEX.fullmatch(source)) is None: 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true

130 raise ArchiveReadError(f"Bad 'source' string for FITS: {source!r}.") 

131 ver = 1 

132 if m.group("extver") is not None: 

133 ver = int(m.group("extver")) 

134 return cls(m.group("extname"), ver) 

135 

136 def check(self) -> None: 

137 if not FITS_SOURCE_REGEX.match(str(self)): 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true

138 raise ValueError( 

139 f"Invalid source key: '{str(self)}'; name characters must be alphanumeric, '-', '_', or '/'." 

140 ) 

141 

142 def __str__(self) -> str: 

143 if self.ver > 1: 

144 return f"fits:{self.name},{self.ver}" 

145 else: 

146 return f"fits:{self.name}" 

147 

148 

149class PointerModel(pydantic.BaseModel): 

150 column: TableColumnModel = pydantic.Field(description="Table column for this cell this pointer targets.") 

151 row: int = pydantic.Field(description="Zero-indexed row for cell this pointer targets.") 

152 

153 

154class InvalidFitsArchiveError(RuntimeError): 

155 """The error type raised when the content of a FITS file presumed to have 

156 been written by FitsOutputArchive is not self-consistent. 

157 """ 

158 

159 

160class FitsCompressionAlgorithm(enum.StrEnum): 

161 """FITS compression algorithms supported by this package. 

162 

163 See the FITS standard for definitions. 

164 """ 

165 

166 GZIP_1 = "GZIP_1" 

167 GZIP_2 = "GZIP_2" 

168 RICE_1 = "RICE_1" 

169 

170 

171class FitsDitherAlgorithm(enum.StrEnum): 

172 """FITS quantization dither algorithms supported by this package. 

173 

174 See the FITS standard for definitions. 

175 """ 

176 

177 NO_DITHER = "NO_DITHER" 

178 SUBTRACTIVE_DITHER_1 = "SUBTRACTIVE_DITHER_1" 

179 SUBTRACTIVE_DITHER_2 = "SUBTRACTIVE_DITHER_2" 

180 

181 def to_astropy_quantize_method(self) -> int: 

182 """Convert to the integer code used by Astropy.""" 

183 match self: 

184 case self.NO_DITHER: 184 ↛ 185line 184 didn't jump to line 185 because the pattern on line 184 never matched

185 return -1 

186 case self.SUBTRACTIVE_DITHER_1: 186 ↛ 187line 186 didn't jump to line 187 because the pattern on line 186 never matched

187 return 1 

188 case self.SUBTRACTIVE_DITHER_2: 

189 return 2 

190 raise AssertionError("Invalid enum value.") 

191 

192 

193class FitsQuantizationOptions(pydantic.BaseModel, frozen=True): 

194 """Quantization options for FITS compression.""" 

195 

196 dither: FitsDitherAlgorithm 

197 """How to add random noise during quantization to reduce biases.""" 

198 

199 level: float 

200 """Quantization level. 

201 

202 When positive, this is the fraction of the measured standard deviation that 

203 corresponds to an integer step. When negative, it is ``-ZSCALE``, the 

204 scaling to apply directly to the original pixels before quantization. 

205 """ 

206 

207 seed: int | None = None 

208 """Random number seed to use for dithering. 

209 

210 Values between 1 and 10000 (inclusive) are used directly. ``0`` will 

211 generate a value from the current time, and ``-1`` will generate a value 

212 from the checksum of the image. 

213 

214 If `None`, the ``compression_seed`` parameter must be passed to 

215 `FitsOutputArchive.open` if any quantized compression is configured. 

216 """ 

217 

218 

219class FitsCompressionOptions(pydantic.BaseModel, frozen=True): 

220 """Configuration options for FITS compression.""" 

221 

222 algorithm: FitsCompressionAlgorithm = FitsCompressionAlgorithm.GZIP_2 

223 """Compression algorithm to use.""" 

224 

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

226 """Shape ``(..., y, x)`` of independently compressed tiles. 

227 

228 The default of `None` leaves the tile shape up to the ``tile_shape`` 

229 argument to `~lsst.images.serialization.OutputArchive.add_array`. 

230 """ 

231 

232 quantization: FitsQuantizationOptions | None = None 

233 """Quantization to apply before compression, if any.""" 

234 

235 DEFAULT: ClassVar[FitsCompressionOptions | None] 

236 """Default compression options (lossless ``GZIP_2``).""" 

237 

238 LOSSY: ClassVar[FitsCompressionOptions] 

239 """Default lossy compression options.""" 

240 

241 def make_hdu(self, data: np.ndarray, name: str) -> astropy.io.fits.CompImageHDU: 

242 """Make an `astropy.io.fits.CompImageHDU` object from these options. 

243 

244 Parameters 

245 ---------- 

246 data 

247 Pixel data to store in the HDU. 

248 name 

249 ``EXTNAME`` of the HDU. 

250 """ 

251 if self.quantization is not None: 

252 return astropy.io.fits.CompImageHDU( 

253 data, 

254 name=name, 

255 compression_type=self.algorithm.value, 

256 tile_shape=self.tile_shape, 

257 quantize_method=self.quantization.dither.to_astropy_quantize_method(), 

258 quantize_level=self.quantization.level, 

259 dither_seed=self.quantization.seed, 

260 ) 

261 else: 

262 return astropy.io.fits.CompImageHDU( 

263 data, 

264 name=name, 

265 compression_type=self.algorithm.value, 

266 tile_shape=self.tile_shape, 

267 quantize_level=0.0, 

268 ) 

269 

270 

271FitsCompressionOptions.DEFAULT = FitsCompressionOptions() 

272FitsCompressionOptions.LOSSY = FitsCompressionOptions( 

273 algorithm=FitsCompressionAlgorithm.RICE_1, 

274 quantization=FitsQuantizationOptions(dither=FitsDitherAlgorithm.SUBTRACTIVE_DITHER_2, level=16.0), 

275) 

276 

277 

278_COMPRESSION_KEYS = frozenset( 

279 ( 

280 "ZIMAGE", 

281 "ZCMPTYPE", 

282 "ZBITPIX", 

283 "ZNAXIS", 

284 "ZMASKCMP", 

285 "ZQUANTIZ", 

286 "ZDITHER0", 

287 "ZCHECKSUM", 

288 "ZDATASUM", 

289 ) 

290) 

291_COMPRESSION_PREFIX_KEYS = ("ZNAXIS", "ZTILE", "ZNAME", "ZVAL") 

292 

293 

294@dataclasses.dataclass 

295class PrecompressedImage: 

296 """Already-compressed FITS HDUs that are attached to high-level objects 

297 via `FitsOpaqueMetadata`, allowing lossy-compressed pixel values to be 

298 round-tripped exactly. 

299 """ 

300 

301 header: astropy.io.fits.Header 

302 """Header for the HDU. 

303 

304 This contains only FITS tile-compression keywords. 

305 """ 

306 

307 data: astropy.io.fits.FITS_rec 

308 """FITS binary table data that serves as the low-level representation of a 

309 tile-compressed image HDU. 

310 """ 

311 

312 @classmethod 

313 def from_bintable(cls, hdu: astropy.io.fits.BinTableHDU) -> Self: 

314 """Construct from a binary table HDU. 

315 

316 Parameters 

317 ---------- 

318 hdu 

319 Binary table HDU, typically read from a FITS file opened with 

320 ``disable_image_compression=True``. 

321 

322 Returns 

323 ------- 

324 PrecompressedImage 

325 A `PrecompressedImage` instance. 

326 """ 

327 header = astropy.io.fits.Header( 

328 [ 

329 card 

330 for card in hdu.header.cards 

331 if card.keyword in _COMPRESSION_KEYS 

332 or any(card.keyword.startswith(k) for k in _COMPRESSION_PREFIX_KEYS) 

333 ] 

334 ) 

335 # This is an opportunity to fix CFITSIO's non-standard writing of the 

336 # old RICE_ONE value instead of RICE_1. 

337 if header["ZCMPTYPE"] == "RICE_ONE": 

338 header["ZCMPTYPE"] = "RICE_1" 

339 return cls(header=header, data=hdu.data) 

340 

341 

342@final 

343class FitsExternalMetadata(ExternalMetadata): 

344 """Read-only, by-keyword access to the values in a FITS header. 

345 

346 Parameters 

347 ---------- 

348 header 

349 Header to read. It is held by reference, so later changes to it are 

350 seen. `None` is equivalent to an empty header. 

351 

352 Notes 

353 ----- 

354 ``COMMENT``, ``HISTORY``, and blank-keyword cards are not visible. 

355 Keywords are reported in upper case, with HIERARCH keywords lacking the 

356 ``HIERARCH`` prefix (e.g. ``LSST ISR UNITS``). Lookups ignore case and 

357 accept an optional ``HIERARCH`` prefix. Cards with no value are 

358 reported as `None`. 

359 """ 

360 

361 _HIDDEN_KEYWORDS: ClassVar[frozenset[str]] = frozenset({"", "COMMENT", "HISTORY"}) 

362 """Keywords of FITS commentary cards, which are not visible.""" 

363 

364 def __init__(self, header: astropy.io.fits.Header | None) -> None: 

365 self._header = header if header is not None else astropy.io.fits.Header() 

366 

367 @staticmethod 

368 def _from_card_value(value: Any) -> ExternalMetadataValue: 

369 """Convert a FITS card value to an `ExternalMetadataValue`, mapping 

370 the value of a card with no value to `None`. 

371 """ 

372 if isinstance(value, astropy.io.fits.card.Undefined): 372 ↛ 373line 372 didn't jump to line 373 because the condition on line 372 was never true

373 return None 

374 return value 

375 

376 def _is_visible(self, key: object) -> bool: 

377 """Return whether the header has a visible card for ``key``.""" 

378 return ( 

379 isinstance(key, str) 

380 and key.upper().removeprefix("HIERARCH ") not in self._HIDDEN_KEYWORDS 

381 and key in self._header 

382 ) 

383 

384 def __getitem__(self, key: str) -> ExternalMetadataValue: 

385 if not self._is_visible(key): 

386 raise KeyError(key) 

387 return self._from_card_value(self._header[key]) 

388 

389 def __contains__(self, key: object) -> bool: 

390 return self._is_visible(key) 

391 

392 def __iter__(self) -> Iterator[str]: 

393 return iter( 

394 dict.fromkeys( 

395 keyword 

396 for keyword in (k.upper() for k in self._header.keys()) 

397 if keyword not in self._HIDDEN_KEYWORDS 

398 ) 

399 ) 

400 

401 def __len__(self) -> int: 

402 return sum(1 for _ in self) 

403 

404 def get_all(self, key: str) -> tuple[ExternalMetadataValue, ...]: 

405 # Docstring inherited. 

406 if not self._is_visible(key): 

407 raise KeyError(key) 

408 return tuple(self._from_card_value(self._header[key, i]) for i in range(self._header.count(key))) 

409 

410 

411@final 

412@dataclasses.dataclass 

413class FitsOpaqueMetadata(OpaqueArchiveMetadata): 

414 """Opaque metadata that may be carried around by a serializable type to 

415 propagate serialization options and opaque information without that type 

416 knowing how it was serialized. 

417 """ 

418 

419 headers: dict[ExtensionKey, astropy.io.fits.Header] = dataclasses.field(default_factory=dict) 

420 """FITS headers found (but not interpreted and stripped) when reading, to 

421 be propagated on write. 

422 

423 Keys are EXTNAME/EXTVER combinations, or ("", 1) for the primary header. 

424 Header information in opaque metadata is considered immutable, allowing it 

425 to be transferred by reference to copies and subsets of the object it is 

426 attached to. 

427 """ 

428 

429 precompressed: dict[str, PrecompressedImage] = dataclasses.field(default_factory=dict) 

430 """FITS tile-compressed HDUs that should be written out directly instead 

431 of the in-memory data provided. 

432 

433 Keys are EXTNAME values, which must be unique (no EXTVER disambiguation). 

434 Precompressed pixel values are never copied or transferred to subsets. 

435 """ 

436 

437 def add_header( 

438 self, 

439 header: astropy.io.fits.Header, 

440 name: str | None = None, 

441 ver: int | None = None, 

442 key: ExtensionKey | None = None, 

443 ) -> None: 

444 """Add a header to the opaque metadata if it is not already present, 

445 and strip EXTNAME, EXTVER, and DATE if present. 

446 

447 Parameters 

448 ---------- 

449 header 

450 Header to add. May be modified in place. 

451 name 

452 EXTNAME (all caps). If not provided, the EXTNAME card must be 

453 present in the header. Use ``""`` for the primary header. 

454 ver 

455 EXTVER. If not provided and the EXTVER card is not present, 

456 defaults to 1. 

457 key 

458 Combination of EXTNAME and EXTVER; used instead of both ``name`` 

459 and ``ver`` if provided. 

460 """ 

461 if key is None: 

462 if name is None: 

463 name = header["EXTNAME"] 

464 if ver is None: 

465 ver = header.get("EXTVER", 1) 

466 key = ExtensionKey(name, ver) 

467 strip_butler_cards(header) 

468 if key not in self.headers: 468 ↛ exitline 468 didn't return from function 'add_header' because the condition on line 468 was always true

469 header.remove("EXTNAME", ignore_missing=True) 

470 header.remove("EXTVER", ignore_missing=True) 

471 # DATE is written fresh on every HDU, so it is regenerated on write 

472 # rather than propagated as opaque metadata. 

473 header.remove("DATE", ignore_missing=True) 

474 self.headers[key] = header 

475 

476 def maybe_use_precompressed(self, name: str) -> astropy.io.fits.BinTableHDU | None: 

477 """Look up the given EXTNAME to see if there is a tile compressed image 

478 HDU that should be used directly, instead of requantizing. 

479 

480 Parameters 

481 ---------- 

482 name 

483 EXTNAME (all caps). 

484 

485 Returns 

486 ------- 

487 `astropy.io.fits.BinTableHDU` | `None` 

488 An already-compressed HDU, in binary table form, or `None` if there 

489 is no precompressed HDU for this EXTNAME. 

490 """ 

491 if (precompressed := self.precompressed.get(name)) is None: 491 ↛ 493line 491 didn't jump to line 493 because the condition on line 491 was always true

492 return None 

493 return astropy.io.fits.BinTableHDU(precompressed.data, header=precompressed.header.copy(), name=name) 

494 

495 def extract_legacy_primary_header(self, header: astropy.io.fits.Header) -> dict[str, Any]: 

496 """Update the opaque metadata with the header of the primary HDU 

497 of a legacy (`lsst.afw.image`) FITS file, stripping cards we know we 

498 don't need and extracting any ``LSST IMAGES ...`` cards into a 

499 dictionary we return. 

500 

501 Parameters 

502 ---------- 

503 header 

504 Primary HDU header of the legacy FITS file. 

505 """ 

506 metadata: dict[str, Any] = {} 

507 for n in itertools.count(): 507 ↛ 512line 507 didn't jump to line 512 because the loop on line 507 didn't complete

508 if (key := header.pop(f"LSST IMAGES KEY {n + 1}", ...)) is ...: 

509 break 

510 value = header.pop(f"LSST IMAGES VALUE {n + 1}") 

511 metadata[key] = value 

512 primary_header = header.copy(strip=True) 

513 # No idea what these spare TAN-SIP headers are doing in the afw 

514 # FITS files, but we'll strip them here: 

515 primary_header.remove("A_ORDER", ignore_missing=True) 

516 primary_header.remove("B_ORDER", ignore_missing=True) 

517 primary_header.remove("DATE", ignore_missing=True) 

518 strip_legacy_exposure_cards(primary_header) 

519 strip_butler_cards(primary_header) 

520 self.headers[ExtensionKey()] = primary_header 

521 return metadata 

522 

523 def add_cutdown_primary_header(self, header: astropy.io.fits.Header) -> None: 

524 """Add the primary header of a cut-down ``lsst.images`` FITS file as 

525 opaque metadata. 

526 

527 A cut-down file is one whose JSON-tree, index, and nested-archive HDUs 

528 have been dropped, leaving only the primary, image, mask, and variance 

529 HDUs (e.g. as written by ``dax_images_cutout``). Only the 

530 container-layout cards that would be misleading after re-serialization 

531 are stripped; all other cards are retained so they survive the round 

532 trip. 

533 

534 Parameters 

535 ---------- 

536 header 

537 Primary header to read. Not modified. 

538 """ 

539 primary_header = header.copy(strip=True) 

540 for keyword in ("FMTVER", "INDXADDR", "INDXSIZE", "JSONADDR", "JSONSIZE", "DATAMODL"): 

541 primary_header.remove(keyword, ignore_missing=True) 

542 self.add_header(primary_header, name="", ver=1) 

543 

544 def copy(self) -> FitsOpaqueMetadata: 

545 # Docstring inherited. 

546 return FitsOpaqueMetadata(headers=self.headers) 

547 

548 def subset(self, bbox: Box) -> FitsOpaqueMetadata: 

549 # Docstring inherited. 

550 return FitsOpaqueMetadata(headers=self.headers) 

551 

552 def external_metadata(self) -> FitsExternalMetadata: 

553 # Docstring inherited. 

554 return FitsExternalMetadata(self.headers.get(ExtensionKey())) 

555 

556 def get_instrumental_unit(self) -> astropy.units.UnitBase | None: 

557 """Extract the ``LSST ISR UNIT`` key from the primary header (if it 

558 exists) and wrap it with Astropy. 

559 """ 

560 if (primary_header := self.headers.get(ExtensionKey())) is not None: 560 ↛ 563line 560 didn't jump to line 563 because the condition on line 560 was always true

561 if (instrumental_unit_str := primary_header.get("LSST ISR UNITS")) is not None: 561 ↛ 562line 561 didn't jump to line 562 because the condition on line 561 was never true

562 return astropy.units.Unit(instrumental_unit_str) 

563 return None 

564 

565 

566def add_offset_wcs(header: astropy.io.fits.Header, *, x: int | float, y: int | float, key: str = "A") -> None: 

567 """Add a trivial FITS WCS to a header that applies the appropriate offset 

568 to map FITS array coordinates to a logical pixel grid. 

569 

570 Parameters 

571 ---------- 

572 header 

573 Header to update in-place. 

574 x 

575 Logical coordinate of the first column. 

576 y 

577 Logical coordinate of the first row. 

578 key 

579 Single-character suffix for this WCS. 

580 """ 

581 header.set(f"CTYPE1{key}", "LINEAR") 

582 header.set(f"CTYPE2{key}", "LINEAR") 

583 header.set(f"CRPIX1{key}", 1.0) 

584 header.set(f"CRPIX2{key}", 1.0) 

585 header.set(f"CRVAL1{key}", float(x)) 

586 header.set(f"CRVAL2{key}", float(y)) 

587 header.set(f"CUNIT1{key}", "PIXEL") 

588 header.set(f"CUNIT2{key}", "PIXEL") 

589 

590 

591def read_offset_wcs(header: astropy.io.fits.Header, *, key: str = "A") -> tuple[int, int] | None: 

592 """Recover the logical pixel origin from a linear offset WCS written by 

593 `add_offset_wcs`. 

594 

595 Parameters 

596 ---------- 

597 header 

598 Header to read from. 

599 key 

600 Single-character suffix for the WCS to read. 

601 

602 Returns 

603 ------- 

604 `tuple` [`int`, `int`] | `None` 

605 The ``(x, y)`` logical coordinates of the first column and row, or 

606 `None` if the header has no ``LINEAR`` WCS with this suffix. 

607 """ 

608 if header.get(f"CTYPE1{key}") != "LINEAR": 

609 return None 

610 x = header[f"CRVAL1{key}"] - (header.get(f"CRPIX1{key}", 1.0) - 1.0) 

611 y = header[f"CRVAL2{key}"] - (header.get(f"CRPIX2{key}", 1.0) - 1.0) 

612 return (round(x), round(y)) 

613 

614 

615def read_yx0(header: astropy.io.fits.Header) -> YX[int]: 

616 """Recover the logical origin of an array from its FITS header. 

617 

618 Parameters 

619 ---------- 

620 header 

621 Header to read from. Not modified. 

622 

623 Returns 

624 ------- 

625 `~lsst.images.YX` [`int`] 

626 Logical coordinate of the first pixel, ordered ``(y, x)``. 

627 

628 Raises 

629 ------ 

630 ValueError 

631 Raised if the header records no origin via either the ``LTV1``/``LTV2`` 

632 cards (written by `lsst.afw.image`) or a linear offset WCS (written by 

633 `add_offset_wcs`). 

634 

635 Notes 

636 ----- 

637 The ``LTV1``/``LTV2`` convention is preferred when present, falling back to 

638 the ``A`` offset WCS otherwise. 

639 """ 

640 if "LTV1" in header: 

641 return YX(y=-round(header["LTV2"]), x=-round(header["LTV1"])) 

642 if (xy0 := read_offset_wcs(header)) is not None: 

643 return YX(y=xy0[1], x=xy0[0]) 

644 raise ValueError("Header records no LTV1/LTV2 cards or linear offset WCS.") 

645 

646 

647_WCS_VECTOR_KEYS = ("CUNIT", "CRPIX", "CRPIX", "CRVAL", "CRDELT", "CROTA", "CRDER", "CSYER", "CDELT") 

648_WCS_MATRIX_KEYS = ("CD{0}_{1}", "PC{0}_{1}") 

649 

650 

651def strip_wcs_cards(header: astropy.io.fits.Header) -> None: 

652 """Strip WCS cards from a FITS header. 

653 

654 This does *not* attempt to cover all possible FITS WCS forms; it focuses on 

655 the ones we actually plan to write (simple undistorted ones + TAN-SIP). 

656 

657 Parameters 

658 ---------- 

659 header 

660 FITS header to strip WCS cards from in place. 

661 """ 

662 wcsaxes = header.pop("WCSAXES", 2) 

663 for wcsname in [""] + list(string.ascii_uppercase): 

664 header.remove("RADESYS" + wcsname, ignore_missing=True) 

665 if "CTYPE1" + wcsname in header: 

666 ctype: str = "" # just for linters that can't figure out that the loop always executes 

667 for n in range(wcsaxes): 

668 suffix = f"{n + 1}{wcsname}" 

669 ctype = header.pop("CTYPE" + suffix) 

670 for key in _WCS_VECTOR_KEYS: 

671 header.remove(key + suffix, ignore_missing=True) 

672 for m in range(wcsaxes): 

673 for tmpl in _WCS_MATRIX_KEYS: 

674 header.remove(tmpl.format(m + 1, suffix), ignore_missing=True) 

675 if ctype.endswith("-SIP"): 675 ↛ 676line 675 didn't jump to line 676 because the condition on line 675 was never true

676 _strip_sip_poly(header, wcsname, "A") 

677 _strip_sip_poly(header, wcsname, "B") 

678 _strip_sip_poly(header, wcsname, "AP") 

679 _strip_sip_poly(header, wcsname, "BP") 

680 header.remove("LONPOLE", ignore_missing=True) 

681 header.remove("LATPOLE", ignore_missing=True) 

682 header.remove("MJDREF", ignore_missing=True) 

683 header.remove("PV1_3", ignore_missing=True) 

684 header.remove("PV1_4", ignore_missing=True) 

685 

686 

687def _strip_sip_poly(header: astropy.io.fits.Header, wcsname: str, which: str) -> None: 

688 order: int | None = header.pop(f"{which}_ORDER{wcsname}", None) 

689 if order is not None: 

690 for i, j in itertools.product(range(order + 1), range(order + 1)): 

691 header.remove(f"{which}_{i}_{j}{wcsname}", ignore_missing=True) 

692 

693 

694def strip_legacy_exposure_cards(header: astropy.io.fits.Header) -> None: 

695 """Strip header keywords added by lsst.afw.image.Exposure. 

696 

697 Parameters 

698 ---------- 

699 header 

700 FITS header to strip exposure cards from in place. 

701 """ 

702 header.remove("AR_HDU", ignore_missing=True) 

703 for name in ( 

704 "FILTER", 

705 "DETECTOR", 

706 "VALID_POLYGON", 

707 "SKYWCS", 

708 "PSF", 

709 "SUMMARYSTATS", 

710 "AP_CORR_MAP", 

711 "PHOTOCALIB", 

712 ): 

713 header.remove(f"{name}_ID", ignore_missing=True) 

714 header.remove(f"ARCHIVE_ID_{name}", ignore_missing=True) 

715 

716 

717def strip_butler_cards(header: astropy.io.fits.Header) -> None: 

718 """Strip header keywords added by butler provenance that would be 

719 incorrect if propagated to a downstream file. 

720 

721 Parameters 

722 ---------- 

723 header 

724 FITS header to strip butler provenance cards from in place. 

725 """ 

726 for key in list(header): 

727 if key.startswith("LSST BUTLER"): 

728 del header[key]