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

384 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-23 14:45 +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 

27import numpy as np 

28 

29import lsst.afw.display as afwDisplay 

30import lsst.afw.math as afwMath 

31import lsst.daf.base as dafBase 

32import lsst.pex.config as pexConfig 

33import lsst.pipe.base as pipeBase 

34from lsst.afw.detection import Psf 

35from lsst.afw.geom.ellipses import Quadrupole 

36from lsst.afw.image import ImageD 

37from lsst.afw.table import SourceTable 

38from lsst.atmospec.utils import isDispersedExp 

39from lsst.geom import Box2I, Extent2I, LinearTransform, Point2D, Point2I, SpherePoint, arcseconds, degrees 

40from lsst.meas.algorithms import SourceDetectionTask, SubtractBackgroundTask 

41from lsst.meas.algorithms.installGaussianPsf import InstallGaussianPsfTask 

42from lsst.meas.base import IdGenerator, SingleFrameMeasurementTask 

43 

44IDX_SENTINEL = -99999 

45 

46 

47def _estimateMode(data, frac=0.5): 

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

49 

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

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

52 

53 Parameters 

54 ---------- 

55 data : array-like 

56 1d array of data values 

57 frac : float, optional 

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

59 

60 Returns 

61 ------- 

62 mode : float 

63 Estimated mode of the data. 

64 """ 

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

66 if len(data) == 0: 

67 return np.nan 

68 elif len(data) == 1: 

69 return data[0] 

70 

71 data = np.sort(data) 

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

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

74 start = np.argmin(spans) 

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

76 

77 

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

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

80 

81 Parameters 

82 ---------- 

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

84 World Coordinate System of image. 

85 bearing : `lsst.geom.Angle` 

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

87 imagePoint : `lsst.geom.Point2D` 

88 Point in the image. 

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

90 Point in the sky. 

91 

92 Returns 

93 ------- 

94 unitVector : `lsst.geom.Extent2D` 

95 Unit vector in the direction of bearing. 

96 """ 

97 if skyPoint is None: 

98 skyPoint = wcs.pixelToSky(imagePoint) 

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

100 return dpt / dpt.computeNorm() 

101 

102 

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

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

105 

106 Parameters 

107 ---------- 

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

109 World Coordinate System of image. 

110 imagePoint : `lsst.geom.Point2D` 

111 Point in the image 

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

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

114 (default: None) 

115 

116 Returns 

117 ------- 

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

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

120 """ 

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

122 skyPoint = wcs.pixelToSky(imagePoint) 

123 bearing = skyPoint.bearingTo(ncp) 

124 

125 out = dict() 

126 out["N"] = _bearingToUnitVector(wcs, bearing, imagePoint, skyPoint=skyPoint) 

127 out["W"] = _bearingToUnitVector(wcs, bearing + 90 * degrees, imagePoint, skyPoint=skyPoint) 

128 

129 if parAng is not None: 

130 out["alt"] = _bearingToUnitVector(wcs, bearing - parAng, imagePoint, skyPoint=skyPoint) 

131 out["az"] = _bearingToUnitVector(wcs, bearing - parAng + 90 * degrees, imagePoint, skyPoint=skyPoint) 

132 

133 return out 

134 

135 

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

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

138 

139 Parameters 

140 ---------- 

141 display : `lsst.afw.display.Display` 

142 Display on which to render rose. 

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

144 World Coordinate System of image. 

145 imagePoint : `lsst.geom.Point2D` 

146 Point in the image at which to render rose. 

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

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

149 (default: None) 

150 len : `float`, optional 

151 Length of the rose vectors (default: 50) 

152 """ 

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

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

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

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

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

158 

159 

160class DonutPsf(Psf): 

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

162 Psf.__init__(self, isFixed=True) 

163 self.size = size 

164 self.outerRad = outerRad 

165 self.innerRad = innerRad 

166 self.dimensions = Extent2I(size, size) 

167 

168 def __deepcopy__(self, memo=None): 

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

