Coverage for python/lsst/drp/tasks/reprocess_visit_image.py: 22%

211 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-10 02:26 -0700

1# This file is part of drp_tasks. 

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 

22__all__ = ["ReprocessVisitImageTask", "ReprocessVisitImageConfig", "combine_backgrounds"] 

23 

24import numpy as np 

25import smatch 

26 

27import lsst.afw.image as afwImage 

28import lsst.afw.table as afwTable 

29import lsst.geom 

30import lsst.meas.algorithms 

31import lsst.meas.deblender 

32import lsst.meas.extensions.photometryKron 

33import lsst.meas.extensions.shapeHSM 

34import lsst.pex.config as pexConfig 

35import lsst.pipe.base as pipeBase 

36from lsst.pipe.base import connectionTypes 

37from lsst.pipe.tasks import computeExposureSummaryStats, repair, snapCombine 

38 

39 

40class ReprocessVisitImageConnections( 

41 pipeBase.PipelineTaskConnections, dimensions=("instrument", "visit", "detector") 

42): 

43 exposures = connectionTypes.Input( 

44 doc="Exposure (or two snaps) to be calibrated, and detected and measured on.", 

45 name="postISRCCD", 

46 storageClass="Exposure", 

47 multiple=True, # to handle 1 exposure or 2 snaps 

48 dimensions=["instrument", "exposure", "detector"], 

49 ) 

50 visit_summary = connectionTypes.Input( 

51 doc="Visit-level catalog summarizing all image characterizations and calibrations.", 

52 name="finalVisitSummary", 

53 storageClass="ExposureCatalog", 

54 dimensions=["instrument", "visit"], 

55 ) 

56 initial_photo_calib = connectionTypes.Input( 

57 doc="Photometric calibration that was applied to exposure during the measurement of background_1." 

58 " Used to uncalibrate the background before subtracting it from the input exposure.", 

59 name="initial_photoCalib_detector", 

60 storageClass="PhotoCalib", 

61 dimensions=("instrument", "visit", "detector"), 

62 ) 

63 background_1 = connectionTypes.Input( 

64 doc="Background models estimated during calibration.", 

65 name="initial_pvi_background", 

66 storageClass="Background", 

67 dimensions=("instrument", "visit", "detector"), 

68 ) 

69 background_2 = connectionTypes.Input( 

70 doc="Background that was fit on top of background_1.", 

71 name="skyCorr", 

72 dimensions=("instrument", "visit", "detector"), 

73 storageClass="Background", 

74 ) 

75 calib_sources = connectionTypes.Input( 

76 doc="Per-visit catalog of measurements to get 'calib_*' flags from.", 

77 name="finalized_src_table", 

78 storageClass="ArrowAstropy", 

79 dimensions=["instrument", "visit"], 

80 ) 

81 background_to_photometric_ratio = connectionTypes.Input( 

82 doc=( 

83 "Ratio of a background-flattened image to a photometric-flattened image. " 

84 "Only used if do_apply_flat_background_ratio is True." 

85 ), 

86 name="background_to_photometric_ratio", 

87 storageClass="Image", 

88 dimensions=("instrument", "visit", "detector"), 

89 ) 

90 # TODO DM-46947: pull in the STREAK mask from CompareWarp. 

91 

92 # outputs 

93 sources_schema = connectionTypes.InitOutput( 

94 doc="Schema of the output sources catalog.", 

95 name="sources_schema", 

96 storageClass="SourceCatalog", 

97 ) 

98 

99 exposure = connectionTypes.Output( 

100 doc="Photometrically calibrated exposure with attached calibrations and summary statistics.", 

101 name="pvi", 

102 storageClass="ExposureF", 

103 dimensions=("instrument", "visit", "detector"), 

104 ) 

105 sources = connectionTypes.Output( 

106 doc="Catalog of measured sources detected on the calibrated exposure.", 

107 name="sources_detector", 

108 storageClass="ArrowAstropy", 

109 dimensions=["instrument", "visit", "detector"], 

110 ) 

