Coverage for python/lsst/summit/utils/peekExposure.py: 19%

384 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-14 12:17 +0000

1# This file is part of summit_utils. 

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__ = [ 

23 "PeekExposureTaskConfig", 

24 "PeekExposureTask", 

25] 

26 

27from lsst.afw.detection import Psf 

28import lsst.afw.display as afwDisplay 

29import lsst.afw.math as afwMath 

30import lsst.daf.base as dafBase 

31import lsst.pex.config as pexConfig 

32import lsst.pipe.base as pipeBase 

33import numpy as np 

34from lsst.afw.geom.ellipses import Quadrupole 

35from lsst.afw.image import ImageD 

36from lsst.atmospec.utils import isDispersedExp 

37from lsst.geom import ( 

38 Box2I, 

39 Extent2I, 

40 LinearTransform, 

41 Point2D, 

42 Point2I, 

43 SpherePoint, 

44 arcseconds, 

45 degrees, 

46) 

47from lsst.meas.algorithms.installGaussianPsf import InstallGaussianPsfTask 

48from lsst.meas.algorithms import ( 

49 SubtractBackgroundTask, 

50 SourceDetectionTask, 

51) 

52from lsst.meas.base import ( 

53 SingleFrameMeasurementTask, 

54 IdGenerator, 

55) 

56from lsst.afw.table import SourceTable 

57 

58 

59IDX_SENTINEL = -99999 

60 

61 

62def _estimateMode(data, frac=0.5): 

63 """Estimate the mode of a 1d distribution. 

64 

65 Finds the smallest interval containing the fraction ``frac`` of the data, 

66 then takes the median of the values in that interval. 

67 

68 Parameters 

69 ---------- 

70 data : array-like 

71 1d array of data values 

72 frac : float, optional 

73 Fraction of data to include in the mode interval. Default is 0.5. 

74 

75 Returns 

76 ------- 

77 mode : float 

78 Estimated mode of the data. 

79 """ 

80 data = data[np.isfinite(data)] 

81 if len(data) == 0: 

82 return np.nan 

83 elif len(data) == 1: 

84 return data[0] 

85 

86 data = np.sort(data) 

87 interval = int(np.ceil(frac * len(data))) 

88 spans = data[interval:] - data[:-interval] 

89 start = np.argmin(spans) 

90 return np.median(data[start:start + interval]) 

91 

92 

93def _bearingToUnitVector(wcs, bearing, imagePoint, skyPoint=None): 

94 """Compute unit vector along given bearing at given point in the sky. 

95 

96 Parameters 

97 ---------- 

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

99 World Coordinate System of image. 

100 bearing : `lsst.geom.Angle` 

101 Bearing (angle North of East) at which to compute unit vector. 

102 imagePoint : `lsst.geom.Point2D` 

103 Point in the image. 

104 skyPoint : `lsst.geom.SpherePoint`, optional 

105 Point in the sky. 

106 

107 Returns 

108 ------- 

109 unitVector : `lsst.geom.Extent2D` 

110 Unit vector in the direction of bearing. 

111 """ 

112 if skyPoint is None: 

113 skyPoint = wcs.pixelToSky(imagePoint) 

114 dpt = wcs.skyToPixel(skyPoint.offset(bearing, 1e-4 * degrees)) - imagePoint 

115 return dpt / dpt.computeNorm() 

116 

117 

118def roseVectors(wcs, imagePoint, parAng=None): 

119 """Compute unit vectors in the N/W and optionally alt/az directions. 

120 

121 Parameters 

122 ---------- 

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

124 World Coordinate System of image. 

125 imagePoint : `lsst.geom.Point2D` 

126 Point in the image 

127 parAng : `lsst.geom.Angle`, optional 

128 Parallactic angle (position angle of zenith measured East from North) 

129 (default: None) 

130 

131 Returns 

132 ------- 

133 unitVectors : `dict` of `lsst.geom.Extent2D` 

134 Unit vectors in the N, W, alt, and az directions. 

135 """ 

136 ncp = SpherePoint(0 * degrees, 90 * degrees) # North Celestial Pole 

137 skyPoint = wcs.pixelToSky(imagePoint) 

138 bearing = skyPoint.bearingTo(ncp) 

139 

140 out = dict() 

141 out["N"] = _bearingToUnitVector( 

142 wcs, bearing, imagePoint, skyPoint=skyPoint 

143 ) 

144 out["W"] = _bearingToUnitVector( 

145 wcs, bearing + 90 * degrees, imagePoint, skyPoint=skyPoint 

146 ) 

147 

148 if parAng is not None: 

149 out["alt"] = _bearingToUnitVector( 

150 wcs, bearing - parAng, imagePoint, skyPoint=skyPoint 

151 ) 

152 out["az"] = _bearingToUnitVector( 

153 wcs, bearing - parAng + 90 * degrees, imagePoint, skyPoint=skyPoint 

154 ) 

155 

156 return out 

157 

158 

159def plotRose(display, wcs, imagePoint, parAng=None, len=50): 

160 """Display unit vectors along N/W and optionally alt/az directions. 

161 

162 Parameters 

163 ---------- 

164 display : `lsst.afw.display.Display` 

165 Display on which to render rose. 

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

167 World Coordinate System of image. 

168 imagePoint : `lsst.geom.Point2D` 

169 Point in the image at which to render rose. 

170 parAng : `lsst.geom.Angle`, optional 

171 Parallactic angle (position angle of zenith measured East from North) 

172 (default: None) 

173 len : `float`, optional 

174 Length of the rose vectors (default: 50) 

175 """ 

176 unitVectors = roseVectors(wcs, imagePoint, parAng=parAng) 

177 colors = dict(N="r", W="r", alt="g", az="g") 

178 for name, unitVector in unitVectors.items(): 

179 display.line([imagePoint, imagePoint + len * unitVector], ctype=colors[name]) 

