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

390 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-04 04:18 -0700

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 

27 

28from typing import Any 

29 

30import astropy 

31import numpy as np 

32 

33import lsst.afw.display as afwDisplay 

34import lsst.afw.geom as afwGeom 

35import lsst.afw.image as afwImage 

36import lsst.afw.math as afwMath 

37import lsst.afw.table as afwTable 

38import lsst.daf.base as dafBase 

39import lsst.geom as geom 

40import lsst.pex.config as pexConfig 

41import lsst.pipe.base as pipeBase 

42from lsst.afw.detection import Psf 

43from lsst.afw.geom.ellipses import Quadrupole 

44from lsst.afw.image import ImageD 

45from lsst.afw.table import SourceTable 

46from lsst.atmospec.utils import isDispersedExp 

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

48from lsst.meas.algorithms import SourceDetectionTask, SubtractBackgroundTask 

49from lsst.meas.algorithms.installGaussianPsf import InstallGaussianPsfTask 

50from lsst.meas.base import IdGenerator, SingleFrameMeasurementTask 

51 

52IDX_SENTINEL = -99999 

53 

54 

55def _estimateMode(data: np.ndarray, frac: float = 0.5): 

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

57 

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

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

60 

61 Parameters 

62 ---------- 

63 data : array-like 

64 1d array of data values 

65 frac : float, optional 

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

67 

68 Returns 

69 ------- 

70 mode : float 

71 Estimated mode of the data. 

72 """ 

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

74 if len(data) == 0: 

75 return np.nan 

76 elif len(data) == 1: 

77 return data[0] 

78 

79 data = np.sort(data) 

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

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

82 start = np.argmin(spans) 

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

84 

85 

86def _bearingToUnitVector( 

87 wcs: afwGeom.SkyWcs, 

88 bearing: geom.Angle, 

89 imagePoint: geom.Point2D, 

90 skyPoint: geom.SpherePoint | None = None, 

91) -> geom.Extent2D: 

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

93 

94 Parameters 

95 ---------- 

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

97 World Coordinate System of image. 

98 bearing : `lsst.geom.Angle` 

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

100 imagePoint : `lsst.geom.Point2D` 

101 Point in the image. 

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

103 Point in the sky. 

104 

105 Returns 

106 ------- 

107 unitVector : `lsst.geom.Extent2D` 

108 Unit vector in the direction of bearing. 

109 """ 

110 if skyPoint is None: 

111 skyPoint = wcs.pixelToSky(imagePoint) 

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

113 return dpt / dpt.computeNorm() 

114 

115 

116def roseVectors(wcs: afwGeom.SkyWcs, imagePoint: geom.Point2D, parAng: geom.Angle | None = None) -> dict: 

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

118 

119 Parameters 

120 ---------- 

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

122 World Coordinate System of image. 

123 imagePoint : `lsst.geom.Point2D` 

124 Point in the image 

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

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

127 (default: None) 

128 

129 Returns 

130 ------- 

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

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

133 """ 

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

135 skyPoint = wcs.pixelToSky(imagePoint) 

136 bearing = skyPoint.bearingTo(ncp) 

137 

138 out = dict() 

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

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

141 

142 if parAng is not None: 

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

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

145 

146 return out 

147 

148 

149def plotRose( 

150 display: afwDisplay.Display, 

151 wcs: afwGeom.SkyWcs, 

152 imagePoint: geom.Point2D, 

153 parAng: geom.Angle | None = None, 

154 len: float = 50, 

155) -> None: 

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

157 

158 Parameters 

159 ---------- 

160 display : `lsst.afw.display.Display` 

161 Display on which to render rose. 

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

163 World Coordinate System of image. 

164 imagePoint : `lsst.geom.Point2D` 

165 Point in the image at which to render rose. 

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

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

168 (default: None) 

169 len : `float`, optional 

170 Length of the rose vectors (default: 50) 

171 """ 

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

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

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

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

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

177 

178 

179class DonutPsf(Psf): 

180 def __init__(self, size: float, outerRad: float, innerRad: float): 

181 Psf.__init__(self, isFixed=True) 

182 self.size = size 