111 sources_footprints = connectionTypes.Output( 

112 doc="Catalog of measured sources detected on the calibrated exposure; includes source footprints.", 

113 name="sources_footprints_detector", 

114 storageClass="SourceCatalog", 

115 dimensions=["instrument", "visit", "detector"], 

116 ) 

117 background = connectionTypes.Output( 

118 doc=( 

119 "Total background model including new detections in this task. " 

120 "Note that the background model has units of ADU, while the corresponding " 

121 "image has units of nJy - the image must be 'uncalibrated' before the background " 

122 "can be restored." 

123 ), 

124 name="pvi_background", 

125 dimensions=("instrument", "visit", "detector"), 

126 storageClass="Background", 

127 ) 

128 

129 def __init__(self, *, config=None): 

130 if not config.do_use_sky_corr: 

131 del self.background_2 

132 if not config.remove_initial_photo_calib: 

133 del self.initial_photo_calib 

134 if not config.do_apply_flat_background_ratio: 

135 del self.background_to_photometric_ratio 

136 

137 

138class ReprocessVisitImageConfig( 

139 pipeBase.PipelineTaskConfig, pipelineConnections=ReprocessVisitImageConnections 

140): 

141 # To generate catalog ids consistently across subtasks. 

142 id_generator = lsst.meas.base.DetectorVisitIdGeneratorConfig.make_field() 

143 

144 do_use_sky_corr = pexConfig.Field( 

145 dtype=bool, 

146 default=False, 

147 doc="Include the skyCorr input for background subtraction?", 

148 ) 

149 remove_initial_photo_calib = pexConfig.Field( 

150 dtype=bool, 

151 default=False, 

152 doc="Remove an already-applied photometric calibration from the backgrounds?", 

153 ) 

154 snap_combine = pexConfig.ConfigurableField( 

155 target=snapCombine.SnapCombineTask, 

156 doc="Task to combine two snaps to make one exposure.", 

157 ) 

158 repair = pexConfig.ConfigurableField( 

159 target=repair.RepairTask, 

160 doc="Task to repair cosmic rays on the exposure before PSF determination.", 

161 ) 

162 detection = pexConfig.ConfigurableField( 

163 target=lsst.meas.algorithms.SourceDetectionTask, 

164 doc="Task to detect sources to return in the output catalog.", 

165 ) 

166 sky_sources = pexConfig.ConfigurableField( 

167 target=lsst.meas.algorithms.SkyObjectsTask, 

168 doc="Task to generate sky sources ('empty' regions where there are no detections).", 

169 ) 

170 deblend = pexConfig.ConfigurableField( 

171 target=lsst.meas.deblender.SourceDeblendTask, doc="Split blended sources into their components." 

172 ) 

173 measurement = pexConfig.ConfigurableField( 

174 target=lsst.meas.base.SingleFrameMeasurementTask, 

175 doc="Task to measure sources to return in the output catalog.", 

176 ) 

177 normalized_calibration_flux = pexConfig.ConfigurableField( 

178 target=lsst.meas.algorithms.NormalizedCalibrationFluxTask, 

179 doc="Task to normalize the calibration flux (e.g. compensated tophats).", 

180 ) 

181 apply_aperture_correction = pexConfig.ConfigurableField( 

182 target=lsst.meas.base.ApplyApCorrTask, 

183 doc="Task to apply aperture corrections to the measured sources.", 

184 ) 

185 set_primary_flags = pexConfig.ConfigurableField( 

186 target=lsst.meas.algorithms.setPrimaryFlags.SetPrimaryFlagsTask, 

187 doc="Task to add isPrimary to the catalog.", 

188 ) 

189 catalog_calculation = pexConfig.ConfigurableField( 

190 target=lsst.meas.base.CatalogCalculationTask, 

191 doc="Task to compute catalog values using only the catalog entries.", 

192 ) 

193 post_calculations = pexConfig.ConfigurableField( 

194 target=lsst.meas.base.SingleFrameMeasurementTask, 

195 doc="Task to compute catalog values after all other calculations have been done.", 

196 ) 

197 compute_summary_stats = pexConfig.ConfigurableField( 

198 target=computeExposureSummaryStats.ComputeExposureSummaryStatsTask, 

199 doc="Task to to compute summary statistics on the calibrated exposure.", 

200 ) 