180 display.dot(name, *(imagePoint + 1.6 * len * unitVector), ctype=colors[name]) 

181 

182 

183class DonutPsf(Psf): 

184 def __init__(self, size, outerRad, innerRad): 

185 Psf.__init__(self, isFixed=True) 

186 self.size = size 

187 self.outerRad = outerRad 

188 self.innerRad = innerRad 

189 self.dimensions = Extent2I(size, size) 

190 

191 def __deepcopy__(self, memo=None): 

192 return DonutPsf(self.size, self.outerRad, self.innerRad) 

193 

194 def resized(self, width, height): 

195 assert width == height 

196 return DonutPsf(width, self.outerRad, self.innerRad) 

197 

198 def _doComputeKernelImage(self, position=None, color=None): 

199 bbox = self.computeBBox(self.getAveragePosition()) 

200 img = ImageD(bbox, 0.0) 

201 x, y = np.ogrid[bbox.minY:bbox.maxY + 1, bbox.minX:bbox.maxX + 1] 

202 rsqr = x**2 + y**2 

203 w = (rsqr < self.outerRad**2) & (rsqr > self.innerRad**2) 

204 img.array[w] = 1.0 

205 img.array /= np.sum(img.array) 

206 return img 

207 

208 def _doComputeBBox(self, position=None, color=None): 

209 return Box2I(Point2I(-self.dimensions / 2), self.dimensions) 

210 

211 def _doComputeShape(self, position=None, color=None): 

212 Ixx = self.outerRad**4 - self.innerRad**4 

213 Ixx /= self.outerRad**2 - self.innerRad**2 

214 return Quadrupole(Ixx, Ixx, 0.0) 

215 

216 def _doComputeApertureFlux(self, radius, position=None, color=None): 

217 return 1 - np.exp(-0.5 * (radius / self.sigma) ** 2) 

218 

219 def __eq__(self, rhs): 

220 if isinstance(rhs, DonutPsf): 

221 return ( 

222 self.size == rhs.size and 

223 self.outerRad == rhs.outerRad and 

224 self.innerRad == rhs.innerRad 

225 ) 

226 return False 

227 

228 

229class PeekTaskConfig(pexConfig.Config): 

230 """Config class for the PeekTask.""" 

231 

232 installPsf = pexConfig.ConfigurableField( 

233 target=InstallGaussianPsfTask, 

234 doc="Install a PSF model", 

235 ) 

236 doInstallPsf = pexConfig.Field( 

237 dtype=bool, 

238 default=True, 

239 doc="Install a PSF model?", 

240 ) 

241 background = pexConfig.ConfigurableField( 

242 target=SubtractBackgroundTask, 

243 doc="Estimate background", 

244 ) 

245 detection = pexConfig.ConfigurableField( 

246 target=SourceDetectionTask, 

247 doc="Detect sources" 

248 ) 

249 measurement = pexConfig.ConfigurableField( 

250 target=SingleFrameMeasurementTask, 

251 doc="Measure sources" 

252 ) 

253 defaultBinSize = pexConfig.Field( 

254 dtype=int, 

255 default=1, 

256 doc="Default binning factor for exposure (often overridden).", 

257 ) 

258 

259 def setDefaults(self): 

260 super().setDefaults() 

261 # Configure to be aggressively fast. 

262 self.detection.thresholdValue = 5.0 

263 self.detection.includeThresholdMultiplier = 10.0 

264 self.detection.reEstimateBackground = False 

265 self.detection.doTempLocalBackground = False 

266 self.measurement.doReplaceWithNoise = False 

267 self.detection.minPixels = 40 

268 self.installPsf.fwhm = 5.0 

269 self.installPsf.width = 21 

270 # minimal set of measurements 

271 self.measurement.plugins.names = [ 

272 "base_PixelFlags", 

273 "base_SdssCentroid", 

274 "ext_shapeHSM_HsmSourceMoments", 

275 "base_GaussianFlux", 

276 "base_PsfFlux", 

277 "base_CircularApertureFlux", 

278 ] 

279 self.measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments" 

280 

281 

282class PeekTask(pipeBase.Task): 

283 """Peek at exposure to quickly detect and measure both the brightest source 

284 in the image, and a set of sources representative of the exposure's overall 

285 image quality. 

286 

287 Optionally bins image and then: 

288 - installs a simple PSF model 

289 - measures and subtracts the background 

290 - detects sources 

291 - measures sources 

292 

293 Designed to be quick at the expense of primarily completeness, but also to 

294 a lesser extent accuracy. 

295 """ 

296 

297 ConfigClass = PeekTaskConfig 

298 _DefaultName = "peek" 

299 

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

301 super().__init__(**kwargs) 

302 

303 if schema is None: 

304 schema = SourceTable.makeMinimalSchema() 

305 self.schema = schema 

306 

307 self.makeSubtask("installPsf") 

308 self.makeSubtask("background") 

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

310 self.algMetadata = dafBase.PropertyList() 

311 self.makeSubtask('measurement', schema=self.schema, algMetadata=self.algMetadata) 

312 

313 def run(self, exposure, binSize=None): 

314 """Peek at exposure. 

315 

316 Parameters 

317 ---------- 

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

319 Exposure at which to peek. 

320 binSize : `int`, optional 

321 Binning factor for exposure. Default is None, which will use the 

322 default binning factor from the config. 

323 

324 Returns 

325 ------- 

326 result : `pipeBase.Struct` 

327 Result of peeking. 

328 Struct containing: 

329 - sourceCat : `lsst.afw.table.SourceCatalog` 

330 Source catalog from the binned exposure. 

331 """ 

332 if binSize is None: 

333 binSize = self.config.defaultBinSize 

334 

335 if binSize != 1: 

336 mi = exposure.getMaskedImage() 

337 binned = afwMath.binImage(mi, binSize) 

338 exposure.setMaskedImage(binned) 

