Coverage for python/lsst/summit/utils/peekExposure.py: 19%
384 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-27 05:01 -0700
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-27 05:01 -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/>.
22__all__ = [
23 "PeekExposureTaskConfig",
24 "PeekExposureTask",
25]
27import numpy as np
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
44IDX_SENTINEL = -99999
47def _estimateMode(data, frac=0.5):
48 """Estimate the mode of a 1d distribution.
50 Finds the smallest interval containing the fraction ``frac`` of the data,
51 then takes the median of the values in that interval.
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.
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]
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])
78def _bearingToUnitVector(wcs, bearing, imagePoint, skyPoint=None):
79 """Compute unit vector along given bearing at given point in the sky.
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.
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()
103def roseVectors(wcs, imagePoint, parAng=None):
104 """Compute unit vectors in the N/W and optionally alt/az directions.
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)
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)
125 out = dict()
126 out["N"] = _bearingToUnitVector(wcs, bearing, imagePoint, skyPoint=skyPoint)
127 out["W"] = _bearingToUnitVector(wcs, bearing + 90 * degrees, imagePoint, skyPoint=skyPoint)
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)
133 return out
136def plotRose(display, wcs, imagePoint, parAng=None, len=50):
137 """Display unit vectors along N/W and optionally alt/az directions.
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])
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)
168 def __deepcopy__(self, memo=None):
169 return DonutPsf(self.size, self.outerRad, self.innerRad)
171 def resized(self, width, height):
172 assert width == height
173 return DonutPsf(width, self.outerRad, self.innerRad)
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
185 def _doComputeBBox(self, position=None, color=None):
186 return Box2I(Point2I(-self.dimensions / 2), self.dimensions)
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)
193 def _doComputeApertureFlux(self, radius, position=None, color=None):
194 return 1 - np.exp(-0.5 * (radius / self.sigma) ** 2)
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
202class PeekTaskConfig(pexConfig.Config):
203 """Config class for the PeekTask."""
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 )
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"
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.
254 Optionally bins image and then:
255 - installs a simple PSF model
256 - measures and subtracts the background
257 - detects sources
258 - measures sources
260 Designed to be quick at the expense of primarily completeness, but also to
261 a lesser extent accuracy.
262 """
264 ConfigClass = PeekTaskConfig
265 _DefaultName = "peek"
267 def __init__(self, schema=None, **kwargs):
268 super().__init__(**kwargs)
270 if schema is None:
271 schema = SourceTable.makeMinimalSchema()
272 self.schema = schema
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)
280 def run(self, exposure, binSize=None):
281 """Peek at exposure.
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.
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
302 if binSize != 1:
303 mi = exposure.getMaskedImage()
304 binned = afwMath.binImage(mi, binSize)
305 exposure.setMaskedImage(binned)
307 if self.config.doInstallPsf:
308 self.installPsf.run(exposure=exposure)
310 self.background.run(exposure)
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
318 self.measurement.run(measCat=sourceCat, exposure=exposure, exposureId=idGenerator.catalog_id)
320 return pipeBase.Struct(
321 sourceCat=sourceCat,
322 )
325class PeekDonutTaskConfig(pexConfig.Config):
326 """Config class for the PeekDonutTask."""
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 )
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
352class PeekDonutTask(pipeBase.Task):
353 """Peek at a donut exposure.
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 """
361 ConfigClass = PeekDonutTaskConfig
362 _DefaultName = "peekDonut"
364 def __init__(self, config, **kwargs):
365 super().__init__(config=config, **kwargs)
366 self.makeSubtask("peek")
368 def run(self, exposure, donutDiameter, binSize=None):
369 """Peek at donut exposure.
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.
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 )
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)
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)
424 peekResult = self.peek.run(exposure, binSize)
426 return pipeBase.Struct(
427 mode="donut",
428 binSize=binSize,
429 binnedSourceCat=peekResult.sourceCat,
430 )
432 def getGoodSources(self, binnedSourceCat):
433 """Perform any filtering on the source catalog.
435 Parameters
436 ----------
437 binnedSourceCat : `lsst.afw.table.SourceCatalog`
438 Source catalog from the binned exposure.
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
450class PeekPhotoTaskConfig(pexConfig.Config):
451 """Config class for the PeekPhotoTask."""
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 )
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
471class PeekPhotoTask(pipeBase.Task):
472 """Peek at a photo (imaging) exposure.
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 """
478 ConfigClass = PeekPhotoTaskConfig
479 _DefaultName = "peekPhoto"
481 def __init__(self, config, **kwargs):
482 super().__init__(config=config, **kwargs)
483 self.makeSubtask("peek")
485 def run(self, exposure, binSize=None):
486 """Peek at donut exposure.
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.
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
511 peekResult = self.peek.run(exposure, binSize)
513 return pipeBase.Struct(
514 mode="photo",
515 binSize=binSize,
516 binnedSourceCat=peekResult.sourceCat,
517 )
519 def getGoodSources(self, binnedSourceCat):
520 """Perform any filtering on the source catalog.
522 Parameters
523 ----------
524 binnedSourceCat : `lsst.afw.table.SourceCatalog`
525 Source catalog from the binned exposure.
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
537class PeekSpecTaskConfig(pexConfig.Config):
538 """Config class for the PeekSpecTask."""
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 )
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
568class PeekSpecTask(pipeBase.Task):
569 """Peek at a spectroscopic exposure.
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 """
578 ConfigClass = PeekSpecTaskConfig
579 _DefaultName = "peekSpec"
581 def __init__(self, config, **kwargs):
582 super().__init__(config=config, **kwargs)
583 self.makeSubtask("peek")
585 def run(self, exposure, binSize=None):
586 """Peek at spectroscopic exposure.
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.
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
611 peekResult = self.peek.run(exposure, binSize)
613 return pipeBase.Struct(
614 mode="spec",
615 binSize=binSize,
616 binnedSourceCat=peekResult.sourceCat,
617 )
619 def getGoodSources(self, binnedSourceCat):
620 """Perform any filtering on the source catalog.
622 Parameters
623 ----------
624 binnedSourceCat : `lsst.afw.table.SourceCatalog`
625 Source catalog from the binned exposure.
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
642class PeekExposureTaskConfig(pexConfig.Config):
643 """Config class for the PeekExposureTask."""
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 )
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.
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.
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.
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 """
697 ConfigClass = PeekExposureTaskConfig
698 _DefaultName = "peekExposureTask"
700 def __init__(self, config, *, display=None, **kwargs):
701 super().__init__(config=config, **kwargs)
703 self.makeSubtask("donut")
704 self.makeSubtask("photo")
705 self.makeSubtask("spec")
707 self._display = display
709 def getDonutDiameter(self, exposure):
710 """Estimate donut diameter from exposure metadata.
712 Parameters
713 ----------
714 exposure : `lsst.afw.image.Exposure`
715 Exposure to estimate donut diameter for.
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
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
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.
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
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])
837 if donutDiameter is None:
838 donutDiameter = self.getDonutDiameter(exposure)
840 mode, binSize, binnedSourceCat = self.runPeek(exposure, mode, donutDiameter, binSize)
842 table = self.transformTable(binSize, binnedSourceCat)
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)
852 # prepare output variables
853 maxFluxIdx, brightCentroid, brightShape = self.getBrightest(binnedSourceCat, binSize, goodSourceMask)
854 psfShape = self.getPsfShape(binnedSourceCat, binSize, goodSourceMask)
856 equatorialShapes, altAzShapes = self.transformShapes(
857 [brightShape, psfShape],
858 exposure,
859 binSize,
860 )
862 if doDisplay:
863 self.updateDisplay(exposure, binSize, binnedSourceCat, maxFluxIdx, doDisplayIndices)
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 )
882 def runPeek(self, exposure, mode, donutDiameter, binSize=None):
883 """Classify exposure and run appropriate PeekTask wrapper.
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.
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"
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
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.
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.
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.
953 Parameters
954 ----------
955 binSize : `int`
956 Binning factor used.
957 binnedSourceCat : `lsst.afw.table.SourceCatalog`
958 Source catalog from the binned exposure.
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"]
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 )
988 return table
990 def getBrightest(self, binnedSourceCat, binSize, goodSourceMask):
991 """Find the brightest source in the catalog.
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.
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))
1014 good = goodSourceMask & np.isfinite(fluxes)
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
1022 fluxes = fluxes[good]
1023 idxs = idxs[good]
1024 maxFluxIdx = idxs[np.nanargmax(fluxes)]
1025 brightest = binnedSourceCat[maxFluxIdx]
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)
1038 return maxFluxIdx, brightCentroid, brightShape
1040 def getPsfShape(self, binnedSourceCat, binSize, goodSourceMask):
1041 """Estimate the modal PSF shape from the sources.
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.
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))
1060 good = goodSourceMask & np.isfinite(fluxes)
1062 if np.sum(good) == 0:
1063 return Quadrupole(np.nan, np.nan, np.nan)
1065 fluxes = fluxes[good]
1066 idxs = idxs[good]
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])
1072 return Quadrupole(
1073 psfIXX * binSize**2,
1074 psfIYY * binSize**2,
1075 psfIXY * binSize**2,
1076 )
1078 def transformShapes(self, shapes, exposure, binSize):
1079 """Transform shapes from x/y pixel coordinates to equitorial and
1080 horizon coordinates.
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.
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
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))
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))
1125 return equatorialShapes, altAzShapes
1127 def updateDisplay(self, exposure, binSize, binnedSourceCat, maxFluxIdx, doDisplayIndices):
1128 """Update the afwDisplay with the exposure and sources.
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()")
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 )
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)
1165 if maxFluxIdx != IDX_SENTINEL:
1166 self._display.dot(
1167 "+",
1168 *binnedSourceCat[maxFluxIdx].getCentroid(),
1169 ctype=afwDisplay.RED,
1170 size=10,
1171 )