183 self.outerRad = outerRad 

184 self.innerRad = innerRad 

185 self.dimensions = Extent2I(size, size) 

186 

187 def __deepcopy__(self, memo=None): 

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

189 

190 def resized(self, width: float, height: float): 

191 assert width == height 

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

193 

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

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

196 img = ImageD(bbox, 0.0) 

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

198 rsqr = x**2 + y**2 

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

200 img.array[w] = 1.0 

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

202 return img 

203 

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

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

206 

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

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

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

210 return Quadrupole(Ixx, Ixx, 0.0) 

211 

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

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

214 

215 def __eq__(self, rhs: object) -> bool: 

216 if isinstance(rhs, DonutPsf): 

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

218 return False 

219 

220 

221class PeekTaskConfig(pexConfig.Config): 

222 """Config class for the PeekTask.""" 

223 

224 installPsf = pexConfig.ConfigurableField( 

225 target=InstallGaussianPsfTask, 

226 doc="Install a PSF model", 

227 ) 

228 doInstallPsf = pexConfig.Field( 

229 dtype=bool, 

230 default=True, 

231 doc="Install a PSF model?", 

232 ) 

233 background = pexConfig.ConfigurableField( 

234 target=SubtractBackgroundTask, 

235 doc="Estimate background", 

236 ) 

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

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

239 defaultBinSize = pexConfig.Field( 

240 dtype=int, 

241 default=1, 

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

243 ) 

244 

245 def setDefaults(self) -> None: 

246 super().setDefaults() 

247 # Configure to be aggressively fast. 

248 self.detection.thresholdValue = 5.0 

249 self.detection.includeThresholdMultiplier = 10.0 

250 self.detection.reEstimateBackground = False 

251 self.detection.doTempLocalBackground = False 

252 self.measurement.doReplaceWithNoise = False 

253 self.detection.minPixels = 40 

254 self.installPsf.fwhm = 5.0 

255 self.installPsf.width = 21 

256 # minimal set of measurements 

257 self.measurement.plugins.names = [ 

258 "base_PixelFlags", 

259 "base_SdssCentroid", 

260 "ext_shapeHSM_HsmSourceMoments", 

261 "base_GaussianFlux", 

262 "base_PsfFlux", 

263 "base_CircularApertureFlux", 

264 ] 

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

266 

267 

268class PeekTask(pipeBase.Task): 

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

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

271 image quality. 

272 

273 Optionally bins image and then: 

274 - installs a simple PSF model 

275 - measures and subtracts the background 

276 - detects sources 

277 - measures sources 

278 

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

280 a lesser extent accuracy. 

281 """ 

282 

283 ConfigClass = PeekTaskConfig 

284 _DefaultName = "peek" 

285 

286 def __init__(self, schema: Any | None = None, **kwargs: Any): 

287 super().__init__(**kwargs) 

288 

289 if schema is None: 

290 schema = SourceTable.makeMinimalSchema() 

291 self.schema = schema 

292 

293 self.makeSubtask("installPsf") 

294 self.makeSubtask("background") 

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

296 self.algMetadata = dafBase.PropertyList() 

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

298 

299 def run(self, exposure: afwImage.Exposure, binSize: int | None = None) -> pipeBase.Struct: 

300 """Peek at exposure. 

301 

302 Parameters 

303 ---------- 

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

305 Exposure at which to peek. 

306 binSize : `int`, optional 

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

308 default binning factor from the config. 

309 

310 Returns 

311 ------- 

312 result : `pipeBase.Struct` 

313 Result of peeking. 

314 Struct containing: 

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

316 Source catalog from the binned exposure. 

