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
« 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.
13from __future__ import annotations
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)
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
49import astropy.io.fits
50import astropy.io.fits.verify
51import numpy as np
52import pydantic
54from .._geom import YX, Box
55from ..serialization import (
56 ArchiveReadError,
57 ExternalMetadata,
58 ExternalMetadataValue,
59 OpaqueArchiveMetadata,
60 TableColumnModel,
61)
63type ExtensionHDU = astropy.io.fits.ImageHDU | astropy.io.fits.CompImageHDU | astropy.io.fits.BinTableHDU
65FITS_SOURCE_REGEX = re.compile(r"fits:(?P<extname>[\w/\-]+)(,(?P<extver>\d+))?(\[\d+\])?")
67JSON_EXTNAME: str = "JSON"
68JSON_COLUMN: str = "JSON"
71@contextlib.contextmanager
72def suppress_fits_card_warnings() -> Iterator[None]:
73 """Silence Astropy warnings for FITS cards it fixes up automatically.
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
95@dataclasses.dataclass(frozen=True)
96class ExtensionKey:
97 """The identifiers for a single FITS extension HDU within a file."""
99 name: str = ""
100 """Value of the EXTNAME keyword, or an empty string for the primary HDU."""
102 ver: int = 1
103 """Value of the EXTVER keyword (which may be absent if it is ``1``)."""
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.
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"]))
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.
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)
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 )
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}"
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.")
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 """
160class FitsCompressionAlgorithm(enum.StrEnum):
161 """FITS compression algorithms supported by this package.
163 See the FITS standard for definitions.
164 """
166 GZIP_1 = "GZIP_1"
167 GZIP_2 = "GZIP_2"
168 RICE_1 = "RICE_1"
171class FitsDitherAlgorithm(enum.StrEnum):
172 """FITS quantization dither algorithms supported by this package.
174 See the FITS standard for definitions.
175 """
177 NO_DITHER = "NO_DITHER"
178 SUBTRACTIVE_DITHER_1 = "SUBTRACTIVE_DITHER_1"
179 SUBTRACTIVE_DITHER_2 = "SUBTRACTIVE_DITHER_2"
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.")
193class FitsQuantizationOptions(pydantic.BaseModel, frozen=True):
194 """Quantization options for FITS compression."""
196 dither: FitsDitherAlgorithm
197 """How to add random noise during quantization to reduce biases."""
199 level: float
200 """Quantization level.
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 """
207 seed: int | None = None
208 """Random number seed to use for dithering.
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.
214 If `None`, the ``compression_seed`` parameter must be passed to
215 `FitsOutputArchive.open` if any quantized compression is configured.
216 """
219class FitsCompressionOptions(pydantic.BaseModel, frozen=True):
220 """Configuration options for FITS compression."""
222 algorithm: FitsCompressionAlgorithm = FitsCompressionAlgorithm.GZIP_2
223 """Compression algorithm to use."""
225 tile_shape: tuple[int, ...] | None = None
226 """Shape ``(..., y, x)`` of independently compressed tiles.
228 The default of `None` leaves the tile shape up to the ``tile_shape``
229 argument to `~lsst.images.serialization.OutputArchive.add_array`.
230 """
232 quantization: FitsQuantizationOptions | None = None
233 """Quantization to apply before compression, if any."""
235 DEFAULT: ClassVar[FitsCompressionOptions | None]
236 """Default compression options (lossless ``GZIP_2``)."""
238 LOSSY: ClassVar[FitsCompressionOptions]
239 """Default lossy compression options."""
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.
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 )
271FitsCompressionOptions.DEFAULT = FitsCompressionOptions()
272FitsCompressionOptions.LOSSY = FitsCompressionOptions(
273 algorithm=FitsCompressionAlgorithm.RICE_1,
274 quantization=FitsQuantizationOptions(dither=FitsDitherAlgorithm.SUBTRACTIVE_DITHER_2, level=16.0),
275)
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")
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 """
301 header: astropy.io.fits.Header
302 """Header for the HDU.
304 This contains only FITS tile-compression keywords.
305 """
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 """
312 @classmethod
313 def from_bintable(cls, hdu: astropy.io.fits.BinTableHDU) -> Self:
314 """Construct from a binary table HDU.
316 Parameters
317 ----------
318 hdu
319 Binary table HDU, typically read from a FITS file opened with
320 ``disable_image_compression=True``.
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)
342@final
343class FitsExternalMetadata(ExternalMetadata):
344 """Read-only, by-keyword access to the values in a FITS header.
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.
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 """
361 _HIDDEN_KEYWORDS: ClassVar[frozenset[str]] = frozenset({"", "COMMENT", "HISTORY"})
362 """Keywords of FITS commentary cards, which are not visible."""
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()
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
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 )
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])
389 def __contains__(self, key: object) -> bool:
390 return self._is_visible(key)
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 )
401 def __len__(self) -> int:
402 return sum(1 for _ in self)
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)))
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 """
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.
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 """
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.
433 Keys are EXTNAME values, which must be unique (no EXTVER disambiguation).
434 Precompressed pixel values are never copied or transferred to subsets.
435 """
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.
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
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.
480 Parameters
481 ----------
482 name
483 EXTNAME (all caps).
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)
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.
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
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.
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.
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)
544 def copy(self) -> FitsOpaqueMetadata:
545 # Docstring inherited.
546 return FitsOpaqueMetadata(headers=self.headers)
548 def subset(self, bbox: Box) -> FitsOpaqueMetadata:
549 # Docstring inherited.
550 return FitsOpaqueMetadata(headers=self.headers)
552 def external_metadata(self) -> FitsExternalMetadata:
553 # Docstring inherited.
554 return FitsExternalMetadata(self.headers.get(ExtensionKey()))
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
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.
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")
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`.
595 Parameters
596 ----------
597 header
598 Header to read from.
599 key
600 Single-character suffix for the WCS to read.
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))
615def read_yx0(header: astropy.io.fits.Header) -> YX[int]:
616 """Recover the logical origin of an array from its FITS header.
618 Parameters
619 ----------
620 header
621 Header to read from. Not modified.
623 Returns
624 -------
625 `~lsst.images.YX` [`int`]
626 Logical coordinate of the first pixel, ordered ``(y, x)``.
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`).
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.")
647_WCS_VECTOR_KEYS = ("CUNIT", "CRPIX", "CRPIX", "CRVAL", "CRDELT", "CROTA", "CRDER", "CSYER", "CDELT")
648_WCS_MATRIX_KEYS = ("CD{0}_{1}", "PC{0}_{1}")
651def strip_wcs_cards(header: astropy.io.fits.Header) -> None:
652 """Strip WCS cards from a FITS header.
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).
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)
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)
694def strip_legacy_exposure_cards(header: astropy.io.fits.Header) -> None:
695 """Strip header keywords added by lsst.afw.image.Exposure.
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)
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.
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]