Coverage for python/lsst/images/cli/_convert.py: 69%
94 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-21 02:10 -0700
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-21 02:10 -0700
1# This file is part of lsst-images.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
11from __future__ import annotations
13__all__ = ("convert",)
15import os
16import tempfile
17from typing import TYPE_CHECKING, Any
19import astropy.io.fits
20import click
21from click.core import ParameterSource
23from ..serialization import backend_for_path, write
25if TYPE_CHECKING:
26 from .. import VisitImage
27 from ..cells import CellCoadd
29_LEGACY_TYPES = ("visit_image", "cell_coadd")
32def detect_legacy_type(path: str) -> str | None:
33 """Return ``"visit_image"`` or ``"cell_coadd"`` from a legacy FITS file's
34 ``HIERARCH LSST BUTLER DATASETTYPE`` header, or `None` if it cannot be
35 determined.
37 A dataset type ending in ``visit_image`` (e.g. ``visit_image``,
38 ``preliminary_visit_image``, difference images) is a `VisitImage`; one
39 containing ``coadd`` is a `CellCoadd`. astropy exposes the
40 ``HIERARCH LSST BUTLER DATASETTYPE`` card as
41 ``header["LSST BUTLER DATASETTYPE"]``.
42 """
43 dataset_type: str | None = None
44 with astropy.io.fits.open(path) as hdul:
45 for hdu in hdul:
46 value = hdu.header.get("LSST BUTLER DATASETTYPE")
47 if value:
48 dataset_type = str(value)
49 break
50 if dataset_type is None:
51 return None
52 if dataset_type.endswith("visit_image"):
53 return "visit_image"
54 if dataset_type == "difference_image": 54 ↛ 55line 54 didn't jump to line 55 because the condition on line 54 was never true
55 return "difference_image"
56 if "coadd" in dataset_type:
57 return "cell_coadd"
58 return None
61def _load_skymap(skymap: str | None, butler: str | None, collection: str | None, skymap_name: str) -> Any:
62 """Load a skymap object from a pickle path or a butler repository."""
63 if skymap is not None:
64 import pickle
66 with open(skymap, "rb") as stream:
67 return pickle.load(stream)
68 if butler is not None:
69 if collection is None:
70 raise click.ClickException("--butler also requires --collection (the skymap's collection).")
71 from lsst.daf.butler import Butler
73 with Butler.from_config(butler) as repo:
74 return repo.get("skyMap", skymap=skymap_name, collections=collection)
75 raise click.ClickException("Converting a cell coadd requires --skymap (a pickled skymap) or --butler.")
78def _read_legacy(
79 input: str,
80 legacy_type: str,
81 skymap: str | None,
82 butler: str | None,
83 collection: str | None,
84 preserve_quantization: bool = False,
85) -> VisitImage | CellCoadd:
86 """Read a legacy FITS file into the corresponding lsst.images object."""
87 if legacy_type == "visit_image":
88 from .. import VisitImage
90 return VisitImage.read_legacy(input, preserve_quantization=preserve_quantization)
91 if legacy_type == "difference_image": 91 ↛ 92line 91 didn't jump to line 92 because the condition on line 91 was never true
92 from .. import DifferenceImage
94 return DifferenceImage.read_legacy(input, preserve_quantization=preserve_quantization)
95 if legacy_type == "cell_coadd": 95 ↛ 109line 95 didn't jump to line 109 because the condition on line 95 was always true
96 from lsst.cell_coadds import MultipleCellCoadd
98 from .. import get_legacy_deep_coadd_mask_planes
99 from ..cells import CellCoadd
101 legacy = MultipleCellCoadd.read_fits(input)
102 sky = _load_skymap(skymap, butler, collection, legacy.identifiers.skymap)
103 tract_info = sky[legacy.identifiers.tract]
104 return CellCoadd.from_legacy(
105 legacy,
106 tract_info=tract_info,
107 plane_map=get_legacy_deep_coadd_mask_planes(),
108 )
109 raise click.ClickException(f"Conversion of {legacy_type!r} is not yet implemented.")
112@click.command(name="convert")
113@click.argument("input", type=click.Path(exists=True, dir_okay=False))
114@click.argument("output", type=click.Path(dir_okay=False))
115@click.option(
116 "--type",
117 "type_",
118 type=click.Choice(_LEGACY_TYPES),
119 default=None,
120 help="Legacy input type; overrides auto-detection.",
121)
122@click.option(
123 "--skymap",
124 type=click.Path(exists=True, dir_okay=False),
125 default=None,
126 help="Pickled skymap (required for cell coadds unless --butler is given).",
127)
128@click.option(
129 "--butler",
130 default=None,
131 help="Butler repository to resolve the skymap (cell coadds only).",
132)
133@click.option(
134 "--collection",
135 default=None,
136 help="Butler collection holding the skymap (required with --butler).",
137)
138@click.option("--overwrite", is_flag=True, default=False, help="Overwrite OUTPUT if it exists.")
139@click.option(
140 "--preserve-quantization/--no-preserve-quantization",
141 default=True,
142 help=(
143 "Preserve quantization-compressed pixel values so they can be written "
144 "back out losslessly (visit images only). On by default."
145 ),
146)
147def convert(
148 input: str,
149 output: str,
150 type_: str | None,
151 skymap: str | None,
152 butler: str | None,
153 collection: str | None,
154 overwrite: bool,
155 preserve_quantization: bool,
156) -> None:
157 """Convert a legacy FITS file to a new lsst.images format.
159 The output format is chosen from OUTPUT's extension.
160 """
161 try:
162 backend = backend_for_path(output)
163 except ValueError as err:
164 raise click.ClickException(str(err)) from None
166 legacy_type = type_ or detect_legacy_type(input)
167 if legacy_type is None: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 raise click.ClickException(f"Could not determine the legacy type of {input!r}; pass --type.")
170 # The flag is on by default, so only object when the user explicitly set
171 # it for a type that does not support it (only visit images do).
172 if legacy_type != "visit_image":
173 source = click.get_current_context().get_parameter_source("preserve_quantization")
174 if source == ParameterSource.COMMANDLINE:
175 raise click.ClickException(
176 f"--preserve-quantization is only valid for visit images, not {legacy_type!r}."
177 )
178 preserve_quantization = False
180 output_abs = os.path.realpath(output)
181 if os.path.realpath(input) == output_abs:
182 raise click.ClickException("INPUT and OUTPUT must be different paths.")
184 if os.path.exists(output_abs) and not overwrite: 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true
185 raise click.ClickException(f"{output!r} already exists; pass --overwrite to replace it.")
187 try:
188 obj = _read_legacy(input, legacy_type, skymap, butler, collection, preserve_quantization)
189 except click.ClickException:
190 raise
191 except ImportError as err:
192 raise click.ClickException(
193 f"Reading a legacy {legacy_type} requires Rubin packages that are not installed: {err}"
194 ) from None
196 # Write to a temporary file in the output's directory and move it into
197 # place only after a successful write, so a read or write failure never
198 # destroys an existing OUTPUT (the backends refuse to overwrite, so the
199 # temporary path must not already exist).
200 output_dir = os.path.dirname(output_abs)
201 with tempfile.TemporaryDirectory(dir=output_dir) as staging:
202 staged = os.path.join(staging, os.path.basename(output_abs))
203 write(obj, staged)
204 os.replace(staged, output_abs)
205 click.echo(f"Wrote {output} ({backend.name}, {legacy_type}).")