317 """ 

318 if binSize is None: 

319 binSize = self.config.defaultBinSize 

320 

321 if binSize != 1: 

322 mi = exposure.getMaskedImage() 

323 binned = afwMath.binImage(mi, binSize) 

324 exposure.setMaskedImage(binned) 

325 

326 if self.config.doInstallPsf: 

327 self.installPsf.run(exposure=exposure) 

328 

329 self.background.run(exposure) 

330 

331 idGenerator = IdGenerator() 

332 sourceIdFactory = idGenerator.make_table_id_factory() 

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

334 table.setMetadata(self.algMetadata) 

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

336 

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

338 

339 return pipeBase.Struct( 

340 sourceCat=sourceCat, 

341 ) 

342 

343 

344class PeekDonutTaskConfig(pexConfig.Config): 

345 """Config class for the PeekDonutTask.""" 

346 

347 peek = pexConfig.ConfigurableField( 

348 target=PeekTask, 

349 doc="Peek configuration", 

350 ) 

351 resolution = pexConfig.Field( 

352 dtype=float, 

353 default=16.0, 

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

355 ) 

356 binSizeMax = pexConfig.Field( 

357 dtype=int, 

358 default=10, 

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

360 ) 

361 

362 def setDefaults(self) -> None: 

363 super().setDefaults() 

364 # Donuts are big even when binned. 

365 self.peek.installPsf.fwhm = 10.0 

366 self.peek.installPsf.width = 31 

367 # Use DonutPSF if not overridden 

368 self.peek.doInstallPsf = False 

369 

370 

371class PeekDonutTask(pipeBase.Task): 

372 """Peek at a donut exposure. 

373 

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

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

376 greatly increases the speed and detection capabilities of PeekTask with 

377 little loss of accuracy for centroids. 

378 """ 

379 

380 ConfigClass = PeekDonutTaskConfig 

381 _DefaultName = "peekDonut" 

382 

383 def __init__(self, config: Any, **kwargs: Any): 

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

385 self.makeSubtask("peek") 

386 

387 def run( 

388 self, exposure: afwImage.Exposure, donutDiameter: float, binSize: int | None = None 

389 ) -> pipeBase.Struct: 

390 """Peek at donut exposure. 

391 

392 Parameters 

393 ---------- 

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

395 Exposure at which to peek. 

396 donutDiameter : `float` 

397 Donut diameter in pixels. 

398 binSize : `int`, optional 

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

400 resolution config value to determine the binSize. 

401 

402 Returns 

403 ------- 

404 result : `pipeBase.Struct` 

405 Result of donut peeking. 

406 Struct containing: 

407 - mode : `str` 

408 Peek mode that was run. 

409 - binSize : `int` 

410 Binning factor used. 

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

412 Source catalog from the binned exposure. 

413 """ 

414 if binSize is None: 

415 binSize = int( 

416 np.floor( 

417 np.clip( 

418 donutDiameter / self.config.resolution, 

419 1, 

420 self.config.binSizeMax, 

421 ) 

422 ) 

423 ) 

424 binnedDonutDiameter = donutDiameter / binSize 

425 psf = DonutPsf( 

426 binnedDonutDiameter * 1.5, binnedDonutDiameter * 0.5, binnedDonutDiameter * 0.5 * 0.3525 

427 ) 

428 

429 # Note that SourceDetectionTask will convolve with a _Gaussian 

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

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

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

433 # convolution algorithm, so we limit the size. 

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

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

436 

437 if factor > 1: 

438 psf = DonutPsf( 

439 binnedDonutDiameter * 1.5 / factor, 

440 binnedDonutDiameter * 0.5 / factor, 

441 binnedDonutDiameter * 0.5 * 0.3525 / factor, 

442 ) 

443 exposure.setPsf(psf) 

444 

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

446 

447 return pipeBase.Struct( 

448 mode="donut", 

449 binSize=binSize, 

450 binnedSourceCat=peekResult.sourceCat, 

451 ) 

452 

453 def getGoodSources(self, binnedSourceCat: afwTable.SourceCatalog) -> np.ndarray: 

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

455 

456 Parameters 

457 ---------- 

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

459 Source catalog from the binned exposure. 

460 

461 Returns 

462 ------- 

463 goodSourceMask : `numpy.ndarray` 

464 Boolean array indicating which sources are good. 

465 """ 

466 # Perform any filtering on the source catalog 

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

468 return goodSourceMask 

469 

470 

471class PeekPhotoTaskConfig(pexConfig.Config): 

472 """Config class for the PeekPhotoTask.""" 

473 