170 

171 def resized(self, width, height): 

172 assert width == height 

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

174 

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

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

177 img = ImageD(bbox, 0.0) 

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

179 rsqr = x**2 + y**2 

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

181 img.array[w] = 1.0 

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

183 return img 

184 

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

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

187 

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

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

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

191 return Quadrupole(Ixx, Ixx, 0.0) 

192 

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

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

195 

196 def __eq__(self, rhs): 

197 if isinstance(rhs, DonutPsf): 

198 return self.size == rhs.size and self.outerRad == rhs.outerRad and self.innerRad == rhs.innerRad 

199 return False 

200 

201 

202class PeekTaskConfig(pexConfig.Config): 

203 """Config class for the PeekTask.""" 

204 

205 installPsf = pexConfig.ConfigurableField( 

206 target=InstallGaussianPsfTask, 

207 doc="Install a PSF model", 

208 ) 

209 doInstallPsf = pexConfig.Field( 

210 dtype=bool, 

211 default=True, 

212 doc="Install a PSF model?", 

213 ) 

214 background = pexConfig.ConfigurableField( 

215 target=SubtractBackgroundTask, 

216 doc="Estimate background", 

217 ) 

218 detection = pexConfig.ConfigurableField(target=SourceDetectionTask, doc="Detect sources") 

219 measurement = pexConfig.ConfigurableField(target=SingleFrameMeasurementTask, doc="Measure sources") 

220 defaultBinSize = pexConfig.Field( 

221 dtype=int, 

222 default=1, 

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

224 ) 

225 

226 def setDefaults(self): 

227 super().setDefaults() 

228 # Configure to be aggressively fast. 

229 self.detection.thresholdValue = 5.0 

230 self.detection.includeThresholdMultiplier = 10.0 

231 self.detection.reEstimateBackground = False 

232 self.detection.doTempLocalBackground = False 

233 self.measurement.doReplaceWithNoise = False 

234 self.detection.minPixels = 40 

235 self.installPsf.fwhm = 5.0 

236 self.installPsf.width = 21 

237 # minimal set of measurements 

238 self.measurement.plugins.names = [ 

239 "base_PixelFlags", 

240 "base_SdssCentroid", 

241 "ext_shapeHSM_HsmSourceMoments", 

242 "base_GaussianFlux", 

243 "base_PsfFlux", 

244 "base_CircularApertureFlux", 

245 ] 

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

247 

248 

249class PeekTask(pipeBase.Task): 

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

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

252 image quality. 

253 

254 Optionally bins image and then: 

255 - installs a simple PSF model 

256 - measures and subtracts the background 

257 - detects sources 

258 - measures sources 

259 

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

261 a lesser extent accuracy. 

262 """ 

263 

264 ConfigClass = PeekTaskConfig 

265 _DefaultName = "peek" 

266 

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

268 super().__init__(**kwargs) 

269 

270 if schema is None: 

271 schema = SourceTable.makeMinimalSchema() 

272 self.schema = schema 

273 

274 self.makeSubtask("installPsf") 

275 self.makeSubtask("background") 

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

277 self.algMetadata = dafBase.PropertyList() 

278 self.makeSubtask("measurement", schema=self.schema, algMetadata=self.algMetadata) 

279 

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

281 """Peek at exposure. 

282 

283 Parameters 

284 ---------- 

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

286 Exposure at which to peek. 

287 binSize : `int`, optional 

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

289 default binning factor from the config. 

290 

291 Returns 

292 ------- 

293 result : `pipeBase.Struct` 

294 Result of peeking. 

295 Struct containing: 

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

297 Source catalog from the binned exposure. 