339 

340 if self.config.doInstallPsf: 

341 self.installPsf.run(exposure=exposure) 

342 

343 self.background.run(exposure) 

344 

345 idGenerator = IdGenerator() 

346 sourceIdFactory = idGenerator.make_table_id_factory() 

347 table = SourceTable.make(self.schema, sourceIdFactory) 

348 table.setMetadata(self.algMetadata) 

349 sourceCat = self.detection.run(table=table, exposure=exposure, doSmooth=True).sources 

350 

351 self.measurement.run(measCat=sourceCat, exposure=exposure, exposureId=idGenerator.catalog_id) 

352 

353 return pipeBase.Struct( 

354 sourceCat=sourceCat, 

355 ) 

356 

357 

358class PeekDonutTaskConfig(pexConfig.Config): 

359 """Config class for the PeekDonutTask.""" 

360 

361 peek = pexConfig.ConfigurableField( 

362 target=PeekTask, 

363 doc="Peek configuration", 

364 ) 

365 resolution = pexConfig.Field( 

366 dtype=float, 

367 default=16.0, 

368 doc="Target number of pixels for a binned donut", 

369 ) 

370 binSizeMax = pexConfig.Field( 

371 dtype=int, 

372 default=10, 

373 doc="Maximum binning factor for donut mode", 

374 ) 

375 

376 def setDefaults(self): 

377 super().setDefaults() 

378 # Donuts are big even when binned. 

379 self.peek.installPsf.fwhm = 10.0 

380 self.peek.installPsf.width = 31 

381 # Use DonutPSF if not overridden 

382 self.peek.doInstallPsf = False 

383 

384 

385class PeekDonutTask(pipeBase.Task): 

386 """Peek at a donut exposure. 

387 

388 The main modification for donuts is to aggressively bin the image to reduce 

389 the size of sources (donuts) from ~100 pixels or more to ~10 pixels. This 

390 greatly increases the speed and detection capabilities of PeekTask with 

391 little loss of accuracy for centroids. 

392 """ 

393 

394 ConfigClass = PeekDonutTaskConfig 

395 _DefaultName = "peekDonut" 

396 

397 def __init__(self, config, **kwargs): 

398 super().__init__(config=config, **kwargs) 

399 self.makeSubtask("peek") 

400 

401 def run(self, exposure, donutDiameter, binSize=None): 

402 """Peek at donut exposure. 

403 

404 Parameters 

405 ---------- 

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

407 Exposure at which to peek. 

408 donutDiameter : `float` 

409 Donut diameter in pixels. 

410 binSize : `int`, optional 

411 Binning factor for exposure. Default is None, which will use the 

412 resolution config value to determine the binSize. 

413 

414 Returns 

415 ------- 

416 result : `pipeBase.Struct` 

417 Result of donut peeking. 

418 Struct containing: 

419 - mode : `str` 

420 Peek mode that was run. 

421 - binSize : `int` 

422 Binning factor used. 

423 - binnedSourceCat : `lsst.afw.table.SourceCatalog` 

424 Source catalog from the binned exposure. 

425 """ 

426 if binSize is None: 

427 binSize = int( 

428 np.floor( 

429 np.clip( 

430 donutDiameter / self.config.resolution, 

431 1, 

432 self.config.binSizeMax, 

433 ) 

434 ) 

435 ) 

436 binnedDonutDiameter = donutDiameter / binSize 

437 psf = DonutPsf( 

438 binnedDonutDiameter*1.5, 

439 binnedDonutDiameter*0.5, 

440 binnedDonutDiameter*0.5*0.3525 

441 ) 

442 

443 # Note that SourceDetectionTask will convolve with a _Gaussian 

444 # approximation to the PSF_ anyway, so we don't really need to be 

445 # precise with the PSF unless this changes. PSFs that approach the 

446 # size of the image, however, can cause problems with the detection 

447 # convolution algorithm, so we limit the size. 

448 sigma = psf.computeShape(psf.getAveragePosition()).getDeterminantRadius() 

449 factor = 8*sigma / (min(exposure.getDimensions())/binSize) 

450 

451 if factor > 1: 

452 psf = DonutPsf( 

453 binnedDonutDiameter*1.5/factor, 

454 binnedDonutDiameter*0.5/factor, 

455 binnedDonutDiameter*0.5*0.3525/factor 

456 ) 

457 exposure.setPsf(psf) 

458 

459 peekResult = self.peek.run(exposure, binSize) 

460 

461 return pipeBase.Struct( 

462 mode="donut", 

463 binSize=binSize, 

464 binnedSourceCat=peekResult.sourceCat, 

465 ) 

466 

467 def getGoodSources(self, binnedSourceCat): 

468 """Perform any filtering on the source catalog. 

469 

470 Parameters 

471 ---------- 

472 binnedSourceCat : `lsst.afw.table.SourceCatalog` 

473 Source catalog from the binned exposure. 

474 

475 Returns 

476 ------- 

477 goodSourceMask : `numpy.ndarray` 

478 Boolean array indicating which sources are good. 

479 """ 

480 # Perform any filtering on the source catalog 

481 goodSourceMask = np.ones(len(binnedSourceCat), dtype=bool) 

482 return goodSourceMask 

483 

484 

485class PeekPhotoTaskConfig(pexConfig.Config): 

486 """Config class for the PeekPhotoTask.""" 

487 

488 peek = pexConfig.ConfigurableField( 

489 target=PeekTask, 

490 doc="Peek configuration", 

491 ) 

492 binSize = pexConfig.Field( 

493 dtype=int, 

494 default=2, 

495 doc="Binning factor for exposure", 

496 ) 

497 

498 def setDefaults(self): 

499 super().setDefaults() 

500 # Use a lower detection threshold in photo mode to go a bit fainter. 

501 self.peek.detection.includeThresholdMultiplier = 1.0 