474 peek = pexConfig.ConfigurableField( 

475 target=PeekTask, 

476 doc="Peek configuration", 

477 ) 

478 binSize = pexConfig.Field( 

479 dtype=int, 

480 default=2, 

481 doc="Binning factor for exposure", 

482 ) 

483 

484 def setDefaults(self) -> None: 

485 super().setDefaults() 

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

487 self.peek.detection.includeThresholdMultiplier = 1.0 

488 self.peek.detection.thresholdValue = 10.0 

489 self.peek.detection.minPixels = 10 

490 

491 

492class PeekPhotoTask(pipeBase.Task): 

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

494 

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

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

497 """ 

498 

499 ConfigClass = PeekPhotoTaskConfig 

500 _DefaultName = "peekPhoto" 

501 

502 def __init__(self, config: Any, **kwargs: Any): 

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

504 self.makeSubtask("peek") 

505 

506 def run(self, exposure: afwImage.Exposure, binSize: int | None = None) -> pipeBase.Struct: 

507 """Peek at donut exposure. 

508 

509 Parameters 

510 ---------- 

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

512 Exposure at which to peek. 

513 binSize : `int`, optional 

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

515 binning factor from the config. 

516 

517 Returns 

518 ------- 

519 result : `pipeBase.Struct` 

520 Result of photo peeking. 

521 Struct containing: 

522 - mode : `str` 

523 Peek mode that was run. 

524 - binSize : `int` 

525 Binning factor used. 

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

527 Source catalog from the binned exposure. 

528 """ 

529 if binSize is None: 

530 binSize = self.config.binSize 

531 

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

533 

534 return pipeBase.Struct( 

535 mode="photo", 

536 binSize=binSize, 

537 binnedSourceCat=peekResult.sourceCat, 

538 ) 

539 

540 def getGoodSources(self, binnedSourceCat: afwTable.SourceCatalog) -> np.ndarray: 

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

542 

543 Parameters 

544 ---------- 

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

546 Source catalog from the binned exposure. 

547 

548 Returns 

549 ------- 

550 goodSourceMask : `numpy.ndarray` 

551 Boolean array indicating which sources are good. 

552 """ 

553 # Perform any filtering on the source catalog 

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

555 return goodSourceMask 

556 

557 

558class PeekSpecTaskConfig(pexConfig.Config): 

559 """Config class for the PeekSpecTask.""" 

560 

561 peek = pexConfig.ConfigurableField( 

562 target=PeekTask, 

563 doc="Peek configuration", 

564 ) 

565 binSize = pexConfig.Field( 

566 dtype=int, 

567 default=2, 

568 doc="binning factor for exposure", 

569 ) 

570 maxFootprintAspectRatio = pexConfig.Field( 

571 dtype=float, 

572 default=10.0, 

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

574 ) 

575 

576 def setDefaults(self) -> None: 

577 super().setDefaults() 

578 # Use bright threshold 

579 self.peek.detection.includeThresholdMultiplier = 1.0 

580 self.peek.detection.thresholdValue = 500.0 

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

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

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

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

585 # a relatively large saturated region. 

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

587 

588 

589class PeekSpecTask(pipeBase.Task): 

590 """Peek at a spectroscopic exposure. 

591 

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

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

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

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

596 saturated objects. 

597 """ 

598 

599 ConfigClass = PeekSpecTaskConfig 

600 _DefaultName = "peekSpec" 

601 

602 def __init__(self, config: Any, **kwargs: Any): 

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

604 self.makeSubtask("peek") 

605 

606 def run(self, exposure: afwImage.Exposure, binSize: int | None = None) -> pipeBase.Struct: 

607 """Peek at spectroscopic exposure. 

608 

609 Parameters 

610 ---------- 

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

612 Exposure at which to peek. 

613 binSize : `int`, optional 

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

615 binning factor from the config. 

616 

617 Returns 

618 ------- 

619 result : `pipeBase.Struct` 

620 Result of spec peeking. 

621 Struct containing: 

622 - mode : `str` 

623 Peek mode that was run. 

624 - binSize : `int` 

625 Binning factor used. 

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

627 Source catalog from the binned exposure. 

628 """ 

629 if binSize is None: 