201 calib_match_radius = pexConfig.Field( 

202 dtype=float, 

203 default=0.2, 

204 doc="Radius in arcseconds to cross-match calib_sources to the output catalog.", 

205 ) 

206 do_apply_flat_background_ratio = pexConfig.Field( 

207 dtype=bool, 

208 default=False, 

209 doc="This should be True if processing was done with an illumination correction.", 

210 ) 

211 

212 def setDefaults(self): 

213 super().setDefaults() 

214 

215 # No need to redo background: we have the global background model. 

216 self.detection.reEstimateBackground = False 

217 self.detection.doTempLocalBackground = False 

218 

219 # NOTE: these apertures were selected for HSC, and may not be 

220 # what we want for LSSTCam. 

221 self.measurement.plugins["base_CircularApertureFlux"].radii = [ 

222 3.0, 

223 4.5, 

224 6.0, 

225 9.0, 

226 12.0, 

227 17.0, 

228 25.0, 

229 35.0, 

230 50.0, 

231 70.0, 

232 ] 

233 lsst.meas.extensions.shapeHSM.configure_hsm(self.measurement) 

234 self.measurement.plugins.names |= ["base_Jacobian", "base_FPPosition", "ext_photometryKron_KronFlux"] 

235 self.measurement.plugins["base_Jacobian"].pixelScale = 0.2 

236 

237 # TODO DM-46306: should make this the ApertureFlux default! 

238 # Use a large aperture to be independent of seeing in calibration 

239 self.measurement.plugins["base_CircularApertureFlux"].maxSincRadius = 12.0 

240 

241 # Only apply calibration fluxes, do not measure them. 

242 self.normalized_calibration_flux.do_measure_ap_corr = False 

243 

244 self.post_calculations.plugins.names = ["base_LocalPhotoCalib", "base_LocalWcs"] 

245 self.post_calculations.doReplaceWithNoise = False 

246 for key in self.post_calculations.slots: 

247 setattr(self.post_calculations.slots, key, None) 

248 

249 def validate(self): 

250 super().validate() 

251 

252 if self.do_apply_flat_background_ratio: 

253 if self.detection.reEstimateBackground: 

254 if not self.detection.doApplyFlatBackgroundRatio: 

255 raise pexConfig.FieldValidationError( 

256 ReprocessVisitImageConfig.detection, 

257 self, 

258 "ReprocessVisitImageConfig.detection background must be configured with " 

259 "doApplyFlatBackgroundRatio if do_apply_flat_background_ratio is True.", 

260 ) 

261 

262 

263class ReprocessVisitImageTask(pipeBase.PipelineTask): 

264 """Use the visit-level calibrations to perform detection and measurement 

265 on the single frame exposures and produce a "final" exposure and catalog. 

266 """ 

267 

268 ConfigClass = ReprocessVisitImageConfig 

269 _DefaultName = "reprocessVisitImage" 

270 

271 def __init__(self, schema=None, **kwargs): 

272 super().__init__(**kwargs) 

273 

274 if schema is None: 

275 schema = afwTable.SourceTable.makeMinimalSchema() 

276 

277 self.makeSubtask("snap_combine") 

278 self.makeSubtask("repair") 

279 self.makeSubtask("detection", schema=schema) 

280 self.makeSubtask("sky_sources", schema=schema) 

281 self.makeSubtask("deblend", schema=schema) 

282 self.makeSubtask("measurement", schema=schema) 

283 self.makeSubtask("normalized_calibration_flux", schema=schema) 

284 self.makeSubtask("apply_aperture_correction", schema=schema) 

285 self.makeSubtask("catalog_calculation", schema=schema) 

286 self.makeSubtask("set_primary_flags", schema=schema, isSingleFrame=True) 

287 self.makeSubtask("post_calculations", schema=schema) 

288 self.makeSubtask("compute_summary_stats") 

289 

290 schema.addField( 

291 "visit", 

292 type="L", 

293 doc="Visit this source appeared on.", 

294 ) 

295 schema.addField( 

296 "detector", 

297 type="U", 

298 doc="Detector this source appeared on.", 

299 ) 

300 

301 # These fields will be propagated from finalizeCharacterization. 

