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

188 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-02 02:02 -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. 

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 os 

75from collections.abc import Callable 

76from typing import Any, cast 

77 

78import numpy as np 

79 

80from .. import DifferenceImage, VisitImage 

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

82from .._geom import YX, Box 

83from .._image import Image 

84from .._mask import Mask 

85from .._transforms import SkyProjection, TractFrame, Transform 

86from .._transforms._ast import PolyMap 

87from ..cells import CellCoadd, CellField, CellPointSpreadFunction, CoaddProvenance 

88from ..convolution_kernels import ImageBasisConvolutionKernel 

89from ..psfs import PiffWrapper 

90from ..serialization import read 

91from ._creation import make_random_sky_projection 

92 

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

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

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

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

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

98_CELL_SIZE = 6 

99_KERNEL_SIZE = 5 

100_MAX_INPUTS = 6 

101 

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

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

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

105# of aperture corrections). 

106_MAX_AMPLIFIERS = 2 

107_MAX_APERTURE_CORRECTIONS = 2 

108 

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

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

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

112_PSF_INTERP_ORDER = 0 

113 

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

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

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

117_PROJECTION_LINEAR_APPROX_TOL = 1e-8 

118 

119 

120def minify(in_path: str, out_path: str) -> None: 

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

122 

123 Parameters 

124 ---------- 

125 in_path 

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

127 file to read. 

128 out_path 

129 Path to the output file to write. The parent directory is 

130 created if it does not exist. Can be any supported file format. 

131 File extension controls the output format. 

132 

133 Raises 

134 ------ 

135 ValueError 

136 If the file extension is not recognised. 

137 NotImplementedError 

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

139 """ 

140 obj = read(in_path) 

141 subsetter = _dispatch(obj) 

142 subset = subsetter(obj) 

143 

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

145 subset.write(out_path) 

146 

147 

148def _dispatch(image: Any) -> Callable[[Any], Any]: 

149 """Return the relevant subsetter for this image object.""" 

150 match image: 

151 case DifferenceImage(): # this branch needs to go first, as DifferenceImage subclasses VisitImage 151 ↛ 152line 151 didn't jump to line 152 because the pattern on line 151 never matched

152 return _subset_difference_image 

153 case VisitImage(): 153 ↛ 154line 153 didn't jump to line 154 because the pattern on line 153 never matched

154 return _subset_visit_image 

155 case CellCoadd(): 155 ↛ 156line 155 didn't jump to line 156 because the pattern on line 155 never matched

156 return _subset_cell_coadd 

157 case _: 

158 raise NotImplementedError(f"No minify rule for image of type {type(image)}.") 

159 

160 

161# -- VisitImage ------------------------------------------------------------ 

162 

163 

164def _subset_visit_image[T: VisitImage]( 

165 visit_like_image: T, 

166 *, 

167 size: int = 16, 

168 max_amplifiers: int = _MAX_AMPLIFIERS, 

169 max_aperture_corrections: int = _MAX_APERTURE_CORRECTIONS, 

170 linearize_projection: bool = True, 

171 projection_tol: float = _PROJECTION_LINEAR_APPROX_TOL, 

172 psf_interp_order: int | None = _PSF_INTERP_ORDER, 

173) -> T: 

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

175 homogeneous collections. 

176 

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

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

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

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

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

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

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

184 that is `None`. 

185 """ 

186 bbox = visit_like_image.bbox 

187 y0 = bbox.y.start 

188 x0 = bbox.x.start 

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

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

191 subset = cast(T, visit_like_image[Box.factory[y0:y1, x0:x1]]) 

192 

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

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

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

196 # attributes. 

197 del subset.detector.amplifiers[max_amplifiers:] 

198 aperture_corrections = subset.aperture_corrections 

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

200 del aperture_corrections[key] 

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

202 _simplify_piff_psf(subset.psf, order=psf_interp_order) 

203 

204 if not linearize_projection or subset.sky_projection is None: 

205 return subset 

206 

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

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

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

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

211 # consistent avoids surprises. 

212 linear = _linear_approx_projection(subset.sky_projection, subset.image.bbox, tol=projection_tol) 