298 """ 

299 if binSize is None: 

300 binSize = self.config.defaultBinSize 

301 

302 if binSize != 1: 

303 mi = exposure.getMaskedImage() 

304 binned = afwMath.binImage(mi, binSize) 

305 exposure.setMaskedImage(binned) 

306 

307 if self.config.doInstallPsf: 

308 self.installPsf.run(exposure=exposure) 

309 

310 self.background.run(exposure) 

311 

312 idGenerator = IdGenerator() 

313 sourceIdFactory = idGenerator.make_table_id_factory() 

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

315 table.setMetadata(self.algMetadata) 

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

317 

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

319 

320 return pipeBase.Struct( 

321 sourceCat=sourceCat, 

322 ) 

323 

324 

325class PeekDonutTaskConfig(pexConfig.Config): 

326 """Config class for the PeekDonutTask.""" 

327 

328 peek = pexConfig.ConfigurableField( 

329 target=PeekTask, 

330 doc="Peek configuration", 

331 ) 

332 resolution = pexConfig.Field( 

333 dtype=float, 

334 default=16.0, 

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

336 ) 

337 binSizeMax = pexConfig.Field( 

338 dtype=int, 

339 default=10, 

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

341 ) 

342 

343 def setDefaults(self): 

344 super().setDefaults() 

345 # Donuts are big even when binned. 

346 self.peek.installPsf.fwhm = 10.0 

347 self.peek.installPsf.width = 31 

348 # Use DonutPSF if not overridden 

349 self.peek.doInstallPsf = False 

350 

351 

352class PeekDonutTask(pipeBase.Task): 

353 """Peek at a donut exposure. 

354 

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

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

357 greatly increases the speed and detection capabilities of PeekTask with 

358 little loss of accuracy for centroids. 

359 """ 

360 

361 ConfigClass = PeekDonutTaskConfig 

362 _DefaultName = "peekDonut" 

363 

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

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

366 self.makeSubtask("peek") 

367 

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

369 """Peek at donut exposure. 

370 

371 Parameters 

372 ---------- 

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

374 Exposure at which to peek. 

375 donutDiameter : `float` 

376 Donut diameter in pixels. 

377 binSize : `int`, optional 

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

379 resolution config value to determine the binSize. 

380 

381 Returns 

382 ------- 

383 result : `pipeBase.Struct` 

384 Result of donut peeking. 

385 Struct containing: 

386 - mode : `str` 

387 Peek mode that was run. 

388 - binSize : `int` 

389 Binning factor used. 

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

391 Source catalog from the binned exposure. 

392 """ 

393 if binSize is None: 

394 binSize = int( 

395 np.floor( 

396 np.clip( 

397 donutDiameter / self.config.resolution, 

398 1, 

399 self.config.binSizeMax, 

400 ) 

401 ) 

402 ) 

403 binnedDonutDiameter = donutDiameter / binSize 

404 psf = DonutPsf( 

405 binnedDonutDiameter * 1.5, binnedDonutDiameter * 0.5, binnedDonutDiameter * 0.5 * 0.3525 

406 ) 

407 

408 # Note that SourceDetectionTask will convolve with a _Gaussian 

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

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

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

412 # convolution algorithm, so we limit the size. 

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

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

415 

416 if factor > 1: 

417 psf = DonutPsf( 

418 binnedDonutDiameter * 1.5 / factor, 

419 binnedDonutDiameter * 0.5 / factor, 

420 binnedDonutDiameter * 0.5 * 0.3525 / factor, 

421 ) 

422 exposure.setPsf(psf) 

423 

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

425 

426 return pipeBase.Struct( 

427 mode="donut", 

428 binSize=binSize, 

429 binnedSourceCat=peekResult.sourceCat, 

430 ) 

431 

432 def getGoodSources(self, binnedSourceCat): 

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

434 

435 Parameters 

436 ---------- 

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

438 Source catalog from the binned exposure. 

439 

440 Returns 

441 ------- 

442 goodSourceMask : `numpy.ndarray` 

443 Boolean array indicating which sources are good. 

444 """ 

445 # Perform any filtering on the source catalog 

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

447 return goodSourceMask 

448 

449 

450class PeekPhotoTaskConfig(pexConfig.Config): 

451 """Config class for the PeekPhotoTask.""" 

