Coverage for python/lsst/summit/utils/peekExposure.py: 20%
390 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-17 08:53 +0000
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-17 08:53 +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/>.
22__all__ = [
23 "PeekExposureTaskConfig",
24 "PeekExposureTask",
25]
28from typing import Any
30import astropy
31import numpy as np
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
52IDX_SENTINEL = -99999
55def _estimateMode(data: np.ndarray, frac: float = 0.5):
56 """Estimate the mode of a 1d distribution.
58 Finds the smallest interval containing the fraction ``frac`` of the data,
59 then takes the median of the values in that interval.
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.
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]
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])
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.
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.
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()
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.
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)
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)
138 out = dict()
139 out["N"] = _bearingToUnitVector(wcs, bearing, imagePoint, skyPoint=skyPoint)
140 out["W"] = _bearingToUnitVector(wcs, bearing + 90 * degrees, imagePoint, skyPoint=skyPoint)
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)
146 return out
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.
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])
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)
187 def __deepcopy__(self, memo=None):
188 return DonutPsf(self.size, self.outerRad, self.innerRad)
190 def resized(self, width: float, height: float):
191 assert width == height
192 return DonutPsf(width, self.outerRad, self.innerRad)
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
204 def _doComputeBBox(self, position=None, color=None):
205 return Box2I(Point2I(-self.dimensions / 2), self.dimensions)
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)
212 def _doComputeApertureFlux(self, radius: float, position=None, color=None):
213 return 1 - np.exp(-0.5 * (radius / self.sigma) ** 2)
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
221class PeekTaskConfig(pexConfig.Config):
222 """Config class for the PeekTask."""
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 )
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"
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.
273 Optionally bins image and then:
274 - installs a simple PSF model
275 - measures and subtracts the background
276 - detects sources
277 - measures sources
279 Designed to be quick at the expense of primarily completeness, but also to
280 a lesser extent accuracy.
281 """
283 ConfigClass = PeekTaskConfig
284 _DefaultName = "peek"
286 def __init__(self, schema: Any | None = None, **kwargs: Any):
287 super().__init__(**kwargs)
289 if schema is None:
290 schema = SourceTable.makeMinimalSchema()
291 self.schema = schema
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)
299 def run(self, exposure: afwImage.Exposure, binSize: int | None = None) -> pipeBase.Struct:
300 """Peek at exposure.
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.
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
321 if binSize != 1:
322 mi = exposure.getMaskedImage()
323 binned = afwMath.binImage(mi, binSize)
324 exposure.setMaskedImage(binned)
326 if self.config.doInstallPsf:
327 self.installPsf.run(exposure=exposure)
329 self.background.run(exposure)
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
337 self.measurement.run(measCat=sourceCat, exposure=exposure, exposureId=idGenerator.catalog_id)
339 return pipeBase.Struct(
340 sourceCat=sourceCat,
341 )
344class PeekDonutTaskConfig(pexConfig.Config):
345 """Config class for the PeekDonutTask."""
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 )
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
371class PeekDonutTask(pipeBase.Task):
372 """Peek at a donut exposure.
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 """
380 ConfigClass = PeekDonutTaskConfig
381 _DefaultName = "peekDonut"
383 def __init__(self, config: Any, **kwargs: Any):
384 super().__init__(config=config, **kwargs)
385 self.makeSubtask("peek")
387 def run(
388 self, exposure: afwImage.Exposure, donutDiameter: float, binSize: int | None = None
389 ) -> pipeBase.Struct:
390 """Peek at donut exposure.
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.
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 )
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)
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)
445 peekResult = self.peek.run(exposure, binSize)
447 return pipeBase.Struct(
448 mode="donut",
449 binSize=binSize,
450 binnedSourceCat=peekResult.sourceCat,
451 )
453 def getGoodSources(self, binnedSourceCat: afwTable.SourceCatalog) -> np.ndarray:
454 """Perform any filtering on the source catalog.
456 Parameters
457 ----------
458 binnedSourceCat : `lsst.afw.table.SourceCatalog`
459 Source catalog from the binned exposure.
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
471class PeekPhotoTaskConfig(pexConfig.Config):
472 """Config class for the PeekPhotoTask."""
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 )
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
492class PeekPhotoTask(pipeBase.Task):
493 """Peek at a photo (imaging) exposure.
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 """
499 ConfigClass = PeekPhotoTaskConfig
500 _DefaultName = "peekPhoto"
502 def __init__(self, config: Any, **kwargs: Any):
503 super().__init__(config=config, **kwargs)
504 self.makeSubtask("peek")
506 def run(self, exposure: afwImage.Exposure, binSize: int | None = None) -> pipeBase.Struct:
507 """Peek at donut exposure.
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.
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
532 peekResult = self.peek.run(exposure, binSize)
534 return pipeBase.Struct(
535 mode="photo",
536 binSize=binSize,
537 binnedSourceCat=peekResult.sourceCat,
538 )
540 def getGoodSources(self, binnedSourceCat: afwTable.SourceCatalog) -> np.ndarray:
541 """Perform any filtering on the source catalog.
543 Parameters
544 ----------
545 binnedSourceCat : `lsst.afw.table.SourceCatalog`
546 Source catalog from the binned exposure.
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
558class PeekSpecTaskConfig(pexConfig.Config):
559 """Config class for the PeekSpecTask."""
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 )
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
589class PeekSpecTask(pipeBase.Task):
590 """Peek at a spectroscopic exposure.
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 """
599 ConfigClass = PeekSpecTaskConfig
600 _DefaultName = "peekSpec"
602 def __init__(self, config: Any, **kwargs: Any):
603 super().__init__(config=config, **kwargs)
604 self.makeSubtask("peek")
606 def run(self, exposure: afwImage.Exposure, binSize: int | None = None) -> pipeBase.Struct:
607 """Peek at spectroscopic exposure.
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.
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
632 peekResult = self.peek.run(exposure, binSize)
634 return pipeBase.Struct(
635 mode="spec",
636 binSize=binSize,
637 binnedSourceCat=peekResult.sourceCat,
638 )
640 def getGoodSources(self, binnedSourceCat: afwTable.SourceCatalog) -> np.ndarray:
641 """Perform any filtering on the source catalog.
643 Parameters
644 ----------
645 binnedSourceCat : `lsst.afw.table.SourceCatalog`
646 Source catalog from the binned exposure.
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
663class PeekExposureTaskConfig(pexConfig.Config):
664 """Config class for the PeekExposureTask."""
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 )
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.
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.
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.
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 """
718 ConfigClass = PeekExposureTaskConfig
719 _DefaultName = "peekExposureTask"
721 def __init__(self, config: Any, *, display: Any = None, **kwargs: Any):
722 super().__init__(config=config, **kwargs)
724 self.makeSubtask("donut")
725 self.makeSubtask("photo")
726 self.makeSubtask("spec")
728 self._display = display
730 def getDonutDiameter(self, exposure: afwImage.Exposure) -> float:
731 """Estimate donut diameter from exposure metadata.
733 Parameters
734 ----------
735 exposure : `lsst.afw.image.Exposure`
736 Exposure to estimate donut diameter for.
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
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
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.
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
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])
866 if donutDiameter is None:
867 donutDiameter = self.getDonutDiameter(exposure)
869 mode, binSize, binnedSourceCat = self.runPeek(exposure, mode, donutDiameter, binSize)
871 table = self.transformTable(binSize, binnedSourceCat)
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)
881 # prepare output variables
882 maxFluxIdx, brightCentroid, brightShape = self.getBrightest(binnedSourceCat, binSize, goodSourceMask)
883 psfShape = self.getPsfShape(binnedSourceCat, binSize, goodSourceMask)
885 equatorialShapes, altAzShapes = self.transformShapes([brightShape, psfShape], exposure, binSize)
887 if doDisplay:
888 self.updateDisplay(exposure, binSize, binnedSourceCat, maxFluxIdx, doDisplayIndices)
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 )
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.
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.
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"
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
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.
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.
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.
984 Parameters
985 ----------
986 binSize : `int`
987 Binning factor used.
988 binnedSourceCat : `lsst.afw.table.SourceCatalog`
989 Source catalog from the binned exposure.
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"]
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 )
1019 return table
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.
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.
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))
1047 good = goodSourceMask & np.isfinite(fluxes)
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
1055 fluxes = fluxes[good]
1056 idxs = idxs[good]
1057 maxFluxIdx = idxs[np.nanargmax(fluxes)]
1058 brightest = binnedSourceCat[maxFluxIdx]
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)
1071 return maxFluxIdx, brightCentroid, brightShape
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.
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.
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))
1095 good = goodSourceMask & np.isfinite(fluxes)
1097 if np.sum(good) == 0:
1098 return Quadrupole(np.nan, np.nan, np.nan)
1100 fluxes = fluxes[good]
1101 idxs = idxs[good]
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])
1107 return Quadrupole(
1108 psfIXX * binSize**2,
1109 psfIYY * binSize**2,
1110 psfIXY * binSize**2,
1111 )
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.
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.
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
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))
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))
1162 return equatorialShapes, altAzShapes
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.
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()")
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 )
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)
1209 if maxFluxIdx != IDX_SENTINEL:
1210 self._display.dot(
1211 "+",
1212 *binnedSourceCat[maxFluxIdx].getCentroid(),
1213 ctype=afwDisplay.RED,
1214 size=10,
1215 )