Coverage for python/lsst/summit/utils/peekExposure.py: 19%
384 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-14 12:17 +0000
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-14 12:17 +0000
1# This file is part of summit_utils.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22__all__ = [
23 "PeekExposureTaskConfig",
24 "PeekExposureTask",
25]
27from lsst.afw.detection import Psf
28import lsst.afw.display as afwDisplay
29import lsst.afw.math as afwMath
30import lsst.daf.base as dafBase
31import lsst.pex.config as pexConfig
32import lsst.pipe.base as pipeBase
33import numpy as np
34from lsst.afw.geom.ellipses import Quadrupole
35from lsst.afw.image import ImageD
36from lsst.atmospec.utils import isDispersedExp
37from lsst.geom import (
38 Box2I,
39 Extent2I,
40 LinearTransform,
41 Point2D,
42 Point2I,
43 SpherePoint,
44 arcseconds,
45 degrees,
46)
47from lsst.meas.algorithms.installGaussianPsf import InstallGaussianPsfTask
48from lsst.meas.algorithms import (
49 SubtractBackgroundTask,
50 SourceDetectionTask,
51)
52from lsst.meas.base import (
53 SingleFrameMeasurementTask,
54 IdGenerator,
55)
56from lsst.afw.table import SourceTable
59IDX_SENTINEL = -99999
62def _estimateMode(data, frac=0.5):
63 """Estimate the mode of a 1d distribution.
65 Finds the smallest interval containing the fraction ``frac`` of the data,
66 then takes the median of the values in that interval.
68 Parameters
69 ----------
70 data : array-like
71 1d array of data values
72 frac : float, optional
73 Fraction of data to include in the mode interval. Default is 0.5.
75 Returns
76 -------
77 mode : float
78 Estimated mode of the data.
79 """
80 data = data[np.isfinite(data)]
81 if len(data) == 0:
82 return np.nan
83 elif len(data) == 1:
84 return data[0]
86 data = np.sort(data)
87 interval = int(np.ceil(frac * len(data)))
88 spans = data[interval:] - data[:-interval]
89 start = np.argmin(spans)
90 return np.median(data[start:start + interval])
93def _bearingToUnitVector(wcs, bearing, imagePoint, skyPoint=None):
94 """Compute unit vector along given bearing at given point in the sky.
96 Parameters
97 ----------
98 wcs : `lsst.afw.geom.SkyWcs`
99 World Coordinate System of image.
100 bearing : `lsst.geom.Angle`
101 Bearing (angle North of East) at which to compute unit vector.
102 imagePoint : `lsst.geom.Point2D`
103 Point in the image.
104 skyPoint : `lsst.geom.SpherePoint`, optional
105 Point in the sky.
107 Returns
108 -------
109 unitVector : `lsst.geom.Extent2D`
110 Unit vector in the direction of bearing.
111 """
112 if skyPoint is None:
113 skyPoint = wcs.pixelToSky(imagePoint)
114 dpt = wcs.skyToPixel(skyPoint.offset(bearing, 1e-4 * degrees)) - imagePoint
115 return dpt / dpt.computeNorm()
118def roseVectors(wcs, imagePoint, parAng=None):
119 """Compute unit vectors in the N/W and optionally alt/az directions.
121 Parameters
122 ----------
123 wcs : `lsst.afw.geom.SkyWcs`
124 World Coordinate System of image.
125 imagePoint : `lsst.geom.Point2D`
126 Point in the image
127 parAng : `lsst.geom.Angle`, optional
128 Parallactic angle (position angle of zenith measured East from North)
129 (default: None)
131 Returns
132 -------
133 unitVectors : `dict` of `lsst.geom.Extent2D`
134 Unit vectors in the N, W, alt, and az directions.
135 """
136 ncp = SpherePoint(0 * degrees, 90 * degrees) # North Celestial Pole
137 skyPoint = wcs.pixelToSky(imagePoint)
138 bearing = skyPoint.bearingTo(ncp)
140 out = dict()
141 out["N"] = _bearingToUnitVector(
142 wcs, bearing, imagePoint, skyPoint=skyPoint
143 )
144 out["W"] = _bearingToUnitVector(
145 wcs, bearing + 90 * degrees, imagePoint, skyPoint=skyPoint
146 )
148 if parAng is not None:
149 out["alt"] = _bearingToUnitVector(
150 wcs, bearing - parAng, imagePoint, skyPoint=skyPoint
151 )
152 out["az"] = _bearingToUnitVector(
153 wcs, bearing - parAng + 90 * degrees, imagePoint, skyPoint=skyPoint
154 )
156 return out
159def plotRose(display, wcs, imagePoint, parAng=None, len=50):
160 """Display unit vectors along N/W and optionally alt/az directions.
162 Parameters
163 ----------
164 display : `lsst.afw.display.Display`
165 Display on which to render rose.
166 wcs : `lsst.afw.geom.SkyWcs`
167 World Coordinate System of image.
168 imagePoint : `lsst.geom.Point2D`
169 Point in the image at which to render rose.
170 parAng : `lsst.geom.Angle`, optional
171 Parallactic angle (position angle of zenith measured East from North)
172 (default: None)
173 len : `float`, optional
174 Length of the rose vectors (default: 50)
175 """
176 unitVectors = roseVectors(wcs, imagePoint, parAng=parAng)
177 colors = dict(N="r", W="r", alt="g", az="g")
178 for name, unitVector in unitVectors.items():
179 display.line([imagePoint, imagePoint + len * unitVector], ctype=colors[name])
180 display.dot(name, *(imagePoint + 1.6 * len * unitVector), ctype=colors[name])
183class DonutPsf(Psf):
184 def __init__(self, size, outerRad, innerRad):
185 Psf.__init__(self, isFixed=True)
186 self.size = size
187 self.outerRad = outerRad
188 self.innerRad = innerRad
189 self.dimensions = Extent2I(size, size)
191 def __deepcopy__(self, memo=None):
192 return DonutPsf(self.size, self.outerRad, self.innerRad)
194 def resized(self, width, height):
195 assert width == height
196 return DonutPsf(width, self.outerRad, self.innerRad)
198 def _doComputeKernelImage(self, position=None, color=None):
199 bbox = self.computeBBox(self.getAveragePosition())
200 img = ImageD(bbox, 0.0)
201 x, y = np.ogrid[bbox.minY:bbox.maxY + 1, bbox.minX:bbox.maxX + 1]
202 rsqr = x**2 + y**2
203 w = (rsqr < self.outerRad**2) & (rsqr > self.innerRad**2)
204 img.array[w] = 1.0
205 img.array /= np.sum(img.array)
206 return img
208 def _doComputeBBox(self, position=None, color=None):
209 return Box2I(Point2I(-self.dimensions / 2), self.dimensions)
211 def _doComputeShape(self, position=None, color=None):
212 Ixx = self.outerRad**4 - self.innerRad**4
213 Ixx /= self.outerRad**2 - self.innerRad**2
214 return Quadrupole(Ixx, Ixx, 0.0)
216 def _doComputeApertureFlux(self, radius, position=None, color=None):
217 return 1 - np.exp(-0.5 * (radius / self.sigma) ** 2)
219 def __eq__(self, rhs):
220 if isinstance(rhs, DonutPsf):
221 return (
222 self.size == rhs.size and
223 self.outerRad == rhs.outerRad and
224 self.innerRad == rhs.innerRad
225 )
226 return False
229class PeekTaskConfig(pexConfig.Config):
230 """Config class for the PeekTask."""
232 installPsf = pexConfig.ConfigurableField(
233 target=InstallGaussianPsfTask,
234 doc="Install a PSF model",
235 )
236 doInstallPsf = pexConfig.Field(
237 dtype=bool,
238 default=True,
239 doc="Install a PSF model?",
240 )
241 background = pexConfig.ConfigurableField(
242 target=SubtractBackgroundTask,
243 doc="Estimate background",
244 )
245 detection = pexConfig.ConfigurableField(
246 target=SourceDetectionTask,
247 doc="Detect sources"
248 )
249 measurement = pexConfig.ConfigurableField(
250 target=SingleFrameMeasurementTask,
251 doc="Measure sources"
252 )
253 defaultBinSize = pexConfig.Field(
254 dtype=int,
255 default=1,
256 doc="Default binning factor for exposure (often overridden).",
257 )
259 def setDefaults(self):
260 super().setDefaults()
261 # Configure to be aggressively fast.
262 self.detection.thresholdValue = 5.0
263 self.detection.includeThresholdMultiplier = 10.0
264 self.detection.reEstimateBackground = False
265 self.detection.doTempLocalBackground = False
266 self.measurement.doReplaceWithNoise = False
267 self.detection.minPixels = 40
268 self.installPsf.fwhm = 5.0
269 self.installPsf.width = 21
270 # minimal set of measurements
271 self.measurement.plugins.names = [
272 "base_PixelFlags",
273 "base_SdssCentroid",
274 "ext_shapeHSM_HsmSourceMoments",
275 "base_GaussianFlux",
276 "base_PsfFlux",
277 "base_CircularApertureFlux",
278 ]
279 self.measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments"
282class PeekTask(pipeBase.Task):
283 """Peek at exposure to quickly detect and measure both the brightest source
284 in the image, and a set of sources representative of the exposure's overall
285 image quality.
287 Optionally bins image and then:
288 - installs a simple PSF model
289 - measures and subtracts the background
290 - detects sources
291 - measures sources
293 Designed to be quick at the expense of primarily completeness, but also to
294 a lesser extent accuracy.
295 """
297 ConfigClass = PeekTaskConfig
298 _DefaultName = "peek"
300 def __init__(self, schema=None, **kwargs):
301 super().__init__(**kwargs)
303 if schema is None:
304 schema = SourceTable.makeMinimalSchema()
305 self.schema = schema
307 self.makeSubtask("installPsf")
308 self.makeSubtask("background")
309 self.makeSubtask("detection", schema=self.schema)
310 self.algMetadata = dafBase.PropertyList()
311 self.makeSubtask('measurement', schema=self.schema, algMetadata=self.algMetadata)
313 def run(self, exposure, binSize=None):
314 """Peek at exposure.
316 Parameters
317 ----------
318 exposure : `lsst.afw.image.Exposure`
319 Exposure at which to peek.
320 binSize : `int`, optional
321 Binning factor for exposure. Default is None, which will use the
322 default binning factor from the config.
324 Returns
325 -------
326 result : `pipeBase.Struct`
327 Result of peeking.
328 Struct containing:
329 - sourceCat : `lsst.afw.table.SourceCatalog`
330 Source catalog from the binned exposure.
331 """
332 if binSize is None:
333 binSize = self.config.defaultBinSize
335 if binSize != 1:
336 mi = exposure.getMaskedImage()
337 binned = afwMath.binImage(mi, binSize)
338 exposure.setMaskedImage(binned)
340 if self.config.doInstallPsf:
341 self.installPsf.run(exposure=exposure)
343 self.background.run(exposure)
345 idGenerator = IdGenerator()
346 sourceIdFactory = idGenerator.make_table_id_factory()
347 table = SourceTable.make(self.schema, sourceIdFactory)
348 table.setMetadata(self.algMetadata)
349 sourceCat = self.detection.run(table=table, exposure=exposure, doSmooth=True).sources
351 self.measurement.run(measCat=sourceCat, exposure=exposure, exposureId=idGenerator.catalog_id)
353 return pipeBase.Struct(
354 sourceCat=sourceCat,
355 )
358class PeekDonutTaskConfig(pexConfig.Config):
359 """Config class for the PeekDonutTask."""
361 peek = pexConfig.ConfigurableField(
362 target=PeekTask,
363 doc="Peek configuration",
364 )
365 resolution = pexConfig.Field(
366 dtype=float,
367 default=16.0,
368 doc="Target number of pixels for a binned donut",
369 )
370 binSizeMax = pexConfig.Field(
371 dtype=int,
372 default=10,
373 doc="Maximum binning factor for donut mode",
374 )
376 def setDefaults(self):
377 super().setDefaults()
378 # Donuts are big even when binned.
379 self.peek.installPsf.fwhm = 10.0
380 self.peek.installPsf.width = 31
381 # Use DonutPSF if not overridden
382 self.peek.doInstallPsf = False
385class PeekDonutTask(pipeBase.Task):
386 """Peek at a donut exposure.
388 The main modification for donuts is to aggressively bin the image to reduce
389 the size of sources (donuts) from ~100 pixels or more to ~10 pixels. This
390 greatly increases the speed and detection capabilities of PeekTask with
391 little loss of accuracy for centroids.
392 """
394 ConfigClass = PeekDonutTaskConfig
395 _DefaultName = "peekDonut"
397 def __init__(self, config, **kwargs):
398 super().__init__(config=config, **kwargs)
399 self.makeSubtask("peek")
401 def run(self, exposure, donutDiameter, binSize=None):
402 """Peek at donut exposure.
404 Parameters
405 ----------
406 exposure : `lsst.afw.image.Exposure`
407 Exposure at which to peek.
408 donutDiameter : `float`
409 Donut diameter in pixels.
410 binSize : `int`, optional
411 Binning factor for exposure. Default is None, which will use the
412 resolution config value to determine the binSize.
414 Returns
415 -------
416 result : `pipeBase.Struct`
417 Result of donut peeking.
418 Struct containing:
419 - mode : `str`
420 Peek mode that was run.
421 - binSize : `int`
422 Binning factor used.
423 - binnedSourceCat : `lsst.afw.table.SourceCatalog`
424 Source catalog from the binned exposure.
425 """
426 if binSize is None:
427 binSize = int(
428 np.floor(
429 np.clip(
430 donutDiameter / self.config.resolution,
431 1,
432 self.config.binSizeMax,
433 )
434 )
435 )
436 binnedDonutDiameter = donutDiameter / binSize
437 psf = DonutPsf(
438 binnedDonutDiameter*1.5,
439 binnedDonutDiameter*0.5,
440 binnedDonutDiameter*0.5*0.3525
441 )
443 # Note that SourceDetectionTask will convolve with a _Gaussian
444 # approximation to the PSF_ anyway, so we don't really need to be
445 # precise with the PSF unless this changes. PSFs that approach the
446 # size of the image, however, can cause problems with the detection
447 # convolution algorithm, so we limit the size.
448 sigma = psf.computeShape(psf.getAveragePosition()).getDeterminantRadius()
449 factor = 8*sigma / (min(exposure.getDimensions())/binSize)
451 if factor > 1:
452 psf = DonutPsf(
453 binnedDonutDiameter*1.5/factor,
454 binnedDonutDiameter*0.5/factor,
455 binnedDonutDiameter*0.5*0.3525/factor
456 )
457 exposure.setPsf(psf)
459 peekResult = self.peek.run(exposure, binSize)
461 return pipeBase.Struct(
462 mode="donut",
463 binSize=binSize,
464 binnedSourceCat=peekResult.sourceCat,
465 )
467 def getGoodSources(self, binnedSourceCat):
468 """Perform any filtering on the source catalog.
470 Parameters
471 ----------
472 binnedSourceCat : `lsst.afw.table.SourceCatalog`
473 Source catalog from the binned exposure.
475 Returns
476 -------
477 goodSourceMask : `numpy.ndarray`
478 Boolean array indicating which sources are good.
479 """
480 # Perform any filtering on the source catalog
481 goodSourceMask = np.ones(len(binnedSourceCat), dtype=bool)
482 return goodSourceMask
485class PeekPhotoTaskConfig(pexConfig.Config):
486 """Config class for the PeekPhotoTask."""
488 peek = pexConfig.ConfigurableField(
489 target=PeekTask,
490 doc="Peek configuration",
491 )
492 binSize = pexConfig.Field(
493 dtype=int,
494 default=2,
495 doc="Binning factor for exposure",
496 )
498 def setDefaults(self):
499 super().setDefaults()
500 # Use a lower detection threshold in photo mode to go a bit fainter.
501 self.peek.detection.includeThresholdMultiplier = 1.0
502 self.peek.detection.thresholdValue = 10.0
503 self.peek.detection.minPixels = 10
506class PeekPhotoTask(pipeBase.Task):
507 """Peek at a photo (imaging) exposure.
509 For photo mode, we keep a relatively small detection threshold value, so we
510 can detect faint sources to use for image quality assessment.
511 """
513 ConfigClass = PeekPhotoTaskConfig
514 _DefaultName = "peekPhoto"
516 def __init__(self, config, **kwargs):
517 super().__init__(config=config, **kwargs)
518 self.makeSubtask("peek")
520 def run(self, exposure, binSize=None):
521 """Peek at donut exposure.
523 Parameters
524 ----------
525 exposure : `lsst.afw.image.Exposure`
526 Exposure at which to peek.
527 binSize : `int`, optional
528 Binning factor for exposure. Default is None, which will use the
529 binning factor from the config.
531 Returns
532 -------
533 result : `pipeBase.Struct`
534 Result of photo peeking.
535 Struct containing:
536 - mode : `str`
537 Peek mode that was run.
538 - binSize : `int`
539 Binning factor used.
540 - binnedSourceCat : `lsst.afw.table.SourceCatalog`
541 Source catalog from the binned exposure.
542 """
543 if binSize is None:
544 binSize = self.config.binSize
546 peekResult = self.peek.run(exposure, binSize)
548 return pipeBase.Struct(
549 mode="photo",
550 binSize=binSize,
551 binnedSourceCat=peekResult.sourceCat,
552 )
554 def getGoodSources(self, binnedSourceCat):
555 """Perform any filtering on the source catalog.
557 Parameters
558 ----------
559 binnedSourceCat : `lsst.afw.table.SourceCatalog`
560 Source catalog from the binned exposure.
562 Returns
563 -------
564 goodSourceMask : `numpy.ndarray`
565 Boolean array indicating which sources are good.
566 """
567 # Perform any filtering on the source catalog
568 goodSourceMask = np.ones(len(binnedSourceCat), dtype=bool)
569 return goodSourceMask
572class PeekSpecTaskConfig(pexConfig.Config):
573 """Config class for the PeekSpecTask."""
575 peek = pexConfig.ConfigurableField(
576 target=PeekTask,
577 doc="Peek configuration",
578 )
579 binSize = pexConfig.Field(
580 dtype=int,
581 default=2,
582 doc="binning factor for exposure",
583 )
584 maxFootprintAspectRatio = pexConfig.Field(
585 dtype=float,
586 default=10.0,
587 doc="Maximum detection footprint aspect ratio to consider as 0th order"
588 " (non-dispersed) light."
589 )
591 def setDefaults(self):
592 super().setDefaults()
593 # Use bright threshold
594 self.peek.detection.includeThresholdMultiplier = 1.0
595 self.peek.detection.thresholdValue = 500.0
596 # Use a large radius aperture flux for spectra to better identify the
597 # brightest source, which for spectra often has a saturated core.
598 self.peek.measurement.slots.apFlux = "base_CircularApertureFlux_70_0"
599 # Also allow a larger distance to peak for centroiding in case there's
600 # a relatively large saturated region.
601 self.peek.measurement.plugins['base_SdssCentroid'].maxDistToPeak = 15.0
604class PeekSpecTask(pipeBase.Task):
605 """Peek at a spectroscopic exposure.
607 For spec mode, we dramatically increase the detection threshold to avoid
608 creating blends with the long spectra objects that appear in these images.
609 We also change the default aperture flux slot to a larger aperture, which
610 helps overcome challenges with lost flux in the interpolated cores of
611 saturated objects.
612 """
614 ConfigClass = PeekSpecTaskConfig
615 _DefaultName = "peekSpec"
617 def __init__(self, config, **kwargs):
618 super().__init__(config=config, **kwargs)
619 self.makeSubtask("peek")
621 def run(self, exposure, binSize=None):
622 """Peek at spectroscopic exposure.
624 Parameters
625 ----------
626 exposure : `lsst.afw.image.Exposure`
627 Exposure at which to peek.
628 binSize : `int`, optional
629 Binning factor for exposure. Default is None, which will use the
630 binning factor from the config.
632 Returns
633 -------
634 result : `pipeBase.Struct`
635 Result of spec peeking.
636 Struct containing:
637 - mode : `str`
638 Peek mode that was run.
639 - binSize : `int`
640 Binning factor used.
641 - binnedSourceCat : `lsst.afw.table.SourceCatalog`
642 Source catalog from the binned exposure.
643 """
644 if binSize is None:
645 binSize = self.config.binSize
647 peekResult = self.peek.run(exposure, binSize)
649 return pipeBase.Struct(
650 mode="spec",
651 binSize=binSize,
652 binnedSourceCat=peekResult.sourceCat,
653 )
655 def getGoodSources(self, binnedSourceCat):
656 """Perform any filtering on the source catalog.
658 Parameters
659 ----------
660 binnedSourceCat : `lsst.afw.table.SourceCatalog`
661 Source catalog from the binned exposure.
663 Returns
664 -------
665 goodSourceMask : `numpy.ndarray`
666 Boolean array indicating which sources are good.
667 """
668 # Perform any filtering on the source catalog
669 goodSourceMask = np.ones(len(binnedSourceCat), dtype=bool)
670 fpShapes = [src.getFootprint().getShape() for src in binnedSourceCat]
671 # Filter out likely spectrum detections
672 goodSourceMask &= np.array(
673 [
674 sh.getIyy() < self.config.maxFootprintAspectRatio * sh.getIxx()
675 for sh in fpShapes
676 ],
677 dtype=bool
678 )
679 return goodSourceMask
682class PeekExposureTaskConfig(pexConfig.Config):
683 """Config class for the PeekExposureTask."""
685 donutThreshold = pexConfig.Field(
686 dtype=float,
687 default=50.0,
688 doc="Size threshold in pixels for when to switch to donut mode.",
689 )
690 doPhotoFallback = pexConfig.Field(
691 dtype=bool,
692 default=True,
693 doc="If True, fall back to photo mode if spec mode fails.",
694 )
695 donut = pexConfig.ConfigurableField(
696 target=PeekDonutTask,
697 doc="PeekDonut task",
698 )
699 photo = pexConfig.ConfigurableField(
700 target=PeekPhotoTask,
701 doc="PeekPhoto task",
702 )
703 spec = pexConfig.ConfigurableField(
704 target=PeekSpecTask,
705 doc="PeekSpec task",
706 )
709class PeekExposureTask(pipeBase.Task):
710 """ Peek at exposure to quickly detect and measure both the brightest
711 source in the image, and a set of sources representative of the
712 exposure's overall image quality.
714 Parameters
715 ----------
716 config : `lsst.summit.utils.peekExposure.PeekExposureTaskConfig`
717 Configuration for the task.
718 display : `lsst.afw.display.Display`, optional
719 For displaying the exposure and sources.
721 Notes
722 -----
723 The basic philosophy of PeekExposureTask is to:
724 1) Classify exposures based on metadata into 'donut', 'spec', or 'photo'.
725 2) Run PeekTask on the exposure through a wrapper with class specific
726 modifications.
727 3) Try only to branch in the code based on the metadata, and not on the
728 data itself. This avoids problematic discontinuities in measurements.
730 The main knobs we fiddle with based on the classification are:
731 - Detection threshold
732 - Minimum number of pixels for a detection
733 - Binning of the image
734 - Installed PSF size
735 """
737 ConfigClass = PeekExposureTaskConfig
738 _DefaultName = "peekExposureTask"
740 def __init__(self, config, *, display=None, **kwargs):
741 super().__init__(config=config, **kwargs)
743 self.makeSubtask("donut")
744 self.makeSubtask("photo")
745 self.makeSubtask("spec")
747 self._display = display
749 def getDonutDiameter(self, exposure):
750 """Estimate donut diameter from exposure metadata.
752 Parameters
753 ----------
754 exposure : `lsst.afw.image.Exposure`
755 Exposure to estimate donut diameter for.
757 Returns
758 -------
759 donutDiameter : `float`
760 Estimated donut diameter in pixels.
761 """
762 visitInfo = exposure.getInfo().getVisitInfo()
763 focusZ = visitInfo.focusZ
764 instrumentLabel = visitInfo.instrumentLabel
766 match instrumentLabel:
767 case "LATISS":
768 focusZ *= 41 # magnification factor
769 fratio = 18.0
770 case "LSSTCam" | "ComCam":
771 fratio = 1.234
772 # AuxTel/ComCam/LSSTCam all have 10 micron pixels (= 10e-3 mm)
773 donutDiameter = abs(focusZ) / fratio / 10e-3
774 self.log.info(f"{focusZ=} mm")
775 self.log.info(f"donutDiameter = {donutDiameter} pixels")
776 return donutDiameter
778 def run(
779 self,
780 exposure,
781 *,
782 doDisplay=False,
783 doDisplayIndices=False,
784 mode="auto",
785 binSize=None,
786 donutDiameter=None,
787 ):
788 """
789 Parameters
790 ----------
791 exposure : `lsst.afw.image.Exposure`
792 Exposure at which to peek.
793 doDisplay : `bool`, optional
794 Display the exposure and sources? Default False. (Requires
795 display to have been passed to task constructor)
796 doDisplayIndices : `bool`, optional
797 Display the source indices? Default False. (Requires display to
798 have been passed to task constructor)
799 mode : {'auto', 'donut', 'spec', 'photo'}, optional
800 Mode to run in. Default 'auto'.
801 binSize : `int`, optional
802 Binning factor for exposure. Default is None, which let's subtasks
803 control rebinning directly.
804 donutDiameter : `float`, optional
805 Donut diameter in pixels. Default is None, which will estimate the
806 donut diameter from the exposure metadata.
808 Returns
809 -------
810 result : `pipeBase.Struct`
811 Result of the peek.
812 Struct containing:
813 - mode : `str`
814 Peek mode that was run.
815 - binSize : `int`
816 Binning factor used.
817 - binnedSourceCat : `lsst.afw.table.SourceCatalog`
818 Source catalog from the binned exposure.
819 - table : `astropy.table.Table`
820 Curated source table in unbinned coordinates.
821 - brightestIdx : `int`
822 Index of brightest source in source catalog.
823 - brightestCentroid : `lsst.geom.Point2D`
824 Brightest source centroid in unbinned pixel coords.
825 - brightestPixelShape : `lsst.geom.Quadrupole`
826 Brightest source shape in unbinned pixel coords.
827 - brightestEquatorialShape : `lsst.geom.Quadrupole`
828 Brightest source shape in equitorial coordinates (arcsec).
829 - brightestAltAzShape : `lsst.geom.Quadrupole`
830 Brightest source shape in alt/az coordinates (arcsec).
831 - psfPixelShape : `lsst.geom.Quadrupole`
832 Estimated PSF shape in unbinned pixel coords.
833 - psfEquatorialShape : `lsst.geom.Quadrupole`
834 Estimated PSF shape in equitorial coordinates (arcsec).
835 - psfAltAzShape : `lsst.geom.Quadrupole`
836 Estimated PSF shape in alt/az coordinates (arcsec).
837 - pixelMedian : `float`
838 Median estimate of entire image.
839 - pixelMode : `float`
840 Mode estimate of entire image.
841 """
842 # Make a copy so the original image is unmodified.
843 exposure = exposure.clone()
844 try:
845 result = self._run(
846 exposure, doDisplay, doDisplayIndices, mode, binSize, donutDiameter
847 )
848 except Exception as e:
849 self.log.warning(f"Peek failed: {e}")
850 result = pipeBase.Struct(
851 mode="failed",
852 binSize=0,
853 binnedSourceCat=None,
854 table=None,
855 brightestIdx=0,
856 brightestCentroid=Point2D(np.nan, np.nan),
857 brightestPixelShape=Quadrupole(np.nan, np.nan, np.nan),
858 brightestEquatorialShape=Quadrupole(np.nan, np.nan, np.nan),
859 brightestAltAzShape=Quadrupole(np.nan, np.nan, np.nan),
860 psfPixelShape=Quadrupole(np.nan, np.nan, np.nan),
861 psfEquatorialShape=Quadrupole(np.nan, np.nan, np.nan),
862 psfAltAzShape=Quadrupole(np.nan, np.nan, np.nan),
863 pixelMedian=np.nan,
864 pixelMode=np.nan,
865 )
866 return result
868 def _run(self, exposure, doDisplay, doDisplayIndices, mode, binSize, donutDiameter):
869 """ The actual run method, called by run().
870 """
871 # If image is ~large, then use a subsampling of the image for
872 # speedy median/mode estimates.
873 arr = exposure.getMaskedImage().getImage().array
874 sampling = 1
875 if arr.size > 250_000:
876 sampling = int(np.floor(np.sqrt(arr.size / 250_000)))
877 pixelMedian = np.nanmedian(arr[::sampling, ::sampling])
878 pixelMode = _estimateMode(arr[::sampling, ::sampling])
880 if donutDiameter is None:
881 donutDiameter = self.getDonutDiameter(exposure)
883 mode, binSize, binnedSourceCat = self.runPeek(
884 exposure, mode, donutDiameter, binSize
885 )
887 table = self.transformTable(binSize, binnedSourceCat)
889 match mode:
890 case "donut":
891 goodSourceMask = self.donut.getGoodSources(binnedSourceCat)
892 case "spec":
893 goodSourceMask = self.spec.getGoodSources(binnedSourceCat)
894 case "photo":
895 goodSourceMask = self.photo.getGoodSources(binnedSourceCat)
897 # prepare output variables
898 maxFluxIdx, brightCentroid, brightShape = self.getBrightest(
899 binnedSourceCat, binSize, goodSourceMask
900 )
901 psfShape = self.getPsfShape(binnedSourceCat, binSize, goodSourceMask)
903 equatorialShapes, altAzShapes = self.transformShapes(
904 [brightShape, psfShape],
905 exposure,
906 binSize,
907 )
909 if doDisplay:
910 self.updateDisplay(
911 exposure, binSize, binnedSourceCat, maxFluxIdx, doDisplayIndices
912 )
914 return pipeBase.Struct(
915 mode=mode,
916 binSize=binSize,
917 binnedSourceCat=binnedSourceCat,
918 table=table,
919 brightestIdx=maxFluxIdx,
920 brightestCentroid=brightCentroid,
921 brightestPixelShape=brightShape,
922 brightestEquatorialShape=equatorialShapes[0],
923 brightestAltAzShape=altAzShapes[0],
924 psfPixelShape=psfShape,
925 psfEquatorialShape=equatorialShapes[1],
926 psfAltAzShape=altAzShapes[1],
927 pixelMedian=pixelMedian,
928 pixelMode=pixelMode,
929 )
931 def runPeek(self, exposure, mode, donutDiameter, binSize=None):
932 """Classify exposure and run appropriate PeekTask wrapper.
934 Parameters
935 ----------
936 exposure : `lsst.afw.image.Exposure`
937 Exposure to peek.
938 mode : {'auto', 'donut', 'spec', 'photo'}
939 Mode to run in.
940 donutDiameter : `float`
941 Donut diameter in pixels.
942 binSize : `int`, optional
943 Binning factor for exposure. Default is None, which let's subtasks
944 control rebinning directly.
946 Returns
947 -------
948 result : `pipeBase.Struct`
949 Result of the peek.
950 Struct containing:
951 - mode : `str`
952 Peek mode that was run.
953 - binSize : `int`
954 Binning factor used.
955 - binnedSourceCat : `lsst.afw.table.SourceCatalog`
956 Source catalog from the binned exposure.
957 """
958 if mode == "auto":
959 # Note, no attempt to handle dispersed donuts. They'll default to
960 # donut mode.
961 if donutDiameter > self.config.donutThreshold:
962 mode = "donut"
963 elif isDispersedExp(exposure):
964 mode = "spec"
965 else:
966 mode = "photo"
968 match mode:
969 case "donut":
970 result = self.donut.run(exposure, donutDiameter, binSize=binSize)
971 binSizeOut = result.binSize
972 case "spec":
973 result = self.spec.run(exposure, binSize=binSize)
974 binSizeOut = result.binSize
975 if len(result.binnedSourceCat) == 0:
976 self.log.warn("No sources found in spec mode.")
977 if self.config.doPhotoFallback:
978 self.log.warn("Falling back to photo mode.")
979 # Note that spec.run already rebinned the image,
980 # so we don't need to do it again.
981 result = self.photo.run(exposure, binSize=1)
982 case "photo":
983 result = self.photo.run(exposure, binSize=binSize)
984 binSizeOut = result.binSize
985 case _:
986 raise ValueError(f"Unknown mode {mode}")
987 return result.mode, binSizeOut, result.binnedSourceCat
989 def transformTable(self, binSize, binnedSourceCat):
990 """Make an astropy table from the source catalog but with
991 transformations back to the original unbinned coordinates.
993 Since there's some ambiguity in the apFlux apertures when binning,
994 we'll only populate the table with the slots columns (slot_apFlux
995 doesn't indicate an aperture radius). For simplicity, do the same for
996 centroids and shapes too.
998 And since we're only copying over the slots_* columns, we remove the
999 "slots_" part of the column names and lowercase the first remaining
1000 letter.
1002 Parameters
1003 ----------
1004 binSize : `int`
1005 Binning factor used.
1006 binnedSourceCat : `lsst.afw.table.SourceCatalog`
1007 Source catalog from the binned exposure.
1009 Returns
1010 -------
1011 table : `astropy.table.Table`
1012 Curated source table in unbinned coordinates.
1013 """
1014 table = binnedSourceCat.asAstropy()
1015 cols = [n for n in table.colnames if n.startswith("slot")]
1016 table = table[cols]
1017 if "slot_Centroid_x" in cols:
1018 table["slot_Centroid_x"] = (
1019 binSize * table["slot_Centroid_x"] + (binSize - 1) / 2
1020 )
1021 table["slot_Centroid_y"] = (
1022 binSize * table["slot_Centroid_y"] + (binSize - 1) / 2
1023 )
1024 if "slot_Shape_x" in cols:
1025 table["slot_Shape_x"] = binSize * table["slot_Shape_x"] + (binSize - 1) / 2
1026 table["slot_Shape_y"] = binSize * table["slot_Shape_y"] + (binSize - 1) / 2
1027 table["slot_Shape_xx"] *= binSize**2
1028 table["slot_Shape_xy"] *= binSize**2
1029 table["slot_Shape_yy"] *= binSize**2
1030 # area and npixels are just confusing when binning, so remove.
1031 if "slot_PsfFlux_area" in cols:
1032 del table["slot_PsfFlux_area"]
1033 if "slot_PsfFlux_npixels" in cols:
1034 del table["slot_PsfFlux_npixels"]
1036 table.rename_columns(
1037 [n for n in table.colnames if n.startswith('slot_')],
1038 [n[5:6].lower()+n[6:] for n in table.colnames if n.startswith('slot_')]
1039 )
1041 return table
1043 def getBrightest(self, binnedSourceCat, binSize, goodSourceMask):
1044 """Find the brightest source in the catalog.
1046 Parameters
1047 ----------
1048 binnedSourceCat : `lsst.afw.table.SourceCatalog`
1049 Source catalog from the binned exposure.
1050 binSize : `int`
1051 Binning factor used.
1052 goodSourceMask : `numpy.ndarray`
1053 Boolean array indicating which sources are good.
1055 Returns
1056 -------
1057 maxFluxIdx : `int`
1058 Index of the brightest source in the catalog.
1059 brightCentroid : `lsst.geom.Point2D`
1060 Centroid of the brightest source (unbinned coords).
1061 brightShape : `lsst.geom.Quadrupole`
1062 Shape of the brightest source (unbinned coords).
1063 """
1064 fluxes = np.array([source.getApInstFlux() for source in binnedSourceCat])
1065 idxs = np.arange(len(binnedSourceCat))
1067 good = (goodSourceMask & np.isfinite(fluxes))
1069 if np.sum(good) == 0:
1070 maxFluxIdx = IDX_SENTINEL
1071 brightCentroid = Point2D(np.nan, np.nan)
1072 brightShape = Quadrupole(np.nan, np.nan, np.nan)
1073 return maxFluxIdx, brightCentroid, brightShape
1075 fluxes = fluxes[good]
1076 idxs = idxs[good]
1077 maxFluxIdx = idxs[np.nanargmax(fluxes)]
1078 brightest = binnedSourceCat[maxFluxIdx]
1080 # Convert binned coordinates back to original unbinned
1081 # coordinates
1082 brightX, brightY = brightest.getCentroid()
1083 brightX = binSize * brightX + (binSize - 1) / 2
1084 brightY = binSize * brightY + (binSize - 1) / 2
1085 brightCentroid = Point2D(brightX, brightY)
1086 brightIXX = brightest.getIxx() * binSize**2
1087 brightIXY = brightest.getIxy() * binSize**2
1088 brightIYY = brightest.getIyy() * binSize**2
1089 brightShape = Quadrupole(brightIXX, brightIYY, brightIXY)
1091 return maxFluxIdx, brightCentroid, brightShape
1093 def getPsfShape(self, binnedSourceCat, binSize, goodSourceMask):
1094 """Estimate the modal PSF shape from the sources.
1096 Parameters
1097 ----------
1098 binnedSourceCat : `lsst.afw.table.SourceCatalog`
1099 Source catalog from the binned exposure.
1100 binSize : `int`
1101 Binning factor used.
1102 goodSourceMask : `numpy.ndarray`
1103 Boolean array indicating which sources are good.
1105 Returns
1106 -------
1107 psfShape : `lsst.geom.Quadrupole`
1108 Estimated PSF shape (unbinned coords).
1109 """
1110 fluxes = np.array([source.getApInstFlux() for source in binnedSourceCat])
1111 idxs = np.arange(len(binnedSourceCat))
1113 good = (goodSourceMask & np.isfinite(fluxes))
1115 if np.sum(good) == 0:
1116 return Quadrupole(np.nan, np.nan, np.nan)
1118 fluxes = fluxes[good]
1119 idxs = idxs[good]
1121 psfIXX = _estimateMode(
1122 np.array([source.getIxx() for source in binnedSourceCat])[goodSourceMask]
1123 )
1124 psfIYY = _estimateMode(
1125 np.array([source.getIyy() for source in binnedSourceCat])[goodSourceMask]
1126 )
1127 psfIXY = _estimateMode(
1128 np.array([source.getIxy() for source in binnedSourceCat])[goodSourceMask]
1129 )
1131 return Quadrupole(
1132 psfIXX * binSize**2,
1133 psfIYY * binSize**2,
1134 psfIXY * binSize**2,
1135 )
1137 def transformShapes(self, shapes, exposure, binSize):
1138 """Transform shapes from x/y pixel coordinates to equitorial and
1139 horizon coordinates.
1141 Parameters
1142 ----------
1143 shapes : `list` of `lsst.geom.Quadrupole`
1144 List of shapes (in pixel coordinates) to transform.
1145 exposure : `lsst.afw.image.Exposure`
1146 Exposure containing WCS and VisitInfo for transformation.
1147 binSize : `int`
1148 Binning factor used.
1150 Returns
1151 -------
1152 equatorialShapes : `list` of `lsst.geom.Quadrupole`
1153 List of shapes transformed to equitorial (North and West)
1154 coordinates. Units are arcseconds.
1155 altAzShapes : `list` of `lsst.geom.Quadrupole`
1156 List of shapes transformed to alt/az coordinates. Units are
1157 arcseconds.
1158 """
1159 pt = Point2D(np.array([*exposure.getBBox().getCenter()]) / binSize)
1160 wcs = exposure.wcs
1161 visitInfo = exposure.info.getVisitInfo()
1162 parAngle = visitInfo.boresightParAngle
1164 equatorialShapes = []
1165 altAzShapes = []
1166 for shape in shapes:
1167 if wcs is None:
1168 equatorialShapes.append(Quadrupole(np.nan, np.nan, np.nan))
1169 altAzShapes.append(Quadrupole(np.nan, np.nan, np.nan))
1170 continue
1171 # The WCS transforms to N (dec) and E (ra), but we want N and W to
1172 # conform with weak-lensing conventions. So we flip the [0]
1173 # component of the transformation.
1174 neTransform = wcs.linearizePixelToSky(pt, arcseconds).getLinear()
1175 nwTransform = LinearTransform(
1176 np.array([[-1, 0], [0, 1]]) @ neTransform.getMatrix()
1177 )
1178 equatorialShapes.append(shape.transform(nwTransform))
1180 # To get from N/W to alt/az, we need to additionally rotate by the
1181 # parallactic angle.
1182 rot = LinearTransform.makeRotation(parAngle).getMatrix()
1183 aaTransform = LinearTransform(nwTransform.getMatrix() @ rot)
1184 altAzShapes.append(shape.transform(aaTransform))
1186 return equatorialShapes, altAzShapes
1188 def updateDisplay(
1189 self, exposure, binSize, binnedSourceCat, maxFluxIdx, doDisplayIndices
1190 ):
1191 """Update the afwDisplay with the exposure and sources.
1193 Parameters
1194 ----------
1195 exposure : `lsst.afw.image.Exposure`
1196 Exposure to peek.
1197 binSize : `int`
1198 Binning factor used.
1199 binnedSourceCat : `lsst.afw.table.SourceCatalog`
1200 Source catalog from the binned exposure.
1201 maxFluxIdx : `int`
1202 Index of the brightest source in the catalog.
1203 doDisplayIndices : `bool`
1204 Display the source indices?
1205 """
1206 if self._display is None:
1207 raise RuntimeError("Display failed as no display provided during init()")
1209 visitInfo = exposure.info.getVisitInfo()
1210 self._display.mtv(exposure)
1211 wcs = exposure.wcs
1212 if wcs is not None:
1213 plotRose(
1214 self._display,
1215 wcs,
1216 Point2D(200 / binSize, 200 / binSize),
1217 parAng=visitInfo.boresightParAngle,
1218 len=100 / binSize,
1219 )
1221 for idx, source in enumerate(binnedSourceCat):
1222 x, y = source.getCentroid()
1223 sh = source.getShape()
1224 self._display.dot(sh, x, y)
1225 if doDisplayIndices:
1226 self._display.dot(str(idx), x, y)
1228 if maxFluxIdx != IDX_SENTINEL:
1229 self._display.dot(
1230 "+",
1231 *binnedSourceCat[maxFluxIdx].getCentroid(),
1232 ctype=afwDisplay.RED,
1233 size=10,
1234 )