502 self.peek.detection.thresholdValue = 10.0 

503 self.peek.detection.minPixels = 10 

504 

505 

506class PeekPhotoTask(pipeBase.Task): 

507 """Peek at a photo (imaging) exposure. 

508 

509 For photo mode, we keep a relatively small detection threshold value, so we 

510 can detect faint sources to use for image quality assessment. 

511 """ 

512 

513 ConfigClass = PeekPhotoTaskConfig 

514 _DefaultName = "peekPhoto" 

515 

516 def __init__(self, config, **kwargs): 

517 super().__init__(config=config, **kwargs) 

518 self.makeSubtask("peek") 

519 

520 def run(self, exposure, binSize=None): 

521 """Peek at donut exposure. 

522 

523 Parameters 

524 ---------- 

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

526 Exposure at which to peek. 

527 binSize : `int`, optional 

528 Binning factor for exposure. Default is None, which will use the 

529 binning factor from the config. 

530 

531 Returns 

532 ------- 

533 result : `pipeBase.Struct` 

534 Result of photo peeking. 

535 Struct containing: 

536 - mode : `str` 

537 Peek mode that was run. 

538 - binSize : `int` 

539 Binning factor used. 

540 - binnedSourceCat : `lsst.afw.table.SourceCatalog` 

541 Source catalog from the binned exposure. 

542 """ 

543 if binSize is None: 

544 binSize = self.config.binSize 

545 

546 peekResult = self.peek.run(exposure, binSize) 

547 

548 return pipeBase.Struct( 

549 mode="photo", 

550 binSize=binSize, 

551 binnedSourceCat=peekResult.sourceCat, 

552 ) 

553 

554 def getGoodSources(self, binnedSourceCat): 

555 """Perform any filtering on the source catalog. 

556 

557 Parameters 

558 ---------- 

559 binnedSourceCat : `lsst.afw.table.SourceCatalog` 

560 Source catalog from the binned exposure. 

561 

562 Returns 

563 ------- 

564 goodSourceMask : `numpy.ndarray` 

565 Boolean array indicating which sources are good. 

566 """ 

567 # Perform any filtering on the source catalog 

568 goodSourceMask = np.ones(len(binnedSourceCat), dtype=bool) 

569 return goodSourceMask 

570 

571 

572class PeekSpecTaskConfig(pexConfig.Config): 

573 """Config class for the PeekSpecTask.""" 

574 

575 peek = pexConfig.ConfigurableField( 

576 target=PeekTask, 

577 doc="Peek configuration", 

578 ) 

579 binSize = pexConfig.Field( 

580 dtype=int, 

581 default=2, 

582 doc="binning factor for exposure", 

583 ) 

584 maxFootprintAspectRatio = pexConfig.Field( 

585 dtype=float, 

586 default=10.0, 

587 doc="Maximum detection footprint aspect ratio to consider as 0th order" 

588 " (non-dispersed) light." 

589 ) 

590 

591 def setDefaults(self): 

592 super().setDefaults() 

593 # Use bright threshold 

594 self.peek.detection.includeThresholdMultiplier = 1.0 

595 self.peek.detection.thresholdValue = 500.0 

596 # Use a large radius aperture flux for spectra to better identify the 

597 # brightest source, which for spectra often has a saturated core. 

598 self.peek.measurement.slots.apFlux = "base_CircularApertureFlux_70_0" 

599 # Also allow a larger distance to peak for centroiding in case there's 

600 # a relatively large saturated region. 

601 self.peek.measurement.plugins['base_SdssCentroid'].maxDistToPeak = 15.0 

602 

603 

604class PeekSpecTask(pipeBase.Task): 

605 """Peek at a spectroscopic exposure. 

606 

607 For spec mode, we dramatically increase the detection threshold to avoid 

608 creating blends with the long spectra objects that appear in these images. 

609 We also change the default aperture flux slot to a larger aperture, which 

610 helps overcome challenges with lost flux in the interpolated cores of 

611 saturated objects. 

612 """ 

613 

614 ConfigClass = PeekSpecTaskConfig 

615 _DefaultName = "peekSpec" 

616 

617 def __init__(self, config, **kwargs): 

618 super().__init__(config=config, **kwargs) 

619 self.makeSubtask("peek") 

620 

621 def run(self, exposure, binSize=None): 

622 """Peek at spectroscopic exposure. 

623 

624 Parameters 

625 ---------- 

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

627 Exposure at which to peek. 

628 binSize : `int`, optional 

629 Binning factor for exposure. Default is None, which will use the 

630 binning factor from the config. 

631 

632 Returns 

633 ------- 

634 result : `pipeBase.Struct` 

635 Result of spec peeking. 

636 Struct containing: 

637 - mode : `str` 

638 Peek mode that was run. 

639 - binSize : `int` 

640 Binning factor used. 

641 - binnedSourceCat : `lsst.afw.table.SourceCatalog` 

642 Source catalog from the binned exposure. 

643 """ 

644 if binSize is None: 

645 binSize = self.config.binSize 

646 

647 peekResult = self.peek.run(exposure, binSize) 

648 

649 return pipeBase.Struct( 

650 mode="spec", 

651 binSize=binSize, 

652 binnedSourceCat=peekResult.sourceCat, 

653 ) 

654 

655 def getGoodSources(self, binnedSourceCat): 

656 """Perform any filtering on the source catalog. 

657 

658 Parameters 

659 ---------- 

660 binnedSourceCat : `lsst.afw.table.SourceCatalog` 

661 Source catalog from the binned exposure. 

662 

663 Returns 

664 ------- 

665 goodSourceMask : `numpy.ndarray` 

666 Boolean array indicating which sources are good. 

667 """ 

668 # Perform any filtering on the source catalog 

669 goodSourceMask = np.ones(len(binnedSourceCat), dtype=bool) 

670 fpShapes = [src.getFootprint().getShape() for src in binnedSourceCat] 

