Coverage for python/lsst/images/tests/_minify_for_fixtures.py: 0%

175 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-06 08:34 +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"""Minify a real on-disk archive into a small JSON test fixture. 

13 

14Reads a FITS or NDF file via the appropriate input archive, takes a 

15small subset of the in-memory object, and writes JSON via 

16``JsonOutputArchive``. Used to populate ``tests/data/schema_v1/legacy/`` 

17with derived-from-real test data that exercises the full read path 

18including the absence-of-stamp legacy default. 

19 

20Per top-level type the subset rule is: 

21 

22 VisitImage Crop the image/mask/variance planes to a small (~16x16) 

23 corner, keeping the real single-instance structures (PSF 

24 such as Piff, detector frames) that synthetic fixtures 

25 cannot reproduce -- the whole point of deriving a fixture 

26 from real data. Homogeneous repeated collections (detector 

27 amplifiers, aperture-correction entries) are trimmed to a 

28 representative few, since one entry exercises the schema as 

29 well as sixteen. The projection's pixel->sky mapping is 

30 replaced by its linear (affine) approximation over the kept 

31 box: a real TAN-SIP WCS serializes as a ~100 KB AST 

32 polynomial dump, but over a 16x16 box it is linear to far 

33 below a pixel, so the affine form is schema-identical and 

34 orders of magnitude smaller. A Piff PSF's field 

35 interpolation is truncated to a low order (the order-4 

36 solution table is ~225 KB; order 0, the field-averaged PSF, 

37 is schema-identical and ~13x smaller). 

38 

39 CellCoadd Crop to a small block of cells (preferring a block that 

40 includes a missing cell so the sparse-grid path is 

41 exercised) and then *morph* that block onto a tiny cell 

42 grid: each cell's planes are decimated from the native 

43 cell size down to a few pixels and re-stitched, and the 

44 PSF kernels are cropped to a small odd window. The grid 

45 topology (number of cells, the missing-cell set, band, 

46 mask schema and provenance shape) is preserved; the pixel 

47 values and WCS are *not* physically meaningful. This is 

48 the "morph cells in place" fallback: it sidesteps the 

49 outer-ring problem (inputs/PSFs that overlap kept cells) 

50 by rebuilding a self-consistent miniature coadd rather 

51 than trying to carve an accurate subset out of the real 

52 one. An accurate per-cell subset would inline several 

53 150x150 planes per cell and produce multi-megabyte JSON, 

54 which defeats the purpose of a fixture. 

55 

56Run interactively (CellCoadd works with just this package installed; 

57VisitImage needs a full Rubin environment so the real PSF can be read):: 

58 

59 python -c " 

60 from lsst.images.tests._minify_for_fixtures import minify 

61 minify('cell_example.fits', 'tests/data/schema_v1/legacy/cell_coadd.json') 

62 minify('dp1.fits', 'tests/data/schema_v1/legacy/visit_image_dp1.json') 

63 minify('dp2.fits', 'tests/data/schema_v1/legacy/visit_image_dp2.json') 

64 " 

65 

66The helper is invoked manually by developers when they have a real 

67on-disk file to derive from; it is not exercised by CI. 

68""" 

69 

70from __future__ import annotations 

71 

72__all__ = ("minify",) 

73 

74import json 

75import os 

76from collections.abc import Callable 

77from typing import Any 

78 

79import numpy as np 

80 

81from .. import DifferenceImage, VisitImage 

82from .. import json as images_json 

83from .._cell_grid import CellGrid, CellGridBounds, CellIJ, PatchDefinition 

84from .._geom import YX, Box 

85from .._image import Image 

86from .._mask import Mask 

87from .._transforms import Projection, TractFrame, Transform 

88from .._transforms._ast import PolyMap 

89from ..cells import CellCoadd 

90from ..cells._provenance import CoaddProvenance 

91from ..cells._psf import CellPointSpreadFunction 

92from ..psfs import PiffWrapper 

93from ..serialization import backend_for_path, read 

94from ._creation import make_random_projection 

95 

96# Default morph parameters for CellCoadd. ``CELL_SIZE`` should divide the 

97# native cell size evenly; ``KERNEL_SIZE`` must be odd. ``MAX_INPUTS`` caps 

