Coverage for tests/test_hsm.py: 14%
579 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-07 13:23 +0000
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-07 13:23 +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 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
627 # truncation of the PSF, i.e. a PSF that is too large for the
628 # box. While this truncated state leads to incorrect
629 # measurements, it is necessary for testing purposes to
630 # evaluate the behavior under these extreme conditions.
631 # Increasing the width beyond 41.3 fails to converge for this
632 # particular test dataset.
633 width=(2.0, 3.0, 4.0, 10.0, 40.0),
634 varyBBox=(True, False),
635 wrongBBox=(True, False),
636 algName=correction_methods,
637 )
638 def testHsmShapeWithVariousPsfsVsDirectGalsim(self, width, varyBBox, wrongBBox, algName):
639 # Set the full algorithm name.
640 algorithmName = "ext_shapeHSM_HsmShape" + algName[0:1].upper() + algName[1:].lower()
642 # Initialize a config and activate the plugins.
643 sfmConfig = base.SingleFrameMeasurementConfig()
644 sfmConfig.plugins.names |= [algorithmName]
646 # Create a minimal schema (columns).
647 schema = lsst.meas.base.tests.TestDataset.makeMinimalSchema()
649 # Create a simple, test dataset.
650 bbox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Extent2I(60, 60))
651 dataset = lsst.meas.base.tests.TestDataset(bbox)
653 # Add a galaxy.
654 center = lsst.geom.Point2D(24.9, 32.5)
655 dataset.addSource(150000.0, center, afwGeom.Quadrupole(3.0, 4.0, 0.5))
657 # Get the exposure.
658 exposure, _ = dataset.realize(noise=10.0, schema=schema, randomSeed=1746)
660 # Create and set the PSF for the exposure.
661 psf = PyGaussianPsf(35, 35, width, varyBBox=varyBBox, wrongBBox=wrongBBox)
662 exposure.getMaskedImage().set(1.0, 0, 1.0)
663 exposure.setPsf(psf)
665 # Conduct the measurement directly by GalSim.
666 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
667 plugin, table = makePluginAndCat(alg, algorithmName, centroid="centroid", metadata=True)
668 record = table.makeRecord()
669 record.set("centroid_x", center.x)
670 record.set("centroid_y", center.y)
671 record.setFootprint(afwDetection.Footprint(afwGeom.SpanSet(exposure.getBBox(afwImage.PARENT))))
672 shapeDirect = self.computeDirectShapeFromGalSim(record, exposure, plugin.config)
674 # Run the shapeHSM measurement task and update the record.
675 plugin.measure(record, exposure)
677 if algName in ("KSB"):
678 g1Direct, g2Direct = shapeDirect.corrected_g1, shapeDirect.corrected_g2
679 sigmaDirect = shapeDirect.corrected_shape_err
680 g1Hsm, g2Hsm = record[algorithmName + "_g1"], record[algorithmName + "_g2"]
681 sigmaHsm = record[algorithmName + "_sigma"]
682 # Check that the answers are "identical" between the two methods.
683 self.assertEqual(g1Direct, g1Hsm)
684 self.assertEqual(g2Direct, g2Hsm)
685 self.assertEqual(sigmaDirect, sigmaHsm)
686 else:
687 e1Direct, e2Direct = shapeDirect.corrected_e1, shapeDirect.corrected_e2
688 sigmaDirect = shapeDirect.corrected_shape_err
689 e1Hsm, e2Hsm = record[algorithmName + "_e1"], record[algorithmName + "_e2"]
690 # The factor of 0.5 is because shapeHSM returns
691 # `2 * corrected_shape_err` as sigma for e-type distortions.
692 sigmaHsm = 0.5 * record[algorithmName + "_sigma"]
693 # Check that the answers are "identical" between the two methods.
694 self.assertEqual(e1Direct, e1Hsm)
695 self.assertEqual(e2Direct, e2Hsm)
696 self.assertEqual(sigmaDirect, sigmaHsm)
698 resolutionDirect = shapeDirect.resolution_factor
699 flagsDirect = shapeDirect.correction_status
700 resolutionHSM = record[algorithmName + "_resolution"]
701 flagsHSM = record[algorithmName + "_flag"]
703 # Check that the resolution factor and the correction status are
704 # exactly the same as when using GalSim directly.
705 self.assertEqual(resolutionDirect, resolutionHSM)
706 self.assertEqual(flagsDirect, flagsHSM)
708 def testValidate(self):
709 for algName in correction_methods:
710 with self.assertRaises(pexConfig.FieldValidationError):
711 algorithmName = "ext_shapeHSM_HsmShape" + algName[0:1].upper() + algName[1:].lower()
712 msConfig = base.SingleFrameMeasurementConfig()
713 msConfig.plugins.names |= [algorithmName]
714 control = msConfig.plugins[algorithmName]
715 control.shearType = "WRONG"
716 control.validate()
719class PyGaussianPsf(afwDetection.Psf):
720 # Like afwDetection.GaussianPsf, but handles computeImage exactly instead of
721 # via interpolation. This is a subminimal implementation. It works for the
722 # tests here but isn't fully functional as a Psf class.
724 def __init__(self, width, height, sigma, varyBBox=False, wrongBBox=False):
725 afwDetection.Psf.__init__(self, isFixed=not varyBBox)
726 self.dimensions = geom.Extent2I(width, height)
727 self.sigma = sigma
728 self.varyBBox = varyBBox # To address DM-29863
729 self.wrongBBox = wrongBBox # To address DM-30426
731 def _doComputeKernelImage(self, position=None, color=None):
732 bbox = self.computeBBox(position, color)
733 img = afwImage.Image(bbox, dtype=np.float64)
734 x, y = np.ogrid[bbox.minY : bbox.maxY + 1, bbox.minX : bbox.maxX + 1]
735 rsqr = x**2 + y**2
736 img.array[:] = np.exp(-0.5 * rsqr / self.sigma**2)
737 img.array /= np.sum(img.array)
738 return img
740 def _doComputeImage(self, position=None, color=None):
741 bbox = self.computeBBox(position, color)
742 if self.wrongBBox:
743 # For DM-30426:
744 # Purposely make computeImage.getBBox() and computeBBox()
745 # inconsistent. Old shapeHSM code attempted to infer the former
746 # from the latter, but was unreliable. New code infers the former
747 # directly, so this inconsistency no longer breaks things.
748 bbox.shift(geom.Extent2I(1, 2))
749 img = afwImage.Image(bbox, dtype=np.float64)
750 y, x = np.ogrid[float(bbox.minY) : bbox.maxY + 1, bbox.minX : bbox.maxX + 1]
751 x -= position.x - np.floor(position.x + 0.5)
752 y -= position.y - np.floor(position.y + 0.5)
753 rsqr = x**2 + y**2
754 img.array[:] = np.exp(-0.5 * rsqr / self.sigma**2)
755 img.array /= np.sum(img.array)
756 img.setXY0(
757 geom.Point2I(img.getX0() + np.floor(position.x + 0.5), img.getY0() + np.floor(position.y + 0.5))
758 )
759 return img
761 def _doComputeBBox(self, position=None, color=None):
762 # Variable size bbox for addressing DM-29863
763 dims = self.dimensions
764 if self.varyBBox:
765 if position.x > 20.0:
766 dims = dims + geom.Extent2I(2, 2)
767 return geom.Box2I(geom.Point2I(-dims / 2), dims)
769 def _doComputeShape(self, position=None, color=None):
770 return afwGeom.ellipses.Quadrupole(self.sigma**2, self.sigma**2, 0.0)
773class PsfMomentsTestCase(unittest.TestCase):
774 """A test case for PSF moments measurement"""
776 @staticmethod
777 def computeDirectPsfMomentsFromGalSim(psf, center, useSourceCentroidOffset=False):
778 """Directly from GalSim."""
779 psfBBox = psf.computeImageBBox(center)
780 psfSigma = psf.computeShape(center).getTraceRadius()
781 if useSourceCentroidOffset:
782 psfImage = psf.computeImage(center)
783 centroid = center
784 else:
785 psfImage = psf.computeKernelImage(center)
786 psfImage.setXY0(psfBBox.getMin())
787 centroid = geom.Point2D(psfBBox.getMin() + psfBBox.getDimensions() // 2)
788 bbox = psfImage.getBBox(afwImage.PARENT)
789 bounds = galsim.BoundsI(bbox.getMinX(), bbox.getMaxX(), bbox.getMinY(), bbox.getMaxY())
790 image = galsim.Image(psfImage.array, bounds=bounds)
791 guessCentroid = galsim.PositionD(centroid.x, centroid.y)
792 shape = galsim.hsm.FindAdaptiveMom(
793 image,
794 weight=None,
795 badpix=None,
796 guess_sig=psfSigma,
797 precision=1e-6,
798 guess_centroid=guessCentroid,
799 strict=True,
800 round_moments=False,
801 hsmparams=None,
802 )
803 ellipse = lsst.afw.geom.ellipses.SeparableDistortionDeterminantRadius(
804 e1=shape.observed_shape.e1,
805 e2=shape.observed_shape.e2,
806 radius=shape.moments_sigma,
807 normalize=True, # Fail if |e|>1.
808 )
809 quad = lsst.afw.geom.ellipses.Quadrupole(ellipse)
810 ixx = quad.getIxx()
811 iyy = quad.getIyy()
812 ixy = quad.getIxy()
813 return ixx, iyy, ixy
815 @lsst.utils.tests.methodParameters(
816 # Make Cartesian product of settings to feed to methodParameters
817 **dict(
818 list(
819 zip(
820 (
821 kwargs := dict(
822 # Increasing the width beyond 4.5 leads to noticeable
823 # truncation of the PSF, i.e. a PSF that is too large for the
824 # box. While this truncated state leads to incorrect
825 # measurements, it is necessary for testing purposes to
826 # evaluate the behavior under these extreme conditions.
827 width=(2.0, 3.0, 4.0, 10.0, 40.0, 100.0),
828 useSourceCentroidOffset=(True, False),
829 varyBBox=(True, False),
830 wrongBBox=(True, False),
831 center=(
832 (23.0, 34.0), # various offsets that might cause trouble
833 (23.5, 34.0),
834 (23.5, 34.5),
835 (23.15, 34.25),
836 (22.81, 34.01),
837 (22.81, 33.99),
838 (1.2, 1.3), # psfImage extends outside exposure; that's okay
839 (-100.0, -100.0),
840 (-100.5, -100.0),
841 (-100.5, -100.5),
842 ),
843 )
844 ).keys(),
845 zip(*itertools.product(*kwargs.values())),
846 )
847 )
848 )
849 )
850 def testHsmPsfMoments(self, width, useSourceCentroidOffset, varyBBox, wrongBBox, center):
851 psf = PyGaussianPsf(35, 35, width, varyBBox=varyBBox, wrongBBox=wrongBBox)
852 exposure = afwImage.ExposureF(45, 56)
853 exposure.getMaskedImage().set(1.0, 0, 1.0)
854 exposure.setPsf(psf)
856 # perform the moment measurement
857 algorithmName = "ext_shapeHSM_HsmPsfMoments"
858 msConfig = base.SingleFrameMeasurementConfig()
859 msConfig.algorithms.names = [algorithmName]
860 control = msConfig.plugins[algorithmName]
861 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
862 self.assertFalse(control.useSourceCentroidOffset)
863 control.useSourceCentroidOffset = useSourceCentroidOffset
864 plugin, cat = makePluginAndCat(
865 alg,
866 algorithmName,
867 centroid="centroid",
868 control=control,
869 metadata=True,
870 )
871 source = cat.addNew()
872 source.set("centroid_x", center[0])
873 source.set("centroid_y", center[1])
874 offset = geom.Point2I(*center)
875 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
876 source.setFootprint(afwDetection.Footprint(tmpSpans))
877 plugin.measure(source, exposure)
878 x = source.get("ext_shapeHSM_HsmPsfMoments_x")
879 y = source.get("ext_shapeHSM_HsmPsfMoments_y")
880 xx = source.get("ext_shapeHSM_HsmPsfMoments_xx")
881 yy = source.get("ext_shapeHSM_HsmPsfMoments_yy")
882 xy = source.get("ext_shapeHSM_HsmPsfMoments_xy")
884 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag"))
885 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag_no_pixels"))
886 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag_not_contained"))
887 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag_parent_source"))
889 if width < 4.5:
890 # i.e., as long as the PSF is not truncated for our 35x35 box.
891 self.assertAlmostEqual(x, 0.0, 3)
892 self.assertAlmostEqual(y, 0.0, 3)
893 expected = afwEll.Quadrupole(afwEll.Axes(width, width, 0.0))
894 self.assertAlmostEqual(xx, expected.getIxx(), SHAPE_DECIMALS)
895 self.assertAlmostEqual(xy, expected.getIxy(), SHAPE_DECIMALS)
896 self.assertAlmostEqual(yy, expected.getIyy(), SHAPE_DECIMALS)
898 # Test schema documentation
899 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy]"):
900 self.assertEqual(
901 cat.schema[fieldName].asField().getDoc(), "Centroid of the PSF via the HSM shape algorithm"
902 )
903 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy][xy]*"):
904 self.assertEqual(
905 cat.schema[fieldName].asField().getDoc(),
906 "Adaptive moments of the PSF via the HSM shape algorithm",
907 )
909 # Test that the moments are identical to those obtained directly by
910 # GalSim. For `width` > 4.5 where the truncation becomes significant,
911 # the answer might not be 'correct' but should remain 'consistent'.
912 xxDirect, yyDirect, xyDirect = self.computeDirectPsfMomentsFromGalSim(
913 psf,
914 geom.Point2D(*center),
915 useSourceCentroidOffset=useSourceCentroidOffset,
916 )
917 self.assertEqual(xx, xxDirect)
918 self.assertEqual(yy, yyDirect)
919 self.assertEqual(xy, xyDirect)
921 @lsst.utils.tests.methodParameters(
922 # Make Cartesian product of settings to feed to methodParameters
923 **dict(
924 list(
925 zip(
926 (
927 kwargs := dict(
928 width=(2.0, 3.0, 4.0),
929 useSourceCentroidOffset=(True, False),
930 varyBBox=(True, False),
931 wrongBBox=(True, False),
932 center=(
933 (23.0, 34.0), # various offsets that might cause trouble
934 (23.5, 34.0),
935 (23.5, 34.5),
936 (23.15, 34.25),
937 (22.81, 34.01),
938 (22.81, 33.99),
939 ),
940 )
941 ).keys(),
942 zip(*itertools.product(*kwargs.values())),
943 )
944 )
945 )
946 )
947 def testHsmPsfMomentsDebiased(self, width, useSourceCentroidOffset, varyBBox, wrongBBox, center):
948 # As a note, it's really hard to actually unit test whether we've
949 # succesfully "debiased" these measurements. That would require a
950 # many-object comparison of moments with and without noise. So we just
951 # test similar to the biased moments above.
952 var = 1.2
953 # As we reduce the flux, our deviation from the expected value
954 # increases, so decrease tolerance.
955 for flux, decimals in [
956 (1e6, 3),
957 (1e4, 1),
958 (1e3, 0),
959 ]:
960 psf = PyGaussianPsf(35, 35, width, varyBBox=varyBBox, wrongBBox=wrongBBox)
961 exposure = afwImage.ExposureF(45, 56)
962 exposure.getMaskedImage().set(1.0, 0, var)
963 exposure.setPsf(psf)
965 algorithmName = "ext_shapeHSM_HsmPsfMomentsDebiased"
966 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
968 # perform the shape measurement
969 control = lsst.meas.extensions.shapeHSM.HsmPsfMomentsDebiasedConfig()
970 self.assertTrue(control.useSourceCentroidOffset)
971 self.assertEqual(control.noiseSource, "variance")
972 control.useSourceCentroidOffset = useSourceCentroidOffset
973 plugin, cat = makePluginAndCat(
974 alg,
975 algorithmName,
976 centroid="centroid",
977 psfflux="base_PsfFlux",
978 control=control,
979 metadata=True,
980 )
981 source = cat.addNew()
982 source.set("centroid_x", center[0])
983 source.set("centroid_y", center[1])
984 offset = geom.Point2I(*center)
985 source.set("base_PsfFlux_instFlux", flux)
986 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
987 source.setFootprint(afwDetection.Footprint(tmpSpans))
989 plugin.measure(source, exposure)
990 x = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_x")
991 y = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_y")
992 xx = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xx")
993 yy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_yy")
994 xy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xy")
995 for flag in [
996 "ext_shapeHSM_HsmPsfMomentsDebiased_flag",
997 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_no_pixels",
998 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_not_contained",
999 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_parent_source",
1000 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_edge",
1001 ]:
1002 self.assertFalse(source.get(flag))
1004 expected = afwEll.Quadrupole(afwEll.Axes(width, width, 0.0))
1006 self.assertAlmostEqual(x, 0.0, decimals)
1007 self.assertAlmostEqual(y, 0.0, decimals)
1009 T = expected.getIxx() + expected.getIyy()
1010 self.assertAlmostEqual((xx - expected.getIxx()) / T, 0.0, decimals)
1011 self.assertAlmostEqual((xy - expected.getIxy()) / T, 0.0, decimals)
1012 self.assertAlmostEqual((yy - expected.getIyy()) / T, 0.0, decimals)
1014 # Repeat using noiseSource='meta'. Should get nearly the same
1015 # results if BGMEAN is set to `var` above.
1016 exposure2 = afwImage.ExposureF(45, 56)
1017 # set the variance plane to something else to ensure we're
1018 # ignoring it
1019 exposure2.getMaskedImage().set(1.0, 0, 2 * var + 1.1)
1020 exposure2.setPsf(psf)
1021 exposure2.getMetadata().set("BGMEAN", var)
1023 control2 = shapeHSM.HsmPsfMomentsDebiasedConfig()
1024 control2.noiseSource = "meta"
1025 control2.useSourceCentroidOffset = useSourceCentroidOffset
1026 plugin2, cat2 = makePluginAndCat(
1027 alg,
1028 algorithmName,
1029 centroid="centroid",
1030 psfflux="base_PsfFlux",
1031 control=control2,
1032 metadata=True,
1033 )
1034 source2 = cat2.addNew()
1035 source2.set("centroid_x", center[0])
1036 source2.set("centroid_y", center[1])
1037 offset2 = geom.Point2I(*center)
1038 source2.set("base_PsfFlux_instFlux", flux)
1039 tmpSpans2 = afwGeom.SpanSet.fromShape(int(width), offset=offset2)
1040 source2.setFootprint(afwDetection.Footprint(tmpSpans2))
1042 plugin2.measure(source2, exposure2)
1043 x2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_x")
1044 y2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_y")
1045 xx2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_xx")
1046 yy2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_yy")
1047 xy2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_xy")
1048 for flag in [
1049 "ext_shapeHSM_HsmPsfMomentsDebiased_flag",
1050 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_no_pixels",
1051 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_not_contained",
1052 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_parent_source",
1053 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_edge",
1054 ]:
1055 self.assertFalse(source.get(flag))
1057 # Would be identically equal, but variance input via "BGMEAN" is
1058 # consumed in c++ as a double, where variance from the variance
1059 # plane is a c++ float.
1060 self.assertAlmostEqual(x, x2, 8)
1061 self.assertAlmostEqual(y, y2, 8)
1062 self.assertAlmostEqual(xx, xx2, 5)
1063 self.assertAlmostEqual(xy, xy2, 5)
1064 self.assertAlmostEqual(yy, yy2, 5)
1066 # Test schema documentation
1067 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy]"):
1068 self.assertEqual(
1069 cat.schema[fieldName].asField().getDoc(),
1070 "Debiased centroid of the PSF via the HSM shape algorithm",
1071 )
1072 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy][xy]*"):
1073 self.assertEqual(
1074 cat.schema[fieldName].asField().getDoc(),
1075 "Debiased adaptive moments of the PSF via the HSM shape algorithm",
1076 )
1078 testHsmPsfMomentsDebiasedEdgeArgs = dict(
1079 width=(2.0, 3.0, 4.0), useSourceCentroidOffset=(True, False), center=((1.2, 1.3), (33.2, 50.1))
1080 )
1082 @lsst.utils.tests.methodParameters(
1083 # Make Cartesian product of settings to feed to methodParameters
1084 **dict(
1085 list(
1086 zip(
1087 (
1088 kwargs := dict(
1089 width=(2.0, 3.0, 4.0),
1090 useSourceCentroidOffset=(True, False),
1091 center=[(1.2, 1.3), (33.2, 50.1)],
1092 )
1093 ).keys(),
1094 zip(*itertools.product(*kwargs.values())),
1095 )
1096 )
1097 )
1098 )
1099 def testHsmPsfMomentsDebiasedEdge(self, width, useSourceCentroidOffset, center):
1100 # As we reduce the flux, our deviation from the expected value
1101 # increases, so decrease tolerance.
1102 var = 1.2
1103 for flux, decimals in [
1104 (1e6, 3),
1105 (1e4, 2),
1106 (1e3, 1),
1107 ]:
1108 psf = PyGaussianPsf(35, 35, width)
1109 exposure = afwImage.ExposureF(45, 56)
1110 exposure.getMaskedImage().set(1.0, 0, 2 * var + 1.1)
1111 exposure.setPsf(psf)
1113 algorithmName = "ext_shapeHSM_HsmPsfMomentsDebiased"
1114 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
1116 # perform the shape measurement
1117 control = shapeHSM.HsmPsfMomentsDebiasedConfig()
1118 control.useSourceCentroidOffset = useSourceCentroidOffset
1119 self.assertEqual(control.noiseSource, "variance")
1120 plugin, cat = makePluginAndCat(
1121 alg,
1122 algorithmName,
1123 centroid="centroid",
1124 psfflux="base_PsfFlux",
1125 control=control,
1126 metadata=True,
1127 )
1128 source = cat.addNew()
1129 source.set("centroid_x", center[0])
1130 source.set("centroid_y", center[1])
1131 offset = geom.Point2I(*center)
1132 source.set("base_PsfFlux_instFlux", flux)
1133 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
1134 source.setFootprint(afwDetection.Footprint(tmpSpans))
1136 # Edge fails when setting noise from var plane
1137 with self.assertRaises(base.MeasurementError):
1138 plugin.measure(source, exposure)
1140 # Succeeds when noise is from meta
1141 exposure.getMetadata().set("BGMEAN", var)
1142 control.noiseSource = "meta"
1143 plugin, cat = makePluginAndCat(
1144 alg,
1145 algorithmName,
1146 centroid="centroid",
1147 psfflux="base_PsfFlux",
1148 control=control,
1149 metadata=True,
1150 )
1151 source = cat.addNew()
1152 source.set("centroid_x", center[0])
1153 source.set("centroid_y", center[1])
1154 offset = geom.Point2I(*center)
1155 source.set("base_PsfFlux_instFlux", flux)
1156 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
1157 source.setFootprint(afwDetection.Footprint(tmpSpans))
1158 plugin.measure(source, exposure)
1160 x = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_x")
1161 y = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_y")
1162 xx = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xx")
1163 yy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_yy")
1164 xy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xy")
1165 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag"))
1166 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_no_pixels"))
1167 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_not_contained"))
1168 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_parent_source"))
1169 # but _does_ set EDGE flag in this case
1170 self.assertTrue(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_edge"))
1172 expected = afwEll.Quadrupole(afwEll.Axes(width, width, 0.0))
1174 self.assertAlmostEqual(x, 0.0, decimals)
1175 self.assertAlmostEqual(y, 0.0, decimals)
1177 T = expected.getIxx() + expected.getIyy()
1178 self.assertAlmostEqual((xx - expected.getIxx()) / T, 0.0, decimals)
1179 self.assertAlmostEqual((xy - expected.getIxy()) / T, 0.0, decimals)
1180 self.assertAlmostEqual((yy - expected.getIyy()) / T, 0.0, decimals)
1182 # But fails hard if meta doesn't contain BGMEAN
1183 exposure.getMetadata().remove("BGMEAN")
1184 plugin, cat = makePluginAndCat(
1185 alg,
1186 algorithmName,
1187 centroid="centroid",
1188 psfflux="base_PsfFlux",
1189 control=control,
1190 metadata=True,
1191 )
1192 source = cat.addNew()
1193 source.set("centroid_x", center[0])
1194 source.set("centroid_y", center[1])
1195 offset = geom.Point2I(*center)
1196 source.set("base_PsfFlux_instFlux", flux)
1197 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
1198 source.setFootprint(afwDetection.Footprint(tmpSpans))
1199 with self.assertRaises(base.FatalAlgorithmError):
1200 plugin.measure(source, exposure)
1202 def testHsmPsfMomentsDebiasedBadNoiseSource(self):
1203 control = shapeHSM.HsmPsfMomentsDebiasedConfig()
1204 with self.assertRaises(pexConfig.FieldValidationError):
1205 control.noiseSource = "ACM"
1208class TestMemory(lsst.utils.tests.MemoryTestCase):
1209 pass
1212def setup_module(module):
1213 lsst.utils.tests.init()
1216if __name__ == "__main__": 1216 ↛ 1217line 1216 didn't jump to line 1217, because the condition on line 1216 was never true
1217 lsst.utils.tests.init()
1218 unittest.main()