671 # Filter out likely spectrum detections 

672 goodSourceMask &= np.array( 

673 [ 

674 sh.getIyy() < self.config.maxFootprintAspectRatio * sh.getIxx() 

675 for sh in fpShapes 

676 ], 

677 dtype=bool 

678 ) 

679 return goodSourceMask 

680 

681 

682class PeekExposureTaskConfig(pexConfig.Config): 

683 """Config class for the PeekExposureTask.""" 

684 

685 donutThreshold = pexConfig.Field( 

686 dtype=float, 

687 default=50.0, 

688 doc="Size threshold in pixels for when to switch to donut mode.", 

689 ) 

690 doPhotoFallback = pexConfig.Field( 

691 dtype=bool, 

692 default=True, 

693 doc="If True, fall back to photo mode if spec mode fails.", 

694 ) 

695 donut = pexConfig.ConfigurableField( 

696 target=PeekDonutTask, 

697 doc="PeekDonut task", 

698 ) 

699 photo = pexConfig.ConfigurableField( 

700 target=PeekPhotoTask, 

701 doc="PeekPhoto task", 

702 ) 

703 spec = pexConfig.ConfigurableField( 

704 target=PeekSpecTask, 

705 doc="PeekSpec task", 

706 ) 

707 

708 

709class PeekExposureTask(pipeBase.Task): 

710 """ Peek at exposure to quickly detect and measure both the brightest 

711 source in the image, and a set of sources representative of the 

712 exposure's overall image quality. 

713 

714 Parameters 

715 ---------- 

716 config : `lsst.summit.utils.peekExposure.PeekExposureTaskConfig` 

717 Configuration for the task. 

718 display : `lsst.afw.display.Display`, optional 

719 For displaying the exposure and sources. 

720 

721 Notes 

722 ----- 

723 The basic philosophy of PeekExposureTask is to: 

724 1) Classify exposures based on metadata into 'donut', 'spec', or 'photo'. 

725 2) Run PeekTask on the exposure through a wrapper with class specific 

726 modifications. 

727 3) Try only to branch in the code based on the metadata, and not on the 

728 data itself. This avoids problematic discontinuities in measurements. 

729 

730 The main knobs we fiddle with based on the classification are: 

731 - Detection threshold 

732 - Minimum number of pixels for a detection 

733 - Binning of the image 

734 - Installed PSF size 

735 """ 

736 

737 ConfigClass = PeekExposureTaskConfig 

738 _DefaultName = "peekExposureTask" 

739 

740 def __init__(self, config, *, display=None, **kwargs): 

741 super().__init__(config=config, **kwargs) 

742 

743 self.makeSubtask("donut") 

744 self.makeSubtask("photo") 

745 self.makeSubtask("spec") 

746 

747 self._display = display 

748 

749 def getDonutDiameter(self, exposure): 

750 """Estimate donut diameter from exposure metadata. 

751 

752 Parameters 

753 ---------- 

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

755 Exposure to estimate donut diameter for. 

756 

757 Returns 

758 ------- 

759 donutDiameter : `float` 

760 Estimated donut diameter in pixels. 

761 """ 

762 visitInfo = exposure.getInfo().getVisitInfo() 

763 focusZ = visitInfo.focusZ 

764 instrumentLabel = visitInfo.instrumentLabel 

765 

766 match instrumentLabel: 

767 case "LATISS": 

768 focusZ *= 41 # magnification factor 

769 fratio = 18.0 

770 case "LSSTCam" | "ComCam": 

771 fratio = 1.234 

772 # AuxTel/ComCam/LSSTCam all have 10 micron pixels (= 10e-3 mm) 

773 donutDiameter = abs(focusZ) / fratio / 10e-3 

774 self.log.info(f"{focusZ=} mm") 

775 self.log.info(f"donutDiameter = {donutDiameter} pixels") 

776 return donutDiameter 

777 

778 def run( 

779 self, 

780 exposure, 

781 *, 

782 doDisplay=False, 

783 doDisplayIndices=False, 

784 mode="auto", 

785 binSize=None, 

786 donutDiameter=None, 

787 ): 

788 """ 

789 Parameters 

790 ---------- 

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

792 Exposure at which to peek. 

793 doDisplay : `bool`, optional 

794 Display the exposure and sources? Default False. (Requires 

795 display to have been passed to task constructor) 

796 doDisplayIndices : `bool`, optional 

797 Display the source indices? Default False. (Requires display to 

798 have been passed to task constructor) 

799 mode : {'auto', 'donut', 'spec', 'photo'}, optional 

800 Mode to run in. Default 'auto'. 

801 binSize : `int`, optional 

802 Binning factor for exposure. Default is None, which let's subtasks 

803 control rebinning directly. 

804 donutDiameter : `float`, optional 

805 Donut diameter in pixels. Default is None, which will estimate the 

806 donut diameter from the exposure metadata. 

807 

808 Returns 

809 ------- 

810 result : `pipeBase.Struct` 

811 Result of the peek. 

812 Struct containing: 

813 - mode : `str` 

814 Peek mode that was run. 

815 - binSize : `int` 

816 Binning factor used. 

817 - binnedSourceCat : `lsst.afw.table.SourceCatalog` 

818 Source catalog from the binned exposure. 

819 - table : `astropy.table.Table` 

820 Curated source table in unbinned coordinates. 

821 - brightestIdx : `int` 

822 Index of brightest source in source catalog. 

823 - brightestCentroid : `lsst.geom.Point2D` 

824 Brightest source centroid in unbinned pixel coords. 

825 - brightestPixelShape : `lsst.geom.Quadrupole` 

826 Brightest source shape in unbinned pixel coords. 

827 - brightestEquatorialShape : `lsst.geom.Quadrupole` 

828 Brightest source shape in equitorial coordinates (arcsec). 

829 - brightestAltAzShape : `lsst.geom.Quadrupole` 

830 Brightest source shape in alt/az coordinates (arcsec). 

831 - psfPixelShape : `lsst.geom.Quadrupole` 

832 Estimated PSF shape in unbinned pixel coords. 

833 - psfEquatorialShape : `lsst.geom.Quadrupole` 

834 Estimated PSF shape in equitorial coordinates (arcsec). 

835 - psfAltAzShape : `lsst.geom.Quadrupole` 

836 Estimated PSF shape in alt/az coordinates (arcsec). 

837 - pixelMedian : `float` 

838 Median estimate of entire image. 

839 - pixelMode : `float` 

840 Mode estimate of entire image. 

841 """ 