213 return type(visit_like_image)( 

214 subset.image.view(sky_projection=linear), 

215 mask=subset.mask.view(sky_projection=linear), 

216 variance=subset.variance.view(sky_projection=linear), 

217 sky_projection=linear, 

218 psf=subset.psf, 

219 obs_info=subset.obs_info, 

220 bounds=subset.bounds, 

221 summary_stats=subset.summary_stats, 

222 detector=subset.detector, 

223 photometric_scaling=subset.photometric_scaling, 

224 aperture_corrections=subset.aperture_corrections, 

225 backgrounds=subset.backgrounds, 

226 band=subset.band, 

227 metadata=subset.metadata, 

228 ) 

229 

230 

231def _linear_approx_projection(sky_projection: SkyProjection, bbox: Box, *, tol: float) -> SkyProjection: 

232 """Return a copy of ``sky_projection`` whose pixel->sky mapping is replaced 

233 by its best linear (affine) approximation over ``bbox``. 

234 

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

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

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

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

239 trivially FITS-representable). 

240 

241 This is written as a self-contained ``sky_projection -> sky_projection`` 

242 transform so it can be promoted to a public 

243 ``SkyProjection.linear_approx(bbox, tol)`` method later with essentially 

244 no change. It assumes a 2-D pixel->sky 

245 mapping. 

246 

247 Parameters 

248 ---------- 

249 sky_projection 

250 The projection to approximate. 

251 bbox 

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

253 tol 

254 Maximum permitted deviation from linearity, as a Cartesian 

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

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

257 """ 

258 transform = sky_projection.pixel_to_sky_transform 

259 mapping = transform._ast_mapping 

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

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

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

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

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

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

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

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

268 jacobian_inv = np.linalg.inv(jacobian) 

269 forward = _affine_polymap_coeffs(jacobian, offset) 

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

271 affine = Transform( 

272 transform.in_frame, 

273 transform.out_frame, 

274 PolyMap(forward, inverse), 

275 in_bounds=sky_projection.pixel_bounds, 

276 ) 

277 return SkyProjection(affine) 

278 

279 

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

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

282 

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

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

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

286 """ 

287 n = len(offset) 

288 coeffs: list[list[float]] = [] 

289 for i in range(n): 

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

291 for j in range(n): 

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

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

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

295 

296 

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

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

299 

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

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

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

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

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

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

306 

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

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

309 left untouched. 

310 

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

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

313 """ 

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

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

316 return 

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

318 return 

319 

320 from piff import BasisPolynomial 

321 

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

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

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

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

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

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

328 

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

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

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

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

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

334 psf.piff_psf.interp = truncated 

335 

336 

337# -- DifferenceImage ------------------------------------------------------- 

338 

339 

340def _subset_difference_image( 

341 difference_image: DifferenceImage, 

342 *, 

343 size: int = 16, 

344 max_amplifiers: int = _MAX_AMPLIFIERS, 

345 max_aperture_corrections: int = _MAX_APERTURE_CORRECTIONS, 

346 linearize_projection: bool = True, 

347 projection_tol: float = _PROJECTION_LINEAR_APPROX_TOL, 

348 psf_interp_order: int | None = _PSF_INTERP_ORDER, 

349) -> DifferenceImage: 

350 """Shrink a difference image. 

351 

352 Most of the shrinking is delegated to `_subset_visit_image`. 

353 

354 Template provenance is shrunk to the first few entries. 

355 

356 Difference kernel basis images are sliced to the innermost pixels and the 

357 number of basis functions is shrunk to the first few. 

358 """ 

359 result = _subset_visit_image( 

360 difference_image, 

361 size=size, 

362 max_amplifiers=max_amplifiers, 

363 max_aperture_corrections=max_aperture_corrections, 

364 linearize_projection=linearize_projection, 

365 projection_tol=projection_tol, 

366 psf_interp_order=psf_interp_order, 

367 ) 

368 if difference_image._kernel is not None: 

369 result.kernel = _subset_difference_kernel(cast(ImageBasisConvolutionKernel, difference_image._kernel)) 

370 result.templates = difference_image.templates[:2] if difference_image.templates is not None else None 

371 return result 

372 

373 

374def _subset_difference_kernel( 

375 kernel: ImageBasisConvolutionKernel, 

376 *, 

377 n_basis_images: int = 2, 

378 basis_radius: int = 1, 

379) -> ImageBasisConvolutionKernel: 

380 kernel_bbox = kernel.kernel_bbox.absolute[ 

381 -basis_radius : basis_radius + 1, -basis_radius : basis_radius + 1 

382 ] 

383 slices = kernel_bbox.slice_within(kernel.kernel_bbox) 

384 return ImageBasisConvolutionKernel( 

385 kernel.basis[:n_basis_images, *slices], 

386 kernel.spatial[:n_basis_images], 

387 ) 