452 

453 peek = pexConfig.ConfigurableField( 

454 target=PeekTask, 

455 doc="Peek configuration", 

456 ) 

457 binSize = pexConfig.Field( 

458 dtype=int, 

459 default=2, 

460 doc="Binning factor for exposure", 

461 ) 

462 

463 def setDefaults(self): 

464 super().setDefaults() 

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

466 self.peek.detection.includeThresholdMultiplier = 1.0 

467 self.peek.detection.thresholdValue = 10.0 

468 self.peek.detection.minPixels = 10 

469 

470 

471class PeekPhotoTask(pipeBase.Task): 

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

473 

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

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

476 """ 

477 

478 ConfigClass = PeekPhotoTaskConfig 

479 _DefaultName = "peekPhoto" 

480 

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

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

483 self.makeSubtask("peek") 

484 

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

486 """Peek at donut exposure. 

487 

488 Parameters 

489 ---------- 

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

491 Exposure at which to peek. 

492 binSize : `int`, optional 

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

494 binning factor from the config. 

495 

496 Returns 

497 ------- 

498 result : `pipeBase.Struct` 

499 Result of photo peeking. 

500 Struct containing: 

501 - mode : `str` 

502 Peek mode that was run. 

503 - binSize : `int` 

504 Binning factor used. 

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

506 Source catalog from the binned exposure. 

507 """ 

508 if binSize is None: 

509 binSize = self.config.binSize 

510 

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

512 

513 return pipeBase.Struct( 

514 mode="photo", 

515 binSize=binSize, 

516 binnedSourceCat=peekResult.sourceCat, 

517 ) 

518 

519 def getGoodSources(self, binnedSourceCat): 

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

521 

522 Parameters 

523 ---------- 

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

525 Source catalog from the binned exposure. 

526 

527 Returns 

528 ------- 

529 goodSourceMask : `numpy.ndarray` 

530 Boolean array indicating which sources are good. 

531 """ 

532 # Perform any filtering on the source catalog 

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

534 return goodSourceMask 

535 

536 

537class PeekSpecTaskConfig(pexConfig.Config): 

538 """Config class for the PeekSpecTask.""" 

539 

540 peek = pexConfig.ConfigurableField( 

541 target=PeekTask, 

542 doc="Peek configuration", 

543 ) 

544 binSize = pexConfig.Field( 

545 dtype=int, 

546 default=2, 

547 doc="binning factor for exposure", 

548 ) 

549 maxFootprintAspectRatio = pexConfig.Field( 

550 dtype=float, 

551 default=10.0, 

552 doc="Maximum detection footprint aspect ratio to consider as 0th order" " (non-dispersed) light.", 

553 ) 

554 

555 def setDefaults(self): 

556 super().setDefaults() 

557 # Use bright threshold 

558 self.peek.detection.includeThresholdMultiplier = 1.0 

559 self.peek.detection.thresholdValue = 500.0 

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

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

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

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

564 # a relatively large saturated region. 

565 self.peek.measurement.plugins["base_SdssCentroid"].maxDistToPeak = 15.0 

566 

567 

568class PeekSpecTask(pipeBase.Task): 

569 """Peek at a spectroscopic exposure. 

570 

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

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

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

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

575 saturated objects. 

576 """ 

577 

578 ConfigClass = PeekSpecTaskConfig 

579 _DefaultName = "peekSpec" 

580 

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

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

583 self.makeSubtask("peek") 

584 

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

586 """Peek at spectroscopic exposure. 

587 

588 Parameters 

589 ---------- 

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

591 Exposure at which to peek. 

592 binSize : `int`, optional 

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

594 binning factor from the config. 

595 

596 Returns 

597 ------- 

598 result : `pipeBase.Struct` 

599 Result of spec peeking. 

600 Struct containing: 

601 - mode : `str` 

602 Peek mode that was run. 

603 - binSize : `int` 

604 Binning factor used. 

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

606 Source catalog from the binned exposure. 

607 """ 

