Coverage for tests/test_hsm.py: 13%
591 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-11 04:17 -0700
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-11 04:17 -0700
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 itertools
23import os
24import unittest
26import galsim
27import lsst.afw.detection as afwDetection
28import lsst.afw.geom as afwGeom
29import lsst.afw.geom.ellipses as afwEll
30import lsst.afw.image as afwImage
31import lsst.afw.math as afwMath
32import lsst.afw.table as afwTable
33import lsst.geom as geom
34import lsst.meas.algorithms as algorithms
35import lsst.meas.base as base
36import lsst.meas.base.tests
37import lsst.meas.extensions.shapeHSM as shapeHSM
38import lsst.pex.config as pexConfig
39import lsst.utils.tests
40import numpy as np
41from lsst.daf.base import PropertySet
43SIZE_DECIMALS = 2 # Number of decimals for equality in sizes
44SHAPE_DECIMALS = 3 # Number of decimals for equality in shapes
46# The following values are pulled directly from GalSim's test_hsm.py:
47file_indices = [0, 2, 4, 6, 8]
48x_centroid = [35.888, 19.44, 8.74, 20.193, 57.94]
49y_centroid = [19.845, 25.047, 11.92, 38.93, 27.73]
50sky_var = [35.01188, 35.93418, 35.15456, 35.11146, 35.16454]
51correction_methods = ["KSB", "BJ", "LINEAR", "REGAUSS"]
52# Note: expected results give shear for KSB and distortion for others, but the results below have
53# converted KSB expected results to distortion for the sake of consistency
54e1_expected = np.array(
55 [
56 [0.467603106752, 0.381211727, 0.398856937, 0.401755571],
57 [0.28618443944, 0.199222784, 0.233883543, 0.234257525],
58 [0.271533794146, 0.158049396, 0.183517068, 0.184893412],
59 [-0.293754156071, -0.457024541, 0.123946584, -0.609233462],
60 [0.557720893779, 0.374143023, 0.714147448, 0.435404409],
61 ]
62)
63e2_expected = np.array(
64 [
65 [-0.867225166489, -0.734855778, -0.777027588, -0.774684891],
66 [-0.469354341577, -0.395520479, -0.502540961, -0.464466257],
67 [-0.519775291311, -0.471589061, -0.574750641, -0.529664935],
68 [0.345688365839, -0.342047099, 0.120603755, -0.44609129428863525],
69 [0.525728304099, 0.370691830, 0.702724807, 0.433999442],
70 ]
71)
72resolution_expected = np.array(
73 [
74 [0.796144249, 0.835624917, 0.835624917, 0.827796187],
75 [0.685023735, 0.699602704, 0.699602704, 0.659457638],
76 [0.634736458, 0.651040481, 0.651040481, 0.614663396],
77 [0.477027015, 0.477210752, 0.477210752, 0.423157447],
78 [0.595205998, 0.611824797, 0.611824797, 0.563582092],
79 ]
80)
81sigma_e_expected = np.array(
82 [
83 [0.016924826, 0.014637648, 0.014637648, 0.014465546],
84 [0.075769504, 0.073602324, 0.073602324, 0.064414520],
85 [0.110253112, 0.106222900, 0.106222900, 0.099357106],
86 [0.185276702, 0.184300955, 0.184300955, 0.173478300],
87 [0.073020065, 0.070270966, 0.070270966, 0.061856263],
88 ]
89)
90# End of GalSim's values
92# These values calculated using GalSim's HSM as part of GalSim
93galsim_e1 = np.array(
94 [
95 [0.399292618036, 0.381213068962, 0.398856908083, 0.401749581099],
96 [0.155929282308, 0.199228107929, 0.233882278204, 0.234371587634],
97 [0.150018423796, 0.158052951097, 0.183515056968, 0.184561833739],
98 [-2.6984937191, -0.457033962011, 0.123932465911, -0.60886412859],
99 [0.33959621191, 0.374140143394, 0.713756918907, 0.43560180068],
100 ]
101)
102galsim_e2 = np.array(
103 [
104 [-0.74053555727, -0.734855830669, -0.777024209499, -0.774700462818],
105 [-0.25573053956, -0.395517915487, -0.50251352787, -0.464388132095],
106 [-0.287168383598, -0.471584022045, -0.574719130993, -0.5296921134],
107 [3.1754450798, -0.342054128647, 0.120592080057, -0.446093201637],
108 [0.320115834475, 0.370669454336, 0.702303349972, 0.433968126774],
109 ]
110)
111galsim_resolution = np.array(
112 [
113 [0.79614430666, 0.835625052452, 0.835625052452, 0.827822327614],
114 [0.685023903847, 0.699601829052, 0.699601829052, 0.659438848495],
115 [0.634736537933, 0.651039719582, 0.651039719582, 0.614759743214],
116 [0.477026551962, 0.47721144557, 0.47721144557, 0.423227936029],
117 [0.595205545425, 0.611821532249, 0.611821532249, 0.563564240932],
118 ]
119)
120galsim_err = np.array(
121 [
122 [0.0169247947633, 0.0146376201883, 0.0146376201883, 0.0144661813974],
123 [0.0757696777582, 0.0736026018858, 0.0736026018858, 0.0644160583615],
124 [0.110252402723, 0.106222368777, 0.106222368777, 0.0993555411696],
125 [0.185278102756, 0.184301897883, 0.184301897883, 0.17346136272],
126 [0.0730196461082, 0.0702708885074, 0.0702708885074, 0.0618583671749],
127 ]
128)
130moments_expected = np.array(
131 [ # sigma, e1, e2
132 [2.24490427971, 0.336240686301, -0.627372910656],
133 [1.9031778574, 0.150566105384, -0.245272792302],
134 [1.77790760994, 0.112286123389, -0.286203939641],
135 [1.45464873314, -0.155597168978, -0.102008266223],
136 [1.63144648075, 0.22886961923, 0.228813588897],
137 ]
138)
139centroid_expected = np.array(
140 [ # x, y
141 [36.218247328, 20.5678722157],
142 [20.325744838, 25.4176650386],
143 [9.54257706283, 12.6134786199],
144 [20.6407850048, 39.5864802706],
145 [58.5008586442, 28.2850942049],
146 ]
147)
149round_moments_expected = np.array(
150 [ # sigma, e1, e2, flux, x, y
151 [2.40270376205, 0.197810277343, -0.372329413891, 3740.22436523, 36.4032272633, 20.4847916447],
152 [1.89714717865, 0.046496052295, -0.0987404286861, 776.709594727, 20.2893584046, 25.4230368047],
153 [1.77995181084, 0.0416346564889, -0.143147706985, 534.59197998, 9.51994111869, 12.6250775205],
154 [1.46549296379, -0.0831127092242, -0.0628845766187, 348.294403076, 20.6242279632, 39.5941625731],
155 [1.64031589031, 0.0867517963052, 0.0940798297524, 793.374450684, 58.4728765002, 28.2686937854],
156 ]
157)
160def makePluginAndCat(alg, name, control=None, metadata=False, centroid=None, psfflux=None, addFlux=False):
161 if control is None:
162 control = alg.ConfigClass()
163 if addFlux:
164 control.addFlux = True
165 schema = afwTable.SourceTable.makeMinimalSchema()
166 if centroid:
167 lsst.afw.table.Point2DKey.addFields(schema, centroid, "centroid", "pixel")
168 schema.getAliasMap().set("slot_Centroid", centroid)
169 if psfflux:
170 base.PsfFluxAlgorithm(base.PsfFluxControl(), psfflux, schema)
171 schema.getAliasMap().set("slot_PsfFlux", psfflux)
172 if metadata:
173 plugin = alg(control, name, schema, PropertySet())
174 else:
175 plugin = alg(control, name, schema)
176 cat = afwTable.SourceCatalog(schema)
177 if centroid:
178 cat.defineCentroid(centroid)
179 return plugin, cat
182class MomentsTestCase(unittest.TestCase):
183 """A test case for shape measurement"""
185 def setUp(self):
186 # load the known values
187 self.dataDir = os.path.join(os.getenv("MEAS_EXTENSIONS_SHAPEHSM_DIR"), "tests", "data")
188 self.bkgd = 1000.0 # standard for atlas image
189 self.offset = geom.Extent2I(1234, 1234)
190 self.xy0 = geom.Point2I(5678, 9876)
192 def tearDown(self):
193 del self.offset
194 del self.xy0
196 def runMeasurement(self, algorithmName, imageid, x, y, v, addFlux=False, maskAll=False):
197 """Run the measurement algorithm on an image"""
198 # load the test image
199 imgFile = os.path.join(self.dataDir, "image.%d.fits" % imageid)
200 img = afwImage.ImageF(imgFile)
201 img -= self.bkgd
202 nx, ny = img.getWidth(), img.getHeight()
203 msk = afwImage.Mask(geom.Extent2I(nx, ny), 0x0)
204 var = afwImage.ImageF(geom.Extent2I(nx, ny), v)
205 mimg = afwImage.MaskedImageF(img, msk, var)
206 msk.getArray()[:] = np.where(np.fabs(img.getArray()) < 1.0e-8, msk.getPlaneBitMask("BAD"), 0)
207 if maskAll:
208 msk.array[:] |= msk.getPlaneBitMask("BAD")
210 # Put it in a bigger image, in case it matters
211 big = afwImage.MaskedImageF(self.offset + mimg.getDimensions())
212 big.getImage().set(0)
213 big.getMask().set(0)
214 big.getVariance().set(v)
215 subBig = afwImage.MaskedImageF(big, geom.Box2I(big.getXY0() + self.offset, mimg.getDimensions()))
216 subBig.assign(mimg)
217 mimg = big
218 mimg.setXY0(self.xy0)
220 exposure = afwImage.makeExposure(mimg)
221 cdMatrix = np.array([1.0 / (2.53 * 3600.0), 0.0, 0.0, 1.0 / (2.53 * 3600.0)])
222 cdMatrix.shape = (2, 2)
223 exposure.setWcs(
224 afwGeom.makeSkyWcs(
225 crpix=geom.Point2D(1.0, 1.0), crval=geom.SpherePoint(0, 0, geom.degrees), cdMatrix=cdMatrix
226 )
227 )
229 # load the corresponding test psf
230 psfFile = os.path.join(self.dataDir, "psf.%d.fits" % imageid)
231 psfImg = afwImage.ImageD(psfFile)
232 psfImg -= self.bkgd
234 kernel = afwMath.FixedKernel(psfImg)
235 kernelPsf = algorithms.KernelPsf(kernel)
236 exposure.setPsf(kernelPsf)
238 # perform the shape measurement
239 msConfig = base.SingleFrameMeasurementConfig()
240 msConfig.plugins.names |= [algorithmName]
241 control = msConfig.plugins[algorithmName]
242 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
243 # NOTE: It is essential to remove the floating point part of the position for the
244 # Algorithm._apply. Otherwise, when the PSF is realised it will have been warped
245 # to account for the sub-pixel offset and we won't get *exactly* this PSF.
246 plugin, table = makePluginAndCat(
247 alg, algorithmName, control, centroid="centroid", metadata=True, addFlux=addFlux
248 )
249 center = geom.Point2D(int(x), int(y)) + geom.Extent2D(self.offset + geom.Extent2I(self.xy0))
250 source = table.makeRecord()
251 source.set("centroid_x", center.getX())
252 source.set("centroid_y", center.getY())
253 source.setFootprint(afwDetection.Footprint(afwGeom.SpanSet(exposure.getBBox(afwImage.PARENT))))
254 plugin.measure(source, exposure)
256 return source
258 def testHsmSourceMoments(self):
259 for i, imageid in enumerate(file_indices):
260 source = self.runMeasurement(
261 "ext_shapeHSM_HsmSourceMoments", imageid, x_centroid[i], y_centroid[i], sky_var[i]
262 )
263 x = source.get("ext_shapeHSM_HsmSourceMoments_x")
264 y = source.get("ext_shapeHSM_HsmSourceMoments_y")
265 xx = source.get("ext_shapeHSM_HsmSourceMoments_xx")
266 yy = source.get("ext_shapeHSM_HsmSourceMoments_yy")
267 xy = source.get("ext_shapeHSM_HsmSourceMoments_xy")
269 # Centroids from GalSim use the FITS lower-left corner of 1,1
270 offset = self.xy0 + self.offset
271 self.assertAlmostEqual(x - offset.getX(), centroid_expected[i][0] - 1, 3)
272 self.assertAlmostEqual(y - offset.getY(), centroid_expected[i][1] - 1, 3)
274 expected = afwEll.Quadrupole(
275 afwEll.SeparableDistortionDeterminantRadius(
276 moments_expected[i][1], moments_expected[i][2], moments_expected[i][0]
277 )
278 )
280 self.assertAlmostEqual(xx, expected.getIxx(), SHAPE_DECIMALS)
281 self.assertAlmostEqual(xy, expected.getIxy(), SHAPE_DECIMALS)
282 self.assertAlmostEqual(yy, expected.getIyy(), SHAPE_DECIMALS)
284 def testHsmSourceMomentsRound(self):
285 for i, imageid in enumerate(file_indices):
286 source = self.runMeasurement(
287 "ext_shapeHSM_HsmSourceMomentsRound",
288 imageid,
289 x_centroid[i],
290 y_centroid[i],
291 sky_var[i],
292 addFlux=True,
293 )
294 x = source.get("ext_shapeHSM_HsmSourceMomentsRound_x")
295 y = source.get("ext_shapeHSM_HsmSourceMomentsRound_y")
296 xx = source.get("ext_shapeHSM_HsmSourceMomentsRound_xx")
297 yy = source.get("ext_shapeHSM_HsmSourceMomentsRound_yy")
298 xy = source.get("ext_shapeHSM_HsmSourceMomentsRound_xy")
299 flux = source.get("ext_shapeHSM_HsmSourceMomentsRound_Flux")
301 # Centroids from GalSim use the FITS lower-left corner of 1,1
302 offset = self.xy0 + self.offset
303 self.assertAlmostEqual(x - offset.getX(), round_moments_expected[i][4] - 1, 3)
304 self.assertAlmostEqual(y - offset.getY(), round_moments_expected[i][5] - 1, 3)
306 expected = afwEll.Quadrupole(
307 afwEll.SeparableDistortionDeterminantRadius(
308 round_moments_expected[i][1], round_moments_expected[i][2], round_moments_expected[i][0]
309 )
310 )
311 self.assertAlmostEqual(xx, expected.getIxx(), SHAPE_DECIMALS)
312 self.assertAlmostEqual(xy, expected.getIxy(), SHAPE_DECIMALS)
313 self.assertAlmostEqual(yy, expected.getIyy(), SHAPE_DECIMALS)
315 self.assertAlmostEqual(flux, round_moments_expected[i][3], SHAPE_DECIMALS)
317 def testHsmSourceMomentsVsSdssShape(self):
318 # Initialize a config and activate the plugins.
319 sfmConfig = base.SingleFrameMeasurementConfig()
320 sfmConfig.plugins.names |= ["ext_shapeHSM_HsmSourceMoments", "base_SdssShape"]
322 # Create a minimal schema (columns).
323 schema = lsst.meas.base.tests.TestDataset.makeMinimalSchema()
325 # Instantiate the task.
326 sfmTask = base.SingleFrameMeasurementTask(config=sfmConfig, schema=schema)
328 # Create a simple, test dataset.
329 bbox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Extent2I(100, 100))
330 dataset = lsst.meas.base.tests.TestDataset(bbox)
332 # First source is a point.
333 dataset.addSource(100000.0, lsst.geom.Point2D(49.5, 49.5))
335 # Second source is a galaxy.
336 dataset.addSource(300000.0, lsst.geom.Point2D(76.3, 79.2), afwGeom.Quadrupole(2.0, 3.0, 0.5))
338 # Third source is also a galaxy.
339 dataset.addSource(250000.0, lsst.geom.Point2D(28.9, 41.35), afwGeom.Quadrupole(1.8, 3.5, 0.4))
341 # Get the exposure and catalog.
342 exposure, catalog = dataset.realize(10.0, sfmTask.schema, randomSeed=0)
344 # Run the measurement task to get the output catalog.
345 sfmTask.run(catalog, exposure)
346 cat = catalog.asAstropy()
348 # Get the moments from the catalog.
349 xSdss, ySdss = cat["base_SdssShape_x"], cat["base_SdssShape_y"]
350 xxSdss, xySdss, yySdss = cat["base_SdssShape_xx"], cat["base_SdssShape_xy"], cat["base_SdssShape_yy"]
351 xHsm, yHsm = cat["ext_shapeHSM_HsmSourceMoments_x"], cat["ext_shapeHSM_HsmSourceMoments_y"]
352 xxHsm, xyHsm, yyHsm = (
353 cat["ext_shapeHSM_HsmSourceMoments_xx"],
354 cat["ext_shapeHSM_HsmSourceMoments_xy"],
355 cat["ext_shapeHSM_HsmSourceMoments_yy"],
356 )
358 # Loop over the sources and check that the moments are the same.
359 for i in range(3):
360 self.assertAlmostEqual(xSdss[i], xHsm[i], 2)
361 self.assertAlmostEqual(ySdss[i], yHsm[i], 2)
362 self.assertAlmostEqual(xxSdss[i], xxHsm[i], SHAPE_DECIMALS)
363 self.assertAlmostEqual(xySdss[i], xyHsm[i], SHAPE_DECIMALS)
364 self.assertAlmostEqual(yySdss[i], yyHsm[i], SHAPE_DECIMALS)
366 def testHsmSourceMomentsAllMasked(self):
367 i = 0
368 imageid = file_indices[0]
369 with self.assertRaises(base.MeasurementError):
370 _ = self.runMeasurement(
371 "ext_shapeHSM_HsmSourceMoments",
372 imageid,
373 x_centroid[i],
374 y_centroid[i],
375 sky_var[i],
376 maskAll=True,
377 )
380class ShapeTestCase(unittest.TestCase):
381 """A test case for shape measurement"""
383 def setUp(self):
384 # load the known values
385 self.dataDir = os.path.join(os.getenv("MEAS_EXTENSIONS_SHAPEHSM_DIR"), "tests", "data")
386 self.bkgd = 1000.0 # standard for atlas image
387 self.offset = geom.Extent2I(1234, 1234)
388 self.xy0 = geom.Point2I(5678, 9876)
390 def tearDown(self):
391 del self.offset
392 del self.xy0
394 @staticmethod
395 def computeDirectShapeFromGalSim(record, exposure, config):
396 """
397 Retrieve the shape as estimated directly by GalSim for comparison
398 purposes.
400 Parameters
401 ----------
402 record : `~lsst.afw.table.SourceRecord`
403 The record containing the center and footprint of the source which
404 needs measurement.
405 exposure : `~lsst.afw.image.Exposure`
406 The exposure containing the source which needs measurement.
407 config : `~lsst.meas.extensions.shapeHSM._hsm_shape.\
408 HsmShapeConfig`
409 The configuration object containing parameters and settings for
410 this measurement. This needs to be a subclass in the format
411 HsmShape<Method>Config, where <Method> represents the name of the
412 correction method being utilized (e.g., Ksb, Regauss, etc.).
414 Returns
415 -------
416 shapeDirect : `~galsim.hsm.ShapeData`
417 An object containing the results of shape measurement.
418 """
420 # Get the center of the source as a Point2D.
421 center = geom.Point2D(record.get("centroid_x"), record.get("centroid_y"))
423 # Get the PSF image evaluated at the source centroid.
424 psfImage = exposure.getPsf().computeImage(center)
425 psfImage.setXY0(0, 0)
427 # Get the GalSim images to use in the EstimateShear call.
428 bbox = record.getFootprint().getBBox()
429 bounds = galsim.BoundsI(bbox.getMinX(), bbox.getMaxX(), bbox.getMinY(), bbox.getMaxY())
430 image = galsim.Image(exposure.image[bbox].array, bounds=bounds)
431 psfBBox = psfImage.getBBox(afwImage.PARENT)
432 psfBounds = galsim.BoundsI(psfBBox.getMinX(), psfBBox.getMaxX(), psfBBox.getMinY(), psfBBox.getMaxY())
433 psf = galsim.Image(psfImage.array, bounds=psfBounds)
435 # Get the mask of bad pixels.
436 subMask = exposure.mask[bbox]
437 badpix = subMask.array.copy() # Copy it since badpix gets modified.
438 bitValue = exposure.mask.getPlaneBitMask(config.badMaskPlanes)
439 badpix &= bitValue
440 badpix = galsim.Image(badpix, bounds=bounds)
442 # Estimate the sky variance.
443 sctrl = afwMath.StatisticsControl()
444 sctrl.setAndMask(bitValue)
445 variance = afwImage.Image(
446 exposure.variance[bbox],
447 dtype=exposure.variance.dtype,
448 deep=True,
449 )
450 stat = afwMath.makeStatistics(variance, subMask, afwMath.MEDIAN, sctrl)
451 skyvar = stat.getValue(afwMath.MEDIAN)
453 # Prepare various values for GalSim's EstimateShear.
454 recomputeFlux = "FIT"
455 precision = 1.0e-6
456 psfSigma = exposure.getPsf().computeShape(center).getTraceRadius()
457 guessCentroid = galsim.PositionD(center.x, center.y)
459 # Estimate the shape using GalSim's Python interface.
460 shapeDirect = galsim.hsm.EstimateShear(
461 gal_image=image,
462 PSF_image=psf,
463 weight=None,
464 badpix=badpix,
465 sky_var=skyvar,
466 shear_est=config.shearType.upper(),
467 recompute_flux=recomputeFlux.upper(),
468 guess_sig_gal=2.5 * psfSigma,
469 guess_sig_PSF=psfSigma,
470 precision=precision,
471 guess_centroid=guessCentroid,
472 hsmparams=None,
473 )
474 return shapeDirect
476 def runMeasurement(self, algorithmName, imageid, x, y, v):
477 """Run the measurement algorithm on an image"""
478 # load the test image
479 imgFile = os.path.join(self.dataDir, "image.%d.fits" % imageid)
480 img = afwImage.ImageF(imgFile)
481 img -= self.bkgd
482 nx, ny = img.getWidth(), img.getHeight()
483 msk = afwImage.Mask(geom.Extent2I(nx, ny), 0x0)
484 var = afwImage.ImageF(geom.Extent2I(nx, ny), v)
485 mimg = afwImage.MaskedImageF(img, msk, var)
486 msk.getArray()[:] = np.where(np.fabs(img.getArray()) < 1.0e-8, msk.getPlaneBitMask("BAD"), 0)
488 # Put it in a bigger image, in case it matters
489 big = afwImage.MaskedImageF(self.offset + mimg.getDimensions())
490 big.getImage().set(0)
491 big.getMask().set(0)
492 big.getVariance().set(v)
493 subBig = afwImage.MaskedImageF(big, geom.Box2I(big.getXY0() + self.offset, mimg.getDimensions()))
494 subBig.assign(mimg)
495 mimg = big
496 mimg.setXY0(self.xy0)
498 exposure = afwImage.makeExposure(mimg)
499 cdMatrix = np.array([1.0 / (2.53 * 3600.0), 0.0, 0.0, 1.0 / (2.53 * 3600.0)])
500 cdMatrix.shape = (2, 2)
501 exposure.setWcs(
502 afwGeom.makeSkyWcs(
503 crpix=geom.Point2D(1.0, 1.0), crval=geom.SpherePoint(0, 0, geom.degrees), cdMatrix=cdMatrix
504 )
505 )
507 # load the corresponding test psf
508 psfFile = os.path.join(self.dataDir, "psf.%d.fits" % imageid)
509 psfImg = afwImage.ImageD(psfFile)
510 psfImg -= self.bkgd
512 kernel = afwMath.FixedKernel(psfImg)
513 kernelPsf = algorithms.KernelPsf(kernel)
514 exposure.setPsf(kernelPsf)
516 # perform the shape measurement
517 msConfig = base.SingleFrameMeasurementConfig()
518 msConfig.plugins.names |= [algorithmName]
519 control = msConfig.plugins[algorithmName]
520 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
521 # NOTE: It is essential to remove the floating point part of the position for the
522 # Algorithm._apply. Otherwise, when the PSF is realised it will have been warped
523 # to account for the sub-pixel offset and we won't get *exactly* this PSF.
524 plugin, table = makePluginAndCat(alg, algorithmName, control, centroid="centroid", metadata=True)
525 center = geom.Point2D(int(x), int(y)) + geom.Extent2D(self.offset + geom.Extent2I(self.xy0))
526 source = table.makeRecord()
527 source.set("centroid_x", center.getX())
528 source.set("centroid_y", center.getY())
529 source.setFootprint(afwDetection.Footprint(afwGeom.SpanSet(exposure.getBBox(afwImage.PARENT))))
530 plugin.measure(source, exposure)
532 shapeDirect = self.computeDirectShapeFromGalSim(source, exposure, control)
534 return source, alg.measTypeSymbol, shapeDirect
536 def testHsmShape(self):
537 """Test that we can instantiate and play with a measureShape"""
539 nFail = 0
540 msg = ""
542 for (algNum, algName), (i, imageid) in itertools.product(
543 enumerate(correction_methods), enumerate(file_indices)
544 ):
545 algorithmName = "ext_shapeHSM_HsmShape" + algName[0:1].upper() + algName[1:].lower()
547 source, preEstimationMeasType, shapeDirect = self.runMeasurement(
548 algorithmName, imageid, x_centroid[i], y_centroid[i], sky_var[i]
549 )
551 postEstimationMeasType = shapeDirect.meas_type
553 # Check consistency with GalSim output
554 self.assertEqual(
555 preEstimationMeasType,
556 postEstimationMeasType,
557 "The plugin setup is incompatible with GalSim output.",
558 )
560 ##########################################
561 # see how we did
562 if algName in ("KSB"):
563 # Need to convert g1,g2 --> e1,e2 because GalSim has done that
564 # for the expected values ("for consistency")
565 g1 = source.get(algorithmName + "_g1")
566 g2 = source.get(algorithmName + "_g2")
567 scale = 2.0 / (1.0 + g1**2 + g2**2)
568 e1 = g1 * scale
569 e2 = g2 * scale
570 sigma = source.get(algorithmName + "_sigma")
571 # Ensure the values calculated are identical to those obtained
572 # from GalSim.
573 self.assertEqual(g1, shapeDirect.corrected_g1)
574 self.assertEqual(g2, shapeDirect.corrected_g2)
575 else:
576 e1 = source.get(algorithmName + "_e1")
577 e2 = source.get(algorithmName + "_e2")
578 sigma = 0.5 * source.get(algorithmName + "_sigma")
579 # Ensure the values calculated are identical to those obtained
580 # from GalSim.
581 self.assertEqual(e1, shapeDirect.corrected_e1)
582 self.assertEqual(e2, shapeDirect.corrected_e2)
584 resolution = source.get(algorithmName + "_resolution")
585 flags = source.get(algorithmName + "_flag")
587 # Check that the shape error and the resolution factor are the same
588 # as GalSim's.
589 self.assertEqual(sigma, shapeDirect.corrected_shape_err)
590 self.assertEqual(resolution, shapeDirect.resolution_factor)
592 tests = [
593 # label, known-value, measured, tolerance
594 ["e1", float(e1_expected[i][algNum]), e1, 0.5 * 10**-SHAPE_DECIMALS],
595 ["e2", float(e2_expected[i][algNum]), e2, 0.5 * 10**-SHAPE_DECIMALS],
596 ["resolution", float(resolution_expected[i][algNum]), resolution, 0.5 * 10**-SIZE_DECIMALS],
597 # sigma won't match exactly because
598 # we're using skyvar=mean(var) instead of measured value ... expected a difference
599 ["sigma", float(sigma_e_expected[i][algNum]), sigma, 0.07],
600 ["shapeStatus", 0, flags, 0],
601 ]
603 for test in tests:
604 label, know, hsm, limit = test
605 err = hsm - know
606 msgTmp = "%-12s %s %5s: %6.6f %6.6f (val-known) = %.3g\n" % (
607 algName,
608 imageid,
609 label,
610 know,
611 hsm,
612 err,
613 )
614 if not np.isfinite(err) or abs(err) > limit:
615 msg += msgTmp
616 nFail += 1
618 self.assertAlmostEqual(g1 if algName in ("KSB") else e1, galsim_e1[i][algNum], SHAPE_DECIMALS)
619 self.assertAlmostEqual(g2 if algName in ("KSB") else e2, galsim_e2[i][algNum], SHAPE_DECIMALS)
620 self.assertAlmostEqual(resolution, galsim_resolution[i][algNum], SIZE_DECIMALS)
621 self.assertAlmostEqual(sigma, galsim_err[i][algNum], delta=0.07)
623 self.assertEqual(nFail, 0, "\n" + msg)
625 @lsst.utils.tests.methodParametersProduct(
626 # Increasing the width beyond 4.5 leads to noticeable truncation of the
627 # PSF, i.e. a PSF that is too large for the box. While this truncated
628 # state leads to incorrect measurements, it is necessary for testing
629 # purposes to evaluate the behavior under these extreme conditions.
630 # Increasing the width beyond 41.3 and zeroPadding beyond 32 with
631 # everything else constant fails to converge for this particular test
632 # dataset.
633 width=(2.0, 3.0, 4.0, 10.0, 40.0),
634 zeroPadding=(2, 5, 10, 20, 30),
635 varyBBox=(True, False),
636 wrongBBox=(True, False),
637 algName=correction_methods,
638 )
639 def testHsmShapeWithVariousPsfsVsDirectGalsim(self, width, zeroPadding, varyBBox, wrongBBox, algName):
640 # Set the full algorithm name.
641 algorithmName = "ext_shapeHSM_HsmShape" + algName[0:1].upper() + algName[1:].lower()
643 # Initialize a config and activate the plugins.
644 sfmConfig = base.SingleFrameMeasurementConfig()
645 sfmConfig.plugins.names |= [algorithmName]
647 # Create a minimal schema (columns).
648 schema = lsst.meas.base.tests.TestDataset.makeMinimalSchema()
650 # Create a simple, test dataset.
651 bbox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Extent2I(60, 60))
652 dataset = lsst.meas.base.tests.TestDataset(bbox)
654 # Add a galaxy.
655 center = lsst.geom.Point2D(24.9, 32.5)
656 dataset.addSource(150000.0, center, afwGeom.Quadrupole(3.0, 4.0, 0.5))
658 # Get the exposure.
659 exposure, _ = dataset.realize(noise=10.0, schema=schema, randomSeed=1746)
661 # Create and set the PSF for the exposure.
662 psf = PyGaussianPsf(
663 35,
664 35,
665 width,
666 varyBBox=varyBBox,
667 wrongBBox=wrongBBox,
668 zeroPadding=zeroPadding,
669 )
670 exposure.getMaskedImage().set(1.0, 0, 1.0)
671 exposure.setPsf(psf)
673 # Conduct the measurement directly by GalSim.
674 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
675 plugin, table = makePluginAndCat(alg, algorithmName, centroid="centroid", metadata=True)
676 record = table.makeRecord()
677 record.set("centroid_x", center.x)
678 record.set("centroid_y", center.y)
679 record.setFootprint(afwDetection.Footprint(afwGeom.SpanSet(exposure.getBBox(afwImage.PARENT))))
680 shapeDirect = self.computeDirectShapeFromGalSim(record, exposure, plugin.config)
682 # Run the shapeHSM measurement task and update the record.
683 plugin.measure(record, exposure)
685 if algName in ("KSB"):
686 g1Direct, g2Direct = shapeDirect.corrected_g1, shapeDirect.corrected_g2
687 sigmaDirect = shapeDirect.corrected_shape_err
688 g1Hsm, g2Hsm = record[algorithmName + "_g1"], record[algorithmName + "_g2"]
689 sigmaHsm = record[algorithmName + "_sigma"]
690 # Check that the answers are "identical" between the two methods.
691 self.assertEqual(g1Direct, g1Hsm)
692 self.assertEqual(g2Direct, g2Hsm)
693 self.assertEqual(sigmaDirect, sigmaHsm)
694 else:
695 e1Direct, e2Direct = shapeDirect.corrected_e1, shapeDirect.corrected_e2
696 sigmaDirect = shapeDirect.corrected_shape_err
697 e1Hsm, e2Hsm = record[algorithmName + "_e1"], record[algorithmName + "_e2"]
698 # The factor of 0.5 is because shapeHSM returns
699 # `2 * corrected_shape_err` as sigma for e-type distortions.
700 sigmaHsm = 0.5 * record[algorithmName + "_sigma"]
701 # Check that the answers are "identical" between the two methods.
702 self.assertEqual(e1Direct, e1Hsm)
703 self.assertEqual(e2Direct, e2Hsm)
704 self.assertEqual(sigmaDirect, sigmaHsm)
706 resolutionDirect = shapeDirect.resolution_factor
707 flagsDirect = shapeDirect.correction_status
708 resolutionHSM = record[algorithmName + "_resolution"]
709 flagsHSM = record[algorithmName + "_flag"]
711 # Check that the resolution factor and the correction status are
712 # exactly the same as when using GalSim directly.
713 self.assertEqual(resolutionDirect, resolutionHSM)
714 self.assertEqual(flagsDirect, flagsHSM)
716 def testValidate(self):
717 for algName in correction_methods:
718 with self.assertRaises(pexConfig.FieldValidationError):
719 algorithmName = "ext_shapeHSM_HsmShape" + algName[0:1].upper() + algName[1:].lower()
720 msConfig = base.SingleFrameMeasurementConfig()
721 msConfig.plugins.names |= [algorithmName]
722 control = msConfig.plugins[algorithmName]
723 control.shearType = "WRONG"
724 control.validate()
727class PyGaussianPsf(afwDetection.Psf):
728 # Like afwDetection.GaussianPsf, but handles computeImage exactly instead of
729 # via interpolation. This is a subminimal implementation. It works for the
730 # tests here but isn't fully functional as a Psf class.
732 def __init__(self, width, height, sigma, varyBBox=False, wrongBBox=False, zeroPadding=0):
733 afwDetection.Psf.__init__(self, isFixed=not varyBBox)
734 self.zeroPadding = int(zeroPadding) # To address DM-42294
735 self.dimensions = geom.Extent2I(width + self.zeroPadding, height + self.zeroPadding)
736 self.sigma = sigma
737 self.varyBBox = varyBBox # To address DM-29863
738 self.wrongBBox = wrongBBox # To address DM-30426
740 def zeroPad(self, img):
741 # The thickness of the zero padding to encase the image edges.
742 p = self.zeroPadding # p must be a positive integer
743 # Replace the outermost p pixels of the top, bottom, left, and right
744 # edges of the image array with zeros.
745 img.array[:p, :] = 0 # Top edge
746 img.array[-p:, :] = 0 # Bottom edge
747 img.array[:, :p] = 0 # Left edge
748 img.array[:, -p:] = 0 # Right edge
749 return img
751 def _doComputeKernelImage(self, position=None, color=None):
752 bbox = self.computeBBox(position, color)
753 img = afwImage.Image(bbox, dtype=np.float64)
754 x, y = np.ogrid[bbox.minY : bbox.maxY + 1, bbox.minX : bbox.maxX + 1]
755 rsqr = x**2 + y**2
756 img.array[:] = np.exp(-0.5 * rsqr / self.sigma**2)
757 if self.zeroPadding > 0:
758 img = self.zeroPad(img)
759 img.array /= np.sum(img.array)
760 return img
762 def _doComputeImage(self, position=None, color=None):
763 bbox = self.computeBBox(position, color)
764 if self.wrongBBox:
765 # For DM-30426:
766 # Purposely make computeImage.getBBox() and computeBBox()
767 # inconsistent. Old shapeHSM code attempted to infer the former
768 # from the latter, but was unreliable. New code infers the former
769 # directly, so this inconsistency no longer breaks things.
770 bbox.shift(geom.Extent2I(1, 2))
771 img = afwImage.Image(bbox, dtype=np.float64)
772 y, x = np.ogrid[float(bbox.minY) : bbox.maxY + 1, bbox.minX : bbox.maxX + 1]
773 x -= position.x - np.floor(position.x + 0.5)
774 y -= position.y - np.floor(position.y + 0.5)
775 rsqr = x**2 + y**2
776 img.array[:] = np.exp(-0.5 * rsqr / self.sigma**2)
777 if self.zeroPadding > 0:
778 img = self.zeroPad(img)
779 img.array /= np.sum(img.array)
780 img.setXY0(
781 geom.Point2I(img.getX0() + np.floor(position.x + 0.5), img.getY0() + np.floor(position.y + 0.5))
782 )
783 return img
785 def _doComputeBBox(self, position=None, color=None):
786 # Variable size bbox for addressing DM-29863.
787 dims = self.dimensions
788 if self.varyBBox:
789 if position.x > 20.0:
790 dims = dims + geom.Extent2I(2, 2)
791 return geom.Box2I(geom.Point2I(-dims / 2), dims)
793 def _doComputeShape(self, position=None, color=None):
794 return afwGeom.ellipses.Quadrupole(self.sigma**2, self.sigma**2, 0.0)
797class PsfMomentsTestCase(unittest.TestCase):
798 """A test case for PSF moments measurement"""
800 @staticmethod
801 def computeDirectPsfMomentsFromGalSim(psf, center, useSourceCentroidOffset=False):
802 """Directly from GalSim."""
803 psfBBox = psf.computeImageBBox(center)
804 psfSigma = psf.computeShape(center).getTraceRadius()
805 if useSourceCentroidOffset:
806 psfImage = psf.computeImage(center)
807 centroid = center
808 else:
809 psfImage = psf.computeKernelImage(center)
810 psfImage.setXY0(psfBBox.getMin())
811 centroid = geom.Point2D(psfBBox.getMin() + psfBBox.getDimensions() // 2)
812 bbox = psfImage.getBBox(afwImage.PARENT)
813 bounds = galsim.BoundsI(bbox.getMinX(), bbox.getMaxX(), bbox.getMinY(), bbox.getMaxY())
814 image = galsim.Image(psfImage.array, bounds=bounds)
815 guessCentroid = galsim.PositionD(centroid.x, centroid.y)
816 shape = galsim.hsm.FindAdaptiveMom(
817 image,
818 weight=None,
819 badpix=None,
820 guess_sig=psfSigma,
821 precision=1e-6,
822 guess_centroid=guessCentroid,
823 strict=True,
824 round_moments=False,
825 hsmparams=None,
826 )
827 ellipse = lsst.afw.geom.ellipses.SeparableDistortionDeterminantRadius(
828 e1=shape.observed_shape.e1,
829 e2=shape.observed_shape.e2,
830 radius=shape.moments_sigma,
831 normalize=True, # Fail if |e|>1.
832 )
833 quad = lsst.afw.geom.ellipses.Quadrupole(ellipse)
834 ixx = quad.getIxx()
835 iyy = quad.getIyy()
836 ixy = quad.getIxy()
837 return ixx, iyy, ixy
839 @lsst.utils.tests.methodParameters(
840 # Make Cartesian product of settings to feed to methodParameters
841 **dict(
842 list(
843 zip(
844 (
845 kwargs := dict(
846 # Increasing the width beyond 4.5 leads to noticeable
847 # truncation of the PSF, i.e. a PSF that is too large for the
848 # box. While this truncated state leads to incorrect
849 # measurements, it is necessary for testing purposes to
850 # evaluate the behavior under these extreme conditions.
851 width=(2.0, 3.0, 4.0, 10.0, 40.0, 100.0),
852 useSourceCentroidOffset=(True, False),
853 varyBBox=(True, False),
854 wrongBBox=(True, False),
855 center=(
856 (23.0, 34.0), # various offsets that might cause trouble
857 (23.5, 34.0),
858 (23.5, 34.5),
859 (23.15, 34.25),
860 (22.81, 34.01),
861 (22.81, 33.99),
862 (1.2, 1.3), # psfImage extends outside exposure; that's okay
863 (-100.0, -100.0),
864 (-100.5, -100.0),
865 (-100.5, -100.5),
866 ),
867 )
868 ).keys(),
869 zip(*itertools.product(*kwargs.values())),
870 )
871 )
872 )
873 )
874 def testHsmPsfMoments(self, width, useSourceCentroidOffset, varyBBox, wrongBBox, center):
875 psf = PyGaussianPsf(35, 35, width, varyBBox=varyBBox, wrongBBox=wrongBBox)
876 exposure = afwImage.ExposureF(45, 56)
877 exposure.getMaskedImage().set(1.0, 0, 1.0)
878 exposure.setPsf(psf)
880 # perform the moment measurement
881 algorithmName = "ext_shapeHSM_HsmPsfMoments"
882 msConfig = base.SingleFrameMeasurementConfig()
883 msConfig.algorithms.names = [algorithmName]
884 control = msConfig.plugins[algorithmName]
885 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
886 self.assertFalse(control.useSourceCentroidOffset)
887 control.useSourceCentroidOffset = useSourceCentroidOffset
888 plugin, cat = makePluginAndCat(
889 alg,
890 algorithmName,
891 centroid="centroid",
892 control=control,
893 metadata=True,
894 )
895 source = cat.addNew()
896 source.set("centroid_x", center[0])
897 source.set("centroid_y", center[1])
898 offset = geom.Point2I(*center)
899 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
900 source.setFootprint(afwDetection.Footprint(tmpSpans))
901 plugin.measure(source, exposure)
902 x = source.get("ext_shapeHSM_HsmPsfMoments_x")
903 y = source.get("ext_shapeHSM_HsmPsfMoments_y")
904 xx = source.get("ext_shapeHSM_HsmPsfMoments_xx")
905 yy = source.get("ext_shapeHSM_HsmPsfMoments_yy")
906 xy = source.get("ext_shapeHSM_HsmPsfMoments_xy")
908 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag"))
909 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag_no_pixels"))
910 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag_not_contained"))
911 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag_parent_source"))
913 if width < 4.5:
914 # i.e., as long as the PSF is not truncated for our 35x35 box.
915 self.assertAlmostEqual(x, 0.0, 3)
916 self.assertAlmostEqual(y, 0.0, 3)
917 expected = afwEll.Quadrupole(afwEll.Axes(width, width, 0.0))
918 self.assertAlmostEqual(xx, expected.getIxx(), SHAPE_DECIMALS)
919 self.assertAlmostEqual(xy, expected.getIxy(), SHAPE_DECIMALS)
920 self.assertAlmostEqual(yy, expected.getIyy(), SHAPE_DECIMALS)
922 # Test schema documentation
923 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy]"):
924 self.assertEqual(
925 cat.schema[fieldName].asField().getDoc(), "Centroid of the PSF via the HSM shape algorithm"
926 )
927 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy][xy]*"):
928 self.assertEqual(
929 cat.schema[fieldName].asField().getDoc(),
930 "Adaptive moments of the PSF via the HSM shape algorithm",
931 )
933 # Test that the moments are identical to those obtained directly by
934 # GalSim. For `width` > 4.5 where the truncation becomes significant,
935 # the answer might not be 'correct' but should remain 'consistent'.
936 xxDirect, yyDirect, xyDirect = self.computeDirectPsfMomentsFromGalSim(
937 psf,
938 geom.Point2D(*center),
939 useSourceCentroidOffset=useSourceCentroidOffset,
940 )
941 self.assertEqual(xx, xxDirect)
942 self.assertEqual(yy, yyDirect)
943 self.assertEqual(xy, xyDirect)
945 @lsst.utils.tests.methodParameters(
946 # Make Cartesian product of settings to feed to methodParameters
947 **dict(
948 list(
949 zip(
950 (
951 kwargs := dict(
952 width=(2.0, 3.0, 4.0),
953 useSourceCentroidOffset=(True, False),
954 varyBBox=(True, False),
955 wrongBBox=(True, False),
956 center=(
957 (23.0, 34.0), # various offsets that might cause trouble
958 (23.5, 34.0),
959 (23.5, 34.5),
960 (23.15, 34.25),
961 (22.81, 34.01),
962 (22.81, 33.99),
963 ),
964 )
965 ).keys(),
966 zip(*itertools.product(*kwargs.values())),
967 )
968 )
969 )
970 )
971 def testHsmPsfMomentsDebiased(self, width, useSourceCentroidOffset, varyBBox, wrongBBox, center):
972 # As a note, it's really hard to actually unit test whether we've
973 # succesfully "debiased" these measurements. That would require a
974 # many-object comparison of moments with and without noise. So we just
975 # test similar to the biased moments above.
976 var = 1.2
977 # As we reduce the flux, our deviation from the expected value
978 # increases, so decrease tolerance.
979 for flux, decimals in [
980 (1e6, 3),
981 (1e4, 1),
982 (1e3, 0),
983 ]:
984 psf = PyGaussianPsf(35, 35, width, varyBBox=varyBBox, wrongBBox=wrongBBox)
985 exposure = afwImage.ExposureF(45, 56)
986 exposure.getMaskedImage().set(1.0, 0, var)
987 exposure.setPsf(psf)
989 algorithmName = "ext_shapeHSM_HsmPsfMomentsDebiased"
990 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
992 # perform the shape measurement
993 control = lsst.meas.extensions.shapeHSM.HsmPsfMomentsDebiasedConfig()
994 self.assertTrue(control.useSourceCentroidOffset)
995 self.assertEqual(control.noiseSource, "variance")
996 control.useSourceCentroidOffset = useSourceCentroidOffset
997 plugin, cat = makePluginAndCat(
998 alg,
999 algorithmName,
1000 centroid="centroid",
1001 psfflux="base_PsfFlux",
1002 control=control,
1003 metadata=True,
1004 )
1005 source = cat.addNew()
1006 source.set("centroid_x", center[0])
1007 source.set("centroid_y", center[1])
1008 offset = geom.Point2I(*center)
1009 source.set("base_PsfFlux_instFlux", flux)
1010 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
1011 source.setFootprint(afwDetection.Footprint(tmpSpans))
1013 plugin.measure(source, exposure)
1014 x = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_x")
1015 y = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_y")
1016 xx = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xx")
1017 yy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_yy")
1018 xy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xy")
1019 for flag in [
1020 "ext_shapeHSM_HsmPsfMomentsDebiased_flag",
1021 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_no_pixels",
1022 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_not_contained",
1023 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_parent_source",
1024 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_edge",
1025 ]:
1026 self.assertFalse(source.get(flag))
1028 expected = afwEll.Quadrupole(afwEll.Axes(width, width, 0.0))
1030 self.assertAlmostEqual(x, 0.0, decimals)
1031 self.assertAlmostEqual(y, 0.0, decimals)
1033 T = expected.getIxx() + expected.getIyy()
1034 self.assertAlmostEqual((xx - expected.getIxx()) / T, 0.0, decimals)
1035 self.assertAlmostEqual((xy - expected.getIxy()) / T, 0.0, decimals)
1036 self.assertAlmostEqual((yy - expected.getIyy()) / T, 0.0, decimals)
1038 # Repeat using noiseSource='meta'. Should get nearly the same
1039 # results if BGMEAN is set to `var` above.
1040 exposure2 = afwImage.ExposureF(45, 56)
1041 # set the variance plane to something else to ensure we're
1042 # ignoring it
1043 exposure2.getMaskedImage().set(1.0, 0, 2 * var + 1.1)
1044 exposure2.setPsf(psf)
1045 exposure2.getMetadata().set("BGMEAN", var)
1047 control2 = shapeHSM.HsmPsfMomentsDebiasedConfig()
1048 control2.noiseSource = "meta"
1049 control2.useSourceCentroidOffset = useSourceCentroidOffset
1050 plugin2, cat2 = makePluginAndCat(
1051 alg,
1052 algorithmName,
1053 centroid="centroid",
1054 psfflux="base_PsfFlux",
1055 control=control2,
1056 metadata=True,
1057 )
1058 source2 = cat2.addNew()
1059 source2.set("centroid_x", center[0])
1060 source2.set("centroid_y", center[1])
1061 offset2 = geom.Point2I(*center)
1062 source2.set("base_PsfFlux_instFlux", flux)
1063 tmpSpans2 = afwGeom.SpanSet.fromShape(int(width), offset=offset2)
1064 source2.setFootprint(afwDetection.Footprint(tmpSpans2))
1066 plugin2.measure(source2, exposure2)
1067 x2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_x")
1068 y2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_y")
1069 xx2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_xx")
1070 yy2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_yy")
1071 xy2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_xy")
1072 for flag in [
1073 "ext_shapeHSM_HsmPsfMomentsDebiased_flag",
1074 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_no_pixels",
1075 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_not_contained",
1076 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_parent_source",
1077 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_edge",
1078 ]:
1079 self.assertFalse(source.get(flag))
1081 # Would be identically equal, but variance input via "BGMEAN" is
1082 # consumed in c++ as a double, where variance from the variance
1083 # plane is a c++ float.
1084 self.assertAlmostEqual(x, x2, 8)
1085 self.assertAlmostEqual(y, y2, 8)
1086 self.assertAlmostEqual(xx, xx2, 5)
1087 self.assertAlmostEqual(xy, xy2, 5)
1088 self.assertAlmostEqual(yy, yy2, 5)
1090 # Test schema documentation
1091 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy]"):
1092 self.assertEqual(
1093 cat.schema[fieldName].asField().getDoc(),
1094 "Debiased centroid of the PSF via the HSM shape algorithm",
1095 )
1096 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy][xy]*"):
1097 self.assertEqual(
1098 cat.schema[fieldName].asField().getDoc(),
1099 "Debiased adaptive moments of the PSF via the HSM shape algorithm",
1100 )
1102 testHsmPsfMomentsDebiasedEdgeArgs = dict(
1103 width=(2.0, 3.0, 4.0), useSourceCentroidOffset=(True, False), center=((1.2, 1.3), (33.2, 50.1))
1104 )
1106 @lsst.utils.tests.methodParameters(
1107 # Make Cartesian product of settings to feed to methodParameters
1108 **dict(
1109 list(
1110 zip(
1111 (
1112 kwargs := dict(
1113 width=(2.0, 3.0, 4.0),
1114 useSourceCentroidOffset=(True, False),
1115 center=[(1.2, 1.3), (33.2, 50.1)],
1116 )
1117 ).keys(),
1118 zip(*itertools.product(*kwargs.values())),
1119 )
1120 )
1121 )
1122 )
1123 def testHsmPsfMomentsDebiasedEdge(self, width, useSourceCentroidOffset, center):
1124 # As we reduce the flux, our deviation from the expected value
1125 # increases, so decrease tolerance.
1126 var = 1.2
1127 for flux, decimals in [
1128 (1e6, 3),
1129 (1e4, 2),
1130 (1e3, 1),
1131 ]:
1132 psf = PyGaussianPsf(35, 35, width)
1133 exposure = afwImage.ExposureF(45, 56)
1134 exposure.getMaskedImage().set(1.0, 0, 2 * var + 1.1)
1135 exposure.setPsf(psf)
1137 algorithmName = "ext_shapeHSM_HsmPsfMomentsDebiased"
1138 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
1140 # perform the shape measurement
1141 control = shapeHSM.HsmPsfMomentsDebiasedConfig()
1142 control.useSourceCentroidOffset = useSourceCentroidOffset
1143 self.assertEqual(control.noiseSource, "variance")
1144 plugin, cat = makePluginAndCat(
1145 alg,
1146 algorithmName,
1147 centroid="centroid",
1148 psfflux="base_PsfFlux",
1149 control=control,
1150 metadata=True,
1151 )
1152 source = cat.addNew()
1153 source.set("centroid_x", center[0])
1154 source.set("centroid_y", center[1])
1155 offset = geom.Point2I(*center)
1156 source.set("base_PsfFlux_instFlux", flux)
1157 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
1158 source.setFootprint(afwDetection.Footprint(tmpSpans))
1160 # Edge fails when setting noise from var plane
1161 with self.assertRaises(base.MeasurementError):
1162 plugin.measure(source, exposure)
1164 # Succeeds when noise is from meta
1165 exposure.getMetadata().set("BGMEAN", var)
1166 control.noiseSource = "meta"
1167 plugin, cat = makePluginAndCat(
1168 alg,
1169 algorithmName,
1170 centroid="centroid",
1171 psfflux="base_PsfFlux",
1172 control=control,
1173 metadata=True,
1174 )
1175 source = cat.addNew()
1176 source.set("centroid_x", center[0])
1177 source.set("centroid_y", center[1])
1178 offset = geom.Point2I(*center)
1179 source.set("base_PsfFlux_instFlux", flux)
1180 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
1181 source.setFootprint(afwDetection.Footprint(tmpSpans))
1182 plugin.measure(source, exposure)
1184 x = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_x")
1185 y = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_y")
1186 xx = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xx")
1187 yy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_yy")
1188 xy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xy")
1189 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag"))
1190 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_no_pixels"))
1191 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_not_contained"))
1192 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_parent_source"))
1193 # but _does_ set EDGE flag in this case
1194 self.assertTrue(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_edge"))
1196 expected = afwEll.Quadrupole(afwEll.Axes(width, width, 0.0))
1198 self.assertAlmostEqual(x, 0.0, decimals)
1199 self.assertAlmostEqual(y, 0.0, decimals)
1201 T = expected.getIxx() + expected.getIyy()
1202 self.assertAlmostEqual((xx - expected.getIxx()) / T, 0.0, decimals)
1203 self.assertAlmostEqual((xy - expected.getIxy()) / T, 0.0, decimals)
1204 self.assertAlmostEqual((yy - expected.getIyy()) / T, 0.0, decimals)
1206 # But fails hard if meta doesn't contain BGMEAN
1207 exposure.getMetadata().remove("BGMEAN")
1208 plugin, cat = makePluginAndCat(
1209 alg,
1210 algorithmName,
1211 centroid="centroid",
1212 psfflux="base_PsfFlux",
1213 control=control,
1214 metadata=True,
1215 )
1216 source = cat.addNew()
1217 source.set("centroid_x", center[0])
1218 source.set("centroid_y", center[1])
1219 offset = geom.Point2I(*center)
1220 source.set("base_PsfFlux_instFlux", flux)
1221 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
1222 source.setFootprint(afwDetection.Footprint(tmpSpans))
1223 with self.assertRaises(base.FatalAlgorithmError):
1224 plugin.measure(source, exposure)
1226 def testHsmPsfMomentsDebiasedBadNoiseSource(self):
1227 control = shapeHSM.HsmPsfMomentsDebiasedConfig()
1228 with self.assertRaises(pexConfig.FieldValidationError):
1229 control.noiseSource = "ACM"
1232class TestMemory(lsst.utils.tests.MemoryTestCase):
1233 pass
1236def setup_module(module):
1237 lsst.utils.tests.init()
1240if __name__ == "__main__": 1240 ↛ 1241line 1240 didn't jump to line 1241, because the condition on line 1240 was never true
1241 lsst.utils.tests.init()
1242 unittest.main()