Coverage for python/lsst/images/cli/_convert.py: 64%

114 statements  

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

12 

13__all__ = ("convert",) 

14 

15import os 

16import tempfile 

17from typing import TYPE_CHECKING, Any 

18 

19import astropy.io.fits 

20import click 

21from click.core import ParameterSource 

22 

23from ..serialization import backend_for_path, write_archive 

24 

25if TYPE_CHECKING: 

26 from .. import VisitImage 

27 from ..cells import CellCoadd 

28 

29_LEGACY_TYPES = ("visit_image", "cell_coadd") 

30 

31 

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. 

36 

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 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 

64 

65 

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 

70 

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 

77 

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.") 

81 

82 

83def _fetch_exposure_record(butler: str, instrument: str, exposure: int) -> Any: 

84 """Fetch the ``exposure`` dimension record from a butler repository. 

85 

86 Raises 

87 ------ 

88 click.ClickException 

89 If the record is missing or ambiguous. 

90 """ 

91 from lsst.daf.butler import Butler 

92 

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] 

105 

106 

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 

139 

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 

145 

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 

151 

152 from .. import get_legacy_deep_coadd_mask_planes 

153 from ..cells import CellCoadd 

154 

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.") 

164 

165 

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. 

236 

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 

243 

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.") 

247 

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 

257 

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.") 

261 

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.") 

264 

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 

282 

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}).")