608 if binSize is None: 

609 binSize = self.config.binSize 

610 

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

612 

613 return pipeBase.Struct( 

614 mode="spec", 

615 binSize=binSize, 

616 binnedSourceCat=peekResult.sourceCat, 

617 ) 

618 

619 def getGoodSources(self, binnedSourceCat): 

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

621 

622 Parameters 

623 ---------- 

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

625 Source catalog from the binned exposure. 

626 

627 Returns 

628 ------- 

629 goodSourceMask : `numpy.ndarray` 

630 Boolean array indicating which sources are good. 

631 """ 

632 # Perform any filtering on the source catalog 

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

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

635 # Filter out likely spectrum detections 

636 goodSourceMask &= np.array( 

637 [sh.getIyy() < self.config.maxFootprintAspectRatio * sh.getIxx() for sh in fpShapes], dtype=bool 

638 ) 

639 return goodSourceMask 

640 

641 

642class PeekExposureTaskConfig(pexConfig.Config): 

643 """Config class for the PeekExposureTask.""" 

644 

645 donutThreshold = pexConfig.Field( 

646 dtype=float, 

647 default=50.0, 

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

649 ) 

650 doPhotoFallback = pexConfig.Field( 

651 dtype=bool, 

652 default=True, 

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

654 ) 

655 donut = pexConfig.ConfigurableField( 

656 target=PeekDonutTask, 

657 doc="PeekDonut task", 

658 ) 

659 photo = pexConfig.ConfigurableField( 

660 target=PeekPhotoTask, 

661 doc="PeekPhoto task", 

662 ) 

663 spec = pexConfig.ConfigurableField( 

664 target=PeekSpecTask, 

665 doc="PeekSpec task", 

666 ) 

667 

668 

669class PeekExposureTask(pipeBase.Task): 

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

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

672 exposure's overall image quality. 

673 

674 Parameters 

675 ---------- 

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

677 Configuration for the task. 

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

679 For displaying the exposure and sources. 

680 

681 Notes 

682 ----- 

683 The basic philosophy of PeekExposureTask is to: 

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

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

686 modifications. 

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

688 data itself. This avoids problematic discontinuities in measurements. 

689 

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

691 - Detection threshold 

692 - Minimum number of pixels for a detection 

693 - Binning of the image 

694 - Installed PSF size 

695 """ 

696 

697 ConfigClass = PeekExposureTaskConfig 

698 _DefaultName = "peekExposureTask" 

699 

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

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

702 

703 self.makeSubtask("donut") 

704 self.makeSubtask("photo") 

705 self.makeSubtask("spec") 

706 

707 self._display = display 

708 

709 def getDonutDiameter(self, exposure): 

710 """Estimate donut diameter from exposure metadata. 

711 

712 Parameters 

713 ---------- 

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

715 Exposure to estimate donut diameter for. 

716 

717 Returns 

718 ------- 

719 donutDiameter : `float` 

720 Estimated donut diameter in pixels. 

721 """ 

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

723 focusZ = visitInfo.focusZ 

724 instrumentLabel = visitInfo.instrumentLabel 

725 

726 match instrumentLabel: 

727 case "LATISS": 

728 focusZ *= 41 # magnification factor 

729 fratio = 18.0 

730 case "LSSTCam" | "ComCam": 

731 fratio = 1.234 

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

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

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

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

736 return donutDiameter 

737 

738 def run( 

739 self, 

740 exposure, 

741 *, 

742 doDisplay=False, 

743 doDisplayIndices=False, 

744 mode="auto", 

745 binSize=None, 

746 donutDiameter=None, 

747 ): 