842 # Make a copy so the original image is unmodified. 

843 exposure = exposure.clone() 

844 try: 

845 result = self._run( 

846 exposure, doDisplay, doDisplayIndices, mode, binSize, donutDiameter 

847 ) 

848 except Exception as e: 

849 self.log.warning(f"Peek failed: {e}") 

850 result = pipeBase.Struct( 

851 mode="failed", 

852 binSize=0, 

853 binnedSourceCat=None, 

854 table=None, 

855 brightestIdx=0, 

856 brightestCentroid=Point2D(np.nan, np.nan), 

857 brightestPixelShape=Quadrupole(np.nan, np.nan, np.nan), 

858 brightestEquatorialShape=Quadrupole(np.nan, np.nan, np.nan), 

859 brightestAltAzShape=Quadrupole(np.nan, np.nan, np.nan), 

860 psfPixelShape=Quadrupole(np.nan, np.nan, np.nan), 

861 psfEquatorialShape=Quadrupole(np.nan, np.nan, np.nan), 

862 psfAltAzShape=Quadrupole(np.nan, np.nan, np.nan), 

863 pixelMedian=np.nan, 

864 pixelMode=np.nan, 

865 ) 

866 return result 

867 

868 def _run(self, exposure, doDisplay, doDisplayIndices, mode, binSize, donutDiameter): 

869 """ The actual run method, called by run(). 

870 """ 

871 # If image is ~large, then use a subsampling of the image for 

872 # speedy median/mode estimates. 

873 arr = exposure.getMaskedImage().getImage().array 

874 sampling = 1 

875 if arr.size > 250_000: 

876 sampling = int(np.floor(np.sqrt(arr.size / 250_000))) 

877 pixelMedian = np.nanmedian(arr[::sampling, ::sampling]) 

878 pixelMode = _estimateMode(arr[::sampling, ::sampling]) 

879 

880 if donutDiameter is None: 

881 donutDiameter = self.getDonutDiameter(exposure) 

882 

883 mode, binSize, binnedSourceCat = self.runPeek( 

884 exposure, mode, donutDiameter, binSize 

885 ) 

886 

887 table = self.transformTable(binSize, binnedSourceCat) 

888 

889 match mode: 

890 case "donut": 

891 goodSourceMask = self.donut.getGoodSources(binnedSourceCat) 

892 case "spec": 

893 goodSourceMask = self.spec.getGoodSources(binnedSourceCat) 

894 case "photo": 

895 goodSourceMask = self.photo.getGoodSources(binnedSourceCat) 

896 

897 # prepare output variables 

898 maxFluxIdx, brightCentroid, brightShape = self.getBrightest( 

899 binnedSourceCat, binSize, goodSourceMask 

900 ) 

901 psfShape = self.getPsfShape(binnedSourceCat, binSize, goodSourceMask) 

902 

903 equatorialShapes, altAzShapes = self.transformShapes( 

904 [brightShape, psfShape], 

905 exposure, 

906 binSize, 

907 ) 

908 

909 if doDisplay: 

910 self.updateDisplay( 

911 exposure, binSize, binnedSourceCat, maxFluxIdx, doDisplayIndices 

912 ) 

913 

914 return pipeBase.Struct( 

915 mode=mode, 

916 binSize=binSize, 

917 binnedSourceCat=binnedSourceCat, 

918 table=table, 

919 brightestIdx=maxFluxIdx, 

920 brightestCentroid=brightCentroid, 

921 brightestPixelShape=brightShape, 

922 brightestEquatorialShape=equatorialShapes[0], 

923 brightestAltAzShape=altAzShapes[0], 

924 psfPixelShape=psfShape, 

925 psfEquatorialShape=equatorialShapes[1], 

926 psfAltAzShape=altAzShapes[1], 

927 pixelMedian=pixelMedian, 

928 pixelMode=pixelMode, 

929 ) 

930 

931 def runPeek(self, exposure, mode, donutDiameter, binSize=None): 

932 """Classify exposure and run appropriate PeekTask wrapper. 

933 

934 Parameters 

935 ---------- 

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

937 Exposure to peek. 

938 mode : {'auto', 'donut', 'spec', 'photo'} 

939 Mode to run in. 

940 donutDiameter : `float` 

941 Donut diameter in pixels. 

942 binSize : `int`, optional 

943 Binning factor for exposure. Default is None, which let's subtasks 

944 control rebinning directly. 

945 

946 Returns 

947 ------- 

948 result : `pipeBase.Struct` 

949 Result of the peek. 

950 Struct containing: 

951 - mode : `str` 

952 Peek mode that was run. 

953 - binSize : `int` 

954 Binning factor used. 

955 - binnedSourceCat : `lsst.afw.table.SourceCatalog` 

956 Source catalog from the binned exposure. 

957 """ 

958 if mode == "auto": 

959 # Note, no attempt to handle dispersed donuts. They'll default to 

960 # donut mode. 

961 if donutDiameter > self.config.donutThreshold: 

962 mode = "donut" 

963 elif isDispersedExp(exposure): 

964 mode = "spec" 

965 else: 

966 mode = "photo" 

967 

968 match mode: 

969 case "donut": 

970 result = self.donut.run(exposure, donutDiameter, binSize=binSize) 