630 binSize = self.config.binSize 

631 

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

633 

634 return pipeBase.Struct( 

635 mode="spec", 

636 binSize=binSize, 

637 binnedSourceCat=peekResult.sourceCat, 

638 ) 

639 

640 def getGoodSources(self, binnedSourceCat: afwTable.SourceCatalog) -> np.ndarray: 

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

642 

643 Parameters 

644 ---------- 

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

646 Source catalog from the binned exposure. 

647 

648 Returns 

649 ------- 

650 goodSourceMask : `numpy.ndarray` 

651 Boolean array indicating which sources are good. 

652 """ 

653 # Perform any filtering on the source catalog 

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

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

656 # Filter out likely spectrum detections 

657 goodSourceMask &= np.array( 

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

659 ) 

660 return goodSourceMask 

661 

662 

663class PeekExposureTaskConfig(pexConfig.Config): 

664 """Config class for the PeekExposureTask.""" 

665 

666 donutThreshold = pexConfig.Field( 

667 dtype=float, 

668 default=50.0, 

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

670 ) 

671 doPhotoFallback = pexConfig.Field( 

672 dtype=bool, 

673 default=True, 

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

675 ) 

676 donut = pexConfig.ConfigurableField( 

677 target=PeekDonutTask, 

678 doc="PeekDonut task", 

679 ) 

680 photo = pexConfig.ConfigurableField( 

681 target=PeekPhotoTask, 

682 doc="PeekPhoto task", 

683 ) 

684 spec = pexConfig.ConfigurableField( 

685 target=PeekSpecTask, 

686 doc="PeekSpec task", 

687 ) 

688 

689 

690class PeekExposureTask(pipeBase.Task): 

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

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

693 exposure's overall image quality. 

694 

695 Parameters 

696 ---------- 

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

698 Configuration for the task. 

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

700 For displaying the exposure and sources. 

701 

702 Notes 

703 ----- 

704 The basic philosophy of PeekExposureTask is to: 

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

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

707 modifications. 

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

709 data itself. This avoids problematic discontinuities in measurements. 

710 

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

712 - Detection threshold 

713 - Minimum number of pixels for a detection 

714 - Binning of the image 

715 - Installed PSF size 

716 """ 

717 

718 ConfigClass = PeekExposureTaskConfig 

719 _DefaultName = "peekExposureTask" 

720 

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

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

723 

724 self.makeSubtask("donut") 

725 self.makeSubtask("photo") 

726 self.makeSubtask("spec") 

727 

728 self._display = display 

729 

730 def getDonutDiameter(self, exposure: afwImage.Exposure) -> float: 

731 """Estimate donut diameter from exposure metadata. 

732 

733 Parameters 

734 ---------- 

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

736 Exposure to estimate donut diameter for. 

737 

738 Returns 

739 ------- 

740 donutDiameter : `float` 

741 Estimated donut diameter in pixels. 

742 """ 

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

744 focusZ = visitInfo.focusZ 

745 instrumentLabel = visitInfo.instrumentLabel 

746 

747 match instrumentLabel: 

748 case "LATISS": 

749 focusZ *= 41 # magnification factor 

750 fratio = 18.0 

751 case "LSSTCam" | "ComCam": 

752 fratio = 1.234 

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

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

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

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

757 return donutDiameter 

758 

759 def run( 

760 self, 

761 exposure: afwImage.Exposure, 

762 *, 

763 doDisplay: bool = False, 

764 doDisplayIndices: bool = False, 

765 mode: str = "auto", 

766 binSize: int | None = None, 

767 donutDiameter: float | None = None, 

768 ): 