302 # It might be better to get them from the finalized catalog instead 

303 # (if it output a schema), so the docstrings exactly match. 

304 schema.addField( 

305 "calib_psf_candidate", 

306 type="Flag", 

307 doc="Set if the source was a candidate for PSF determination, " 

308 "as determined from FinalizeCharacterizationTask.", 

309 ) 

310 schema.addField( 

311 "calib_psf_reserved", 

312 type="Flag", 

313 doc="set if source was reserved from PSF determination by FinalizeCharacterizationTask.", 

314 ) 

315 schema.addField( 

316 "calib_psf_used", 

317 type="Flag", 

318 doc="Set if source was used in the PSF determination by FinalizeCharacterizationTask.", 

319 ) 

320 self.psf_fields = ("calib_psf_candidate", "calib_psf_used", "calib_psf_reserved") 

321 

322 # TODO (DM-46971): 

323 # These fields are only here to satisfy the SDM schema, and will 

324 # be removed from there as they are misleading (because we don't 

325 # propagate this information from gbdes/fgcmcal). 

326 schema.addField( 

327 "calib_photometry_used", 

328 type="Flag", 

329 doc="Unused; placeholder for SDM schemas.", 

330 ) 

331 schema.addField( 

332 "calib_photometry_reserved", 

333 type="Flag", 

334 doc="Unused; placeholder for SDM schemas.", 

335 ) 

336 schema.addField( 

337 "calib_astrometry_used", 

338 type="Flag", 

339 doc="Unused; placeholder for SDM schemas.", 

340 ) 

341 schema.addField( 

342 "calib_astrometry_reserved", 

343 type="Flag", 

344 doc="Unused; placeholder for SDM schemas.", 

345 ) 

346 # This pre-calibration schema is the one that most methods should use. 

347 self.schema = schema 

348 # The final catalog will have calibrated flux columns, which we add to 

349 # the init-output schema by calibrating our zero-length catalog with an 

350 # arbitrary dummy PhotoCalib. 

351 dummy_photo_calib = afwImage.PhotoCalib(1.0, 0, bbox=lsst.geom.Box2I()) 

352 self.sources_schema = dummy_photo_calib.calibrateCatalog(afwTable.SourceCatalog(schema)) 

353 

354 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

355 inputs = butlerQC.get(inputRefs) 

356 id_generator = self.config.id_generator.apply(butlerQC.quantum.dataId) 

357 

358 detector = outputRefs.exposure.dataId["detector"] 

359 exposures = inputs.pop("exposures") 

360 visit_summary = inputs.pop("visit_summary") 

361 calib_sources = inputs.pop("calib_sources") 

362 if self.config.remove_initial_photo_calib: 

363 initial_photo_calib = inputs.pop("initial_photo_calib") 

364 else: 

365 initial_photo_calib = None 

366 background_1 = inputs.pop("background_1") 

367 if self.config.do_use_sky_corr: 

368 background_2 = inputs.pop("background_2") 

369 background = combine_backgrounds(background_1, background_2) 

370 else: 

371 background = background_1 

372 if self.config.do_apply_flat_background_ratio: 

373 background_to_photometric_ratio = inputs.pop("background_to_photometric_ratio") 

374 else: 

375 background_to_photometric_ratio = None 

376 

377 # This should not happen with a properly configured execution context. 

378 assert not inputs, "runQuantum got more inputs than expected" 

379 

380 detector_summary = visit_summary.find(detector) 

381 lines = [] 

382 if detector_summary is None: 

383 lines.append(" > no entry for the detector was found in the visit summary table") 

384 else: 

385 if detector_summary.psf is None: 

386 lines.append(" > the PSF model for the detector is None") 

387 if detector_summary.wcs is None: 

388 lines.append(" > the WCS model for the detector is None") 

389 if detector_summary.apCorrMap is None: 

390 lines.append(" > the aperture correction model map for the detector is None") 

391 if detector_summary.photoCalib is None: 

392 lines.append(" > the photometric calibration model for the detector is None") 

393 

394 if lines: 

395 msg = "\n".join(lines) 

