Coverage for python/lsst/dax/images/cutout/_image_cutout.py: 64%

306 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-08 02:23 -0700

1# This file is part of dax_images_cutout. 

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# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22from __future__ import annotations 

23 

24__all__ = ("CutoutMode", "Extraction", "ImageCutoutFactory") 

25 

26import dataclasses 

27import logging 

28from collections.abc import Sequence 

29from enum import Enum, auto 

30from typing import TYPE_CHECKING 

31from uuid import UUID, uuid4 

32 

33import astropy.io.fits 

34import astropy.time 

35 

36import lsst.images 

37import lsst.images.serialization 

38from lsst.daf.butler import Butler, DataId, DatasetRef 

39from lsst.images import Box, MaskPlane, MaskSchema 

40from lsst.images import Mask as ImagesMask 

41from lsst.images.fits import DEFAULT_PAGE_SIZE, READ_CACHE_MAX_BYTES, ExtensionKey, FitsOpaqueMetadata 

42from lsst.images.fits._input_archive import _READ_CACHE_TYPE 

43from lsst.resources import ResourcePath, ResourcePathExpression 

44from lsst.utils.timer import time_this 

45 

46from ._fits_projection import projection_and_bbox_from_fits_header 

47from .projection_finders import ProjectionFinder 

48from .stencils import PixelStencil, SkyStencil 

49from .version import __version__ 

50 

51if TYPE_CHECKING: 

52 from lsst.afw.image import Exposure, Image, Mask, MaskedImage 

53 from lsst.daf.base import PropertyList 

54 

55 

56# Default logger. 

57_LOG = logging.getLogger(__name__) 

58_TIMER_LOG_LEVEL = logging.INFO 

59 

60 

61class CutoutMode(Enum): 

62 # The entire Exposure plus all associated metadata. 

63 FULL_EXPOSURE = auto() 

64 # Retrieve the full Exposure but only return MaskedImage/SkyWcs/metadata 

65 STRIPPED_EXPOSURE = auto() 

66 # Retrieve and return Image with no WCS or extra metadata. 

67 IMAGE_ONLY = auto() 

68 # MaskedImage using butler+afw 

69 MASKED_IMAGE = auto() 

70 # Astropy Image + primary HDU + WCS 

71 ASTROPY_IMAGE = auto() 

72 # Astropy with masked image. 

73 ASTROPY_MASKED_IMAGE = auto() 

74 

75 

76def _property_list_from_header(header: astropy.io.fits.Header) -> PropertyList: 

77 """Convert an `astropy.io.fits.Header` to a `~lsst.daf.base.PropertyList`, 

78 preserving each card's comment. 

79 """ 

80 from lsst.daf.base import PropertyList 

81 

82 metadata = PropertyList() 

83 for card in header.cards: 

84 metadata.set(card.keyword, card.value, card.comment) 

85 return metadata 

86 

87 

88@dataclasses.dataclass 

89class Extraction: 

90 """A struct that aggregates the results of extracting subimages in the 

91 image cutout backend. 

92 """ 

93 

94 cutout: Image | Mask | MaskedImage | Exposure | lsst.images.GeneralizedImage 

95 """The image cutout itself. 

96 """ 

97 

98 sky_stencil: SkyStencil 

99 """The original sky-coordinate stencil. 

100 """ 

101 

102 pixel_stencil: PixelStencil 

103 """A pixel-coordinate representation of the stencil. 

104 """ 

105 

106 metadata: astropy.io.fits.Header 

107 """Provenance FITS header cards describing the cutout process. 

108 

109 Written to the primary header on output. For afw cutout types it is 

110 converted to a `~lsst.daf.base.PropertyList` first. 

111 """ 

112 

113 origin_ref: DatasetRef 

114 """Fully-resolved reference to the dataset the cutout is from. 

115 """ 

116 

117 def mask(self, name: str = "OUTSIDE_STENCIL") -> None: 

118 """Set the bitmask to show the approximate coverage of nonrectangular 

119 stencils. 

120 

121 Parameters 

122 ---------- 

123 name : `str`, optional 

124 Name of the mask plane to add and set. 

125 

126 Notes 

127 ----- 

128 This method modifies `cutout` in place if it is a `Mask`, 

129 `MaskedImage`, or `Exposure`. It does nothing if `cutout` is an 

130 `Image`. 

131 """ 

132 if isinstance(self.cutout, lsst.images.MaskedImage): 

133 mask = self.cutout.mask 

134 # Create the new plane if it's not there. 

135 if name not in mask.schema.names: 135 ↛ 139line 135 didn't jump to line 139 because the condition on line 135 was always true

136 # Adding a plane creates a whole new mask. 

137 mask = mask.add_plane(name, "Pixel lies outside the stencil") 

138 self.cutout.mask = mask 

139 self.pixel_stencil.set_mask(mask, name, covered=False) 

140 self._record_legacy_mask_plane(self.cutout, name) 

141 return 

142 

143 # Try afw variants. Protect the imports (if the imports fail it is 