98# the provenance ``inputs`` table (a real coadd has hundreds of visits); the 

99# full provenance schema is already exercised by the ``coadd_provenance`` 

100# fixture, so here we keep just enough rows to be representative. 

101_CELL_SIZE = 6 

102_KERNEL_SIZE = 5 

103_MAX_INPUTS = 6 

104 

105# Default trim parameters for VisitImage. Amplifiers and aperture-correction 

106# entries are homogeneous collections, so a couple of each cover the schema 

107# just as well as the full set (a real detector has 16 amplifiers and dozens 

108# of aperture corrections). 

109_MAX_AMPLIFIERS = 2 

110_MAX_APERTURE_CORRECTIONS = 2 

111 

112# Field-interpolation order to truncate a Piff PSF to (the solution table of a 

113# real order-4 PixelGrid PSF dominates the fixture at ~225 KB). Order 0 is the 

114# field-averaged PSF; set to `None` to leave the PSF untouched. 

115_PSF_INTERP_ORDER = 0 

116 

117# Maximum permitted deviation (radians) when approximating a projection's 

118# pixel->sky mapping with an affine one. Over a fixture's tiny box the real 

119# mapping is linear well below this, so the fit always succeeds. 

120_PROJECTION_LINEAR_APPROX_TOL = 1e-8 

121 

122 

123def minify(in_path: str, out_path: str, *, schema_name: str | None = None) -> None: 

124 """Read a real archive at ``in_path``, take a small subset, and write JSON. 

125 

126 Parameters 

127 ---------- 

128 in_path 

129 Path to a FITS (``.fits`` / ``.fits.gz``) or NDF (``.sdf`` / ``.ndf``) 

130 file to read. 

131 out_path 

132 Path to the JSON fixture to write. The parent directory is 

133 created if it does not exist. 

134 schema_name 

135 Top-level schema name (e.g. ``"visit_image"`` or ``"cell_coadd"``). 

136 If `None`, it is auto-detected from the file. 

137 

138 Raises 

139 ------ 

140 ValueError 

141 If the file extension is not recognised. 

142 NotImplementedError 

143 If the top-level type is not one this helper knows how to subset. 

144 """ 

145 backend = backend_for_path(in_path) 

146 if schema_name is None: 

147 schema_name = backend.input_archive.get_basic_info(in_path).schema_name 

148 

149 cls, subsetter = _dispatch(schema_name) 

150 

151 obj: Any = read(in_path, cls) 

152 subset = subsetter(obj) 

153 

154 tree = images_json.write(subset) 

155 dumped = tree.model_dump(mode="json") 

156 os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True) 

157 with open(out_path, "w") as stream: 

158 stream.write(json.dumps(dumped, indent=2, sort_keys=False) + "\n") 

159 

160 

161def _dispatch(schema_name: str) -> tuple[type, Callable[[Any], Any]]: 

162 """Return the ``(class, subsetter)`` pair for a top-level schema name.""" 

163 registry: dict[str, tuple[type, Callable[[Any], Any]]] = { 

164 "visit_image": (VisitImage, _subset_visit_image), 

165 "difference_image": (DifferenceImage, _subset_visit_image), 

166 "cell_coadd": (CellCoadd, _subset_cell_coadd), 

167 } 

168 try: 

169 return registry[schema_name] 

170 except KeyError: 

171 raise NotImplementedError( 

172 f"No minify rule for schema {schema_name!r}; supported: {sorted(registry)}." 

173 ) from None 

174 

175 

176# -- VisitImage ------------------------------------------------------------ 

177 

178 

179def _subset_visit_image( 

180 visit_like_image: VisitImage | DifferenceImage, 

181 *, 

182 size: int = 16, 

183 max_amplifiers: int = _MAX_AMPLIFIERS, 

184 max_aperture_corrections: int = _MAX_APERTURE_CORRECTIONS, 

185 linearize_projection: bool = True, 

186 projection_tol: float = _PROJECTION_LINEAR_APPROX_TOL, 

187 psf_interp_order: int | None = _PSF_INTERP_ORDER, 

188) -> VisitImage | DifferenceImage: 