396 raise pipeBase.UpstreamFailureNoWorkFound( 

397 f"Skipping reprocessing of detector {detector} because:\n{msg}" 

398 ) 

399 

400 # Specify the fields that `annotate` needs below, to ensure they 

401 # exist, even as None. 

402 result = pipeBase.Struct( 

403 exposure=None, 

404 sources_footprints=None, 

405 ) 

406 try: 

407 self.run( 

408 exposures=exposures, 

409 initial_photo_calib=initial_photo_calib, 

410 psf=detector_summary.psf, 

411 background=background, 

412 ap_corr=detector_summary.apCorrMap, 

413 photo_calib=detector_summary.photoCalib, 

414 wcs=detector_summary.wcs, 

415 calib_sources=calib_sources, 

416 result=result, 

417 id_generator=id_generator, 

418 background_to_photometric_ratio=background_to_photometric_ratio, 

419 ) 

420 except pipeBase.AlgorithmError as e: 

421 error = pipeBase.AnnotatedPartialOutputsError.annotate( 

422 e, self, result.exposure, result.sources_footprints, log=self.log 

423 ) 

424 butlerQC.put(result, outputRefs) 

425 raise error from e 

426 

427 butlerQC.put(result, outputRefs) 

428 

429 def run( 

430 self, 

431 *, 

432 exposures, 

433 initial_photo_calib, 

434 psf, 

435 background, 

436 ap_corr, 

437 photo_calib, 

438 wcs, 

439 calib_sources, 

440 id_generator=None, 

441 background_to_photometric_ratio=None, 

442 result=None, 

443 ): 

444 """Detect and measure sources on the exposure(s) (snap combined as 

445 necessary), and make a "final" Processed Visit Image using all of the 

446 supplied metadata, plus a catalog measured on it. 

447 

448 Parameters 

449 ---------- 

450 exposures : `lsst.afw.image.Exposure` or 

451 `list` [`lsst.afw.image.Exposure`] 

452 Post-ISR exposure(s), with an initial WCS, VisitInfo, and Filter. 

453 Modified in-place during processing if only one is passed. 

454 If two exposures are passed, treat them as snaps and combine 

455 before doing further processing. 

456 initial_photo_calib : `lsst.afw.image.PhotoCalib` or `None` 

457 Photometric calibration that was applied to exposure during the 

458 measurement of the background. Should be `None` if and only if 

459 ``config.remove_initial_photo_calib` is false. 

460 psf : `lsst.afw.detection.Psf` 

461 PSF model for this exposure. 

462 background : `lsst.afw.math.BackgroundList` 

463 Total background that had been fit to the exposure so far; 

464 modified in place to include background fit when detecting sources. 

465 ap_corr : `lsst.afw.image.ApCorrMap` 

466 Aperture Correction model for this exposure. 

467 photo_calib : `lsst.afw.image.PhotoCalib` 

468 Photometric calibration model for this exposure. 

469 wcs : `lsst.afw.geom.SkyWcs` 

470 World Coordinate System model for this exposure. 

471 calib_sources : `astropy.table.Table` 

472 Per-visit catalog of measurements to get 'calib_*' flags from. 

473 id_generator : `lsst.meas.base.IdGenerator`, optional 

474 Object that generates source IDs and provides random seeds. 

475 background_to_photometric_ratio : `lsst.afw.image.ImageF`, optional 

476 Background to photometric ratio image, to convert between 

477 photometric flattened and background flattened image. 

478 result : `lsst.pipe.base.Struct`, optional 

479 Result struct that is modified to allow saving of partial outputs 

480 for some failure conditions. If the task completes successfully, 

481 this is also returned. 

482 

483 Returns 

484 ------- 

485 result : `lsst.pipe.base.Struct` 

486 Results as a struct with attributes: 

487 

488 ``exposure`` 

489 Calibrated exposure, with pixels in nJy units. 

490 (`lsst.afw.image.Exposure`) 

491 ``sources`` 

492 Sources that were measured on the exposure, with calibrated 

493 fluxes and magnitudes. (`astropy.table.Table`) 

494 ``sources_footprints`` 

495 Footprints of sources that were measured on the exposure. 

496 (`lsst.afw.table.SourceCatalog`) 

497 ``background`` 

498 Total background that was fit to, and subtracted from the 

499 exposure when detecting ``sources``, in the same nJy units as 

500 ``exposure``. (`lsst.afw.math.BackgroundList`) 

501 """ 