144 # not possible for it to be an afw type). 

145 try: 

146 from lsst.afw.image import Exposure, Mask, MaskedImage 

147 

148 match self.cutout: 

149 case Exposure() | MaskedImage(): 149 ↛ 150line 149 didn't jump to line 150 because the pattern on line 149 never matched

150 mask = self.cutout.mask 

151 case Mask(): 151 ↛ 152line 151 didn't jump to line 152 because the pattern on line 151 never matched

152 mask = self.cutout 

153 case _: 

154 return 

155 except ImportError: 

156 # Can not determine the type so do not mask anything. 

157 return 

158 

159 # Stage the coverage in an lsst.images.Mask, then OR it into the afw 

160 # mask plane so stencils never sees an afw object. 

161 images_mask = ImagesMask( 

162 schema=MaskSchema([MaskPlane(name, "Pixel lies outside the stencil")]), 

163 bbox=Box.from_legacy(mask.getBBox()), 

164 ) 

165 self.pixel_stencil.set_mask(images_mask, name, covered=False) 

166 outside = images_mask.get(name) 

167 mask.addMaskPlane(name) 

168 bits = mask.getPlaneBitMask(name) 

169 mask.array[:, :] |= (bits * outside).astype(mask.array.dtype) 

170 

171 def _record_legacy_mask_plane(self, masked_image: lsst.images.MaskedImage, name: str) -> None: 

172 """Add an ``MP_`` card for a newly added mask plane if the cutout came 

173 from a legacy afw file. 

174 

175 Parameters 

176 ---------- 

177 masked_image : `lsst.images.MaskedImage` 

178 The cutout whose mask just gained the ``name`` plane. 

179 name : `str` 

180 Name of the mask plane just added to ``masked_image.mask``. 

181 

182 Notes 

183 ----- 

184 ``MaskedImage.from_hdu_list`` keeps the legacy ``MP_*`` mask-plane 

185 cards (re-indexed to the reshuffled schema) in the ``MASK`` opaque 

186 metadata so ``lsst.afw.image`` can still read the inherited planes. A 

187 plane added afterwards (e.g. ``OUTSIDE_STENCIL``) is only described by 

188 the native ``MSKN`` cards, which afw does not understand, so afw would 

189 not see it. When the cutout carries that legacy provenance, record a 

190 matching ``MP_`` card for the new plane at the bit it occupies in the 

191 serialized (single-element) schema, so the whole cutout mask stays 

192 readable by legacy tooling. Files without legacy ``MP_`` cards are 

193 left untouched. 

194 

195 Reaching into ``_opaque_metadata`` mirrors how the cutout's ``TIMESYS`` 

196 is recovered elsewhere in this module. 

197 """ 

198 opaque = masked_image._opaque_metadata 

199 if not isinstance(opaque, FitsOpaqueMetadata): 

200 return 

201 key = ExtensionKey("MASK") 

202 mask_header = opaque.headers.get(key) 

203 if mask_header is None or not any(keyword.startswith("MP_") for keyword in mask_header): 203 ↛ 207line 203 didn't jump to line 207 because the condition on line 203 was always true

204 return 

205 # The serialized form packs every plane into one element, so a plane's 

206 # bit index is its position among the non-placeholder planes. 

207 serialized_bit = 0 

208 for plane in masked_image.mask.schema: 

209 if plane is None: 

210 continue 

211 if plane.name == name: 

212 break 

213 serialized_bit += 1 

214 else: 

215 return 

216 # Opaque headers are treated as immutable, so replace, don't mutate. 

217 updated = mask_header.copy() 

218 updated[f"MP_{name}"] = serialized_bit 

219 opaque.headers[key] = updated 

220 

221 def write_fits(self, path: str, logger: logging.Logger | None = None) -> None: 

222 """Write the cutout to a FITS file. 

223 

224 Parameters 

225 ---------- 

226 path : `str` 

227 Local path to the file. 

228 logger : `logging.Logger`, optional 

229 Logger to use for timing messages. If `None`, a default logger 

230 will be used. 

231 

232 Notes 

233 ----- 

234 If ``cutout`` is an `Exposure`, this will merge `metadata` into the 

235 cutout's attached metadata. In other cases, `metadata` is written 

236 to the primary header without modifying `cutout`. 

237 """ 

238 logger = logger if logger is not None else _LOG 

239 with time_this(logger, msg="Writing FITS file to %s", args=(path,), level=_TIMER_LOG_LEVEL): 

240 if isinstance(self.cutout, lsst.images.GeneralizedImage): 240 ↛ 247line 240 didn't jump to line 247 because the condition on line 240 was always true

241 # Write the cutout provenance into the primary FITS header via 

242 # the archive's update_header hook. ``metadata`` is an astropy 

243 # Header, so card comments are preserved. Storing it in the 

244 # object's flexible metadata would instead place it in the JSON 

245 # tree, where it would not appear in the primary header. 

246 self.cutout.write(path, update_header=lambda header: header.update(self.metadata)) 