971 binSizeOut = result.binSize 

972 case "spec": 

973 result = self.spec.run(exposure, binSize=binSize) 

974 binSizeOut = result.binSize 

975 if len(result.binnedSourceCat) == 0: 

976 self.log.warn("No sources found in spec mode.") 

977 if self.config.doPhotoFallback: 

978 self.log.warn("Falling back to photo mode.") 

979 # Note that spec.run already rebinned the image, 

980 # so we don't need to do it again. 

981 result = self.photo.run(exposure, binSize=1) 

982 case "photo": 

983 result = self.photo.run(exposure, binSize=binSize) 

984 binSizeOut = result.binSize 

985 case _: 

986 raise ValueError(f"Unknown mode {mode}") 

987 return result.mode, binSizeOut, result.binnedSourceCat 

988 

989 def transformTable(self, binSize, binnedSourceCat): 

990 """Make an astropy table from the source catalog but with 

991 transformations back to the original unbinned coordinates. 

992 

993 Since there's some ambiguity in the apFlux apertures when binning, 

994 we'll only populate the table with the slots columns (slot_apFlux 

995 doesn't indicate an aperture radius). For simplicity, do the same for 

996 centroids and shapes too. 

997 

998 And since we're only copying over the slots_* columns, we remove the 

999 "slots_" part of the column names and lowercase the first remaining 

1000 letter. 

1001 

1002 Parameters 

1003 ---------- 

1004 binSize : `int` 

1005 Binning factor used. 

1006 binnedSourceCat : `lsst.afw.table.SourceCatalog` 

1007 Source catalog from the binned exposure. 

1008 

1009 Returns 

1010 ------- 

1011 table : `astropy.table.Table` 

1012 Curated source table in unbinned coordinates. 

1013 """ 

1014 table = binnedSourceCat.asAstropy() 

1015 cols = [n for n in table.colnames if n.startswith("slot")] 

1016 table = table[cols] 

1017 if "slot_Centroid_x" in cols: 

1018 table["slot_Centroid_x"] = ( 

1019 binSize * table["slot_Centroid_x"] + (binSize - 1) / 2 

1020 ) 

1021 table["slot_Centroid_y"] = ( 

1022 binSize * table["slot_Centroid_y"] + (binSize - 1) / 2 

1023 ) 

1024 if "slot_Shape_x" in cols: 

1025 table["slot_Shape_x"] = binSize * table["slot_Shape_x"] + (binSize - 1) / 2 

1026 table["slot_Shape_y"] = binSize * table["slot_Shape_y"] + (binSize - 1) / 2 

1027 table["slot_Shape_xx"] *= binSize**2 

1028 table["slot_Shape_xy"] *= binSize**2 

1029 table["slot_Shape_yy"] *= binSize**2 

1030 # area and npixels are just confusing when binning, so remove. 

1031 if "slot_PsfFlux_area" in cols: 

1032 del table["slot_PsfFlux_area"] 

1033 if "slot_PsfFlux_npixels" in cols: 

1034 del table["slot_PsfFlux_npixels"] 

1035 

1036 table.rename_columns( 

1037 [n for n in table.colnames if n.startswith('slot_')], 

1038 [n[5:6].lower()+n[6:] for n in table.colnames if n.startswith('slot_')] 

1039 ) 

1040 

1041 return table 

1042 

1043 def getBrightest(self, binnedSourceCat, binSize, goodSourceMask): 

1044 """Find the brightest source in the catalog. 

1045 

1046 Parameters 

1047 ---------- 

1048 binnedSourceCat : `lsst.afw.table.SourceCatalog` 

1049 Source catalog from the binned exposure. 

1050 binSize : `int` 

1051 Binning factor used. 

1052 goodSourceMask : `numpy.ndarray` 

1053 Boolean array indicating which sources are good. 

1054 

1055 Returns 

1056 ------- 

1057 maxFluxIdx : `int` 

1058 Index of the brightest source in the catalog. 

1059 brightCentroid : `lsst.geom.Point2D` 

1060 Centroid of the brightest source (unbinned coords). 

1061 brightShape : `lsst.geom.Quadrupole` 

1062 Shape of the brightest source (unbinned coords). 

1063 """ 

1064 fluxes = np.array([source.getApInstFlux() for source in binnedSourceCat]) 

1065 idxs = np.arange(len(binnedSourceCat)) 

1066 

1067 good = (goodSourceMask & np.isfinite(fluxes)) 

1068 

1069 if np.sum(good) == 0: 

1070 maxFluxIdx = IDX_SENTINEL 

1071 brightCentroid = Point2D(np.nan, np.nan) 

1072 brightShape = Quadrupole(np.nan, np.nan, np.nan) 

1073 return maxFluxIdx, brightCentroid, brightShape 

1074 

1075 fluxes = fluxes[good] 

1076 idxs = idxs[good] 

1077 maxFluxIdx = idxs[np.nanargmax(fluxes)] 

1078 brightest = binnedSourceCat[maxFluxIdx] 

1079 

1080 # Convert binned coordinates back to original unbinned 

1081 # coordinates 

1082 brightX, brightY = brightest.getCentroid() 

1083 brightX = binSize * brightX + (binSize - 1) / 2 

1084 brightY = binSize * brightY + (binSize - 1) / 2 

1085 brightCentroid = Point2D(brightX, brightY) 

1086 brightIXX = brightest.getIxx() * binSize**2 

1087 brightIXY = brightest.getIxy() * binSize**2 

1088 brightIYY = brightest.getIyy() * binSize**2 

1089 brightShape = Quadrupole(brightIXX, brightIYY, brightIXY) 

1090 

1091 return maxFluxIdx, brightCentroid, brightShape 

1092 

1093 def getPsfShape(self, binnedSourceCat, binSize, goodSourceMask): 

