Coverage for python/lsst/meas/extensions/shapeHSM/_hsm_moments.py: 30%
191 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-05 12:20 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-05 12:20 +0000
1# This file is part of meas_extensions_shapeHSM.
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/>.
22import logging
24import galsim
25import lsst.afw.geom as afwGeom
26import lsst.afw.image as afwImage
27import lsst.afw.math as afwMath
28import lsst.afw.table as afwTable
29import lsst.meas.base as measBase
30import lsst.pex.config as pexConfig
31import numpy as np
32from lsst.geom import Box2I, Point2D, Point2I
33from lsst.pex.exceptions import InvalidParameterError, NotFoundError
35__all__ = [
36 "HsmSourceMomentsConfig",
37 "HsmSourceMomentsPlugin",
38 "HsmSourceMomentsRoundConfig",
39 "HsmSourceMomentsRoundPlugin",
40 "HsmPsfMomentsConfig",
41 "HsmPsfMomentsPlugin",
42 "HsmPsfMomentsDebiasedConfig",
43 "HsmPsfMomentsDebiasedPlugin",
44]
47class HsmMomentsConfig(measBase.SingleFramePluginConfig):
48 """Base configuration for HSM adaptive moments measurement."""
50 roundMoments = pexConfig.Field[bool](doc="Use round weight function?", default=False)
51 addFlux = pexConfig.Field[bool](doc="Store measured flux?", default=False)
52 subtractCenter = pexConfig.Field[bool](doc="Subtract starting center from x/y outputs?", default=False)
55class HsmMomentsPlugin(measBase.SingleFramePlugin):
56 """Base plugin for HSM adaptive moments measurement."""
58 ConfigClass = HsmMomentsConfig
60 def __init__(self, config, name, schema, metadata, logName=None):
61 if logName is None:
62 logName = __name__
63 super().__init__(config, name, schema, metadata, logName=logName)
65 # Define flags for possible issues that might arise during measurement.
66 flagDefs = measBase.FlagDefinitionList()
67 self.FAILURE = flagDefs.addFailureFlag("General failure flag, set if anything went wrong")
68 self.NO_PIXELS = flagDefs.add("flag_no_pixels", "No pixels to measure")
69 self.NOT_CONTAINED = flagDefs.add(
70 "flag_not_contained", "Center not contained in footprint bounding box"
71 )
72 self.PARENT_SOURCE = flagDefs.add("flag_parent_source", "Parent source, ignored")
73 self.GALSIM = flagDefs.add("flag_galsim", "GalSim failure")
74 self.INVALID_PARAM = flagDefs.add("flag_invalid_param", "Invalid combination of moments")
75 self.EDGE = flagDefs.add("flag_edge", "Variance undefined outside image edge")
76 self.NO_PSF = flagDefs.add("flag_no_psf", "Exposure lacks PSF")
78 # Embed the flag definitions in the schema using a flag handler.
79 self.flagHandler = measBase.FlagHandler.addFields(schema, name, flagDefs)
81 # Utilize a safe centroid extractor that uses the detection footprint
82 # as a fallback if necessary.
83 self.centroidExtractor = measBase.SafeCentroidExtractor(schema, name)
84 self.log = logging.getLogger(self.logName)
86 @classmethod
87 def getExecutionOrder(cls):
88 return cls.SHAPE_ORDER
90 def _calculate(
91 self,
92 record: afwTable.SourceRecord,
93 *,
94 image: galsim.Image,
95 weight: galsim.Image | None = None,
96 badpix: galsim.Image | None = None,
97 sigma: float = 5.0,
98 precision: float = 1.0e-6,
99 centroid: Point2D | None = None,
100 ) -> None:
101 """
102 Calculate adaptive moments using GalSim's HSM and modify the record in
103 place.
105 Parameters
106 ----------
107 record : `~lsst.afw.table.SourceRecord`
108 Record to store measurements.
109 image : `~galsim.Image`
110 Image on which to perform measurements.
111 weight : `~galsim.Image`, optional
112 The weight image for the galaxy being measured. Can be an int or a
113 float array. No weighting is done if None. Default is None.
114 badpix : `~galsim.Image`, optional
115 Image representing bad pixels, where zero indicates good pixels and
116 any nonzero value denotes a bad pixel. No bad pixel masking is done
117 if None. Default is None.
118 sigma : `float`, optional
119 Estimate of object's Gaussian sigma in pixels. Default is 5.0.
120 precision : `float`, optional
121 Precision for HSM adaptive moments. Default is 1.0e-6.
122 centroid : `~lsst.geom.Point2D`, optional
123 Centroid guess for HSM adaptive moments, defaulting to the image's
124 true center if None. Default is None.
126 Raises
127 ------
128 MeasurementError
129 Raised for errors in measurement.
130 """
131 # Convert centroid to GalSim's PositionD type.
132 guessCentroid = galsim.PositionD(centroid.x, centroid.y)
134 try:
135 # Attempt to compute HSM moments.
136 shape = galsim.hsm.FindAdaptiveMom(
137 image,
138 weight=weight,
139 badpix=badpix,
140 guess_sig=sigma,
141 precision=precision,
142 guess_centroid=guessCentroid,
143 strict=True,
144 round_moments=self.config.roundMoments,
145 hsmparams=None,
146 )
147 except galsim.hsm.GalSimHSMError as error:
148 raise measBase.MeasurementError(str(error), self.GALSIM.number)
150 # Retrieve computed moments sigma and centroid.
151 determinantRadius = shape.moments_sigma
152 centroidResult = shape.moments_centroid
154 # Subtract center if required by configuration.
155 if self.config.subtractCenter:
156 centroidResult.x -= centroid.getX()
157 centroidResult.y -= centroid.getY()
159 # Convert GalSim's `galsim.PositionD` to `lsst.geom.Point2D`.
160 centroidResult = Point2D(centroidResult.x, centroidResult.y)
162 # Populate the record with the centroid results.
163 record.set(self.centroidResultKey, centroidResult)
165 # Convert GalSim measurements to lsst measurements.
166 try:
167 # Create an ellipse for the shape.
168 ellipse = afwGeom.ellipses.SeparableDistortionDeterminantRadius(
169 e1=shape.observed_shape.e1,
170 e2=shape.observed_shape.e2,
171 radius=determinantRadius,
172 normalize=True, # Fail if |e|>1.
173 )
174 # Get the quadrupole moments from the ellipse.
175 quad = afwGeom.ellipses.Quadrupole(ellipse)
176 except InvalidParameterError as error:
177 raise measBase.MeasurementError(error, self.INVALID_PARAM.number)
179 # Store the quadrupole moments in the record.
180 record.set(self.shapeKey, quad)
182 # Store the flux if required by configuration.
183 if self.config.addFlux:
184 record.set(self.fluxKey, shape.moments_amp)
186 def fail(self, record, error=None):
187 # Docstring inherited.
188 self.flagHandler.handleFailure(record)
189 if error:
190 centroid = self.centroidExtractor(record, self.flagHandler)
191 self.log.debug(
192 "Failed to measure shape for %d at (%f, %f): %s",
193 record.getId(),
194 centroid.getX(),
195 centroid.getY(),
196 error,
197 )
200class HsmSourceMomentsConfig(HsmMomentsConfig):
201 """Configuration for HSM adaptive moments measurement for sources."""
203 badMaskPlanes = pexConfig.ListField[str](
204 doc="Mask planes used to reject bad pixels.", default=["BAD", "SAT"]
205 )
208@measBase.register("ext_shapeHSM_HsmSourceMoments")
209class HsmSourceMomentsPlugin(HsmMomentsPlugin):
210 """Plugin for HSM adaptive moments measurement for sources."""
212 ConfigClass = HsmSourceMomentsConfig
214 def __init__(self, config, name, schema, metadata, logName=None):
215 super().__init__(config, name, schema, metadata, logName=logName)
216 self.centroidResultKey = afwTable.Point2DKey.addFields(
217 schema, name, "Centroid of the source via the HSM shape algorithm", "pixel"
218 )
219 self.shapeKey = afwTable.QuadrupoleKey.addFields(
220 schema,
221 name,
222 "Adaptive moments of the source via the HSM shape algorithm",
223 afwTable.CoordinateType.PIXEL,
224 )
225 if config.addFlux:
226 self.fluxKey = schema.addField(
227 schema.join(name, "Flux"), type=float, doc="Flux of the source via the HSM shape algorithm"
228 )
230 def measure(self, record, exposure):
231 """
232 Measure adaptive moments of sources given an exposure and set the
233 results in the record in place.
235 Parameters
236 ----------
237 record : `~lsst.afw.table.SourceRecord`
238 The record where measurement outputs will be stored.
239 exposure : `~lsst.afw.image.Exposure`
240 The exposure containing the source which needs measurement.
242 Raises
243 ------
244 MeasurementError
245 Raised for errors in measurement.
246 """
247 # Extract the centroid from the record.
248 center = self.centroidExtractor(record, self.flagHandler)
250 # Get the bounding box of the source's footprint.
251 bbox = record.getFootprint().getBBox()
253 # Check that the bounding box has non-zero area.
254 if bbox.getArea() == 0:
255 raise measBase.MeasurementError(self.NO_PIXELS.doc, self.NO_PIXELS.number)
257 # Ensure that the centroid is within the bounding box.
258 if not bbox.contains(Point2I(center)):
259 raise measBase.MeasurementError(self.NOT_CONTAINED.doc, self.NOT_CONTAINED.number)
261 # Get the trace radius of the PSF.
262 psfSigma = exposure.getPsf().computeShape(center).getTraceRadius()
264 # Turn bounding box corners into GalSim bounds.
265 xmin, xmax = bbox.getMinX(), bbox.getMaxX()
266 ymin, ymax = bbox.getMinY(), bbox.getMaxY()
267 bounds = galsim.bounds.BoundsI(xmin, xmax, ymin, ymax)
269 # Get the `lsst.meas.base` mask for bad pixels.
270 badpix = exposure.mask[bbox].array.copy()
271 bitValue = exposure.mask.getPlaneBitMask(self.config.badMaskPlanes)
272 badpix &= bitValue
274 # Extract the numpy array underlying the image within the bounding box
275 # of the source.
276 imageArray = exposure.image[bbox].array
278 # Create a GalSim image using the extracted array.
279 # NOTE: GalSim's HSM uses the FITS convention of 1,1 for the
280 # lower-left corner.
281 image = galsim.Image(imageArray, bounds=bounds, copy=False)
283 # Convert the mask of bad pixels to a format suitable for galsim.
284 # NOTE: galsim.Image will match whatever dtype the input array is
285 # (here int32).
286 badpix = galsim.Image(badpix, bounds=bounds, copy=False)
288 # Call the internal method to calculate adaptive moments using GalSim.
289 self._calculate(
290 record,
291 image=image,
292 weight=None,
293 badpix=badpix,
294 sigma=2.5 * psfSigma,
295 precision=1.0e-6,
296 centroid=center,
297 )
300class HsmSourceMomentsRoundConfig(HsmSourceMomentsConfig):
301 """Configuration for HSM adaptive moments measurement for sources using
302 round weight function.
303 """
305 def setDefaults(self):
306 super().setDefaults()
307 self.roundMoments = True
309 def validate(self):
310 if not self.roundMoments:
311 raise pexConfig.FieldValidationError(
312 self.roundMoments, self, "roundMoments should be set to `True`."
313 )
314 super().validate()
317@measBase.register("ext_shapeHSM_HsmSourceMomentsRound")
318class HsmSourceMomentsRoundPlugin(HsmSourceMomentsPlugin):
319 """Plugin for HSM adaptive moments measurement for sources using round
320 weight function.
321 """
323 ConfigClass = HsmSourceMomentsRoundConfig
326class HsmPsfMomentsConfig(HsmMomentsConfig):
327 """Configuration for HSM adaptive moments measurement for PSFs."""
329 useSourceCentroidOffset = pexConfig.Field[bool](
330 doc="If True, then draw the PSF to be measured in the coordinate "
331 "system of the original image (the PSF model origin - which is "
332 "commonly the PSF centroid - may end up near a pixel edge or corner). "
333 "If False, then draw the PSF to be measured in a shifted coordinate "
334 "system such that the PSF model origin lands precisely in the center "
335 "of the central pixel of the PSF image.",
336 default=False,
337 )
339 def setDefaults(self):
340 super().setDefaults()
341 self.subtractCenter = True
344@measBase.register("ext_shapeHSM_HsmPsfMoments")
345class HsmPsfMomentsPlugin(HsmMomentsPlugin):
346 """Plugin for HSM adaptive moments measurement for PSFs."""
348 ConfigClass = HsmPsfMomentsConfig
349 _debiased = False
351 def __init__(self, config, name, schema, metadata, logName=None):
352 super().__init__(config, name, schema, metadata, logName=logName)
353 docPrefix = "Debiased centroid" if self._debiased else "Centroid"
354 self.centroidResultKey = afwTable.Point2DKey.addFields(
355 schema, name, docPrefix + " of the PSF via the HSM shape algorithm", "pixel"
356 )
357 docPrefix = "Debiased adaptive" if self._debiased else "Adaptive"
358 self.shapeKey = afwTable.QuadrupoleKey.addFields(
359 schema,
360 name,
361 docPrefix + " moments of the PSF via the HSM shape algorithm",
362 afwTable.CoordinateType.PIXEL,
363 )
364 if config.addFlux:
365 self.fluxKey = schema.addField(
366 schema.join(name, "Flux"),
367 type=float,
368 doc="Flux of the PSF via the HSM shape algorithm",
369 )
371 def measure(self, record, exposure):
372 """
373 Measure adaptive moments of the PSF given an exposure and set the
374 results in the record in place.
376 Parameters
377 ----------
378 record : `~lsst.afw.table.SourceRecord`
379 The record where measurement outputs will be stored.
380 exposure : `~lsst.afw.image.Exposure`
381 The exposure containing the PSF which needs measurement.
383 Raises
384 ------
385 MeasurementError
386 Raised for errors in measurement.
387 """
388 # Extract the centroid from the record.
389 center = self.centroidExtractor(record, self.flagHandler)
391 # Retrieve the PSF from the exposure.
392 psf = exposure.getPsf()
394 # Check that the PSF is not None.
395 if not psf:
396 raise measBase.MeasurementError(self.NO_PSF.doc, self.NO_PSF.number)
398 # Get the bounding box of the PSF.
399 psfBBox = psf.computeImageBBox(center)
401 # Two methods for getting PSF image evaluated at the source centroid:
402 if self.config.useSourceCentroidOffset:
403 # 1. Using `computeImage()` returns an image in the same coordinate
404 # system as the pixelized image.
405 psfImage = psf.computeImage(center)
406 else:
407 psfImage = psf.computeKernelImage(center)
408 # 2. Using `computeKernelImage()` to return an image does not
409 # retain any information about the original bounding box of the
410 # PSF. We therefore reset the origin to be the same as the
411 # pixelized image.
412 psfImage.setXY0(psfBBox.getMin())
414 # Get the trace radius of the PSF.
415 psfSigma = psf.computeShape(center).getTraceRadius()
417 # Get the bounding box in the parent coordinate system.
418 bbox = psfImage.getBBox(afwImage.PARENT)
420 # Turn bounding box corners into GalSim bounds.
421 xmin, xmax = bbox.getMinX(), bbox.getMaxX()
422 ymin, ymax = bbox.getMinY(), bbox.getMaxY()
423 bounds = galsim.bounds.BoundsI(xmin, xmax, ymin, ymax)
425 # Adjust the psfImage for noise as needed, and retrieve the mask of bad
426 # pixels.
427 badpix = self._adjustNoise(psfImage, psfBBox, exposure, record, bounds)
429 # Extract the numpy array underlying the PSF image.
430 imageArray = psfImage.array
432 # Create a GalSim image using the PSF image array.
433 image = galsim.Image(imageArray, bounds=bounds, copy=False)
435 # Decide on the centroid position based on configuration.
436 if self.config.useSourceCentroidOffset:
437 # If the source centroid offset should be used, use the source
438 # centroid.
439 centroid = center
440 else:
441 # Otherwise, use the center of the bounding box of psfImage.
442 centroid = Point2D(psfBBox.getMin() + psfBBox.getDimensions() // 2)
444 # Call the internal method to calculate adaptive moments using GalSim.
445 self._calculate(
446 record,
447 image=image,
448 weight=None,
449 badpix=badpix,
450 sigma=psfSigma,
451 centroid=centroid,
452 )
454 def _adjustNoise(self, *args) -> None:
455 """A noop in the base class, returning None for the bad pixel mask.
456 This method is designed to be overridden in subclasses."""
457 pass
460class HsmPsfMomentsDebiasedConfig(HsmPsfMomentsConfig):
461 """Configuration for debiased HSM adaptive moments measurement for PSFs."""
463 noiseSource = pexConfig.ChoiceField[str](
464 doc="Noise source. How to choose variance of the zero-mean Gaussian noise added to image.",
465 allowed={
466 "meta": "variance = the 'BGMEAN' metadata entry",
467 "variance": "variance = the image's variance plane",
468 },
469 default="variance",
470 )
471 seedOffset = pexConfig.Field[int](doc="Seed offset for random number generator.", default=0)
472 badMaskPlanes = pexConfig.ListField[str](
473 doc="Mask planes used to reject bad pixels.", default=["BAD", "SAT"]
474 )
476 def setDefaults(self):
477 super().setDefaults()
478 self.useSourceCentroidOffset = True
481@measBase.register("ext_shapeHSM_HsmPsfMomentsDebiased")
482class HsmPsfMomentsDebiasedPlugin(HsmPsfMomentsPlugin):
483 """Plugin for debiased HSM adaptive moments measurement for PSFs."""
485 ConfigClass = HsmPsfMomentsDebiasedConfig
486 _debiased = True
488 @classmethod
489 def getExecutionOrder(cls):
490 # Since the standard execution order increases in steps of 1, it's
491 # safer to keep the increase by hand to less than 1. The exact value
492 # does not matter.
493 return cls.FLUX_ORDER + 0.1
495 def _adjustNoise(
496 self,
497 psfImage: afwImage.Image,
498 psfBBox: Box2I,
499 exposure: afwImage.Exposure,
500 record: afwTable.SourceRecord,
501 bounds: galsim.bounds.BoundsI,
502 ) -> galsim.Image:
503 """
504 Adjusts noise in the PSF image and updates the bad pixel mask based on
505 exposure data. This method modifies `psfImage` in place and returns a
506 newly created `badpix` mask.
508 Parameters
509 ----------
510 psfImage : `~lsst.afw.image.Image`
511 The PSF image to be adjusted. This image is modified in place.
512 psfBBox : `~lsst.geom.Box2I`
513 The bounding box of the PSF.
514 exposure : `~lsst.afw.image.Exposure`
515 The exposure object containing relevant metadata and mask
516 information.
517 record : `~lsst.afw.table.SourceRecord`
518 The source record where measurement outputs will be stored. May be
519 modified in place to set flags.
520 bounds : `~galsim.bounds.BoundsI`
521 The bounding box of the PSF as a GalSim bounds object.
523 Returns
524 -------
525 badpix : `~galSim.Image`
526 Image representing bad pixels, where zero indicates good pixels and
527 any nonzero value denotes a bad pixel.
529 Raises
530 ------
531 MeasurementError
532 If there's an issue during the noise adjustment process.
533 FatalAlgorithmError
534 If BGMEAN is not present in the metadata when using the meta noise
535 source.
536 """
537 # Psf image crossing exposure edge is fine if we're getting the
538 # variance from metadata, but not okay if we're getting the
539 # variance from the variance plane. In both cases, set the EDGE
540 # flag, but only fail hard if using variance plane.
541 overlap = psfImage.getBBox()
542 overlap.clip(exposure.getBBox())
543 if overlap != psfImage.getBBox():
544 self.flagHandler.setValue(record, self.EDGE.number, True)
545 if self.config.noiseSource == "variance":
546 self.flagHandler.setValue(record, self.FAILURE.number, True)
547 raise measBase.MeasurementError(self.EDGE.doc, self.EDGE.number)
549 # Match PSF flux to source.
550 psfImage *= record.getPsfInstFlux()
552 # Add Gaussian noise to image in 4 steps:
553 # 1. Initialize the noise image and random number generator.
554 noise = afwImage.Image(psfImage.getBBox(), dtype=psfImage.dtype, initialValue=0.0)
555 seed = record.getId() + self.config.seedOffset
556 rand = afwMath.Random("MT19937", seed)
558 # 2. Generate Gaussian noise image.
559 afwMath.randomGaussianImage(noise, rand)
561 # 3. Determine the noise scaling based on the noise source.
562 if self.config.noiseSource == "meta":
563 # Retrieve BGMEAN from the exposure metadata.
564 try:
565 bgmean = exposure.getMetadata().getAsDouble("BGMEAN")
566 except NotFoundError as error:
567 raise measBase.FatalAlgorithmError(str(error))
568 # Scale the noise by the square root of the background mean.
569 noise *= np.sqrt(bgmean)
570 elif self.config.noiseSource == "variance":
571 # Get the variance image from the exposure and restrict to the
572 # PSF bounding box.
573 var = afwImage.Image(exposure.variance[psfImage.getBBox()], dtype=psfImage.dtype, deep=True)
574 # Scale the noise by the square root of the variance.
575 var.sqrt() # In-place square root.
576 noise *= var
578 # 4. Add the scaled noise to the PSF image.
579 psfImage += noise
581 # Masking is needed for debiased PSF moments.
582 badpix = afwImage.Mask(psfBBox)
583 # NOTE: We repeat the `overlap` calculation in the two lines below to
584 # align with the old C++ version and minimize potential discrepancies.
585 # There's zero chance this will be a time sink, and using the bbox from
586 # the image that's about to be cropped seems safer than using the bbox
587 # from a different image, even if they're nominally supposed to have
588 # the same bounds.
589 overlap = badpix.getBBox()
590 overlap.clip(exposure.getBBox())
591 badpix[overlap] = exposure.mask[overlap]
592 badpix = badpix.array
594 bitValue = exposure.mask.getPlaneBitMask(self.config.badMaskPlanes)
595 badpix &= bitValue
597 # Convert the mask of bad pixels to a format suitable for galsim.
598 # NOTE: galsim.Image will match whatever dtype the input array is
599 # (here int32).
600 badpix = galsim.Image(badpix, bounds=bounds, copy=False)
602 return badpix