247 elif hasattr(self.cutout, "getMetadata") and hasattr(self.cutout, "writeFits"): 

248 self.cutout.metadata.update(_property_list_from_header(self.metadata)) 

249 self.cutout.writeFits(path) 

250 else: 

251 self.cutout.writeFits(path, metadata=_property_list_from_header(self.metadata)) 

252 

253 

254class ImageCutoutFactory: 

255 """High-level interface to the image cutout functionality. 

256 

257 Parameters 

258 ---------- 

259 butler : `Butler` 

260 Butler that subimages are extracted from. 

261 projection_finder : `ProjectionObject` 

262 Object that obtains the WCS and bounding box for butler datasets of 

263 different types. May include caches. 

264 output_root : convertible to `ResourcePath` 

265 Root of output file URIs. 

266 temporary_root : convertible to `ResourcePath`, optional 

267 Local filesystem root to write files to before they are transferred to 

268 ``output_root`` (passed as the prefix argument to 

269 `ResourcePath.temporary_uri`). 

270 logger : `logging.Logger`, optional 

271 Logger to use for timing messages. If `None`, a default logger 

272 will be used. 

273 """ 

274 

275 def __init__( 

276 self, 

277 butler: Butler, 

278 projection_finder: ProjectionFinder, 

279 output_root: ResourcePathExpression, 

280 temporary_root: ResourcePathExpression | None = None, 

281 logger: logging.Logger | None = None, 

282 ): 

283 self.butler = butler 

284 self.projection_finder = projection_finder 

285 self.output_root = ResourcePath(output_root, forceAbsolute=True, forceDirectory=True) 

286 self.temporary_root = ( 

287 ResourcePath(temporary_root, forceDirectory=True) if temporary_root is not None else None 

288 ) 

289 self.logger = logger if logger is not None else _LOG 

290 

291 butler: Butler 

292 """Butler that subimage are extracted from (`Butler`). 

293 """ 

294 

295 projection_finder: ProjectionFinder 

296 """Object that obtains the WCS and bounding box for butler datasets of 

297 different types (`ProjectionFinder`). 

298 """ 

299 

300 output_root: ResourcePath 

301 """Root path that extracted cutouts are written to (`ResourcePath`). 

302 """ 

303 

304 temporary_root: ResourcePath | None 

305 """Local filesystem root to write files to before they are transferred to 

306 ``output_root`` 

307 """ 

308 

309 def process_ref( 

310 self, 

311 stencil: SkyStencil, 

312 ref: DatasetRef, 

313 *, 

314 mask_plane: str | None = "OUTSIDE_STENCIL", 

315 cutout_mode: CutoutMode = CutoutMode.FULL_EXPOSURE, 

316 ) -> ResourcePath: 

317 """Extract and write a cutout from a fully-resolved `DatasetRef`. 

318 

319 Parameters 

320 ---------- 

321 stencil : `SkyStencil` 

322 Definition of the cutout region, in sky coordinates. 

323 ref : `DatasetRef` 

324 Fully-resolved reference to the dataset to obtain the cutout from. 

325 Must have ``DatasetRef.id`` not `None` (use `extract_search` 

326 instead when this is not the case). Need not have an expanded data 

327 ID. May represent an image-like dataset component. 

328 mask_plane : `str`, optional 

329 If not `None`, set this mask plane in the extracted cutout to flag 

330 pixels that lie outside the stencil region. Does nothing if the 

331 image type does not have a mask plane. Defaults to 

332 ``OUTSIDE_STENCIL``. 

333 

334 Returns 

335 ------- 

336 uri : `ResourcePath` 

337 Full path to the extracted cutout. 

338 """ 

339 extract_result = self.extract_ref(stencil, ref, cutout_mode=cutout_mode) 

340 if mask_plane is not None: 340 ↛ 342line 340 didn't jump to line 342 because the condition on line 340 was always true

341 extract_result.mask(mask_plane) 

342 return self.write_fits(extract_result) 

343 

344 def process_uuid( 

345 self, 

346 stencil: SkyStencil, 

347 uuid: UUID, 

348 *, 

349 component: str | None = None, 

350 mask_plane: str | None = "OUTSIDE_STENCIL", 

351 cutout_mode: CutoutMode = CutoutMode.FULL_EXPOSURE, 

352 ) -> ResourcePath: 

353 """Extract and write a cutout from a dataset identified by its UUID. 

354 

355 Parameters 

356 ---------- 

357 stencil : `SkyStencil` 

358 Definition of the cutout region, in sky coordinates. 

359 uuid : `UUID` 

360 Unique ID of the dataset to extract the subimage from. 

361 component : `str`, optional 

362 If not `None` (default), read this component instead of the 

363 composite dataset. 

364 mask_plane : `str`, optional 

365 If not `None`, set this mask plane in the extracted cutout to flag 

366 pixels that lie outside the stencil region. Does nothing if the 

367 image type does not have a mask plane. Defaults to 

368 ``OUTSIDE_STENCIL``. 

369 

370 Returns 

371 ------- 

372 uri : `ResourcePath` 

373 Full path to the extracted cutout. 

374 """ 