748 """ 

749 Parameters 

750 ---------- 

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

752 Exposure at which to peek. 

753 doDisplay : `bool`, optional 

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

755 display to have been passed to task constructor) 

756 doDisplayIndices : `bool`, optional 

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

758 have been passed to task constructor) 

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

760 Mode to run in. Default 'auto'. 

761 binSize : `int`, optional 

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

763 control rebinning directly. 

764 donutDiameter : `float`, optional 

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

766 donut diameter from the exposure metadata. 

767 

768 Returns 

769 ------- 

770 result : `pipeBase.Struct` 

771 Result of the peek. 

772 Struct containing: 

773 - mode : `str` 

774 Peek mode that was run. 

775 - binSize : `int` 

776 Binning factor used. 

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

778 Source catalog from the binned exposure. 

779 - table : `astropy.table.Table` 

780 Curated source table in unbinned coordinates. 

781 - brightestIdx : `int` 

782 Index of brightest source in source catalog. 

783 - brightestCentroid : `lsst.geom.Point2D` 

784 Brightest source centroid in unbinned pixel coords. 

785 - brightestPixelShape : `lsst.geom.Quadrupole` 

786 Brightest source shape in unbinned pixel coords. 

787 - brightestEquatorialShape : `lsst.geom.Quadrupole` 

788 Brightest source shape in equitorial coordinates (arcsec). 

789 - brightestAltAzShape : `lsst.geom.Quadrupole` 

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

791 - psfPixelShape : `lsst.geom.Quadrupole` 

792 Estimated PSF shape in unbinned pixel coords. 

793 - psfEquatorialShape : `lsst.geom.Quadrupole` 

794 Estimated PSF shape in equitorial coordinates (arcsec). 

795 - psfAltAzShape : `lsst.geom.Quadrupole` 

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

797 - pixelMedian : `float` 

798 Median estimate of entire image. 

799 - pixelMode : `float` 

800 Mode estimate of entire image. 

801 """ 

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

803 exposure = exposure.clone() 

804 try: 

805 result = self._run(exposure, doDisplay, doDisplayIndices, mode, binSize, donutDiameter) 

806 except Exception as e: 

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

808 result = pipeBase.Struct( 

809 mode="failed", 

810 binSize=0, 

811 binnedSourceCat=None, 

812 table=None, 

813 brightestIdx=0, 

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

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

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

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

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

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

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

821 pixelMedian=np.nan, 

822 pixelMode=np.nan, 

823 ) 

824 return result 

825 

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

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

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

829 # speedy median/mode estimates. 

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

831 sampling = 1 

832 if arr.size > 250_000: 

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

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

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

836 

837 if donutDiameter is None: 

838 donutDiameter = self.getDonutDiameter(exposure) 

839 

840 mode, binSize, binnedSourceCat = self.runPeek(exposure, mode, donutDiameter, binSize) 

841 

842 table = self.transformTable(binSize, binnedSourceCat) 

843 

844 match mode: 

845 case "donut": 

846 goodSourceMask = self.donut.getGoodSources(binnedSourceCat) 

847 case "spec": 

848 goodSourceMask = self.spec.getGoodSources(binnedSourceCat) 

849 case "photo": 

850 goodSourceMask = self.photo.getGoodSources(binnedSourceCat) 

851 

852 # prepare output variables 

853 maxFluxIdx, brightCentroid, brightShape = self.getBrightest(binnedSourceCat, binSize, goodSourceMask) 

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

855 

856 equatorialShapes, altAzShapes = self.transformShapes( 

857 [brightShape, psfShape], 

858 exposure, 

859 binSize, 

860 ) 

861 

862 if doDisplay: 

863 self.updateDisplay(exposure, binSize, binnedSourceCat, maxFluxIdx, doDisplayIndices) 

864 

865 return pipeBase.Struct( 

866 mode=mode, 

867 binSize=binSize, 

868 binnedSourceCat=binnedSourceCat, 

869 table=table, 

870 brightestIdx=maxFluxIdx, 

871 brightestCentroid=brightCentroid, 

872 brightestPixelShape=brightShape, 

873 brightestEquatorialShape=equatorialShapes[0], 

874 brightestAltAzShape=altAzShapes[0], 

875 psfPixelShape=psfShape, 

876 psfEquatorialShape=equatorialShapes[1], 

877 psfAltAzShape=altAzShapes[1], 

878 pixelMedian=pixelMedian, 

879 pixelMode=pixelMode, 

880 ) 