189 """Crop a VisitImage's pixel planes to a small corner and trim its 

190 homogeneous collections. 

191 

192 The detector frames are a single structure carried through unchanged by 

193 ``__getitem__``. The detector's amplifiers and the aperture-correction map 

194 are repeated, schema-identical entries, so they are trimmed to a 

195 representative few. The projection's pixel->sky mapping is replaced by its 

196 affine approximation over the kept box (see ``_linear_approx_projection``) 

197 unless ``linearize_projection`` is false. A Piff PSF's field interpolation 

198 is truncated to ``psf_interp_order`` (see ``_simplify_piff_psf``) unless 

199 that is `None`. 

200 """ 

201 bbox = visit_like_image.bbox 

202 y0 = bbox.y.start 

203 x0 = bbox.x.start 

204 y1 = min(y0 + size, bbox.y.stop) 

205 x1 = min(x0 + size, bbox.x.stop) 

206 subset = visit_like_image[Box.factory[y0:y1, x0:x1]] 

207 

208 # ``subset`` is a fresh throwaway object whose detector amplifier list, 

209 # aperture-correction map and PSF are live, mutable components. Trim them 

210 # in place through the public accessors rather than reaching for private 

211 # attributes. 

212 del subset.detector.amplifiers[max_amplifiers:] 

213 aperture_corrections = subset.aperture_corrections 

214 for key in list(aperture_corrections)[max_aperture_corrections:]: 

215 del aperture_corrections[key] 

216 if psf_interp_order is not None and isinstance(subset.psf, PiffWrapper): 

217 _simplify_piff_psf(subset.psf, order=psf_interp_order) 

218 

219 if not linearize_projection or subset.projection is None: 

220 return subset 

221 

222 # The pixel planes carry the projection immutably (there is no public 

223 # setter for it), so install the affine approximation by rebuilding the 

224 # VisitImage from its public components with re-viewed planes. Only the 

225 # image plane's projection is actually serialized, but keeping all three 

226 # consistent avoids surprises. 

227 linear = _linear_approx_projection(subset.projection, subset.image.bbox, tol=projection_tol) 

228 return type(visit_like_image)( 

229 subset.image.view(projection=linear), 

230 mask=subset.mask.view(projection=linear), 

231 variance=subset.variance.view(projection=linear), 

232 projection=linear, 

233 psf=subset.psf, 

234 obs_info=subset.obs_info, 

235 bounds=subset.bounds, 

236 summary_stats=subset.summary_stats, 

237 detector=subset.detector, 

238 photometric_scaling=subset.photometric_scaling, 

239 aperture_corrections=subset.aperture_corrections, 

240 backgrounds=subset.backgrounds, 

241 band=subset.band, 

242 metadata=subset.metadata, 

243 ) 

244 

245 

246def _linear_approx_projection(projection: Projection, bbox: Box, *, tol: float) -> Projection: 

247 """Return a copy of ``projection`` whose pixel->sky mapping is replaced by 

248 its best linear (affine) approximation over ``bbox``. 

249 

250 Real WCS mappings (e.g. TAN-SIP) serialize as large AST polynomial dumps. 

251 Over the small box of a fixture they are linear to far below a pixel, so 

252 an affine approximation is schema-identical but orders of magnitude 

253 smaller. The result carries no FITS approximation (the affine is itself 

254 trivially FITS-representable). 

255 

256 This is written as a self-contained ``projection -> projection`` transform 

257 so it can be promoted to a public ``Projection.linear_approx(bbox, tol)`` 

258 method later with essentially no change. It assumes a 2-D pixel->sky 

259 mapping. 

260 

261 Parameters 

262 ---------- 

263 projection 

264 The projection to approximate. 

265 bbox 

266 Box (in pixel coordinates) over which the approximation must hold. 

267 tol 

268 Maximum permitted deviation from linearity, as a Cartesian 

269 displacement in the output (sky, radians) coordinates. AST raises 

270 ``RuntimeError`` if no fit within ``tol`` exists. 

271 """ 

272 transform = projection.pixel_to_sky_transform 

273 mapping = transform._ast_mapping 

274 lbnd = [bbox.x.start, bbox.y.start] 

275 ubnd = [bbox.x.stop, bbox.y.stop] 