769 """ 

770 Parameters 

771 ---------- 

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

773 Exposure at which to peek. 

774 doDisplay : `bool`, optional 

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

776 display to have been passed to task constructor) 

777 doDisplayIndices : `bool`, optional 

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

779 have been passed to task constructor) 

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

781 Mode to run in. Default 'auto'. 

782 binSize : `int`, optional 

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

784 control rebinning directly. 

785 donutDiameter : `float`, optional 

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

787 donut diameter from the exposure metadata. 

788 

789 Returns 

790 ------- 

791 result : `pipeBase.Struct` 

792 Result of the peek. 

793 Struct containing: 

794 - mode : `str` 

795 Peek mode that was run. 

796 - binSize : `int` 

797 Binning factor used. 

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

799 Source catalog from the binned exposure. 

800 - table : `astropy.table.Table` 

801 Curated source table in unbinned coordinates. 

802 - brightestIdx : `int` 

803 Index of brightest source in source catalog. 

804 - brightestCentroid : `lsst.geom.Point2D` 

805 Brightest source centroid in unbinned pixel coords. 

806 - brightestPixelShape : `lsst.afw.geom.Quadrupole` 

807 Brightest source shape in unbinned pixel coords. 

808 - brightestEquatorialShape : `lsst.afw.geom.Quadrupole` 

809 Brightest source shape in equitorial coordinates (arcsec). 

810 - brightestAltAzShape : `lsst.afw.geom.Quadrupole` 

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

812 - psfPixelShape : `lsst.afw.geom.Quadrupole` 

813 Estimated PSF shape in unbinned pixel coords. 

814 - psfEquatorialShape : `lsst.afw.geom.Quadrupole` 

815 Estimated PSF shape in equitorial coordinates (arcsec). 

816 - psfAltAzShape : `lsst.afw.geom.Quadrupole` 

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

818 - pixelMedian : `float` 

819 Median estimate of entire image. 

820 - pixelMode : `float` 

821 Mode estimate of entire image. 

822 """ 

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

824 exposure = exposure.clone() 

825 try: 

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

827 except Exception as e: 

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

829 result = pipeBase.Struct( 

830 mode="failed", 

831 binSize=0, 

832 binnedSourceCat=None, 

833 table=None, 

834 brightestIdx=0, 

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

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

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

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

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

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

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

842 pixelMedian=np.nan, 

843 pixelMode=np.nan, 

844 ) 

845 return result 

846 

847 def _run( 

848 self, 

849 exposure: afwImage.Exposure, 

850 doDisplay: bool, 

851 doDisplayIndices: bool, 

852 mode: str, 

853 binSize: int | None, 

854 donutDiameter: float | None, 

855 ) -> pipeBase.Struct: 

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

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

858 # speedy median/mode estimates. 

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

860 sampling = 1 

861 if arr.size > 250_000: 

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

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

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

865 

866 if donutDiameter is None: 

867 donutDiameter = self.getDonutDiameter(exposure) 

868 

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

870 

871 table = self.transformTable(binSize, binnedSourceCat) 

872 

873 match mode: 

874 case "donut": 

875 goodSourceMask = self.donut.getGoodSources(binnedSourceCat) 

876 case "spec": 

877 goodSourceMask = self.spec.getGoodSources(binnedSourceCat) 

878 case "photo": 

879 goodSourceMask = self.photo.getGoodSources(binnedSourceCat) 

880 

881 # prepare output variables 

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

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

884 

885 equatorialShapes, altAzShapes = self.transformShapes([brightShape, psfShape], exposure, binSize) 

886 

887 if doDisplay: 

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

889 

890 return pipeBase.Struct( 

891 mode=mode, 

892 binSize=binSize, 

893 binnedSourceCat=binnedSourceCat, 

894 table=table, 

895 brightestIdx=maxFluxIdx, 

896 brightestCentroid=brightCentroid, 

897 brightestPixelShape=brightShape, 

898 brightestEquatorialShape=equatorialShapes[0], 

899 brightestAltAzShape=altAzShapes[0], 

900 psfPixelShape=psfShape, 

901 psfEquatorialShape=equatorialShapes[1], 

902 psfAltAzShape=altAzShapes[1], 

903 pixelMedian=pixelMedian, 

904 pixelMode=pixelMode, 

905 ) 

906 

907 def runPeek( 

908 self, 

909 exposure: afwImage.Exposure, 

910 mode: str, 

911 donutDiameter: float, 

912 binSize: int | None = None, 

913 ) -> tuple[str, int, afwTable.SourceCatalog]: 

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

915 

916 Parameters 

917 ---------- 

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

919 Exposure to peek. 

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