375 extract_result = self.extract_uuid(stencil, uuid, component=component, cutout_mode=cutout_mode) 

376 if mask_plane is not None: 376 ↛ 378line 376 didn't jump to line 378 because the condition on line 376 was always true

377 extract_result.mask(mask_plane) 

378 return self.write_fits(extract_result) 

379 

380 def process_search( 

381 self, 

382 stencil: SkyStencil, 

383 dataset_type_name: str, 

384 data_id: DataId, 

385 collections: Sequence[str], 

386 *, 

387 mask_plane: str | None = "OUTSIDE_STENCIL", 

388 ) -> ResourcePath: 

389 """Extract and write a cutout from a dataset identified by a 

390 (dataset type, data ID, collection path) tuple. 

391 

392 Parameters 

393 ---------- 

394 stencil : `SkyStencil` 

395 Definition of the cutout region, in sky coordinates. 

396 dataset_type_name : `str` 

397 Name of the butler dataset. Use ``.``-separate terms to read an 

398 image-like component. 

399 data_id : `dict` or `DataCoordinate` 

400 Mapping-of-dimensions identifier for the dataset within its 

401 collection. 

402 collections : `Iterable` [ `str` ] 

403 Collections to search for the dataset, in the order they should be 

404 searched. 

405 mask_plane : `str`, optional 

406 If not `None`, set this mask plane in the extracted cutout to flag 

407 pixels that lie outside the stencil region. Does nothing if the 

408 image type does not have a mask plane. Defaults to 

409 ``OUTSIDE_STENCIL``. 

410 

411 Returns 

412 ------- 

413 uri : `ResourcePath` 

414 Full path to the extracted cutout. 

415 """ 

416 extract_result = self.extract_search(stencil, dataset_type_name, data_id, collections) 

417 if mask_plane is not None: 

418 extract_result.mask(mask_plane) 

419 return self.write_fits(extract_result) 

420 

421 def extract_ref( 

422 self, stencil: SkyStencil, ref: DatasetRef, cutout_mode: CutoutMode = CutoutMode.FULL_EXPOSURE 

423 ) -> Extraction: 

424 """Extract a subimage from a fully-resolved `DatasetRef`. 

425 

426 Parameters 

427 ---------- 

428 stencil : `SkyStencil` 

429 Definition of the cutout region, in sky coordinates. 

430 ref : `DatasetRef` 

431 Fully-resolved reference to the dataset to obtain the cutout from. 

432 Must have ``DatasetRef.id`` not `None` (use `extract_search` 

433 instead when this is not the case). Need not have an expanded data 

434 ID. May represent an image-like dataset component. 

435 

436 Returns 

437 ------- 

438 result : `Extraction` 

439 Struct that combines the cutout itself with additional metadata 

440 and the pixel-coordinate stencil. The cutout is not masked; 

441 `Extraction.mask` must be called explicitly if desired. 

442 """ 

443 if issubclass(ref.datasetType.storageClass.pytype, lsst.images.GeneralizedImage): 443 ↛ 445line 443 didn't jump to line 445 because the condition on line 443 was always true

444 return self._extract_ref_v2(stencil, ref, cutout_mode=cutout_mode) 

445 return self._extract_ref_legacy(stencil, ref, cutout_mode=cutout_mode) 

446 

447 def _read_astropy_hdulist( 

448 self, 

449 cutout_mode: CutoutMode, 

450 stencil: SkyStencil, 

451 ref: DatasetRef, 

452 ) -> tuple[lsst.images.GeneralizedImage, PixelStencil | None, str]: 

453 """Read the primary header and pixel HDUs, cut to the stencil. 

454 

455 Parameters 

456 ---------- 

457 cutout_mode 

458 The requested cutout mode. Must be either an Astropy image or 

459 masked image request. 

460 stencil 

461 Sky-coordinate stencil defining the cutout region. 

462 ref : `DatasetRef` 

463 Resolved reference to the dataset to read. 

464 

465 Returns 

466 ------- 

467 result : `lsst.images.GeneralizedImage` 

468 The resultant generalized image constructed from the individual 

469 HDUs. 

470 pixel_stencil : `PixelStencil` or `None` 

471 Pixel-coordinate stencil computed from the first pixel HDU, or 

472 `None` if no pixel HDU was found. 

473 timesys : `str` 

474 ``TIMESYS`` from the primary header, or ``"UTC"``. 

475 """ 

476 if cutout_mode not in (CutoutMode.ASTROPY_IMAGE, CutoutMode.ASTROPY_MASKED_IMAGE): 476 ↛ 477line 476 didn't jump to line 477 because the condition on line 476 was never true

477 raise ValueError(f"Unexpected cutout mode {cutout_mode} encountered") 

478 

479 pixel_components = ( 

480 {"image"} if cutout_mode == CutoutMode.ASTROPY_IMAGE else {"image", "mask", "variance"} 

481 ) 

482 # Tune the fsspec cache to match what we use in lsst.images. 