276 # linearApprox yields [offsets; Jacobian] as a (1 + n_out, n_in) array on 

277 # both AST backends (astshim returns the flat buffer in the same order, so 

278 # the reshape recovers the same layout the starlink-pyast bridge returns). 

279 fit = np.asarray(mapping.linearApprox(lbnd, ubnd, tol), dtype=float).reshape(3, 2) 

280 offset = fit[0] # (lon0, lat0), radians 

281 jacobian = fit[1:] # jacobian[i, j] = d(out_i) / d(in_j), in = (x, y) 

282 jacobian_inv = np.linalg.inv(jacobian) 

283 forward = _affine_polymap_coeffs(jacobian, offset) 

284 inverse = _affine_polymap_coeffs(jacobian_inv, -jacobian_inv @ offset) 

285 affine = Transform( 

286 transform.in_frame, 

287 transform.out_frame, 

288 PolyMap(forward, inverse), 

289 in_bounds=projection.pixel_bounds, 

290 ) 

291 return affine.as_projection() 

292 

293 

294def _affine_polymap_coeffs(matrix: np.ndarray, offset: np.ndarray) -> np.ndarray: 

295 """Build AST ``PolyMap`` coefficients for ``out = matrix @ in + offset``. 

296 

297 Each row is ``[coefficient, output_axis (1-based), power_of_in_1, ...]``; 

298 one constant row plus one row per input per output axis. Returned as a 

299 float array, which is the form both AST backends require. 

300 """ 

301 n = len(offset) 

302 coeffs: list[list[float]] = [] 

303 for i in range(n): 

304 coeffs.append([float(offset[i]), i + 1, *([0] * n)]) 

305 for j in range(n): 

306 powers = [1 if k == j else 0 for k in range(n)] 

307 coeffs.append([float(matrix[i][j]), i + 1, *powers]) 

308 return np.array(coeffs, dtype=float) 

309 

310 

311def _simplify_piff_psf(psf: PiffWrapper, *, order: int) -> None: 

312 """Truncate a Piff PSF's field interpolation to ``order``, in place. 

313 

314 A real Piff PSF interpolates a per-pixel model across the focal plane with 

315 a high-order 2-D polynomial; that solution table dominates the serialized 

316 size (a 25x25 PixelGrid x order-4 polynomial is ~225 KB). Truncating to 

317 ``order`` keeps only the lowest-order field terms -- order 0 is the 

318 field-averaged PSF -- which is schema-identical but far smaller, and needs 

319 no stars or refit (the fitted ``stars`` are already dropped on serialize). 

320 

321 Only ``BasisPolynomial``-interpolated PSFs are handled; anything else (a 

322 higher-order model already at/under ``order``, a non-polynomial interp) is 

323 left untouched. 

324 

325 ``piff`` is imported lazily because it is an optional dependency; this is 

326 only ever reached when the PSF being simplified is itself a Piff PSF. 

327 """ 

328 interp = getattr(psf.piff_psf, "interp", None) 

329 if interp is None or type(interp).__name__ != "BasisPolynomial" or interp.q is None: 

330 return 

331 if order >= max(interp._orders): 

332 return 

333 

334 from piff import BasisPolynomial 

335 

336 # ``q`` has one column per active basis term; the terms are the True cells 

337 # of ``_mask`` in row-major (i, j) order (see BasisPolynomial.basis). Make 

338 # the same ordering for a lower-order interp and copy the shared columns. 

339 def _terms(orders: tuple[int, ...], mask: np.ndarray) -> list[tuple[int, ...]]: 

340 grids = np.meshgrid(*[np.arange(o + 1) for o in orders], indexing="ij") 

341 return list(zip(*(grid[mask].tolist() for grid in grids))) 

342 

343 old_terms = _terms(interp._orders, interp._mask) 

344 truncated = BasisPolynomial(order, keys=list(interp._keys)) 

345 new_terms = _terms(truncated._orders, truncated._mask) 

346 column_of = {term: index for index, term in enumerate(old_terms)} 

347 truncated.q = np.ascontiguousarray(interp.q[:, [column_of[term] for term in new_terms]]) 

348 psf.piff_psf.interp = truncated 

349 