921 Mode to run in. 

922 donutDiameter : `float` 

923 Donut diameter in pixels. 

924 binSize : `int`, optional 

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

926 control rebinning directly. 

927 

928 Returns 

929 ------- 

930 result : `pipeBase.Struct` 

931 Result of the peek. 

932 Struct containing: 

933 - mode : `str` 

934 Peek mode that was run. 

935 - binSize : `int` 

936 Binning factor used. 

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

938 Source catalog from the binned exposure. 

939 """ 

940 if mode == "auto": 

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

942 # donut mode. 

943 if donutDiameter > self.config.donutThreshold: 

944 mode = "donut" 

945 elif isDispersedExp(exposure): 

946 mode = "spec" 

947 else: 

948 mode = "photo" 

949 

950 match mode: 

951 case "donut": 

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

953 binSizeOut = result.binSize 

954 case "spec": 

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

956 binSizeOut = result.binSize 

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

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

959 if self.config.doPhotoFallback: 

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

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

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

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

964 case "photo": 

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

966 binSizeOut = result.binSize 

967 case _: 

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

969 return result.mode, binSizeOut, result.binnedSourceCat 

970 

971 def transformTable(self, binSize: int, binnedSourceCat: afwTable.SourceCatalog) -> astropy.table.Table: 

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

973 transformations back to the original unbinned coordinates. 

974 

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

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

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

978 centroids and shapes too. 

979 

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

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

982 letter. 

983 

984 Parameters 

985 ---------- 

986 binSize : `int` 

987 Binning factor used. 

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

989 Source catalog from the binned exposure. 

990 

991 Returns 

992 ------- 

993 table : `astropy.table.Table` 

994 Curated source table in unbinned coordinates. 

995 """ 

996 table = binnedSourceCat.asAstropy() 

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

998 table = table[cols] 

999 if "slot_Centroid_x" in cols: 

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

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

1002 if "slot_Shape_x" in cols: 

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

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

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

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

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

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

1009 if "slot_PsfFlux_area" in cols: 

1010 del table["slot_PsfFlux_area"] 

1011 if "slot_PsfFlux_npixels" in cols: 

1012 del table["slot_PsfFlux_npixels"] 

1013 

1014 table.rename_columns( 

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

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

1017 ) 

1018 

1019 return table 

1020 

1021 def getBrightest( 

1022 self, binnedSourceCat: afwTable.SourceCatalog, binSize: int, goodSourceMask: np.ndarray[bool] 

1023 ) -> tuple[int, geom.Point2D, afwGeom.Quadrupole]: 

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

1025 

1026 Parameters 

1027 ---------- 

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

1029 Source catalog from the binned exposure. 

1030 binSize : `int` 

1031 Binning factor used. 

1032 goodSourceMask : `numpy.ndarray` 

1033 Boolean array indicating which sources are good. 

1034 

1035 Returns 

1036 ------- 

1037 maxFluxIdx : `int` 

1038 Index of the brightest source in the catalog. 

1039 brightCentroid : `lsst.geom.Point2D` 

1040 Centroid of the brightest source (unbinned coords). 

1041 brightShape : `lsst.afw.geom.Quadrupole` 

1042 Shape of the brightest source (unbinned coords). 

1043 """ 

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

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

1046 

1047 good = goodSourceMask & np.isfinite(fluxes) 

1048 

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

1050 maxFluxIdx = IDX_SENTINEL 

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

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

1053 return maxFluxIdx, brightCentroid, brightShape 

1054 

1055 fluxes = fluxes[good] 

1056 idxs = idxs[good] 

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

1058 brightest = binnedSourceCat[maxFluxIdx] 

1059 

1060 # Convert binned coordinates back to original unbinned 

1061 # coordinates 

1062 brightX, brightY = brightest.getCentroid() 

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

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

1065 brightCentroid = Point2D(brightX, brightY) 

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

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

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

1069 brightShape = Quadrupole(brightIXX, brightIYY, brightIXY) 

1070 

1071 return maxFluxIdx, brightCentroid, brightShape 

1072 

1073 def getPsfShape( 

1074 self, binnedSourceCat: afwTable.SourceCatalog, binSize: int, goodSourceMask: np.ndarray[bool] 

1075 ) -> afwGeom.Quadrupole: 

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

1077 

1078 Parameters 

1079 ---------- 

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

1081 Source catalog from the binned exposure. 

1082 binSize : `int` 

1083 Binning factor used. 

1084 goodSourceMask : `numpy.ndarray` 

1085 Boolean array indicating which sources are good. 

1086 

1087 Returns 

1088 ------- 

1089 psfShape : `lsst.afw.geom.Quadrupole` 

1090 Estimated PSF shape (unbinned coords). 

1091 """ 

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

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