388 

389 

390# -- CellCoadd ------------------------------------------------------------- 

391 

392 

393def _subset_cell_coadd( 

394 cell_coadd: CellCoadd, 

395 *, 

396 cell_size: int = _CELL_SIZE, 

397 kernel_size: int = _KERNEL_SIZE, 

398 max_inputs: int = _MAX_INPUTS, 

399) -> CellCoadd: 

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

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

402 """ 

403 if kernel_size % 2 == 0: 

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

405 

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

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

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

409 block = cell_coadd[_choose_block_bbox(cell_coadd)] 

410 

411 grid = block.grid 

412 cs = grid.cell_shape 

413 start = block.bounds.subgrid_start 

414 stop = block.bounds.subgrid_stop 

415 n_i = stop.i - start.i 

416 n_j = stop.j - start.j 

417 

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

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

420 full_shape = grid.grid_size 

421 new_grid = CellGrid( 

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

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

424 ) 

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

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

427 

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

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

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

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

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

433 ny = n_i * cell_size 

434 nx = n_j * cell_size 

435 

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

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

438 

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

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

441 

442 # 4. Synthetic-but-valid sky_projection over the tiny tract frame. 

443 rng = np.random.default_rng(0) 

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

445 sky_projection = make_random_sky_projection(rng, tract_frame, new_block_bbox) 

446 

447 unit = cell_coadd.unit 

448 image = Image(shrink2d(block.image.array), bbox=new_block_bbox, unit=unit, sky_projection=sky_projection) 

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

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

451 mask_fractions = { 

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

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

454 } 

455 noise_realizations = [ 

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

457 ] 

458 

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

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

461 psf_array = block.psf._array 

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

463 half = kernel_size // 2 

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

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

466 psf = CellPointSpreadFunction(psf_array, bounds=new_bounds) 

467 

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

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

470 patch = PatchDefinition( 

471 id=block.patch.id, 

472 index=block.patch.index, 

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

474 cells=new_grid, 

475 ) 

476 

477 provenance = block._provenance 

478 if provenance is not None: 

479 provenance = _trim_provenance(provenance, max_inputs=max_inputs) 

480 

481 # Aperture corrections are not subset when CellCoadd is subset with a 

482 # bounding box, because they're always tiny. But that makes setting 

483 # up a consistent new grid for them tricky. 

484 aperture_corrections = {} 

485 new_apcorr_bounds = None 

486 for i, (name, field) in enumerate(block.aperture_corrections.items()): 

487 if new_apcorr_bounds is None: 

488 new_apcorr_bounds = CellGridBounds( 

489 grid=new_grid, 

490 bbox=_scale_box_to_grid(field.bounds.bbox, grid, cell_size), 

491 missing=cell_coadd.bounds.missing, 

492 ) 

493 aperture_corrections[name] = CellField(new_apcorr_bounds, field._array) 

494 if i >= 2: 

495 break 

496 

497 return CellCoadd( 

498 image, 

499 mask=mask, 

500 variance=variance, 

501 mask_fractions=mask_fractions, 

502 noise_realizations=noise_realizations, 

503 sky_projection=sky_projection, 

504 band=block.band, 

505 psf=psf, 

506 patch=patch, 

507 provenance=provenance, 

508 backgrounds=block._backgrounds, 

509 aperture_corrections=aperture_corrections, 

510 ) 

511 

512 

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

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

515 contributions that reference the removed inputs. 

516 

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

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

519 """ 

520 inputs = provenance.inputs 

521 if len(inputs) <= max_inputs: 

522 return provenance 

523 kept_inputs = inputs[:max_inputs] 

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

525 contributions = provenance.contributions 

526 mask = np.array( 

527 [ 

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

529 for instrument, visit, detector in zip( 

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

531 ) 

532 ], 

533 dtype=bool, 

534 ) 

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

536 

537 

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

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

540 

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

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

543 cell. 

544 """ 

545 bounds = cell_coadd.bounds 

546 grid = bounds.grid 

547 start = bounds.subgrid_start 

548 stop = bounds.subgrid_stop 

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

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

551 

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

553 if target is not None: 

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

555 # populated index range. 

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

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

558 else: 

559 i0 = start.i 

560 j0 = start.j 

561 

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

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

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

565 

566 

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

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

569 anchored at the origin. 

570 """ 

571 cs = grid.cell_shape 

572 s = grid.bbox.start 

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

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

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

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

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