502 if result is None: 

503 result = pipeBase.Struct() 

504 if id_generator is None: 

505 id_generator = lsst.meas.base.IdGenerator() 

506 

507 result.exposure = self.snap_combine.run(exposures).exposure 

508 

509 # Apply the illumination correction if required. 

510 # This assumes the input images have had a background-flat applied. 

511 if self.config.do_apply_flat_background_ratio: 

512 result.exposure.maskedImage /= background_to_photometric_ratio 

513 

514 if self.config.remove_initial_photo_calib: 

515 # Calibrate the image, so it's on the same units as the background. 

516 result.exposure.maskedImage = initial_photo_calib.calibrateImage(result.exposure.maskedImage) 

517 

518 with lsst.meas.algorithms.backgroundFlatContext( 

519 result.exposure.maskedImage, 

520 self.config.do_apply_flat_background_ratio, 

521 backgroundToPhotometricRatio=background_to_photometric_ratio, 

522 ): 

523 result.exposure.maskedImage -= background.getImage() 

524 

525 if self.config.remove_initial_photo_calib: 

526 # Uncalibrate so that we do the measurements in instFlux, because 

527 # we don't have a way to identify measurements as being in nJy. 

528 result.exposure.maskedImage /= initial_photo_calib.getCalibrationMean() 

529 

530 result.exposure.setPsf(psf) 

531 result.exposure.setApCorrMap(ap_corr) 

532 result.exposure.setWcs(wcs) 

533 result.exposure.setPhotoCalib(photo_calib) 

534 

535 result.sources_footprints = self._find_sources( 

536 result.exposure, 

537 background, 

538 calib_sources, 

539 id_generator, 

540 background_to_photometric_ratio=background_to_photometric_ratio, 

541 ) 

542 result.background = background 

543 # TODO (DM-46971): 

544 # Now that we're running them before we apply the PhotoCalib to the 

545 # image pixels, there's no need for post_calibrations to exist as 

546 # a separate measurement instance from the main one (which is invoked 

547 # in _find_sources), but it's better to save removal (which may need 

548 # to involve a deprecation) until after we've got everything running. 

549 self.post_calculations.run(result.sources_footprints, result.exposure) 

550 result.exposure.info.setSummaryStats( 

551 self.compute_summary_stats.run(result.exposure, result.sources_footprints, background) 

552 ) 

553 result.sources_footprints = self._apply_photo_calib( 

554 result.exposure, 

555 result.sources_footprints, 

556 photo_calib, 

557 ) 

558 result.sources = result.sources_footprints.asAstropy() 

559 

560 return result 

561 

562 def _find_sources( 

563 self, 

564 exposure, 

565 background, 

566 calib_sources, 

567 id_generator, 

568 background_to_photometric_ratio=None, 

569 ): 

570 """Detect and measure sources on the exposure. 

571 

572 Parameters 

573 ---------- 

574 exposure : `lsst.afw.image.Exposure` 

575 Exposure to detect and measure sources on; must have a valid PSF. 

576 background : `lsst.afw.math.BackgroundList` 

577 Background that was fit to the exposure during detection; 

578 modified in-place during subsequent detection. 

579 calib_sources : `astropy.table.Table` 

580 Per-visit catalog of measurements to get 'calib_*' flags from. 

581 id_generator : `lsst.meas.base.IdGenerator` 

582 Object that generates source IDs and provides random seeds. 

583 background_to_photometric_ratio : `lsst.afw.image.Image`, optional 

584 Image to convert photometric-flattened image to 

585 background-flattened image. 

586 

587 Returns 

588 ------- 

589 sources 

590 Catalog that was detected and measured on the exposure. 

591 """ 

592 table = afwTable.SourceTable.make(self.schema, id_generator.make_table_id_factory()) 

593 

594 self.repair.run(exposure=exposure) 

595 detections = self.detection.run( 

596 table=table, 

597 exposure=exposure, 

598 background=background, 

599 backgroundToPhotometricRatio=background_to_photometric_ratio, 

600 ) 