483 maxblocks = max(2, READ_CACHE_MAX_BYTES // DEFAULT_PAGE_SIZE) 

484 fsspec_kwargs = { 

485 "block_size": DEFAULT_PAGE_SIZE, 

486 "cache_type": _READ_CACHE_TYPE, 

487 "cache_options": {"maxblocks": maxblocks}, 

488 } 

489 timesys = "UTC" 

490 pixel_stencil: PixelStencil | None = None 

491 bbox: Box | None = None 

492 uri = self.butler.getURI(ref) 

493 fs, fspath = uri.to_fsspec() 

494 hdul: list[astropy.io.fits.hdu.base.ExtensionHDU] = [] 

495 found_primary = False 

496 with fs.open(fspath, **fsspec_kwargs) as f, astropy.io.fits.open(f) as fits_obj: 

497 for hdu in fits_obj: 497 ↛ 553line 497 didn't jump to line 553

498 if not found_primary: 

499 hdul.append(hdu.copy()) 

500 timesys_hdr = hdul[0].header.get("TIMESYS", timesys) 

501 if timesys_hdr: 501 ↛ 506line 501 didn't jump to line 506 because the condition on line 501 was always true

502 # For mypy since in theory a FITS header can exist 

503 # but be undefined. 

504 timesys = str(timesys_hdr) 

505 

506 found_primary = True 

507 

508 # Some old legacy data erroneously wrote "IMAGE" to the 

509 # EXTNAME of the primary header. Since we are assuming 

510 # here that the primary never has pixel data, fix the 

511 # header to prevent downstream confusion. 

512 if "EXTNAME" in hdul[0].header: 512 ↛ 513line 512 didn't jump to line 513 because the condition on line 512 was never true

513 del hdul[0].header["EXTNAME"] 

514 

515 continue 

516 

517 hdr = hdu.header 

518 extname = hdr.get("EXTNAME") 

519 if extname and extname.lower() in pixel_components: 519 ↛ 550line 519 didn't jump to line 550 because the condition on line 519 was always true

520 pixel_components.remove(extname.lower()) 

521 projection, full_bbox = projection_and_bbox_from_fits_header(hdr, hdu.shape) 

522 if pixel_stencil is None: 

523 pixel_stencil = stencil.to_pixels(projection, full_bbox) 

524 bbox = pixel_stencil.bbox 

525 

526 assert bbox is not None 

527 # Offsets of the cutout within the full HDU (array order). 

528 min_x = bbox.x.start - full_bbox.x.start 

529 max_x = bbox.x.stop - full_bbox.x.start 

530 min_y = bbox.y.start - full_bbox.y.start 

531 max_y = bbox.y.stop - full_bbox.y.start 

532 data = hdu.section[min_y:max_y, min_x:max_x].copy() 

533 

534 # Correct the header WCS for the cutout offset. 

535 if (k := "CRPIX1") in hdr: 535 ↛ 537line 535 didn't jump to line 537 because the condition on line 535 was always true

536 hdr[k] -= min_x 

537 if (k := "CRPIX2") in hdr: 537 ↛ 539line 537 didn't jump to line 539 because the condition on line 537 was always true

538 hdr[k] -= min_y 

539 if (k := "LTV1") in hdr: 539 ↛ 540line 539 didn't jump to line 540 because the condition on line 539 was never true

540 hdr[k] = -bbox.x.start 

541 if (k := "LTV2") in hdr: 541 ↛ 542line 541 didn't jump to line 542 because the condition on line 541 was never true

542 hdr[k] = -bbox.y.start 

543 if (k := "CRVAL1A") in hdr: 543 ↛ 545line 543 didn't jump to line 545 because the condition on line 543 was always true

544 hdr[k] = bbox.x.start 

545 if (k := "CRVAL2A") in hdr: 545 ↛ 548line 545 didn't jump to line 548 because the condition on line 545 was always true

546 hdr[k] = bbox.y.start 

547 

548 hdul.append(astropy.io.fits.ImageHDU(data=data, header=hdr.copy())) 

549 

550 if not pixel_components: 

551 break 

552 

553 hdulist = astropy.io.fits.HDUList(hdus=hdul) 

554 result: lsst.images.GeneralizedImage 

555 if cutout_mode == CutoutMode.ASTROPY_IMAGE: 

556 result = lsst.images.Image.from_hdu_list(hdulist) 

557 else: 

558 result = lsst.images.MaskedImage.from_hdu_list(hdulist) 

559 

560 return result, pixel_stencil, timesys 

561 

562 def _extract_ref_legacy( 

563 self, stencil: SkyStencil, ref: DatasetRef, cutout_mode: CutoutMode = CutoutMode.FULL_EXPOSURE 

564 ) -> Extraction: 

565 """Extract a subimage from a fully-resolved `DatasetRef` associated 

566 with a legacy exposure. 

567 

568 Parameters 

569 ---------- 

570 stencil : `SkyStencil` 

571 Definition of the cutout region, in sky coordinates. 

572 ref : `DatasetRef` 

573 Fully-resolved reference to the dataset to obtain the cutout from. 

574 Must have ``DatasetRef.id`` not `None` (use `extract_search` 

575 instead when this is not the case). Need not have an expanded data 

576 ID. May represent an image-like dataset component. 

577 

578 Returns 

579 ------- 

580 result : `Extraction` 

581 Struct that combines the cutout itself with additional metadata 

582 and the pixel-coordinate stencil. The cutout is not masked; 

583 `Extraction.mask` must be called explicitly if desired. 

584 """ 

585 # We know that afw is available here since we received an afw Exposure. 

586 from lsst.afw.image import makeExposure, makeMaskedImage 

587 

588 if ref.id is None: 

589 raise ValueError(f"A resolved DatasetRef is required; got {ref}.") 

590 # Timestamp of the cutout extraction. 

591 now = astropy.time.Time.now() 

592 timesys = "UTC" 

593 # Get the WCS and bbox of this dataset unless we are in astropy mode. 

594 wcs = None 

595 bbox = None 

596 pixel_stencil = None 

597 if cutout_mode not in (CutoutMode.ASTROPY_IMAGE, CutoutMode.ASTROPY_MASKED_IMAGE): 

598 projection, bbox = self.projection_finder(ref, self.butler, logger=self.logger) 

599 # Transform the stencil to pixel coordinates. 

600 pixel_stencil = stencil.to_pixels(projection, bbox) 

601 

602 # Actually read the cutout. Leave it to the butler to cache remote 

603 # files locally or do partial remote reads. 

604 with time_this( 

605 self.logger, 

606 msg="Extract cutout", 

607 kwargs={"id": str(ref.id), "cutout_mode": str(cutout_mode), "stencil": str(stencil)}, 

608 level=_TIMER_LOG_LEVEL, 

609 ): 

610 match cutout_mode: 

611 case CutoutMode.FULL_EXPOSURE: 

612 assert pixel_stencil is not None 

613 cutout = self.butler.get(ref, parameters={"bbox": pixel_stencil.bbox.to_legacy()}) 

614 timesys = cutout.metadata.get("TIMESYS", timesys) 

615 case CutoutMode.STRIPPED_EXPOSURE: 

616 assert pixel_stencil is not None 

617 cutout = self.butler.get(ref, parameters={"bbox": pixel_stencil.bbox.to_legacy()}) 

618 original_metadata = cutout.metadata 

619 timesys = original_metadata.get("TIMESYS", timesys) 

620 cutout = makeExposure(cutout.maskedImage, wcs=cutout.wcs) 

621 

622 # A full exposure so we can store the metadata back in it. 

623 cutout.setMetadata(original_metadata) 

624 case CutoutMode.IMAGE_ONLY: 

625 assert pixel_stencil is not None 

626 cutout = self.butler.get( 

627 ref.makeComponentRef("image"), parameters={"bbox": pixel_stencil.bbox.to_legacy()} 

628 ) 

629 # No metadata so UTC is default. 

630 timesys = "UTC" 

631 case CutoutMode.MASKED_IMAGE: 

632 # Rely on the file being cached on first read. Faster than 

633 # reading entire exposure. 

634 assert pixel_stencil is not None 

635 image = self.butler.get( 

636 ref.makeComponentRef("image"), parameters={"bbox": pixel_stencil.bbox.to_legacy()} 

637 ) 

638 variance = self.butler.get( 

639 ref.makeComponentRef("variance"), parameters={"bbox": pixel_stencil.bbox.to_legacy()} 

640 ) 

641 mask = self.butler.get( 

642 ref.makeComponentRef("mask"), parameters={"bbox": pixel_stencil.bbox.to_legacy()} 

643 ) 

644 wcs = self.butler.get(ref.makeComponentRef("wcs")) 

645 masked_image = makeMaskedImage(image, mask, variance) 

646 cutout = makeExposure(masked_image, wcs=wcs) 

647 

648 original_metadata = self.butler.get(ref.makeComponentRef("metadata")) 

649 timesys = original_metadata.get("TIMESYS", timesys) 

650 

651 # The cutout is an Exposure so we can attach metadata. 

652 cutout.setMetadata(original_metadata) 

653 

654 case CutoutMode.ASTROPY_IMAGE | CutoutMode.ASTROPY_MASKED_IMAGE: 

655 # Bypass butler and read the pixel HDU directly. 

656 cutout, pixel_stencil, timesys = self._read_astropy_hdulist(cutout_mode, stencil, ref) 

657 case _: 

658 raise ValueError(f"Unsupported cutout mode: {cutout_mode}") 

659 

660 # Create the provenance header with the cutout parameters. 

661 metadata = self._record_cutout_provenance(ref, now, stencil, timesys) 

662 

663 # Every supported cutout mode produces a pixel stencil above. 

664 assert pixel_stencil is not None 

665 return Extraction( 

666 cutout=cutout, 

667 sky_stencil=stencil, 

668 pixel_stencil=pixel_stencil, 

669 metadata=metadata, 

670 origin_ref=ref, 

671 ) 

672 

673 def _extract_ref_v2( 

674 self, stencil: SkyStencil, ref: DatasetRef, cutout_mode: CutoutMode = CutoutMode.FULL_EXPOSURE 

675 ) -> Extraction: 

676 """Extract a subimage from a fully-resolved `DatasetRef` associated 

677 with a lsst.images model. 

678 

679 Parameters 

680 ---------- 

681 stencil : `SkyStencil` 

682 Definition of the cutout region, in sky coordinates. 

683 ref : `DatasetRef` 

684 Fully-resolved reference to the dataset to obtain the cutout from. 

685 Must have ``DatasetRef.id`` not `None` (use `extract_search` 

686 instead when this is not the case). Need not have an expanded data 

687 ID. May represent an image-like dataset component. 

688 

689 Returns 

690 ------- 

691 result : `Extraction` 

692 Struct that combines the cutout itself with additional metadata 

693 and the pixel-coordinate stencil. The cutout is not masked; 

694 `Extraction.mask` must be called explicitly if desired. 

695 """ 

696 if ref.id is None: 696 ↛ 697line 696 didn't jump to line 697 because the condition on line 696 was never true

697 raise ValueError(f"A resolved DatasetRef is required; got {ref}.") 

698 # Timestamp of the cutout extraction. 

699 now = astropy.time.Time.now() 

700 timesys = "UTC" 

701 pixel_stencil = None 

702 

703 # There are two distinct modes of operation. One is using the native 

704 # lsst.images interface, the other is using Astropy to deal with 

705 # IMAGE and MASKED_IMAGE by using standard FITS conventions. 

706 if cutout_mode not in (CutoutMode.ASTROPY_IMAGE, CutoutMode.ASTROPY_MASKED_IMAGE): 

707 # To reduce round-trips to an object store we want to open the file 

708 # once and then read out the components we need. In theory we can 

709 # ask Butler get to return multiple components but if we do that 

710 # we do not know the bounding box to use unless we add a skybox 

711 # parameter to get that lets us pass in a stencil directly. 

712 # This means we have to use the URI for direct access, bypassing 

713 # butler get for now. 

714 uri = self.butler.getURI(ref) 

715 

716 # Time the full open and retrieval. 

717 with time_this( 

718 self.logger, 

719 msg="Extract cutout", 

720 kwargs={"id": str(ref.id), "cutout_mode": str(cutout_mode), "stencil": str(stencil)}, 

721 level=_TIMER_LOG_LEVEL, 

722 ): 

723 with lsst.images.serialization.open(uri) as reader: 

724 sky_projection = reader.get_component("sky_projection") 

725 bbox = reader.get_component("bbox") 

726 

727 # Transform the stencil to pixel coordinates. 

728 pixel_stencil = stencil.to_pixels(sky_projection, bbox) 

729 modern_bbox = pixel_stencil.bbox 

730 

731 match cutout_mode: 

732 case CutoutMode.FULL_EXPOSURE: 

733 cutout = reader.read(bbox=modern_bbox) 

734 case CutoutMode.IMAGE_ONLY: 

735 cutout = reader.get_component("image", bbox=modern_bbox) 

736 case CutoutMode.MASKED_IMAGE | CutoutMode.STRIPPED_EXPOSURE: 736 ↛ 741line 736 didn't jump to line 741 because the pattern on line 736 always matched

737 # A Stripped exposure is meaningless with the 

738 # new models since MaskedImage now carries a 

739 # WCS and metadata. 

740 cutout = reader.get_component("masked_image", bbox=modern_bbox) 

741 case _: 

742 raise ValueError(f"Unsupported cutout mode: {cutout_mode}") 

743 

744 if cutout._opaque_metadata is not None: 

745 timesys = cutout._opaque_metadata.headers[ExtensionKey()].get("TIMESYS", timesys) 

746 

747 else: 

748 # This is the Astropy direct branch. 

749 with time_this( 

750 self.logger, 

751 msg="Extract cutout", 

752 kwargs={"id": str(ref.id), "cutout_mode": str(cutout_mode), "stencil": str(stencil)}, 

753 level=_TIMER_LOG_LEVEL, 

754 ): 

755 # Bypass butler and lsst.images and go straight to the HDUs. 

756 cutout, pixel_stencil, timesys = self._read_astropy_hdulist(cutout_mode, stencil, ref) 

757 

758 # Create some FITS metadata with the cutout parameters. 

759 metadata = self._record_cutout_provenance(ref, now, stencil, timesys) 

760 

761 # Every supported cutout mode produces a pixel stencil above. 

762 assert pixel_stencil is not None 

763 return Extraction( 

764 cutout=cutout, 

765 sky_stencil=stencil, 

766 pixel_stencil=pixel_stencil, 

767 metadata=metadata, 

768 origin_ref=ref, 

769 ) 

770 

771 def _record_cutout_provenance( 

772 self, ref: DatasetRef, start_time: astropy.time.Time, stencil: SkyStencil, timesys: str 

773 ) -> astropy.io.fits.Header: 

774 header = astropy.io.fits.Header() 

775 

776 # Create some FITS metadata with the cutout parameters. 

777 # Some of these are added as provenance by the butler on write so may 

778 # no longer be necessary. 

779 header.set("BTLRUUID", ref.id.hex, "Butler UUID of full image") 

780 header.set("BTLRNAME", ref.datasetType.name, "Butler dataset type") 

781 for n, (k, v) in enumerate(ref.dataId.required.items()): 

782 # Write data ID dictionary sort of like a list of 2-tuples, to make 

783 # it easier to stay within the FITS 8-char key limit. 

784 header.set(f"BTLRK{n:03}", k, f"Name of dimension {n} in the data ID") 

785 header.set(f"BTLRV{n:03}", v, f"Value of dimension {n} in the data ID") 

786 header.extend(stencil.to_fits_metadata()) 

787 

788 # Record the time and software version. 

789 start_time.format = "fits" 

790 start_time = start_time.tai if timesys.lower() == "tai" else start_time.utc 

791 header.set("DATE-CUT", str(start_time), "Time of cutout extraction") 

792 header.set("CUTVERS", __version__, "dax_images_cutout software version") 

793 

794 return header 

795 

796 def extract_uuid( 

797 self, 

798 stencil: SkyStencil, 

799 uuid: UUID, 

800 *, 

801 component: str | None = None, 

802 cutout_mode: CutoutMode = CutoutMode.FULL_EXPOSURE, 

803 ) -> Extraction: 

804 """Extract a subimage from a dataset identified by its UUID. 

805 

806 Parameters 

807 ---------- 

808 stencil : `SkyStencil` 

809 Definition of the cutout region, in sky coordinates. 

810 uuid : `UUID` 

811 Unique ID of the dataset to read from. 

812 component : `str`, optional 

813 If not `None` (default), read this component instead of the 

814 composite dataset. 

815 

816 Returns 

817 ------- 

818 result : `Extraction` 

819 Struct that combines the cutout itself with additional metadata 

820 and the pixel-coordinate stencil. The cutout is not masked; 

821 `Extraction.mask` must be called explicitly if desired. 

822 """ 

823 ref = self.butler.get_dataset(uuid) 

824 if ref is None: 824 ↛ 825line 824 didn't jump to line 825 because the condition on line 824 was never true

825 raise LookupError(f"No dataset found with UUID {uuid}.") 

826 if component is not None: 826 ↛ 827line 826 didn't jump to line 827 because the condition on line 826 was never true

827 ref = ref.makeComponentRef(component) 

828 return self.extract_ref(stencil, ref, cutout_mode=cutout_mode) 

829 

830 def extract_search( 

831 self, stencil: SkyStencil, dataset_type_name: str, data_id: DataId, collections: Sequence[str] 

832 ) -> Extraction: 

833 """Extract a subimage from a dataset identified by a (dataset type, 

834 data ID, collection path) tuple. 

835 

836 Parameters 

837 ---------- 

838 stencil : `SkyStencil` 

839 Definition of the cutout region, in sky coordinates. 

840 dataset_type_name : `str` 

841 Name of the butler dataset. Use ``.``-separate terms to read an 

842 image-like component. 

843 data_id : `dict` or `DataCoordinate` 

844 Mapping-of-dimensions identifier for the dataset within its 

845 collection. 

846 collections : `Iterable` [ `str` ] 

847 Collections to search for the dataset, in the order they should be 

848 searched. 

849 

850 Returns 

851 ------- 

852 result : `Extraction` 

853 Struct that combines the cutout itself with additional metadata 

854 and the pixel-coordinate stencil. The cutout is not masked; 

855 `Extraction.mask` must be called explicitly if desired. 

856 """ 

857 ref = self.butler.find_dataset(dataset_type_name, data_id, collections=collections) 

858 if ref is None: 

859 raise LookupError( 

860 f"No {dataset_type_name} dataset found with data ID {data_id} in {collections}." 

861 ) 

862 return self.extract_ref(stencil, ref) 

863 

864 def write_fits(self, extract_result: Extraction) -> ResourcePath: 

865 """Write a `Extraction` to a remote FITS file in `output_root`. 

866 

867 Parameters 

868 ---------- 

869 extract_result : `Extraction` 

870 Result of a call to a ``extract_*`` method. 

871 

872 Returns 

873 ------- 

874 uri : `ResourcePath` 

875 Full path to the extracted cutout. 

876 """ 

877 output_uuid = uuid4() 

878 remote_uri = self.output_root.join(output_uuid.hex + ".fits") 

879 with ResourcePath.temporary_uri(prefix=self.temporary_root, suffix=".fits") as tmp_uri: 

880 tmp_uri.parent().mkdir() 

881 extract_result.write_fits(tmp_uri.ospath, logger=self.logger) 

882 remote_uri.transfer_from(tmp_uri, transfer="copy") 

883 return remote_uri