Coverage for tests/test_hsm.py: 14%
506 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-14 12:23 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-14 12: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 lsst.afw.detection as afwDetection
27import lsst.afw.geom as afwGeom
28import lsst.afw.geom.ellipses as afwEll
29import lsst.afw.image as afwImage
30import lsst.afw.math as afwMath
31import lsst.afw.table as afwTable
32import lsst.geom as geom
33import lsst.meas.algorithms as algorithms
34import lsst.meas.base as base
35import lsst.meas.base.tests
36import lsst.meas.extensions.shapeHSM as shapeHSM
37import lsst.utils.tests
38import numpy as np
39from lsst.daf.base import PropertySet
40import lsst.pex.config as pexConfig
41import galsim
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 [0.467603106752, 0.381211727, 0.398856937, 0.401755571],
56 [0.28618443944, 0.199222784, 0.233883543, 0.234257525],
57 [0.271533794146, 0.158049396, 0.183517068, 0.184893412],
58 [-0.293754156071, -0.457024541, 0.123946584, -0.609233462],
59 [0.557720893779, 0.374143023, 0.714147448, 0.435404409]])
60e2_expected = np.array([
61 [-0.867225166489, -0.734855778, -0.777027588, -0.774684891],
62 [-0.469354341577, -0.395520479, -0.502540961, -0.464466257],
63 [-0.519775291311, -0.471589061, -0.574750641, -0.529664935],
64 [0.345688365839, -0.342047099, 0.120603755, -0.44609129428863525],
65 [0.525728304099, 0.370691830, 0.702724807, 0.433999442]])
66resolution_expected = np.array([
67 [0.796144249, 0.835624917, 0.835624917, 0.827796187],
68 [0.685023735, 0.699602704, 0.699602704, 0.659457638],
69 [0.634736458, 0.651040481, 0.651040481, 0.614663396],
70 [0.477027015, 0.477210752, 0.477210752, 0.423157447],
71 [0.595205998, 0.611824797, 0.611824797, 0.563582092]])
72sigma_e_expected = np.array([
73 [0.016924826, 0.014637648, 0.014637648, 0.014465546],
74 [0.075769504, 0.073602324, 0.073602324, 0.064414520],
75 [0.110253112, 0.106222900, 0.106222900, 0.099357106],
76 [0.185276702, 0.184300955, 0.184300955, 0.173478300],
77 [0.073020065, 0.070270966, 0.070270966, 0.061856263]])
78# End of GalSim's values
80# These values calculated using GalSim's HSM as part of GalSim
81galsim_e1 = np.array([
82 [0.399292618036, 0.381213068962, 0.398856908083, 0.401749581099],
83 [0.155929282308, 0.199228107929, 0.233882278204, 0.234371587634],
84 [0.150018423796, 0.158052951097, 0.183515056968, 0.184561833739],
85 [-2.6984937191, -0.457033962011, 0.123932465911, -0.60886412859],
86 [0.33959621191, 0.374140143394, 0.713756918907, 0.43560180068],
87])
88galsim_e2 = np.array([
89 [-0.74053555727, -0.734855830669, -0.777024209499, -0.774700462818],
90 [-0.25573053956, -0.395517915487, -0.50251352787, -0.464388132095],
91 [-0.287168383598, -0.471584022045, -0.574719130993, -0.5296921134],
92 [3.1754450798, -0.342054128647, 0.120592080057, -0.446093201637],
93 [0.320115834475, 0.370669454336, 0.702303349972, 0.433968126774],
94])
95galsim_resolution = np.array([
96 [0.79614430666, 0.835625052452, 0.835625052452, 0.827822327614],
97 [0.685023903847, 0.699601829052, 0.699601829052, 0.659438848495],
98 [0.634736537933, 0.651039719582, 0.651039719582, 0.614759743214],
99 [0.477026551962, 0.47721144557, 0.47721144557, 0.423227936029],
100 [0.595205545425, 0.611821532249, 0.611821532249, 0.563564240932],
101])
102galsim_err = np.array([
103 [0.0169247947633, 0.0146376201883, 0.0146376201883, 0.0144661813974],
104 [0.0757696777582, 0.0736026018858, 0.0736026018858, 0.0644160583615],
105 [0.110252402723, 0.106222368777, 0.106222368777, 0.0993555411696],
106 [0.185278102756, 0.184301897883, 0.184301897883, 0.17346136272],
107 [0.0730196461082, 0.0702708885074, 0.0702708885074, 0.0618583671749],
108])
110moments_expected = np.array([ # sigma, e1, e2
111 [2.24490427971, 0.336240686301, -0.627372910656],
112 [1.9031778574, 0.150566105384, -0.245272792302],
113 [1.77790760994, 0.112286123389, -0.286203939641],
114 [1.45464873314, -0.155597168978, -0.102008266223],
115 [1.63144648075, 0.22886961923, 0.228813588897],
116])
117centroid_expected = np.array([ # x, y
118 [36.218247328, 20.5678722157],
119 [20.325744838, 25.4176650386],
120 [9.54257706283, 12.6134786199],
121 [20.6407850048, 39.5864802706],
122 [58.5008586442, 28.2850942049],
123])
125round_moments_expected = np.array([ # sigma, e1, e2, flux, x, y
126 [2.40270376205, 0.197810277343, -0.372329413891, 3740.22436523, 36.4032272633, 20.4847916447],
127 [1.89714717865, 0.046496052295, -0.0987404286861, 776.709594727, 20.2893584046, 25.4230368047],
128 [1.77995181084, 0.0416346564889, -0.143147706985, 534.59197998, 9.51994111869, 12.6250775205],
129 [1.46549296379, -0.0831127092242, -0.0628845766187, 348.294403076, 20.6242279632, 39.5941625731],
130 [1.64031589031, 0.0867517963052, 0.0940798297524, 793.374450684, 58.4728765002, 28.2686937854],
131])
134def makePluginAndCat(alg, name, control=None, metadata=False, centroid=None, psfflux=None, addFlux=False):
135 if control is None:
136 control = alg.ConfigClass()
137 if addFlux:
138 control.addFlux = True
139 schema = afwTable.SourceTable.makeMinimalSchema()
140 if centroid:
141 lsst.afw.table.Point2DKey.addFields(schema, centroid, "centroid", "pixel")
142 schema.getAliasMap().set("slot_Centroid", centroid)
143 if psfflux:
144 base.PsfFluxAlgorithm(base.PsfFluxControl(), psfflux, schema)
145 schema.getAliasMap().set("slot_PsfFlux", psfflux)
146 if metadata:
147 plugin = alg(control, name, schema, PropertySet())
148 else:
149 plugin = alg(control, name, schema)
150 cat = afwTable.SourceCatalog(schema)
151 if centroid:
152 cat.defineCentroid(centroid)
153 return plugin, cat
156class MomentsTestCase(unittest.TestCase):
157 """A test case for shape measurement"""
159 def setUp(self):
160 # load the known values
161 self.dataDir = os.path.join(os.getenv("MEAS_EXTENSIONS_SHAPEHSM_DIR"), "tests", "data")
162 self.bkgd = 1000.0 # standard for atlas image
163 self.offset = geom.Extent2I(1234, 1234)
164 self.xy0 = geom.Point2I(5678, 9876)
166 def tearDown(self):
167 del self.offset
168 del self.xy0
170 def runMeasurement(self, algorithmName, imageid, x, y, v, addFlux=False, maskAll=False):
171 """Run the measurement algorithm on an image"""
172 # load the test image
173 imgFile = os.path.join(self.dataDir, "image.%d.fits" % imageid)
174 img = afwImage.ImageF(imgFile)
175 img -= self.bkgd
176 nx, ny = img.getWidth(), img.getHeight()
177 msk = afwImage.Mask(geom.Extent2I(nx, ny), 0x0)
178 var = afwImage.ImageF(geom.Extent2I(nx, ny), v)
179 mimg = afwImage.MaskedImageF(img, msk, var)
180 msk.getArray()[:] = np.where(np.fabs(img.getArray()) < 1.0e-8, msk.getPlaneBitMask("BAD"), 0)
181 if maskAll:
182 msk.array[:] |= msk.getPlaneBitMask("BAD")
184 # Put it in a bigger image, in case it matters
185 big = afwImage.MaskedImageF(self.offset + mimg.getDimensions())
186 big.getImage().set(0)
187 big.getMask().set(0)
188 big.getVariance().set(v)
189 subBig = afwImage.MaskedImageF(big, geom.Box2I(big.getXY0() + self.offset, mimg.getDimensions()))
190 subBig.assign(mimg)
191 mimg = big
192 mimg.setXY0(self.xy0)
194 exposure = afwImage.makeExposure(mimg)
195 cdMatrix = np.array([1.0 / (2.53 * 3600.0), 0.0, 0.0, 1.0 / (2.53 * 3600.0)])
196 cdMatrix.shape = (2, 2)
197 exposure.setWcs(
198 afwGeom.makeSkyWcs(
199 crpix=geom.Point2D(1.0, 1.0), crval=geom.SpherePoint(0, 0, geom.degrees), cdMatrix=cdMatrix
200 )
201 )
203 # load the corresponding test psf
204 psfFile = os.path.join(self.dataDir, "psf.%d.fits" % imageid)
205 psfImg = afwImage.ImageD(psfFile)
206 psfImg -= self.bkgd
208 kernel = afwMath.FixedKernel(psfImg)
209 kernelPsf = algorithms.KernelPsf(kernel)
210 exposure.setPsf(kernelPsf)
212 # perform the shape measurement
213 msConfig = base.SingleFrameMeasurementConfig()
214 msConfig.plugins.names |= [algorithmName]
215 control = msConfig.plugins[algorithmName]
216 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
217 # NOTE: It is essential to remove the floating point part of the position for the
218 # Algorithm._apply. Otherwise, when the PSF is realised it will have been warped
219 # to account for the sub-pixel offset and we won't get *exactly* this PSF.
220 plugin, table = makePluginAndCat(
221 alg, algorithmName, control, centroid="centroid", metadata=True, addFlux=addFlux
222 )
223 center = geom.Point2D(int(x), int(y)) + geom.Extent2D(self.offset + geom.Extent2I(self.xy0))
224 source = table.makeRecord()
225 source.set("centroid_x", center.getX())
226 source.set("centroid_y", center.getY())
227 source.setFootprint(afwDetection.Footprint(afwGeom.SpanSet(exposure.getBBox(afwImage.PARENT))))
228 plugin.measure(source, exposure)
230 return source
232 def testHsmSourceMoments(self):
233 for i, imageid in enumerate(file_indices):
234 source = self.runMeasurement(
235 "ext_shapeHSM_HsmSourceMoments", imageid, x_centroid[i], y_centroid[i], sky_var[i]
236 )
237 x = source.get("ext_shapeHSM_HsmSourceMoments_x")
238 y = source.get("ext_shapeHSM_HsmSourceMoments_y")
239 xx = source.get("ext_shapeHSM_HsmSourceMoments_xx")
240 yy = source.get("ext_shapeHSM_HsmSourceMoments_yy")
241 xy = source.get("ext_shapeHSM_HsmSourceMoments_xy")
243 # Centroids from GalSim use the FITS lower-left corner of 1,1
244 offset = self.xy0 + self.offset
245 self.assertAlmostEqual(x - offset.getX(), centroid_expected[i][0] - 1, 3)
246 self.assertAlmostEqual(y - offset.getY(), centroid_expected[i][1] - 1, 3)
248 expected = afwEll.Quadrupole(
249 afwEll.SeparableDistortionDeterminantRadius(
250 moments_expected[i][1], moments_expected[i][2], moments_expected[i][0]
251 )
252 )
254 self.assertAlmostEqual(xx, expected.getIxx(), SHAPE_DECIMALS)
255 self.assertAlmostEqual(xy, expected.getIxy(), SHAPE_DECIMALS)
256 self.assertAlmostEqual(yy, expected.getIyy(), SHAPE_DECIMALS)
258 def testHsmSourceMomentsRound(self):
259 for i, imageid in enumerate(file_indices):
260 source = self.runMeasurement(
261 "ext_shapeHSM_HsmSourceMomentsRound",
262 imageid,
263 x_centroid[i],
264 y_centroid[i],
265 sky_var[i],
266 addFlux=True,
267 )
268 x = source.get("ext_shapeHSM_HsmSourceMomentsRound_x")
269 y = source.get("ext_shapeHSM_HsmSourceMomentsRound_y")
270 xx = source.get("ext_shapeHSM_HsmSourceMomentsRound_xx")
271 yy = source.get("ext_shapeHSM_HsmSourceMomentsRound_yy")
272 xy = source.get("ext_shapeHSM_HsmSourceMomentsRound_xy")
273 flux = source.get("ext_shapeHSM_HsmSourceMomentsRound_Flux")
275 # Centroids from GalSim use the FITS lower-left corner of 1,1
276 offset = self.xy0 + self.offset
277 self.assertAlmostEqual(x - offset.getX(), round_moments_expected[i][4] - 1, 3)
278 self.assertAlmostEqual(y - offset.getY(), round_moments_expected[i][5] - 1, 3)
280 expected = afwEll.Quadrupole(
281 afwEll.SeparableDistortionDeterminantRadius(
282 round_moments_expected[i][1], round_moments_expected[i][2], round_moments_expected[i][0]
283 )
284 )
285 self.assertAlmostEqual(xx, expected.getIxx(), SHAPE_DECIMALS)
286 self.assertAlmostEqual(xy, expected.getIxy(), SHAPE_DECIMALS)
287 self.assertAlmostEqual(yy, expected.getIyy(), SHAPE_DECIMALS)
289 self.assertAlmostEqual(flux, round_moments_expected[i][3], SHAPE_DECIMALS)
291 def testHsmSourceMomentsVsSdssShape(self):
292 # Initialize a config and activate the plugins.
293 sfmConfig = base.SingleFrameMeasurementConfig()
294 sfmConfig.plugins.names |= ["ext_shapeHSM_HsmSourceMoments", "base_SdssShape"]
296 # Create a minimal schema (columns).
297 schema = lsst.meas.base.tests.TestDataset.makeMinimalSchema()
299 # Instantiate the task.
300 sfmTask = base.SingleFrameMeasurementTask(config=sfmConfig, schema=schema)
302 # Create a simple, test dataset.
303 bbox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Extent2I(100, 100))
304 dataset = lsst.meas.base.tests.TestDataset(bbox)
306 # First source is a point.
307 dataset.addSource(100000.0, lsst.geom.Point2D(49.5, 49.5))
309 # Second source is a galaxy.
310 dataset.addSource(300000.0, lsst.geom.Point2D(76.3, 79.2), afwGeom.Quadrupole(2.0, 3.0, 0.5))
312 # Third source is also a galaxy.
313 dataset.addSource(250000.0, lsst.geom.Point2D(28.9, 41.35), afwGeom.Quadrupole(1.8, 3.5, 0.4))
315 # Get the exposure and catalog.
316 exposure, catalog = dataset.realize(10.0, sfmTask.schema, randomSeed=0)
318 # Run the measurement task.
319 sfmTask.run(catalog, exposure)
320 cat = catalog.asAstropy()
322 # Get the moments from the catalog.
323 xSdss, ySdss = cat["base_SdssShape_x"], cat["base_SdssShape_y"]
324 xxSdss, xySdss, yySdss = cat["base_SdssShape_xx"], cat["base_SdssShape_xy"], cat["base_SdssShape_yy"]
325 xHsm, yHsm = cat["ext_shapeHSM_HsmSourceMoments_x"], cat["ext_shapeHSM_HsmSourceMoments_y"]
326 xxHsm, xyHsm, yyHsm = (
327 cat["ext_shapeHSM_HsmSourceMoments_xx"],
328 cat["ext_shapeHSM_HsmSourceMoments_xy"],
329 cat["ext_shapeHSM_HsmSourceMoments_yy"],
330 )
332 # Loop over the sources and check that the moments are the same.
333 for i in range(3):
334 self.assertAlmostEqual(xSdss[i], xHsm[i], 2)
335 self.assertAlmostEqual(ySdss[i], yHsm[i], 2)
336 self.assertAlmostEqual(xxSdss[i], xxHsm[i], SHAPE_DECIMALS)
337 self.assertAlmostEqual(xySdss[i], xyHsm[i], SHAPE_DECIMALS)
338 self.assertAlmostEqual(yySdss[i], yyHsm[i], SHAPE_DECIMALS)
340 def testHsmSourceMomentsAllMasked(self):
341 i = 0
342 imageid = file_indices[0]
343 with self.assertRaises(base.MeasurementError):
344 _ = self.runMeasurement(
345 "ext_shapeHSM_HsmSourceMoments",
346 imageid,
347 x_centroid[i],
348 y_centroid[i],
349 sky_var[i],
350 maskAll=True,
351 )
354class ShapeTestCase(unittest.TestCase):
355 """A test case for shape measurement"""
357 def setUp(self):
359 # load the known values
360 self.dataDir = os.path.join(os.getenv('MEAS_EXTENSIONS_SHAPEHSM_DIR'), "tests", "data")
361 self.bkgd = 1000.0 # standard for atlas image
362 self.offset = geom.Extent2I(1234, 1234)
363 self.xy0 = geom.Point2I(5678, 9876)
365 def tearDown(self):
366 del self.offset
367 del self.xy0
369 def runMeasurement(self, algorithmName, imageid, x, y, v):
370 """Run the measurement algorithm on an image"""
371 # load the test image
372 imgFile = os.path.join(self.dataDir, "image.%d.fits" % imageid)
373 img = afwImage.ImageF(imgFile)
374 img -= self.bkgd
375 nx, ny = img.getWidth(), img.getHeight()
376 msk = afwImage.Mask(geom.Extent2I(nx, ny), 0x0)
377 var = afwImage.ImageF(geom.Extent2I(nx, ny), v)
378 mimg = afwImage.MaskedImageF(img, msk, var)
379 msk.getArray()[:] = np.where(np.fabs(img.getArray()) < 1.0e-8, msk.getPlaneBitMask("BAD"), 0)
381 # Put it in a bigger image, in case it matters
382 big = afwImage.MaskedImageF(self.offset + mimg.getDimensions())
383 big.getImage().set(0)
384 big.getMask().set(0)
385 big.getVariance().set(v)
386 subBig = afwImage.MaskedImageF(big, geom.Box2I(big.getXY0() + self.offset, mimg.getDimensions()))
387 subBig.assign(mimg)
388 mimg = big
389 mimg.setXY0(self.xy0)
391 exposure = afwImage.makeExposure(mimg)
392 cdMatrix = np.array([1.0/(2.53*3600.0), 0.0, 0.0, 1.0/(2.53*3600.0)])
393 cdMatrix.shape = (2, 2)
394 exposure.setWcs(afwGeom.makeSkyWcs(crpix=geom.Point2D(1.0, 1.0),
395 crval=geom.SpherePoint(0, 0, geom.degrees),
396 cdMatrix=cdMatrix))
398 # load the corresponding test psf
399 psfFile = os.path.join(self.dataDir, "psf.%d.fits" % imageid)
400 psfImg = afwImage.ImageD(psfFile)
401 psfImg -= self.bkgd
403 kernel = afwMath.FixedKernel(psfImg)
404 kernelPsf = algorithms.KernelPsf(kernel)
405 exposure.setPsf(kernelPsf)
407 # perform the shape measurement
408 msConfig = base.SingleFrameMeasurementConfig()
409 msConfig.plugins.names |= [algorithmName]
410 control = msConfig.plugins[algorithmName]
411 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
412 # NOTE: It is essential to remove the floating point part of the position for the
413 # Algorithm._apply. Otherwise, when the PSF is realised it will have been warped
414 # to account for the sub-pixel offset and we won't get *exactly* this PSF.
415 plugin, table = makePluginAndCat(alg, algorithmName, control, centroid="centroid", metadata=True)
416 center = geom.Point2D(int(x), int(y)) + geom.Extent2D(self.offset + geom.Extent2I(self.xy0))
417 source = table.makeRecord()
418 source.set("centroid_x", center.getX())
419 source.set("centroid_y", center.getY())
420 source.setFootprint(afwDetection.Footprint(afwGeom.SpanSet(exposure.getBBox(afwImage.PARENT))))
421 plugin.measure(source, exposure)
423 # Get the trace radius of the PSF and GalSim images to use in the
424 # EstimateShear call.
425 bbox = source.getFootprint().getBBox()
426 bounds = galsim.bounds.BoundsI(bbox.getMinX(), bbox.getMaxX(), bbox.getMinY(), bbox.getMaxY())
427 image = galsim.Image(exposure.image[bbox].array, bounds=bounds, copy=False)
428 psf = galsim.Image(psfImg.array, copy=False)
430 # Retrieve the measurement "type" that Galsim outputs after estimation.
431 # NOTE: not passing weight, badpix, sky_var, and some guess parameters
432 # as the objective is solely to deduce the `meas_type` for this setup.
433 postEstimationMeasType = galsim.hsm.EstimateShear(
434 gal_image=image,
435 PSF_image=psf,
436 shear_est=control.shearType,
437 guess_centroid=galsim.PositionD(center.getX(), center.getY()),
438 strict=False,
439 ).meas_type
441 return source, alg.measTypeSymbol, postEstimationMeasType
443 def testHsmShape(self):
444 """Test that we can instantiate and play with a measureShape"""
446 nFail = 0
447 msg = ""
449 for (algNum, algName), (i, imageid) in itertools.product(enumerate(correction_methods),
450 enumerate(file_indices)):
451 algorithmName = "ext_shapeHSM_HsmShape" + algName[0:1].upper() + algName[1:].lower()
453 source, preEstimationMeasType, postEstimationMeasType = self.runMeasurement(
454 algorithmName, imageid, x_centroid[i], y_centroid[i], sky_var[i]
455 )
457 # Check consistency with GalSim output
458 self.assertEqual(
459 preEstimationMeasType,
460 postEstimationMeasType,
461 "The plugin setup is incompatible with GalSim output.",
462 )
464 ##########################################
465 # see how we did
466 if algName in ("KSB"):
467 # Need to convert g1,g2 --> e1,e2 because GalSim has done that
468 # for the expected values ("for consistency")
469 g1 = source.get(algorithmName + "_g1")
470 g2 = source.get(algorithmName + "_g2")
471 scale = 2.0/(1.0 + g1**2 + g2**2)
472 e1 = g1*scale
473 e2 = g2*scale
474 sigma = source.get(algorithmName + "_sigma")
475 else:
476 e1 = source.get(algorithmName + "_e1")
477 e2 = source.get(algorithmName + "_e2")
478 sigma = 0.5*source.get(algorithmName + "_sigma")
479 resolution = source.get(algorithmName + "_resolution")
480 flags = source.get(algorithmName + "_flag")
482 tests = [
483 # label known-value measured tolerance
484 ["e1", float(e1_expected[i][algNum]), e1, 0.5*10**-SHAPE_DECIMALS],
485 ["e2", float(e2_expected[i][algNum]), e2, 0.5*10**-SHAPE_DECIMALS],
486 ["resolution", float(resolution_expected[i][algNum]), resolution, 0.5*10**-SIZE_DECIMALS],
488 # sigma won't match exactly because
489 # we're using skyvar=mean(var) instead of measured value ... expected a difference
490 ["sigma", float(sigma_e_expected[i][algNum]), sigma, 0.07],
491 ["shapeStatus", 0, flags, 0],
492 ]
494 for test in tests:
495 label, know, hsm, limit = test
496 err = hsm - know
497 msgTmp = "%-12s %s %5s: %6.6f %6.6f (val-known) = %.3g\n" % (algName, imageid,
498 label, know, hsm, err)
499 if not np.isfinite(err) or abs(err) > limit:
500 msg += msgTmp
501 nFail += 1
503 self.assertAlmostEqual(g1 if algName in ("KSB") else e1, galsim_e1[i][algNum], SHAPE_DECIMALS)
504 self.assertAlmostEqual(g2 if algName in ("KSB") else e2, galsim_e2[i][algNum], SHAPE_DECIMALS)
505 self.assertAlmostEqual(resolution, galsim_resolution[i][algNum], SIZE_DECIMALS)
506 self.assertAlmostEqual(sigma, galsim_err[i][algNum], delta=0.07)
508 self.assertEqual(nFail, 0, "\n"+msg)
510 def testValidate(self):
511 for algName in correction_methods:
512 with self.assertRaises(pexConfig.FieldValidationError):
513 algorithmName = "ext_shapeHSM_HsmShape" + algName[0:1].upper() + algName[1:].lower()
514 msConfig = base.SingleFrameMeasurementConfig()
515 msConfig.plugins.names |= [algorithmName]
516 control = msConfig.plugins[algorithmName]
517 control.shearType = "WRONG"
518 control.validate()
521class PyGaussianPsf(afwDetection.Psf):
522 # Like afwDetection.GaussianPsf, but handles computeImage exactly instead of
523 # via interpolation. This is a subminimal implementation. It works for the
524 # tests here but isn't fully functional as a Psf class.
526 def __init__(self, width, height, sigma, varyBBox=False, wrongBBox=False):
527 afwDetection.Psf.__init__(self, isFixed=not varyBBox)
528 self.dimensions = geom.Extent2I(width, height)
529 self.sigma = sigma
530 self.varyBBox = varyBBox # To address DM-29863
531 self.wrongBBox = wrongBBox # To address DM-30426
533 def _doComputeKernelImage(self, position=None, color=None):
534 bbox = self.computeBBox(position, color)
535 img = afwImage.Image(bbox, dtype=np.float64)
536 x, y = np.ogrid[bbox.minY:bbox.maxY+1, bbox.minX:bbox.maxX+1]
537 rsqr = x**2 + y**2
538 img.array[:] = np.exp(-0.5*rsqr/self.sigma**2)
539 img.array /= np.sum(img.array)
540 return img
542 def _doComputeImage(self, position=None, color=None):
543 bbox = self.computeBBox(position, color)
544 if self.wrongBBox:
545 # For DM-30426:
546 # Purposely make computeImage.getBBox() and computeBBox()
547 # inconsistent. Old shapeHSM code attempted to infer the former
548 # from the latter, but was unreliable. New code infers the former
549 # directly, so this inconsistency no longer breaks things.
550 bbox.shift(geom.Extent2I(1, 1))
551 img = afwImage.Image(bbox, dtype=np.float64)
552 y, x = np.ogrid[float(bbox.minY):bbox.maxY+1, bbox.minX:bbox.maxX+1]
553 x -= (position.x - np.floor(position.x+0.5))
554 y -= (position.y - np.floor(position.y+0.5))
555 rsqr = x**2 + y**2
556 img.array[:] = np.exp(-0.5*rsqr/self.sigma**2)
557 img.array /= np.sum(img.array)
558 img.setXY0(geom.Point2I(
559 img.getX0() + np.floor(position.x+0.5),
560 img.getY0() + np.floor(position.y+0.5)
561 ))
562 return img
564 def _doComputeBBox(self, position=None, color=None):
565 # Variable size bbox for addressing DM-29863
566 dims = self.dimensions
567 if self.varyBBox:
568 if position.x > 20.0:
569 dims = dims + geom.Extent2I(2, 2)
570 return geom.Box2I(geom.Point2I(-dims/2), dims)
572 def _doComputeShape(self, position=None, color=None):
573 return afwGeom.ellipses.Quadrupole(self.sigma**2, self.sigma**2, 0.0)
576class PsfMomentsTestCase(unittest.TestCase):
577 """A test case for PSF moments measurement"""
579 @staticmethod
580 def computeDirectPsfMomentsFromGalSim(
581 psf,
582 center,
583 useSourceCentroidOffset=False
584 ):
585 """Directly from GalSim."""
586 psfBBox = psf.computeImageBBox(center)
587 psfSigma = psf.computeShape(center).getTraceRadius()
588 if useSourceCentroidOffset:
589 psfImage = psf.computeImage(center)
590 centroid = center
591 else:
592 psfImage = psf.computeKernelImage(center)
593 psfImage.setXY0(psfBBox.getMin())
594 centroid = geom.Point2D(psfBBox.getMin() + psfBBox.getDimensions() // 2)
595 bbox = psfImage.getBBox(afwImage.PARENT)
596 bounds = galsim.bounds.BoundsI(bbox.getMinX(), bbox.getMaxX(), bbox.getMinY(), bbox.getMaxY())
597 image = galsim.Image(psfImage.array, bounds=bounds, copy=False)
598 guessCentroid = galsim.PositionD(centroid.x, centroid.y)
599 shape = galsim.hsm.FindAdaptiveMom(
600 image,
601 weight=None,
602 badpix=None,
603 guess_sig=psfSigma,
604 precision=1e-6,
605 guess_centroid=guessCentroid,
606 strict=True,
607 round_moments=False,
608 hsmparams=None,
609 )
610 ellipse = lsst.afw.geom.ellipses.SeparableDistortionDeterminantRadius(
611 e1=shape.observed_shape.e1,
612 e2=shape.observed_shape.e2,
613 radius=shape.moments_sigma,
614 normalize=True, # Fail if |e|>1.
615 )
616 quad = lsst.afw.geom.ellipses.Quadrupole(ellipse)
617 ixx = quad.getIxx()
618 iyy = quad.getIyy()
619 ixy = quad.getIxy()
620 return ixx, iyy, ixy
622 @lsst.utils.tests.methodParameters(
623 # Make Cartesian product of settings to feed to methodParameters
624 **dict(list(zip(
625 (kwargs := dict(
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 width=(2.0, 3.0, 4.0, 10.0, 40.0, 100.0),
632 useSourceCentroidOffset=(True, False),
633 varyBBox=(True, False),
634 wrongBBox=(True, False),
635 center=(
636 (23.0, 34.0), # various offsets that might cause trouble
637 (23.5, 34.0),
638 (23.5, 34.5),
639 (23.15, 34.25),
640 (22.81, 34.01),
641 (22.81, 33.99),
642 (1.2, 1.3), # psfImage extends outside exposure; that's okay
643 (-100.0, -100.0),
644 (-100.5, -100.0),
645 (-100.5, -100.5),
646 )
647 )).keys(),
648 zip(*itertools.product(*kwargs.values()))
649 )))
650 )
651 def testHsmPsfMoments(
652 self, width, useSourceCentroidOffset, varyBBox, wrongBBox, center
653 ):
654 psf = PyGaussianPsf(
655 35, 35, width,
656 varyBBox=varyBBox,
657 wrongBBox=wrongBBox
658 )
659 exposure = afwImage.ExposureF(45, 56)
660 exposure.getMaskedImage().set(1.0, 0, 1.0)
661 exposure.setPsf(psf)
663 # perform the moment measurement
664 algorithmName = "ext_shapeHSM_HsmPsfMoments"
665 msConfig = base.SingleFrameMeasurementConfig()
666 msConfig.algorithms.names = [algorithmName]
667 control = msConfig.plugins[algorithmName]
668 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
669 self.assertFalse(control.useSourceCentroidOffset)
670 control.useSourceCentroidOffset = useSourceCentroidOffset
671 plugin, cat = makePluginAndCat(
672 alg, algorithmName,
673 centroid="centroid",
674 control=control,
675 metadata=True,
676 )
677 source = cat.addNew()
678 source.set("centroid_x", center[0])
679 source.set("centroid_y", center[1])
680 offset = geom.Point2I(*center)
681 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
682 source.setFootprint(afwDetection.Footprint(tmpSpans))
683 plugin.measure(source, exposure)
684 x = source.get("ext_shapeHSM_HsmPsfMoments_x")
685 y = source.get("ext_shapeHSM_HsmPsfMoments_y")
686 xx = source.get("ext_shapeHSM_HsmPsfMoments_xx")
687 yy = source.get("ext_shapeHSM_HsmPsfMoments_yy")
688 xy = source.get("ext_shapeHSM_HsmPsfMoments_xy")
690 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag"))
691 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag_no_pixels"))
692 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag_not_contained"))
693 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag_parent_source"))
695 if width < 4.5:
696 # i.e., as long as the PSF is not truncated for our 35x35 box.
697 self.assertAlmostEqual(x, 0.0, 3)
698 self.assertAlmostEqual(y, 0.0, 3)
699 expected = afwEll.Quadrupole(afwEll.Axes(width, width, 0.0))
700 self.assertAlmostEqual(xx, expected.getIxx(), SHAPE_DECIMALS)
701 self.assertAlmostEqual(xy, expected.getIxy(), SHAPE_DECIMALS)
702 self.assertAlmostEqual(yy, expected.getIyy(), SHAPE_DECIMALS)
704 # Test schema documentation
705 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy]"):
706 self.assertEqual(cat.schema[fieldName].asField().getDoc(),
707 "Centroid of the PSF via the HSM shape algorithm")
708 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy][xy]*"):
709 self.assertEqual(cat.schema[fieldName].asField().getDoc(),
710 "Adaptive moments of the PSF via the HSM shape algorithm")
712 # Test that the moments are identical to those obtained directly by
713 # GalSim. For `width` > 4.5 where the truncation becomes significant,
714 # the answer might not be 'correct' but should remain 'consistent'.
715 xxDirect, yyDirect, xyDirect = self.computeDirectPsfMomentsFromGalSim(
716 psf,
717 geom.Point2D(*center),
718 useSourceCentroidOffset=useSourceCentroidOffset,
719 )
720 self.assertEqual(xx, xxDirect)
721 self.assertEqual(yy, yyDirect)
722 self.assertEqual(xy, xyDirect)
724 @lsst.utils.tests.methodParameters(
725 # Make Cartesian product of settings to feed to methodParameters
726 **dict(list(zip(
727 (kwargs := dict(
728 width=(2.0, 3.0, 4.0),
729 useSourceCentroidOffset=(True, False),
730 varyBBox=(True, False),
731 wrongBBox=(True, False),
732 center=(
733 (23.0, 34.0), # various offsets that might cause trouble
734 (23.5, 34.0),
735 (23.5, 34.5),
736 (23.15, 34.25),
737 (22.81, 34.01),
738 (22.81, 33.99),
739 )
740 )).keys(),
741 zip(*itertools.product(*kwargs.values()))
742 )))
743 )
744 def testHsmPsfMomentsDebiased(
745 self, width, useSourceCentroidOffset, varyBBox, wrongBBox, center
746 ):
747 # As a note, it's really hard to actually unit test whether we've
748 # succesfully "debiased" these measurements. That would require a
749 # many-object comparison of moments with and without noise. So we just
750 # test similar to the biased moments above.
751 var = 1.2
752 # As we reduce the flux, our deviation from the expected value
753 # increases, so decrease tolerance.
754 for flux, decimals in [
755 (1e6, 3),
756 (1e4, 1),
757 (1e3, 0),
758 ]:
759 psf = PyGaussianPsf(
760 35, 35, width,
761 varyBBox=varyBBox,
762 wrongBBox=wrongBBox
763 )
764 exposure = afwImage.ExposureF(45, 56)
765 exposure.getMaskedImage().set(1.0, 0, var)
766 exposure.setPsf(psf)
768 algorithmName = "ext_shapeHSM_HsmPsfMomentsDebiased"
769 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
771 # perform the shape measurement
772 control = lsst.meas.extensions.shapeHSM.HsmPsfMomentsDebiasedConfig()
773 self.assertTrue(control.useSourceCentroidOffset)
774 self.assertEqual(control.noiseSource, "variance")
775 control.useSourceCentroidOffset = useSourceCentroidOffset
776 plugin, cat = makePluginAndCat(
777 alg,
778 algorithmName,
779 centroid="centroid",
780 psfflux="base_PsfFlux",
781 control=control,
782 metadata=True,
783 )
784 source = cat.addNew()
785 source.set("centroid_x", center[0])
786 source.set("centroid_y", center[1])
787 offset = geom.Point2I(*center)
788 source.set("base_PsfFlux_instFlux", flux)
789 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
790 source.setFootprint(afwDetection.Footprint(tmpSpans))
792 plugin.measure(source, exposure)
793 x = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_x")
794 y = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_y")
795 xx = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xx")
796 yy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_yy")
797 xy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xy")
798 for flag in [
799 "ext_shapeHSM_HsmPsfMomentsDebiased_flag",
800 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_no_pixels",
801 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_not_contained",
802 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_parent_source",
803 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_edge"
804 ]:
805 self.assertFalse(source.get(flag))
807 expected = afwEll.Quadrupole(afwEll.Axes(width, width, 0.0))
809 self.assertAlmostEqual(x, 0.0, decimals)
810 self.assertAlmostEqual(y, 0.0, decimals)
812 T = expected.getIxx() + expected.getIyy()
813 self.assertAlmostEqual((xx-expected.getIxx())/T, 0.0, decimals)
814 self.assertAlmostEqual((xy-expected.getIxy())/T, 0.0, decimals)
815 self.assertAlmostEqual((yy-expected.getIyy())/T, 0.0, decimals)
817 # Repeat using noiseSource='meta'. Should get nearly the same
818 # results if BGMEAN is set to `var` above.
819 exposure2 = afwImage.ExposureF(45, 56)
820 # set the variance plane to something else to ensure we're
821 # ignoring it
822 exposure2.getMaskedImage().set(1.0, 0, 2*var+1.1)
823 exposure2.setPsf(psf)
824 exposure2.getMetadata().set("BGMEAN", var)
826 control2 = shapeHSM.HsmPsfMomentsDebiasedConfig()
827 control2.noiseSource = "meta"
828 control2.useSourceCentroidOffset = useSourceCentroidOffset
829 plugin2, cat2 = makePluginAndCat(
830 alg,
831 algorithmName,
832 centroid="centroid",
833 psfflux="base_PsfFlux",
834 control=control2,
835 metadata=True,
836 )
837 source2 = cat2.addNew()
838 source2.set("centroid_x", center[0])
839 source2.set("centroid_y", center[1])
840 offset2 = geom.Point2I(*center)
841 source2.set("base_PsfFlux_instFlux", flux)
842 tmpSpans2 = afwGeom.SpanSet.fromShape(int(width), offset=offset2)
843 source2.setFootprint(afwDetection.Footprint(tmpSpans2))
845 plugin2.measure(source2, exposure2)
846 x2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_x")
847 y2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_y")
848 xx2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_xx")
849 yy2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_yy")
850 xy2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_xy")
851 for flag in [
852 "ext_shapeHSM_HsmPsfMomentsDebiased_flag",
853 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_no_pixels",
854 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_not_contained",
855 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_parent_source",
856 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_edge"
857 ]:
858 self.assertFalse(source.get(flag))
860 # Would be identically equal, but variance input via "BGMEAN" is
861 # consumed in c++ as a double, where variance from the variance
862 # plane is a c++ float.
863 self.assertAlmostEqual(x, x2, 8)
864 self.assertAlmostEqual(y, y2, 8)
865 self.assertAlmostEqual(xx, xx2, 5)
866 self.assertAlmostEqual(xy, xy2, 5)
867 self.assertAlmostEqual(yy, yy2, 5)
869 # Test schema documentation
870 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy]"):
871 self.assertEqual(cat.schema[fieldName].asField().getDoc(),
872 "Debiased centroid of the PSF via the HSM shape algorithm")
873 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy][xy]*"):
874 self.assertEqual(cat.schema[fieldName].asField().getDoc(),
875 "Debiased adaptive moments of the PSF via the HSM shape algorithm")
877 testHsmPsfMomentsDebiasedEdgeArgs = dict(
878 width=(2.0, 3.0, 4.0),
879 useSourceCentroidOffset=(True, False),
880 center=(
881 (1.2, 1.3),
882 (33.2, 50.1)
883 )
884 )
886 @lsst.utils.tests.methodParameters(
887 # Make Cartesian product of settings to feed to methodParameters
888 **dict(list(zip(
889 (kwargs := dict(
890 width=(2.0, 3.0, 4.0),
891 useSourceCentroidOffset=(True, False),
892 center=[
893 (1.2, 1.3),
894 (33.2, 50.1)
895 ]
896 )).keys(),
897 zip(*itertools.product(*kwargs.values()))
898 )))
899 )
900 def testHsmPsfMomentsDebiasedEdge(self, width, useSourceCentroidOffset, center):
901 # As we reduce the flux, our deviation from the expected value
902 # increases, so decrease tolerance.
903 var = 1.2
904 for flux, decimals in [
905 (1e6, 3),
906 (1e4, 2),
907 (1e3, 1),
908 ]:
909 psf = PyGaussianPsf(35, 35, width)
910 exposure = afwImage.ExposureF(45, 56)
911 exposure.getMaskedImage().set(1.0, 0, 2*var+1.1)
912 exposure.setPsf(psf)
914 algorithmName = "ext_shapeHSM_HsmPsfMomentsDebiased"
915 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
917 # perform the shape measurement
918 control = shapeHSM.HsmPsfMomentsDebiasedConfig()
919 control.useSourceCentroidOffset = useSourceCentroidOffset
920 self.assertEqual(control.noiseSource, "variance")
921 plugin, cat = makePluginAndCat(
922 alg,
923 algorithmName,
924 centroid="centroid",
925 psfflux="base_PsfFlux",
926 control=control,
927 metadata=True,
928 )
929 source = cat.addNew()
930 source.set("centroid_x", center[0])
931 source.set("centroid_y", center[1])
932 offset = geom.Point2I(*center)
933 source.set("base_PsfFlux_instFlux", flux)
934 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
935 source.setFootprint(afwDetection.Footprint(tmpSpans))
937 # Edge fails when setting noise from var plane
938 with self.assertRaises(base.MeasurementError):
939 plugin.measure(source, exposure)
941 # Succeeds when noise is from meta
942 exposure.getMetadata().set("BGMEAN", var)
943 control.noiseSource = "meta"
944 plugin, cat = makePluginAndCat(
945 alg,
946 algorithmName,
947 centroid="centroid",
948 psfflux="base_PsfFlux",
949 control=control,
950 metadata=True,
951 )
952 source = cat.addNew()
953 source.set("centroid_x", center[0])
954 source.set("centroid_y", center[1])
955 offset = geom.Point2I(*center)
956 source.set("base_PsfFlux_instFlux", flux)
957 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
958 source.setFootprint(afwDetection.Footprint(tmpSpans))
959 plugin.measure(source, exposure)
961 x = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_x")
962 y = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_y")
963 xx = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xx")
964 yy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_yy")
965 xy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xy")
966 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag"))
967 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_no_pixels"))
968 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_not_contained"))
969 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_parent_source"))
970 # but _does_ set EDGE flag in this case
971 self.assertTrue(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_edge"))
973 expected = afwEll.Quadrupole(afwEll.Axes(width, width, 0.0))
975 self.assertAlmostEqual(x, 0.0, decimals)
976 self.assertAlmostEqual(y, 0.0, decimals)
978 T = expected.getIxx() + expected.getIyy()
979 self.assertAlmostEqual((xx-expected.getIxx())/T, 0.0, decimals)
980 self.assertAlmostEqual((xy-expected.getIxy())/T, 0.0, decimals)
981 self.assertAlmostEqual((yy-expected.getIyy())/T, 0.0, decimals)
983 # But fails hard if meta doesn't contain BGMEAN
984 exposure.getMetadata().remove("BGMEAN")
985 plugin, cat = makePluginAndCat(
986 alg,
987 algorithmName,
988 centroid="centroid",
989 psfflux="base_PsfFlux",
990 control=control,
991 metadata=True,
992 )
993 source = cat.addNew()
994 source.set("centroid_x", center[0])
995 source.set("centroid_y", center[1])
996 offset = geom.Point2I(*center)
997 source.set("base_PsfFlux_instFlux", flux)
998 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
999 source.setFootprint(afwDetection.Footprint(tmpSpans))
1000 with self.assertRaises(base.FatalAlgorithmError):
1001 plugin.measure(source, exposure)
1003 def testHsmPsfMomentsDebiasedBadNoiseSource(self):
1004 control = shapeHSM.HsmPsfMomentsDebiasedConfig()
1005 with self.assertRaises(pexConfig.FieldValidationError):
1006 control.noiseSource = "ACM"
1009class TestMemory(lsst.utils.tests.MemoryTestCase):
1010 pass
1013def setup_module(module):
1014 lsst.utils.tests.init()
1017if __name__ == "__main__": 1017 ↛ 1018line 1017 didn't jump to line 1018, because the condition on line 1017 was never true
1018 lsst.utils.tests.init()
1019 unittest.main()