881 

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

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

884 

885 Parameters 

886 ---------- 

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

888 Exposure to peek. 

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

890 Mode to run in. 

891 donutDiameter : `float` 

892 Donut diameter in pixels. 

893 binSize : `int`, optional 

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

895 control rebinning directly. 

896 

897 Returns 

898 ------- 

899 result : `pipeBase.Struct` 

900 Result of the peek. 

901 Struct containing: 

902 - mode : `str` 

903 Peek mode that was run. 

904 - binSize : `int` 

905 Binning factor used. 

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

907 Source catalog from the binned exposure. 

908 """ 

909 if mode == "auto": 

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

911 # donut mode. 

912 if donutDiameter > self.config.donutThreshold: 

913 mode = "donut" 

914 elif isDispersedExp(exposure): 

915 mode = "spec" 

916 else: 

917 mode = "photo" 

918 

919 match mode: 

920 case "donut": 

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

922 binSizeOut = result.binSize 

923 case "spec": 

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

925 binSizeOut = result.binSize 

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

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

928 if self.config.doPhotoFallback: 

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

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

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

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

933 case "photo": 

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

935 binSizeOut = result.binSize 

936 case _: 

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

938 return result.mode, binSizeOut, result.binnedSourceCat 

939 

940 def transformTable(self, binSize, binnedSourceCat): 

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

942 transformations back to the original unbinned coordinates. 

943 

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

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

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

947 centroids and shapes too. 

948 

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

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

951 letter. 

952 

953 Parameters 

954 ---------- 

955 binSize : `int` 

956 Binning factor used. 

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

958 Source catalog from the binned exposure. 

959 

960 Returns 

961 ------- 

962 table : `astropy.table.Table` 

963 Curated source table in unbinned coordinates. 

964 """ 

965 table = binnedSourceCat.asAstropy() 

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

967 table = table[cols] 

968 if "slot_Centroid_x" in cols: 

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

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

971 if "slot_Shape_x" in cols: 

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

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

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

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

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

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

978 if "slot_PsfFlux_area" in cols: 

979 del table["slot_PsfFlux_area"] 

980 if "slot_PsfFlux_npixels" in cols: 

981 del table["slot_PsfFlux_npixels"] 

982 

983 table.rename_columns( 

984 [n for n in table.colnames if n.startswith("slot_")], 

985 [n[5:6].lower() + n[6:] for n in table.colnames if n.startswith("slot_")], 

986 ) 

987 

988 return table 

989 

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

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

992 

993 Parameters 

994 ---------- 

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

996 Source catalog from the binned exposure. 

997 binSize : `int` 

998 Binning factor used. 

999 goodSourceMask : `numpy.ndarray` 

1000 Boolean array indicating which sources are good. 

1001 

1002 Returns 

1003 ------- 

1004 maxFluxIdx : `int` 

1005 Index of the brightest source in the catalog. 

1006 brightCentroid : `lsst.geom.Point2D` 

1007 Centroid of the brightest source (unbinned coords). 

1008 brightShape : `lsst.geom.Quadrupole` 

1009 Shape of the brightest source (unbinned coords). 

1010 """ 

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

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

1013 

1014 good = goodSourceMask & np.isfinite(fluxes) 

1015 

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

1017 maxFluxIdx = IDX_SENTINEL 

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

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

1020 return maxFluxIdx, brightCentroid, brightShape 

1021 

1022 fluxes = fluxes[good] 

1023 idxs = idxs[good] 

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

1025 brightest = binnedSourceCat[maxFluxIdx] 

1026 

1027 # Convert binned coordinates back to original unbinned 

1028 # coordinates 

1029 brightX, brightY = brightest.getCentroid() 

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

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

1032 brightCentroid = Point2D(brightX, brightY) 

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

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

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

1036 brightShape = Quadrupole(brightIXX, brightIYY, brightIXY) 

1037 

1038 return maxFluxIdx, brightCentroid, brightShape 

1039 

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

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

1042 

1043 Parameters 

1044 ---------- 

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

1046 Source catalog from the binned exposure. 

1047 binSize : `int` 

1048 Binning factor used. 

1049 goodSourceMask : `numpy.ndarray` 

1050 Boolean array indicating which sources are good. 

1051 

1052 Returns 

1053 ------- 

1054 psfShape : `lsst.geom.Quadrupole` 

1055 Estimated PSF shape (unbinned coords). 

1056 """ 

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

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

