Coverage for python/lsst/meas/extensions/shapeHSM/_hsm_moments.py: 28%
203 statements
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-10 13:01 +0000
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-10 13:01 +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_image: galsim.Image,
96 centroid: Point2D,
97 sigma: float = 5.0,
98 precision: float = 1.0e-6,
99 ) -> None:
100 """
101 Calculate adaptive moments using GalSim's HSM and modify the record in
102 place.
104 Parameters
105 ----------
106 record : `~lsst.afw.table.SourceRecord`
107 Record to store measurements.
108 image : `~galsim.Image`
109 Image on which to perform measurements.
110 weight_image : `~galsim.Image`
111 The combined badpix/weight image for input to galsim HSM code.
112 centroid : `~lsst.geom.Point2D`
113 Centroid guess for HSM adaptive moments.
114 sigma : `float`, optional
115 Estimate of object's Gaussian sigma in pixels. Default is 5.0.
116 precision : `float`, optional
117 Precision for HSM adaptive moments. Default is 1.0e-6.
119 Raises
120 ------
121 MeasurementError
122 Raised for errors in measurement.
123 """
124 # Convert centroid to GalSim's PositionD type.
125 guessCentroid = galsim._PositionD(centroid.x, centroid.y)
126 try:
127 # Attempt to compute HSM moments.
129 # Use galsim c++/python interface directly.
130 shape = galsim.hsm.ShapeData(
131 image_bounds=galsim._BoundsI(0, 0, 1, 1),
132 observed_shape=galsim._Shear(0j),
133 psf_shape=galsim._Shear(0j),
134 moments_centroid=galsim._PositionD(0, 0),
135 )
136 hsmparams = galsim.hsm.HSMParams.default
138 # TODO: DM-42047 Change to public API when an optimized
139 # version is available.
140 galsim._galsim.FindAdaptiveMomView(
141 shape._data,
142 image._image,
143 weight_image._image,
144 float(sigma),
145 float(precision),
146 guessCentroid._p,
147 bool(self.config.roundMoments),
148 hsmparams._hsmp,
149 )
151 except RuntimeError as error:
152 raise measBase.MeasurementError(str(error), self.GALSIM.number)
154 # Retrieve computed moments sigma and centroid.
155 determinantRadius = shape.moments_sigma
156 centroidResult = shape.moments_centroid
158 # Subtract center if required by configuration.
159 if self.config.subtractCenter:
160 centroidResult.x -= centroid.getX()
161 centroidResult.y -= centroid.getY()
163 # Convert GalSim's `galsim.PositionD` to `lsst.geom.Point2D`.
164 centroidResult = Point2D(centroidResult.x, centroidResult.y)
166 # Populate the record with the centroid results.
167 record.set(self.centroidResultKey, centroidResult)
169 # Convert GalSim measurements to lsst measurements.
170 try:
171 # Create an ellipse for the shape.
172 observed_shape = shape.observed_shape
173 ellipse = afwGeom.ellipses.SeparableDistortionDeterminantRadius(
174 e1=observed_shape.e1,
175 e2=observed_shape.e2,
176 radius=determinantRadius,
177 normalize=True, # Fail if |e|>1.
178 )
179 # Get the quadrupole moments from the ellipse.
180 quad = afwGeom.ellipses.Quadrupole(ellipse)
181 except InvalidParameterError as error:
182 raise measBase.MeasurementError(error, self.INVALID_PARAM.number)
184 # Store the quadrupole moments in the record.
185 record.set(self.shapeKey, quad)
187 # Store the flux if required by configuration.
188 if self.config.addFlux:
189 record.set(self.fluxKey, shape.moments_amp)
191 def fail(self, record, error=None):
192 # Docstring inherited.
193 self.flagHandler.handleFailure(record)
194 if error:
195 centroid = self.centroidExtractor(record, self.flagHandler)
196 self.log.debug(
197 "Failed to measure shape for %d at (%f, %f): %s",
198 record.getId(),
199 centroid.getX(),
200 centroid.getY(),
201 error,
202 )
205class HsmSourceMomentsConfig(HsmMomentsConfig):
206 """Configuration for HSM adaptive moments measurement for sources."""
208 badMaskPlanes = pexConfig.ListField[str](
209 doc="Mask planes used to reject bad pixels.", default=["BAD", "SAT"]
210 )
213@measBase.register("ext_shapeHSM_HsmSourceMoments")
214class HsmSourceMomentsPlugin(HsmMomentsPlugin):
215 """Plugin for HSM adaptive moments measurement for sources."""
217 ConfigClass = HsmSourceMomentsConfig
219 def __init__(self, config, name, schema, metadata, logName=None):
220 super().__init__(config, name, schema, metadata, logName=logName)
221 self.centroidResultKey = afwTable.Point2DKey.addFields(
222 schema, name, "Centroid of the source via the HSM shape algorithm", "pixel"
223 )
224 self.shapeKey = afwTable.QuadrupoleKey.addFields(
225 schema,
226 name,
227 "Adaptive moments of the source via the HSM shape algorithm",
228 afwTable.CoordinateType.PIXEL,
229 )
230 if config.addFlux:
231 self.fluxKey = schema.addField(
232 schema.join(name, "Flux"), type=float, doc="Flux of the source via the HSM shape algorithm"
233 )
235 def measure(self, record, exposure):
236 """
237 Measure adaptive moments of sources given an exposure and set the
238 results in the record in place.
240 Parameters
241 ----------
242 record : `~lsst.afw.table.SourceRecord`
243 The record where measurement outputs will be stored.
244 exposure : `~lsst.afw.image.Exposure`
245 The exposure containing the source which needs measurement.
247 Raises
248 ------
249 MeasurementError
250 Raised for errors in measurement.
251 """
252 # Extract the centroid from the record.
253 center = self.centroidExtractor(record, self.flagHandler)
255 # Get the bounding box of the source's footprint.
256 bbox = record.getFootprint().getBBox()
258 # Check that the bounding box has non-zero area.
259 if bbox.getArea() == 0:
260 raise measBase.MeasurementError(self.NO_PIXELS.doc, self.NO_PIXELS.number)
262 # Ensure that the centroid is within the bounding box.
263 if not bbox.contains(Point2I(center)):
264 raise measBase.MeasurementError(self.NOT_CONTAINED.doc, self.NOT_CONTAINED.number)
266 # Get the trace radius of the PSF.
267 psfSigma = exposure.getPsf().computeShape(center).getTraceRadius()
269 # Turn bounding box corners into GalSim bounds.
270 xmin, xmax = bbox.getMinX(), bbox.getMaxX()
271 ymin, ymax = bbox.getMinY(), bbox.getMaxY()
272 bounds = galsim._BoundsI(xmin, xmax, ymin, ymax)
274 # Get the `lsst.meas.base` mask for bad pixels.
275 badpix = exposure.mask[bbox].array.copy()
276 bitValue = exposure.mask.getPlaneBitMask(self.config.badMaskPlanes)
277 badpix &= bitValue
279 # Extract the numpy array underlying the image within the bounding box
280 # of the source.
281 imageArray = exposure.image[bbox].array
283 # Create a GalSim image using the extracted array.
284 # NOTE: GalSim's HSM uses the FITS convention of 1,1 for the
285 # lower-left corner.
286 image = galsim._Image(imageArray, bounds, None)
288 # Convert the mask of bad pixels to a format suitable for galsim.
289 gd = badpix == 0
290 badpix[gd] = 1
291 badpix[~gd] = 0
293 weight_image = galsim._Image(badpix, bounds, None)
295 # Call the internal method to calculate adaptive moments using GalSim.
296 self._calculate(
297 record,
298 image=image,
299 weight_image=weight_image,
300 sigma=2.5 * psfSigma,
301 precision=1.0e-6,
302 centroid=center,
303 )
306class HsmSourceMomentsRoundConfig(HsmSourceMomentsConfig):
307 """Configuration for HSM adaptive moments measurement for sources using
308 round weight function.
309 """
311 def setDefaults(self):
312 super().setDefaults()
313 self.roundMoments = True
315 def validate(self):
316 if not self.roundMoments:
317 raise pexConfig.FieldValidationError(
318 self.roundMoments, self, "roundMoments should be set to `True`."
319 )
320 super().validate()
323@measBase.register("ext_shapeHSM_HsmSourceMomentsRound")
324class HsmSourceMomentsRoundPlugin(HsmSourceMomentsPlugin):
325 """Plugin for HSM adaptive moments measurement for sources using round
326 weight function.
327 """
329 ConfigClass = HsmSourceMomentsRoundConfig
332class HsmPsfMomentsConfig(HsmMomentsConfig):
333 """Configuration for HSM adaptive moments measurement for PSFs."""
335 useSourceCentroidOffset = pexConfig.Field[bool](
336 doc="If True, then draw the PSF to be measured in the coordinate "
337 "system of the original image (the PSF model origin - which is "
338 "commonly the PSF centroid - may end up near a pixel edge or corner). "
339 "If False, then draw the PSF to be measured in a shifted coordinate "
340 "system such that the PSF model origin lands precisely in the center "
341 "of the central pixel of the PSF image.",
342 default=False,
343 )
345 def setDefaults(self):
346 super().setDefaults()
347 self.subtractCenter = True
350@measBase.register("ext_shapeHSM_HsmPsfMoments")
351class HsmPsfMomentsPlugin(HsmMomentsPlugin):
352 """Plugin for HSM adaptive moments measurement for PSFs."""
354 ConfigClass = HsmPsfMomentsConfig
355 _debiased = False
357 def __init__(self, config, name, schema, metadata, logName=None):
358 super().__init__(config, name, schema, metadata, logName=logName)
359 docPrefix = "Debiased centroid" if self._debiased else "Centroid"
360 self.centroidResultKey = afwTable.Point2DKey.addFields(
361 schema, name, docPrefix + " of the PSF via the HSM shape algorithm", "pixel"
362 )
363 docPrefix = "Debiased adaptive" if self._debiased else "Adaptive"
364 self.shapeKey = afwTable.QuadrupoleKey.addFields(
365 schema,
366 name,
367 docPrefix + " moments of the PSF via the HSM shape algorithm",
368 afwTable.CoordinateType.PIXEL,
369 )
370 if config.addFlux:
371 self.fluxKey = schema.addField(
372 schema.join(name, "Flux"),
373 type=float,
374 doc="Flux of the PSF via the HSM shape algorithm",
375 )
377 def measure(self, record, exposure):
378 """
379 Measure adaptive moments of the PSF given an exposure and set the
380 results in the record in place.
382 Parameters
383 ----------
384 record : `~lsst.afw.table.SourceRecord`
385 The record where measurement outputs will be stored.
386 exposure : `~lsst.afw.image.Exposure`
387 The exposure containing the PSF which needs measurement.
389 Raises
390 ------
391 MeasurementError
392 Raised for errors in measurement.
393 """
394 # Extract the centroid from the record.
395 center = self.centroidExtractor(record, self.flagHandler)
397 # Retrieve the PSF from the exposure.
398 psf = exposure.getPsf()
400 # Check that the PSF is not None.
401 if not psf:
402 raise measBase.MeasurementError(self.NO_PSF.doc, self.NO_PSF.number)
404 # Get the bounding box of the PSF.
405 psfBBox = psf.computeImageBBox(center)
407 # Two methods for getting PSF image evaluated at the source centroid:
408 if self.config.useSourceCentroidOffset:
409 # 1. Using `computeImage()` returns an image in the same coordinate
410 # system as the pixelized image.
411 psfImage = psf.computeImage(center)
412 else:
413 psfImage = psf.computeKernelImage(center)
414 # 2. Using `computeKernelImage()` to return an image does not
415 # retain any information about the original bounding box of the
416 # PSF. We therefore reset the origin to be the same as the
417 # pixelized image.
418 psfImage.setXY0(psfBBox.getMin())
420 # Get the trace radius of the PSF.
421 psfSigma = psf.computeShape(center).getTraceRadius()
423 # Get the bounding box in the parent coordinate system.
424 bbox = psfImage.getBBox(afwImage.PARENT)
426 # Turn bounding box corners into GalSim bounds.
427 xmin, xmax = bbox.getMinX(), bbox.getMaxX()
428 ymin, ymax = bbox.getMinY(), bbox.getMaxY()
429 bounds = galsim._BoundsI(xmin, xmax, ymin, ymax)
431 # Adjust the psfImage for noise as needed, and retrieve the mask of bad
432 # pixels.
433 badpix = self._adjustNoise(psfImage, psfBBox, exposure, record, bounds)
435 # Extract the numpy array underlying the PSF image.
436 imageArray = psfImage.array
438 # Create a GalSim image using the PSF image array.
439 image = galsim._Image(imageArray, bounds, None)
441 if badpix is not None:
442 gd = badpix == 0
443 badpix[gd] = 1
444 badpix[~gd] = 0
446 weight_image = galsim._Image(badpix, bounds, None)
447 else:
448 arr = np.ones(imageArray.shape, dtype=np.int32)
449 weight_image = galsim._Image(arr, bounds, None)
451 # Decide on the centroid position based on configuration.
452 if self.config.useSourceCentroidOffset:
453 # If the source centroid offset should be used, use the source
454 # centroid.
455 centroid = center
456 else:
457 # Otherwise, use the center of the bounding box of psfImage.
458 centroid = Point2D(psfBBox.getMin() + psfBBox.getDimensions() // 2)
460 # Call the internal method to calculate adaptive moments using GalSim.
461 self._calculate(
462 record,
463 image=image,
464 weight_image=weight_image,
465 sigma=psfSigma,
466 centroid=centroid,
467 )
469 def _adjustNoise(self, *args) -> None:
470 """A noop in the base class, returning None for the bad pixel mask.
471 This method is designed to be overridden in subclasses."""
472 pass
475class HsmPsfMomentsDebiasedConfig(HsmPsfMomentsConfig):
476 """Configuration for debiased HSM adaptive moments measurement for PSFs."""
478 noiseSource = pexConfig.ChoiceField[str](
479 doc="Noise source. How to choose variance of the zero-mean Gaussian noise added to image.",
480 allowed={
481 "meta": "variance = the 'BGMEAN' metadata entry",
482 "variance": "variance = the image's variance plane",
483 },
484 default="variance",
485 )
486 seedOffset = pexConfig.Field[int](doc="Seed offset for random number generator.", default=0)
487 badMaskPlanes = pexConfig.ListField[str](
488 doc="Mask planes used to reject bad pixels.", default=["BAD", "SAT"]
489 )
491 def setDefaults(self):
492 super().setDefaults()
493 self.useSourceCentroidOffset = True
496@measBase.register("ext_shapeHSM_HsmPsfMomentsDebiased")
497class HsmPsfMomentsDebiasedPlugin(HsmPsfMomentsPlugin):
498 """Plugin for debiased HSM adaptive moments measurement for PSFs."""
500 ConfigClass = HsmPsfMomentsDebiasedConfig
501 _debiased = True
503 @classmethod
504 def getExecutionOrder(cls):
505 # Since the standard execution order increases in steps of 1, it's
506 # safer to keep the increase by hand to less than 1. The exact value
507 # does not matter.
508 return cls.FLUX_ORDER + 0.1
510 def _adjustNoise(
511 self,
512 psfImage: afwImage.Image,
513 psfBBox: Box2I,
514 exposure: afwImage.Exposure,
515 record: afwTable.SourceRecord,
516 bounds: galsim.bounds.BoundsI,
517 ) -> np.ndarray:
518 """
519 Adjusts noise in the PSF image and updates the bad pixel mask based on
520 exposure data. This method modifies `psfImage` in place and returns a
521 newly created `badpix` mask.
523 Parameters
524 ----------
525 psfImage : `~lsst.afw.image.Image`
526 The PSF image to be adjusted. This image is modified in place.
527 psfBBox : `~lsst.geom.Box2I`
528 The bounding box of the PSF.
529 exposure : `~lsst.afw.image.Exposure`
530 The exposure object containing relevant metadata and mask
531 information.
532 record : `~lsst.afw.table.SourceRecord`
533 The source record where measurement outputs will be stored. May be
534 modified in place to set flags.
535 bounds : `~galsim.bounds.BoundsI`
536 The bounding box of the PSF as a GalSim bounds object.
538 Returns
539 -------
540 badpix : `~np.ndarray`
541 Numpy image array (np.int32) representing bad pixels, where zero
542 indicates good pixels and any nonzero value denotes a bad pixel.
544 Raises
545 ------
546 MeasurementError
547 If there's an issue during the noise adjustment process.
548 FatalAlgorithmError
549 If BGMEAN is not present in the metadata when using the meta noise
550 source.
551 """
552 # Psf image crossing exposure edge is fine if we're getting the
553 # variance from metadata, but not okay if we're getting the
554 # variance from the variance plane. In both cases, set the EDGE
555 # flag, but only fail hard if using variance plane.
556 overlap = psfImage.getBBox()
557 overlap.clip(exposure.getBBox())
558 if overlap != psfImage.getBBox():
559 self.flagHandler.setValue(record, self.EDGE.number, True)
560 if self.config.noiseSource == "variance":
561 self.flagHandler.setValue(record, self.FAILURE.number, True)
562 raise measBase.MeasurementError(self.EDGE.doc, self.EDGE.number)
564 # Match PSF flux to source.
565 psfImage *= record.getPsfInstFlux()
567 # Add Gaussian noise to image in 4 steps:
568 # 1. Initialize the noise image and random number generator.
569 noise = afwImage.Image(psfImage.getBBox(), dtype=psfImage.dtype, initialValue=0.0)
570 seed = record.getId() + self.config.seedOffset
571 rand = afwMath.Random("MT19937", seed)
573 # 2. Generate Gaussian noise image.
574 afwMath.randomGaussianImage(noise, rand)
576 # 3. Determine the noise scaling based on the noise source.
577 if self.config.noiseSource == "meta":
578 # Retrieve BGMEAN from the exposure metadata.
579 try:
580 bgmean = exposure.getMetadata().getAsDouble("BGMEAN")
581 except NotFoundError as error:
582 raise measBase.FatalAlgorithmError(str(error))
583 # Scale the noise by the square root of the background mean.
584 noise *= np.sqrt(bgmean)
585 elif self.config.noiseSource == "variance":
586 # Get the variance image from the exposure and restrict to the
587 # PSF bounding box.
588 var = afwImage.Image(exposure.variance[psfImage.getBBox()], dtype=psfImage.dtype, deep=True)
589 # Scale the noise by the square root of the variance.
590 var.sqrt() # In-place square root.
591 noise *= var
593 # 4. Add the scaled noise to the PSF image.
594 psfImage += noise
596 # Masking is needed for debiased PSF moments.
597 badpix = afwImage.Mask(psfBBox)
598 # NOTE: We repeat the `overlap` calculation in the two lines below to
599 # align with the old C++ version and minimize potential discrepancies.
600 # There's zero chance this will be a time sink, and using the bbox from
601 # the image that's about to be cropped seems safer than using the bbox
602 # from a different image, even if they're nominally supposed to have
603 # the same bounds.
604 overlap = badpix.getBBox()
605 overlap.clip(exposure.getBBox())
606 badpix[overlap] = exposure.mask[overlap]
607 # Pull out the numpy view of the badpix mask image.
608 badpix = badpix.array
610 bitValue = exposure.mask.getPlaneBitMask(self.config.badMaskPlanes)
611 badpix &= bitValue
613 return badpix