1094 

1095 good = goodSourceMask & np.isfinite(fluxes) 

1096 

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

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

1099 

1100 fluxes = fluxes[good] 

1101 idxs = idxs[good] 

1102 

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

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

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

1106 

1107 return Quadrupole( 

1108 psfIXX * binSize**2, 

1109 psfIYY * binSize**2, 

1110 psfIXY * binSize**2, 

1111 ) 

1112 

1113 def transformShapes( 

1114 self, shapes: afwGeom.Quadrupole, exposure: afwImage.Exposure, binSize: int 

1115 ) -> tuple[list[afwGeom.Quadrupole], list[afwGeom.Quadrupole]]: 

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

1117 horizon coordinates. 

1118 

1119 Parameters 

1120 ---------- 

1121 shapes : `list` of `lsst.afw.geom.Quadrupole` 

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

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

1124 Exposure containing WCS and VisitInfo for transformation. 

1125 binSize : `int` 

1126 Binning factor used. 

1127 

1128 Returns 

1129 ------- 

1130 equatorialShapes : `list` of `lsst.afw.geom.Quadrupole` 

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

1132 coordinates. Units are arcseconds. 

1133 altAzShapes : `list` of `lsst.afw.geom.Quadrupole` 

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

1135 arcseconds. 

1136 """ 

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

1138 wcs = exposure.wcs 

1139 visitInfo = exposure.info.getVisitInfo() 

1140 parAngle = visitInfo.boresightParAngle 

1141 

1142 equatorialShapes = [] 

1143 altAzShapes = [] 

1144 for shape in shapes: 

1145 if wcs is None: 

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

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

1148 continue 

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

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

1151 # component of the transformation. 

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

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

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

1155 

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

1157 # parallactic angle. 

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

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

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

1161 

1162 return equatorialShapes, altAzShapes 

1163 

1164 def updateDisplay( 

1165 self, 

1166 exposure: afwImage.Exposure, 

1167 binSize: int, 

1168 binnedSourceCat: afwTable.SourceCatalog, 

1169 maxFluxIdx: int, 

1170 doDisplayIndices: bool, 

1171 ) -> None: 

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

1173 

1174 Parameters 

1175 ---------- 

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

1177 Exposure to peek. 

1178 binSize : `int` 

1179 Binning factor used. 

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

1181 Source catalog from the binned exposure. 

1182 maxFluxIdx : `int` 

1183 Index of the brightest source in the catalog. 

1184 doDisplayIndices : `bool` 

1185 Display the source indices? 

1186 """ 

1187 if self._display is None: 

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

1189 

1190 visitInfo = exposure.info.getVisitInfo() 

1191 self._display.mtv(exposure) 

1192 wcs = exposure.wcs 

1193 if wcs is not None: 

1194 plotRose( 

1195 self._display, 

1196 wcs, 

1197 Point2D(200 / binSize, 200 / binSize), 

1198 parAng=visitInfo.boresightParAngle, 

1199 len=100 / binSize, 

1200 ) 

1201 

1202 for idx, source in enumerate(binnedSourceCat): 

1203 x, y = source.getCentroid() 

1204 sh = source.getShape() 

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

1206 if doDisplayIndices: 

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

1208 

1209 if maxFluxIdx != IDX_SENTINEL: 

1210 self._display.dot( 

1211 "+", 

1212 *binnedSourceCat[maxFluxIdx].getCentroid(), 

1213 ctype=afwDisplay.RED, 

1214 size=10, 

1215 )