601 sources = detections.sources 

602 

603 self.sky_sources.run(exposure.mask, id_generator.catalog_id, sources) 

604 

605 self.deblend.run(exposure=exposure, sources=sources) 

606 # The deblender may not produce a contiguous catalog; ensure 

607 # contiguity for subsequent tasks. 

608 if not sources.isContiguous(): 

609 sources = sources.copy(deep=True) 

610 

611 self.measurement.run(sources, exposure) 

612 self.normalized_calibration_flux.run(exposure=exposure, catalog=sources) 

613 self.apply_aperture_correction.run(sources, exposure.apCorrMap) 

614 self.catalog_calculation.run(sources) 

615 self.set_primary_flags.run(sources) 

616 

617 sources["visit"] = exposure.visitInfo.id 

618 sources["detector"] = exposure.info.getDetector().getId() 

619 

620 self._match_calib_sources(sources, calib_sources, exposure.info.getDetector().getId()) 

621 

622 return sources 

623 

624 def _match_calib_sources(self, sources, calib_sources, detector): 

625 """Match with calib_sources to set `calib_*` flags in the output 

626 catalog. 

627 

628 Parameters 

629 ---------- 

630 sources : `lsst.afw.table.SourceCatalog` 

631 Catalog that was detected and measured on the exposure. Modified 

632 in place to set the psf_fields. 

633 calib_sources : `astropy.table.Table` 

634 Per-visit catalog of measurements to get 'calib_*' flags from. 

635 detector : `int` 

636 Id of detector for this exposure, to get the correct sources from 

637 calib_sources for cross-matching. 

638 """ 

639 # NOTE: we don't remove the sky sources here, but they should be very 

640 # far from any actual source, and so should not match with anything. 

641 use = calib_sources["detector"] == detector 

642 with smatch.Matcher(sources["coord_ra"], sources["coord_dec"]) as matcher: 

643 _, i1, i2, _ = matcher.query_knn( 

644 calib_sources[use]["coord_ra"], 

645 calib_sources[use]["coord_dec"], 

646 1, 

647 self.config.calib_match_radius / 3600.0, 

648 return_indices=True, 

649 ) 

650 

651 for field in self.psf_fields: 

652 # NOTE: Have to fill a full-sized array first, then set with it. 

653 result = np.zeros(len(sources), dtype=bool) 

654 result[i1] = calib_sources[use][i2][field] 

655 sources[field] = result 

656 

657 def _apply_photo_calib(self, exposure, sources_footprints, photo_calib): 

658 """Photometrically calibrate the exposure and catalog with the 

659 supplied PhotoCalib, and set the exposure's PhotoCalib to 1. 

660 

661 Parameters 

662 ---------- 

663 exposures : `lsst.afw.image.Exposure` 

664 Exposure to calibrate and set PhotoCalib on; Modified in place. 

665 sources_footprints : `lsst.afw.table.SourceCatalog` 

666 Catalog to calibrate. 

667 photo_calib : `lsst.afw.image.PhotoCalib` 

668 Photometric calibration to apply. 

669 calibrated_stars : `lsst.afw.table.SourceCatalog` 

670 Star catalog with flux/magnitude columns computed from the 

671 supplied PhotoCalib. 

672 """ 

673 calibrated_sources_footprints = photo_calib.calibrateCatalog(sources_footprints) 

674 exposure.maskedImage = photo_calib.calibrateImage(exposure.maskedImage) 

675 identity = afwImage.PhotoCalib(1.0, photo_calib.getCalibrationErr(), bbox=exposure.getBBox()) 

676 exposure.setPhotoCalib(identity) 

677 exposure.metadata["BUNIT"] = "nJy" 

678 return calibrated_sources_footprints 

679 

680 

681def combine_backgrounds(initial_pvi_background, sky_corr): 

682 """Return the total background that was applied to the original 

683 processing. 

684 """ 

685 background = lsst.afw.math.BackgroundList() 

686 for item in initial_pvi_background: 

687 background.append(item) 

688 for item in sky_corr: 

689 background.append(item) 

690 return background