1059 

1060 good = goodSourceMask & np.isfinite(fluxes) 

1061 

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

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

1064 

1065 fluxes = fluxes[good] 

1066 idxs = idxs[good] 

1067 

1068 psfIXX = _estimateMode(np.array([source.getIxx() for source in binnedSourceCat])[goodSourceMask]) 

1069 psfIYY = _estimateMode(np.array([source.getIyy() for source in binnedSourceCat])[goodSourceMask]) 

1070 psfIXY = _estimateMode(np.array([source.getIxy() for source in binnedSourceCat])[goodSourceMask]) 

1071 

1072 return Quadrupole( 

1073 psfIXX * binSize**2, 

1074 psfIYY * binSize**2, 

1075 psfIXY * binSize**2, 

1076 ) 

1077 

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

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

1080 horizon coordinates. 

1081 

1082 Parameters 

1083 ---------- 

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

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

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

1087 Exposure containing WCS and VisitInfo for transformation. 

1088 binSize : `int` 

1089 Binning factor used. 

1090 

1091 Returns 

1092 ------- 

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

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

1095 coordinates. Units are arcseconds. 

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

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

1098 arcseconds. 

1099 """ 

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

1101 wcs = exposure.wcs 

1102 visitInfo = exposure.info.getVisitInfo() 

1103 parAngle = visitInfo.boresightParAngle 

1104 

1105 equatorialShapes = [] 

1106 altAzShapes = [] 

1107 for shape in shapes: 

1108 if wcs is None: 

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

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

1111 continue 

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

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

1114 # component of the transformation. 

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

1116 nwTransform = LinearTransform(np.array([[-1, 0], [0, 1]]) @ neTransform.getMatrix()) 

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

1118 

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

1120 # parallactic angle. 

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

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

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

1124 

1125 return equatorialShapes, altAzShapes 

1126 

1127 def updateDisplay(self, exposure, binSize, binnedSourceCat, maxFluxIdx, doDisplayIndices): 

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

1129 

1130 Parameters 

1131 ---------- 

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

1133 Exposure to peek. 

1134 binSize : `int` 

1135 Binning factor used. 

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

1137 Source catalog from the binned exposure. 

1138 maxFluxIdx : `int` 

1139 Index of the brightest source in the catalog. 

1140 doDisplayIndices : `bool` 

1141 Display the source indices? 

1142 """ 

1143 if self._display is None: 

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

1145 

1146 visitInfo = exposure.info.getVisitInfo() 

1147 self._display.mtv(exposure) 

1148 wcs = exposure.wcs 

1149 if wcs is not None: 

1150 plotRose( 

1151 self._display, 

1152 wcs, 

1153 Point2D(200 / binSize, 200 / binSize), 

1154 parAng=visitInfo.boresightParAngle, 

1155 len=100 / binSize, 

1156 ) 

1157 

1158 for idx, source in enumerate(binnedSourceCat): 

1159 x, y = source.getCentroid() 

1160 sh = source.getShape() 

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

1162 if doDisplayIndices: 

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

1164 

1165 if maxFluxIdx != IDX_SENTINEL: 

1166 self._display.dot( 

1167 "+", 

1168 *binnedSourceCat[maxFluxIdx].getCentroid(), 

1169 ctype=afwDisplay.RED, 

1170 size=10, 

1171 )