Coverage for python/lsst/images/cli/_convert.py: 64%
114 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-10 09:31 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-10 09:31 +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.
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_archive
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"]``.
43 Parameters
44 ----------
45 path
46 Path to the legacy FITS file to inspect.
47 """
48 dataset_type: str | None = None
49 with astropy.io.fits.open(path) as hdul:
50 for hdu in hdul:
51 value = hdu.header.get("LSST BUTLER DATASETTYPE")
52 if value:
53 dataset_type = str(value)
54 break
55 if dataset_type is None:
56 return None
57 if dataset_type.endswith("visit_image"):
58 return "visit_image"
59 if dataset_type == "difference_image": 59 ↛ 60line 59 didn't jump to line 60 because the condition on line 59 was never true
60 return "difference_image"
61 if "coadd" in dataset_type:
62 return "cell_coadd"
63 return None
66def _load_skymap(skymap: str | None, butler: str | None, collection: str | None, skymap_name: str) -> Any:
67 """Load a skymap object from a pickle path or a butler repository."""
68 if skymap is not None:
69 import pickle
71 with open(skymap, "rb") as stream:
72 return pickle.load(stream)
73 if butler is not None:
74 if collection is None:
75 raise click.ClickException("--butler also requires --collection (the skymap's collection).")
76 from lsst.daf.butler import Butler
78 with Butler.from_config(butler) as repo:
79 return repo.get("skyMap", skymap=skymap_name, collections=collection)
80 raise click.ClickException("Converting a cell coadd requires --skymap (a pickled skymap) or --butler.")
83def _fetch_exposure_record(butler: str, instrument: str, exposure: int) -> Any:
84 """Fetch the ``exposure`` dimension record from a butler repository.
86 Raises
87 ------
88 click.ClickException
89 If the record is missing or ambiguous.
90 """
91 from lsst.daf.butler import Butler
93 with Butler.from_config(butler) as repo:
94 records = list(
95 repo.registry.queryDimensionRecords(
96 "exposure", dataId={"instrument": instrument, "exposure": exposure}
97 )
98 )
99 if not records:
100 raise click.ClickException(
101 f"No exposure dimension record for instrument {instrument!r}, exposure {exposure} in {butler!r}."
102 )
103 assert len(records) == 1
104 return records[0]
107def _read_legacy(
108 input: str,
109 legacy_type: str,
110 skymap: str | None,
111 butler: str | None,
112 collection: str | None,
113 preserve_quantization: bool = False,
114 instrument: str | None = None,
115 exposure: int | None = None,
116) -> VisitImage | CellCoadd:
117 """Read a legacy FITS file into the corresponding lsst.images object."""
118 exposure_record: Any = None
119 if legacy_type in ("visit_image", "difference_image"):
120 if (butler is None) != (exposure is None): 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true
121 raise click.ClickException("--butler and --exposure must be given together.")
122 if butler is not None and exposure is not None: 122 ↛ 129line 122 didn't jump to line 129 because the condition on line 122 was always true
123 if instrument is None: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 raise click.ClickException(
125 "--butler and --exposure require --instrument, which together"
126 " with --exposure identifies the exposure dimension record."
127 )
128 exposure_record = _fetch_exposure_record(butler, instrument, exposure)
129 elif instrument is not None:
130 raise click.ClickException(
131 "--instrument identifies the exposure dimension record together"
132 " with --butler and --exposure; without them it has no effect."
133 )
134 # With no --butler/--exposure there is no exposure dimension record;
135 # the conversion proceeds with a degraded ObservationInfo (and a
136 # warning from lsst.images).
137 if legacy_type == "visit_image":
138 from .. import VisitImage
140 return VisitImage.read_legacy(
141 input, preserve_quantization=preserve_quantization, exposure_record=exposure_record
142 )
143 if legacy_type == "difference_image": 143 ↛ 144line 143 didn't jump to line 144 because the condition on line 143 was never true
144 from .. import DifferenceImage
146 return DifferenceImage.read_legacy(
147 input, preserve_quantization=preserve_quantization, exposure_record=exposure_record
148 )
149 if legacy_type == "cell_coadd": 149 ↛ 163line 149 didn't jump to line 163 because the condition on line 149 was always true
150 from lsst.cell_coadds import MultipleCellCoadd
152 from .. import get_legacy_deep_coadd_mask_planes
153 from ..cells import CellCoadd
155 legacy = MultipleCellCoadd.read_fits(input)
156 sky = _load_skymap(skymap, butler, collection, legacy.identifiers.skymap)
157 tract_info = sky[legacy.identifiers.tract]
158 return CellCoadd.from_legacy_cell_coadd(
159 legacy,
160 tract_info=tract_info,
161 plane_map=get_legacy_deep_coadd_mask_planes(),
162 )
163 raise click.ClickException(f"Conversion of {legacy_type!r} is not yet implemented.")
166@click.command(name="convert")
167@click.argument("input", type=click.Path(exists=True, dir_okay=False))
168@click.argument("output", type=click.Path(dir_okay=False))
169@click.option(
170 "--type",
171 "type_",
172 type=click.Choice(_LEGACY_TYPES),
173 default=None,
174 help="Legacy input type; overrides auto-detection.",
175)
176@click.option(
177 "--skymap",
178 type=click.Path(exists=True, dir_okay=False),
179 default=None,
180 help="Pickled skymap (required for cell coadds unless --butler is given).",
181)
182@click.option(
183 "--butler",
184 default=None,
185 help=(
186 "Butler repository: for the skymap (cell coadds) or for the exposure"
187 " dimension record (visit and difference images)."
188 ),
189)
190@click.option(
191 "--collection",
192 default=None,
193 help="Butler collection holding the skymap (required with --butler).",
194)
195@click.option(
196 "--instrument",
197 default=None,
198 help=(
199 "Instrument name; with --butler and --exposure, identifies the"
200 " exposure dimension record. It is an error without them."
201 ),
202)
203@click.option(
204 "--exposure",
205 type=int,
206 default=None,
207 help=(
208 "Exposure ID; with --butler and --instrument, identifies the exposure"
209 " dimension record. Without them, visit and difference images are"
210 " converted with a degraded ObservationInfo (fields the record would"
211 " supply are None)."
212 ),
213)
214@click.option("--overwrite", is_flag=True, default=False, help="Overwrite OUTPUT if it exists.")
215@click.option(
216 "--preserve-quantization/--no-preserve-quantization",
217 default=True,
218 help=(
219 "Preserve quantization-compressed pixel values so they can be written "
220 "back out losslessly (visit images only). On by default."
221 ),
222)
223def convert(
224 input: str,
225 output: str,
226 type_: str | None,
227 skymap: str | None,
228 butler: str | None,
229 collection: str | None,
230 instrument: str | None,
231 exposure: int | None,
232 overwrite: bool,
233 preserve_quantization: bool,
234) -> None:
235 """Convert a legacy FITS file to a new lsst.images format.
237 The output format is chosen from OUTPUT's extension.
238 """
239 try:
240 backend = backend_for_path(output)
241 except ValueError as err:
242 raise click.ClickException(str(err)) from None
244 legacy_type = type_ or detect_legacy_type(input)
245 if legacy_type is None: 245 ↛ 246line 245 didn't jump to line 246 because the condition on line 245 was never true
246 raise click.ClickException(f"Could not determine the legacy type of {input!r}; pass --type.")
248 # The flag is on by default, so only object when the user explicitly set
249 # it for a type that does not support it (only visit images do).
250 if legacy_type != "visit_image":
251 source = click.get_current_context().get_parameter_source("preserve_quantization")
252 if source == ParameterSource.COMMANDLINE:
253 raise click.ClickException(
254 f"--preserve-quantization is only valid for visit images, not {legacy_type!r}."
255 )
256 preserve_quantization = False
258 output_abs = os.path.realpath(output)
259 if os.path.realpath(input) == output_abs:
260 raise click.ClickException("INPUT and OUTPUT must be different paths.")
262 if os.path.exists(output_abs) and not overwrite: 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true
263 raise click.ClickException(f"{output!r} already exists; pass --overwrite to replace it.")
265 try:
266 obj = _read_legacy(
267 input,
268 legacy_type,
269 skymap,
270 butler,
271 collection,
272 preserve_quantization,
273 instrument=instrument,
274 exposure=exposure,
275 )
276 except click.ClickException:
277 raise
278 except ImportError as err:
279 raise click.ClickException(
280 f"Reading a legacy {legacy_type} requires Rubin packages that are not installed: {err}"
281 ) from None
283 # Write to a temporary file in the output's directory and move it into
284 # place only after a successful write, so a read or write failure never
285 # destroys an existing OUTPUT (the backends refuse to overwrite, so the
286 # temporary path must not already exist).
287 output_dir = os.path.dirname(output_abs)
288 with tempfile.TemporaryDirectory(dir=output_dir) as staging:
289 staged = os.path.join(staging, os.path.basename(output_abs))
290 write_archive(obj, staged)
291 os.replace(staged, output_abs)
292 click.echo(f"Wrote {output} ({backend.name}, {legacy_type}).")