350 

351# -- CellCoadd ------------------------------------------------------------- 

352 

353 

354def _subset_cell_coadd( 

355 cell_coadd: CellCoadd, 

356 *, 

357 cell_size: int = _CELL_SIZE, 

358 kernel_size: int = _KERNEL_SIZE, 

359 max_inputs: int = _MAX_INPUTS, 

360) -> CellCoadd: 

361 """Crop a CellCoadd to a small block of cells and morph it onto a tiny 

362 grid (see the module docstring for the rationale). 

363 """ 

364 if kernel_size % 2 == 0: 

365 raise ValueError(f"kernel_size must be odd, got {kernel_size}.") 

366 

367 # 1. Pick a block of (up to) 2x2 cells, preferring one that contains a 

368 # missing cell so the sparse-grid path is exercised. Falls back to the 

369 # first available block when the coadd is fully dense. 

370 block = cell_coadd[_choose_block_bbox(cell_coadd)] 

371 

372 grid = block.grid 

373 cs = grid.cell_shape 

374 start = block.bounds.grid_start 

375 stop = block.bounds.grid_stop 

376 n_i = stop.i - start.i 

377 n_j = stop.j - start.j 

378 

379 # 2. Build a tiny full-patch grid with the same cell *count* as the 

380 # original patch but ``cell_size`` pixels per cell, anchored at (0, 0). 

381 full_shape = grid.grid_shape 

382 new_grid = CellGrid( 

383 bbox=Box.factory[0 : full_shape.i * cell_size, 0 : full_shape.j * cell_size], 

384 cell_shape=YX(y=cell_size, x=cell_size), 

385 ) 

386 new_block_bbox = _scale_box_to_grid(block.bbox, grid, cell_size) 

387 new_bounds = CellGridBounds(grid=new_grid, bbox=new_block_bbox, missing=block.bounds.missing) 

388 

389 # 3. Decimate each plane. Because the block's planes tile the kept cells 

390 # contiguously, a uniform stride that maps one native cell onto 

391 # ``cell_size`` samples is equivalent to per-cell decimation. 