1094 """Estimate the modal PSF shape from the sources. 

1095 

1096 Parameters 

1097 ---------- 

1098 binnedSourceCat : `lsst.afw.table.SourceCatalog` 

1099 Source catalog from the binned exposure. 

1100 binSize : `int` 

1101 Binning factor used. 

1102 goodSourceMask : `numpy.ndarray` 

1103 Boolean array indicating which sources are good. 

1104 

1105 Returns 

1106 ------- 

1107 psfShape : `lsst.geom.Quadrupole` 

1108 Estimated PSF shape (unbinned coords). 

1109 """ 

1110 fluxes = np.array([source.getApInstFlux() for source in binnedSourceCat]) 

1111 idxs = np.arange(len(binnedSourceCat)) 

1112 

1113 good = (goodSourceMask & np.isfinite(fluxes)) 

1114 

1115 if np.sum(good) == 0: 

1116 return Quadrupole(np.nan, np.nan, np.nan) 

1117 

1118 fluxes = fluxes[good] 

1119 idxs = idxs[good] 

1120 

1121 psfIXX = _estimateMode( 

1122 np.array([source.getIxx() for source in binnedSourceCat])[goodSourceMask] 

1123 ) 

1124 psfIYY = _estimateMode( 

1125 np.array([source.getIyy() for source in binnedSourceCat])[goodSourceMask] 

1126 ) 

1127 psfIXY = _estimateMode( 

1128 np.array([source.getIxy() for source in binnedSourceCat])[goodSourceMask] 

1129 ) 

1130 

1131 return Quadrupole( 

1132 psfIXX * binSize**2, 

1133 psfIYY * binSize**2, 

1134 psfIXY * binSize**2, 

1135 ) 

1136 

1137 def transformShapes(self, shapes, exposure, binSize): 

1138 """Transform shapes from x/y pixel coordinates to equitorial and 

1139 horizon coordinates. 

1140 

1141 Parameters 

1142 ---------- 

1143 shapes : `list` of `lsst.geom.Quadrupole` 

1144 List of shapes (in pixel coordinates) to transform. 

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

1146 Exposure containing WCS and VisitInfo for transformation. 

1147 binSize : `int` 

1148 Binning factor used. 

1149 

1150 Returns 

1151 ------- 

1152 equatorialShapes : `list` of `lsst.geom.Quadrupole` 

1153 List of shapes transformed to equitorial (North and West) 

1154 coordinates. Units are arcseconds. 

1155 altAzShapes : `list` of `lsst.geom.Quadrupole` 

1156 List of shapes transformed to alt/az coordinates. Units are 

1157 arcseconds. 

1158 """ 

1159 pt = Point2D(np.array([*exposure.getBBox().getCenter()]) / binSize) 

1160 wcs = exposure.wcs 

1161 visitInfo = exposure.info.getVisitInfo() 

1162 parAngle = visitInfo.boresightParAngle 

1163 

1164 equatorialShapes = [] 

1165 altAzShapes = [] 

1166 for shape in shapes: 

1167 if wcs is None: 

1168 equatorialShapes.append(Quadrupole(np.nan, np.nan, np.nan)) 

1169 altAzShapes.append(Quadrupole(np.nan, np.nan, np.nan)) 

1170 continue 

1171 # The WCS transforms to N (dec) and E (ra), but we want N and W to 

1172 # conform with weak-lensing conventions. So we flip the [0] 

1173 # component of the transformation. 

1174 neTransform = wcs.linearizePixelToSky(pt, arcseconds).getLinear() 

1175 nwTransform = LinearTransform( 

1176 np.array([[-1, 0], [0, 1]]) @ neTransform.getMatrix() 

1177 ) 

1178 equatorialShapes.append(shape.transform(nwTransform)) 

1179 

1180 # To get from N/W to alt/az, we need to additionally rotate by the 

1181 # parallactic angle. 

1182 rot = LinearTransform.makeRotation(parAngle).getMatrix() 

1183 aaTransform = LinearTransform(nwTransform.getMatrix() @ rot) 

1184 altAzShapes.append(shape.transform(aaTransform)) 

1185 

1186 return equatorialShapes, altAzShapes 

1187 

1188 def updateDisplay( 

1189 self, exposure, binSize, binnedSourceCat, maxFluxIdx, doDisplayIndices 

1190 ): 

1191 """Update the afwDisplay with the exposure and sources. 

1192 

1193 Parameters 

1194 ---------- 

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

1196 Exposure to peek. 

1197 binSize : `int` 

1198 Binning factor used. 

1199 binnedSourceCat : `lsst.afw.table.SourceCatalog` 

1200 Source catalog from the binned exposure. 

1201 maxFluxIdx : `int` 

1202 Index of the brightest source in the catalog. 

1203 doDisplayIndices : `bool` 

1204 Display the source indices? 

1205 """ 

1206 if self._display is None: 

1207 raise RuntimeError("Display failed as no display provided during init()") 

1208 

1209 visitInfo = exposure.info.getVisitInfo() 

1210 self._display.mtv(exposure) 

1211 wcs = exposure.wcs 

1212 if wcs is not None: 

1213 plotRose( 

1214 self._display, 

1215 wcs, 

1216 Point2D(200 / binSize, 200 / binSize), 

1217 parAng=visitInfo.boresightParAngle, 

1218 len=100 / binSize, 

1219 ) 

1220 

1221 for idx, source in enumerate(binnedSourceCat): 

1222 x, y = source.getCentroid() 

1223 sh = source.getShape() 

1224 self._display.dot(sh, x, y) 

1225 if doDisplayIndices: 

1226 self._display.dot(str(idx), x, y) 

1227 

1228 if maxFluxIdx != IDX_SENTINEL: 

1229 self._display.dot( 

1230 "+", 

1231 *binnedSourceCat[maxFluxIdx].getCentroid(), 

1232 ctype=afwDisplay.RED, 

1233 size=10, 

1234 )