392 step_y = max(1, cs.y // cell_size) 

393 step_x = max(1, cs.x // cell_size) 

394 ny = n_i * cell_size 

395 nx = n_j * cell_size 

396 

397 def shrink2d(array: np.ndarray) -> np.ndarray: 

398 return np.ascontiguousarray(array[::step_y, ::step_x][:ny, :nx]) 

399 

400 def shrink3d(array: np.ndarray) -> np.ndarray: 

401 return np.ascontiguousarray(array[::step_y, ::step_x, :][:ny, :nx, :]) 

402 

403 # 4. Synthetic-but-valid projection over the tiny tract frame. 

404 rng = np.random.default_rng(0) 

405 tract_frame = TractFrame(skymap=cell_coadd.skymap, tract=cell_coadd.tract, bbox=new_grid.bbox) 

406 projection = make_random_projection(rng, tract_frame, new_block_bbox) 

407 

408 unit = cell_coadd.unit 

409 image = Image(shrink2d(block.image.array), bbox=new_block_bbox, unit=unit, projection=projection) 

410 mask = Mask(shrink3d(block.mask.array), schema=block.mask.schema, bbox=new_block_bbox) 

411 variance = Image(shrink2d(block.variance.array), bbox=new_block_bbox, unit=unit**2) 

412 mask_fractions = { 

413 name: Image(shrink2d(plane.array), bbox=new_block_bbox) 

414 for name, plane in block.mask_fractions.items() 

415 } 

416 noise_realizations = [ 

417 Image(shrink2d(plane.array), bbox=new_block_bbox) for plane in block.noise_realizations 

418 ] 

419 

420 # 5. Crop the PSF kernels to a small odd window about their centre, 

421 # keeping the (n_i, n_j) per-cell structure and NaN-for-missing cells. 

422 psf_array = block.psf._array 

423 ky, kx = psf_array.shape[2:] 

424 half = kernel_size // 2 

425 cy, cx = ky // 2, kx // 2 

426 psf_array = np.ascontiguousarray(psf_array[:, :, cy - half : cy + half + 1, cx - half : cx + half + 1]) 

427 psf = CellPointSpreadFunction(psf_array, bounds=new_bounds) 

428 

429 # 6. Patch geometry scaled onto the tiny grid; provenance and backgrounds 

430 # are reused as-is (provenance is cell-indexed and already subset). 

431 patch = PatchDefinition( 

432 id=block.patch.id, 

433 index=block.patch.index, 

434 inner_bbox=_scale_box_to_grid(block.patch.inner_bbox, grid, cell_size), 

435 cells=new_grid, 

436 ) 

437 

438 provenance = block._provenance 

439 if provenance is not None: 

440 provenance = _trim_provenance(provenance, max_inputs=max_inputs) 

441 

442 return CellCoadd( 

443 image, 

444 mask=mask, 

445 variance=variance, 

446 mask_fractions=mask_fractions, 

447 noise_realizations=noise_realizations, 

448 projection=projection, 

449 band=block.band, 

450 psf=psf, 

451 patch=patch, 

452 provenance=provenance, 

453 backgrounds=block._backgrounds, 

454 ) 

455 

456 

457def _trim_provenance(provenance: CoaddProvenance, *, max_inputs: int) -> CoaddProvenance: 

458 """Cap the provenance ``inputs`` table to ``max_inputs`` rows and drop any 

459 contributions that reference the removed inputs. 

460 

461 The two-table structure, polygon arrays and string dictionary-compression 

462 paths are all preserved; only the number of contributing visits shrinks. 

463 """ 

464 inputs = provenance.inputs 

465 if len(inputs) <= max_inputs: 

466 return provenance 

467 kept_inputs = inputs[:max_inputs] 

468 keys = {(str(row["instrument"]), int(row["visit"]), int(row["detector"])) for row in kept_inputs} 

469 contributions = provenance.contributions 

470 mask = np.array( 

471 [ 

472 (str(instrument), int(visit), int(detector)) in keys 

473 for instrument, visit, detector in zip( 

474 contributions["instrument"], contributions["visit"], contributions["detector"] 

475 ) 

476 ], 

477 dtype=bool, 

478 ) 

479 return CoaddProvenance(inputs=kept_inputs, contributions=contributions[mask]) 

480 

481 

482def _choose_block_bbox(cell_coadd: CellCoadd) -> Box: 

483 """Return the pixel bbox of a (up to) 2x2 block of cells to keep. 

484 

485 Prefers a block containing a missing cell; otherwise the block anchored at 

486 the start of the populated region. Never raises if there is no missing 

487 cell. 

488 """ 

489 bounds = cell_coadd.bounds 

490 grid = bounds.grid 

491 start = bounds.grid_start 

492 stop = bounds.grid_stop 

493 span_i = min(2, stop.i - start.i) 

494 span_j = min(2, stop.j - start.j) 

495 

496 target = next(iter(sorted(bounds.missing)), None) 

497 if target is not None: 

498 # Anchor the block so it includes the missing cell, clamped to the 

499 # populated index range. 

500 i0 = min(max(target.i, start.i), stop.i - span_i) 

501 j0 = min(max(target.j, start.j), stop.j - span_j) 

502 else: 

503 i0 = start.i 

504 j0 = start.j 

505 

506 lo = grid.bbox_of(CellIJ(i=i0, j=j0)) 

507 hi = grid.bbox_of(CellIJ(i=i0 + span_i - 1, j=j0 + span_j - 1)) 

508 return Box.factory[lo.y.start : hi.y.stop, lo.x.start : hi.x.stop] 

509 

510 

511def _scale_box_to_grid(box: Box, grid: CellGrid, cell_size: int) -> Box: 

512 """Map a grid-aligned box onto a grid with ``cell_size`` pixels per cell, 

513 anchored at the origin. 

514 """ 

515 cs = grid.cell_shape 

516 s = grid.bbox.start 

517 iy0 = (box.y.start - s.y) // cs.y 

518 iy1 = (box.y.stop - s.y) // cs.y 

519 ix0 = (box.x.start - s.x) // cs.x 

520 ix1 = (box.x.stop - s.x) // cs.x 

521 return Box.factory[iy0 * cell_size : iy1 * cell_size, ix0 * cell_size : ix1 * cell_size]