Coverage for tests/test_hsm.py: 14%
484 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-05 12:20 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-05 12:20 +0000
1# This file is part of meas_extensions_shapeHSM.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22import 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):
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)
182 # Put it in a bigger image, in case it matters
183 big = afwImage.MaskedImageF(self.offset + mimg.getDimensions())
184 big.getImage().set(0)
185 big.getMask().set(0)
186 big.getVariance().set(v)
187 subBig = afwImage.MaskedImageF(big, geom.Box2I(big.getXY0() + self.offset, mimg.getDimensions()))
188 subBig.assign(mimg)
189 mimg = big
190 mimg.setXY0(self.xy0)
192 exposure = afwImage.makeExposure(mimg)
193 cdMatrix = np.array([1.0 / (2.53 * 3600.0), 0.0, 0.0, 1.0 / (2.53 * 3600.0)])
194 cdMatrix.shape = (2, 2)
195 exposure.setWcs(
196 afwGeom.makeSkyWcs(
197 crpix=geom.Point2D(1.0, 1.0), crval=geom.SpherePoint(0, 0, geom.degrees), cdMatrix=cdMatrix
198 )
199 )
201 # load the corresponding test psf
202 psfFile = os.path.join(self.dataDir, "psf.%d.fits" % imageid)
203 psfImg = afwImage.ImageD(psfFile)
204 psfImg -= self.bkgd
206 kernel = afwMath.FixedKernel(psfImg)
207 kernelPsf = algorithms.KernelPsf(kernel)
208 exposure.setPsf(kernelPsf)
210 # perform the shape measurement
211 msConfig = base.SingleFrameMeasurementConfig()
212 msConfig.plugins.names |= [algorithmName]
213 control = msConfig.plugins[algorithmName]
214 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
215 # NOTE: It is essential to remove the floating point part of the position for the
216 # Algorithm._apply. Otherwise, when the PSF is realised it will have been warped
217 # to account for the sub-pixel offset and we won't get *exactly* this PSF.
218 plugin, table = makePluginAndCat(
219 alg, algorithmName, control, centroid="centroid", metadata=True, addFlux=addFlux
220 )
221 center = geom.Point2D(int(x), int(y)) + geom.Extent2D(self.offset + geom.Extent2I(self.xy0))
222 source = table.makeRecord()
223 source.set("centroid_x", center.getX())
224 source.set("centroid_y", center.getY())
225 source.setFootprint(afwDetection.Footprint(afwGeom.SpanSet(exposure.getBBox(afwImage.PARENT))))
226 plugin.measure(source, exposure)
228 return source
230 def testHsmSourceMoments(self):
231 for i, imageid in enumerate(file_indices):
232 source = self.runMeasurement(
233 "ext_shapeHSM_HsmSourceMoments", imageid, x_centroid[i], y_centroid[i], sky_var[i]
234 )
235 x = source.get("ext_shapeHSM_HsmSourceMoments_x")
236 y = source.get("ext_shapeHSM_HsmSourceMoments_y")
237 xx = source.get("ext_shapeHSM_HsmSourceMoments_xx")
238 yy = source.get("ext_shapeHSM_HsmSourceMoments_yy")
239 xy = source.get("ext_shapeHSM_HsmSourceMoments_xy")
241 # Centroids from GalSim use the FITS lower-left corner of 1,1
242 offset = self.xy0 + self.offset
243 self.assertAlmostEqual(x - offset.getX(), centroid_expected[i][0] - 1, 3)
244 self.assertAlmostEqual(y - offset.getY(), centroid_expected[i][1] - 1, 3)
246 expected = afwEll.Quadrupole(
247 afwEll.SeparableDistortionDeterminantRadius(
248 moments_expected[i][1], moments_expected[i][2], moments_expected[i][0]
249 )
250 )
252 self.assertAlmostEqual(xx, expected.getIxx(), SHAPE_DECIMALS)
253 self.assertAlmostEqual(xy, expected.getIxy(), SHAPE_DECIMALS)
254 self.assertAlmostEqual(yy, expected.getIyy(), SHAPE_DECIMALS)
256 def testHsmSourceMomentsRound(self):
257 for i, imageid in enumerate(file_indices):
258 source = self.runMeasurement(
259 "ext_shapeHSM_HsmSourceMomentsRound",
260 imageid,
261 x_centroid[i],
262 y_centroid[i],
263 sky_var[i],
264 addFlux=True,
265 )
266 x = source.get("ext_shapeHSM_HsmSourceMomentsRound_x")
267 y = source.get("ext_shapeHSM_HsmSourceMomentsRound_y")
268 xx = source.get("ext_shapeHSM_HsmSourceMomentsRound_xx")
269 yy = source.get("ext_shapeHSM_HsmSourceMomentsRound_yy")
270 xy = source.get("ext_shapeHSM_HsmSourceMomentsRound_xy")
271 flux = source.get("ext_shapeHSM_HsmSourceMomentsRound_Flux")
273 # Centroids from GalSim use the FITS lower-left corner of 1,1
274 offset = self.xy0 + self.offset
275 self.assertAlmostEqual(x - offset.getX(), round_moments_expected[i][4] - 1, 3)
276 self.assertAlmostEqual(y - offset.getY(), round_moments_expected[i][5] - 1, 3)
278 expected = afwEll.Quadrupole(
279 afwEll.SeparableDistortionDeterminantRadius(
280 round_moments_expected[i][1], round_moments_expected[i][2], round_moments_expected[i][0]
281 )
282 )
283 self.assertAlmostEqual(xx, expected.getIxx(), SHAPE_DECIMALS)
284 self.assertAlmostEqual(xy, expected.getIxy(), SHAPE_DECIMALS)
285 self.assertAlmostEqual(yy, expected.getIyy(), SHAPE_DECIMALS)
287 self.assertAlmostEqual(flux, round_moments_expected[i][3], SHAPE_DECIMALS)
289 def testHsmSourceMomentsVsSdssShape(self):
290 # Initialize a config and activate the plugins.
291 sfmConfig = base.SingleFrameMeasurementConfig()
292 sfmConfig.plugins.names |= ["ext_shapeHSM_HsmSourceMoments", "base_SdssShape"]
294 # Create a minimal schema (columns).
295 schema = lsst.meas.base.tests.TestDataset.makeMinimalSchema()
297 # Instantiate the task.
298 sfmTask = base.SingleFrameMeasurementTask(config=sfmConfig, schema=schema)
300 # Create a simple, test dataset.
301 bbox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Extent2I(100, 100))
302 dataset = lsst.meas.base.tests.TestDataset(bbox)
304 # First source is a point.
305 dataset.addSource(100000.0, lsst.geom.Point2D(49.5, 49.5))
307 # Second source is a galaxy.
308 dataset.addSource(300000.0, lsst.geom.Point2D(76.3, 79.2), afwGeom.Quadrupole(2.0, 3.0, 0.5))
310 # Third source is also a galaxy.
311 dataset.addSource(250000.0, lsst.geom.Point2D(28.9, 41.35), afwGeom.Quadrupole(1.8, 3.5, 0.4))
313 # Get the exposure and catalog.
314 exposure, catalog = dataset.realize(10.0, sfmTask.schema, randomSeed=0)
316 # Run the measurement task.
317 sfmTask.run(catalog, exposure)
318 cat = catalog.asAstropy()
320 # Get the moments from the catalog.
321 xSdss, ySdss = cat["base_SdssShape_x"], cat["base_SdssShape_y"]
322 xxSdss, xySdss, yySdss = cat["base_SdssShape_xx"], cat["base_SdssShape_xy"], cat["base_SdssShape_yy"]
323 xHsm, yHsm = cat["ext_shapeHSM_HsmSourceMoments_x"], cat["ext_shapeHSM_HsmSourceMoments_y"]
324 xxHsm, xyHsm, yyHsm = (
325 cat["ext_shapeHSM_HsmSourceMoments_xx"],
326 cat["ext_shapeHSM_HsmSourceMoments_xy"],
327 cat["ext_shapeHSM_HsmSourceMoments_yy"],
328 )
330 # Loop over the sources and check that the moments are the same.
331 for i in range(3):
332 self.assertAlmostEqual(xSdss[i], xHsm[i], 2)
333 self.assertAlmostEqual(ySdss[i], yHsm[i], 2)
334 self.assertAlmostEqual(xxSdss[i], xxHsm[i], SHAPE_DECIMALS)
335 self.assertAlmostEqual(xySdss[i], xyHsm[i], SHAPE_DECIMALS)
336 self.assertAlmostEqual(yySdss[i], yyHsm[i], SHAPE_DECIMALS)
339class ShapeTestCase(unittest.TestCase):
340 """A test case for shape measurement"""
342 def setUp(self):
344 # load the known values
345 self.dataDir = os.path.join(os.getenv('MEAS_EXTENSIONS_SHAPEHSM_DIR'), "tests", "data")
346 self.bkgd = 1000.0 # standard for atlas image
347 self.offset = geom.Extent2I(1234, 1234)
348 self.xy0 = geom.Point2I(5678, 9876)
350 def tearDown(self):
351 del self.offset
352 del self.xy0
354 def runMeasurement(self, algorithmName, imageid, x, y, v):
355 """Run the measurement algorithm on an image"""
356 # load the test image
357 imgFile = os.path.join(self.dataDir, "image.%d.fits" % imageid)
358 img = afwImage.ImageF(imgFile)
359 img -= self.bkgd
360 nx, ny = img.getWidth(), img.getHeight()
361 msk = afwImage.Mask(geom.Extent2I(nx, ny), 0x0)
362 var = afwImage.ImageF(geom.Extent2I(nx, ny), v)
363 mimg = afwImage.MaskedImageF(img, msk, var)
364 msk.getArray()[:] = np.where(np.fabs(img.getArray()) < 1.0e-8, msk.getPlaneBitMask("BAD"), 0)
366 # Put it in a bigger image, in case it matters
367 big = afwImage.MaskedImageF(self.offset + mimg.getDimensions())
368 big.getImage().set(0)
369 big.getMask().set(0)
370 big.getVariance().set(v)
371 subBig = afwImage.MaskedImageF(big, geom.Box2I(big.getXY0() + self.offset, mimg.getDimensions()))
372 subBig.assign(mimg)
373 mimg = big
374 mimg.setXY0(self.xy0)
376 exposure = afwImage.makeExposure(mimg)
377 cdMatrix = np.array([1.0/(2.53*3600.0), 0.0, 0.0, 1.0/(2.53*3600.0)])
378 cdMatrix.shape = (2, 2)
379 exposure.setWcs(afwGeom.makeSkyWcs(crpix=geom.Point2D(1.0, 1.0),
380 crval=geom.SpherePoint(0, 0, geom.degrees),
381 cdMatrix=cdMatrix))
383 # load the corresponding test psf
384 psfFile = os.path.join(self.dataDir, "psf.%d.fits" % imageid)
385 psfImg = afwImage.ImageD(psfFile)
386 psfImg -= self.bkgd
388 kernel = afwMath.FixedKernel(psfImg)
389 kernelPsf = algorithms.KernelPsf(kernel)
390 exposure.setPsf(kernelPsf)
392 # perform the shape measurement
393 msConfig = base.SingleFrameMeasurementConfig()
394 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass.AlgClass
395 control = base.SingleFramePlugin.registry[algorithmName].PluginClass.ConfigClass().makeControl()
396 msConfig.algorithms.names = [algorithmName]
397 # Note: It is essential to remove the floating point part of the position for the
398 # Algorithm._apply. Otherwise, when the PSF is realised it will have been warped
399 # to account for the sub-pixel offset and we won't get *exactly* this PSF.
400 plugin, table = makePluginAndCat(alg, algorithmName, control, centroid="centroid")
401 center = geom.Point2D(int(x), int(y)) + geom.Extent2D(self.offset + geom.Extent2I(self.xy0))
402 source = table.makeRecord()
403 source.set("centroid_x", center.getX())
404 source.set("centroid_y", center.getY())
405 source.setFootprint(afwDetection.Footprint(afwGeom.SpanSet(exposure.getBBox(afwImage.PARENT))))
406 plugin.measure(source, exposure)
408 return source
410 def testHsmShape(self):
411 """Test that we can instantiate and play with a measureShape"""
413 nFail = 0
414 msg = ""
416 for (algNum, algName), (i, imageid) in itertools.product(enumerate(correction_methods),
417 enumerate(file_indices)):
418 algorithmName = "ext_shapeHSM_HsmShape" + algName[0:1].upper() + algName[1:].lower()
420 source = self.runMeasurement(algorithmName, imageid, x_centroid[i], y_centroid[i], sky_var[i])
422 ##########################################
423 # see how we did
424 if algName in ("KSB"):
425 # Need to convert g1,g2 --> e1,e2 because GalSim has done that
426 # for the expected values ("for consistency")
427 g1 = source.get(algorithmName + "_g1")
428 g2 = source.get(algorithmName + "_g2")
429 scale = 2.0/(1.0 + g1**2 + g2**2)
430 e1 = g1*scale
431 e2 = g2*scale
432 sigma = source.get(algorithmName + "_sigma")
433 else:
434 e1 = source.get(algorithmName + "_e1")
435 e2 = source.get(algorithmName + "_e2")
436 sigma = 0.5*source.get(algorithmName + "_sigma")
437 resolution = source.get(algorithmName + "_resolution")
438 flags = source.get(algorithmName + "_flag")
440 tests = [
441 # label known-value measured tolerance
442 ["e1", float(e1_expected[i][algNum]), e1, 0.5*10**-SHAPE_DECIMALS],
443 ["e2", float(e2_expected[i][algNum]), e2, 0.5*10**-SHAPE_DECIMALS],
444 ["resolution", float(resolution_expected[i][algNum]), resolution, 0.5*10**-SIZE_DECIMALS],
446 # sigma won't match exactly because
447 # we're using skyvar=mean(var) instead of measured value ... expected a difference
448 ["sigma", float(sigma_e_expected[i][algNum]), sigma, 0.07],
449 ["shapeStatus", 0, flags, 0],
450 ]
452 for test in tests:
453 label, know, hsm, limit = test
454 err = hsm - know
455 msgTmp = "%-12s %s %5s: %6.6f %6.6f (val-known) = %.3g\n" % (algName, imageid,
456 label, know, hsm, err)
457 if not np.isfinite(err) or abs(err) > limit:
458 msg += msgTmp
459 nFail += 1
461 self.assertAlmostEqual(g1 if algName in ("KSB") else e1, galsim_e1[i][algNum], SHAPE_DECIMALS)
462 self.assertAlmostEqual(g2 if algName in ("KSB") else e2, galsim_e2[i][algNum], SHAPE_DECIMALS)
463 self.assertAlmostEqual(resolution, galsim_resolution[i][algNum], SIZE_DECIMALS)
464 self.assertAlmostEqual(sigma, galsim_err[i][algNum], delta=0.07)
466 self.assertEqual(nFail, 0, "\n"+msg)
469class PyGaussianPsf(afwDetection.Psf):
470 # Like afwDetection.GaussianPsf, but handles computeImage exactly instead of
471 # via interpolation. This is a subminimal implementation. It works for the
472 # tests here but isn't fully functional as a Psf class.
474 def __init__(self, width, height, sigma, varyBBox=False, wrongBBox=False):
475 afwDetection.Psf.__init__(self, isFixed=not varyBBox)
476 self.dimensions = geom.Extent2I(width, height)
477 self.sigma = sigma
478 self.varyBBox = varyBBox # To address DM-29863
479 self.wrongBBox = wrongBBox # To address DM-30426
481 def _doComputeKernelImage(self, position=None, color=None):
482 bbox = self.computeBBox(position, color)
483 img = afwImage.Image(bbox, dtype=np.float64)
484 x, y = np.ogrid[bbox.minY:bbox.maxY+1, bbox.minX:bbox.maxX+1]
485 rsqr = x**2 + y**2
486 img.array[:] = np.exp(-0.5*rsqr/self.sigma**2)
487 img.array /= np.sum(img.array)
488 return img
490 def _doComputeImage(self, position=None, color=None):
491 bbox = self.computeBBox(position, color)
492 if self.wrongBBox:
493 # For DM-30426:
494 # Purposely make computeImage.getBBox() and computeBBox()
495 # inconsistent. Old shapeHSM code attempted to infer the former
496 # from the latter, but was unreliable. New code infers the former
497 # directly, so this inconsistency no longer breaks things.
498 bbox.shift(geom.Extent2I(1, 1))
499 img = afwImage.Image(bbox, dtype=np.float64)
500 y, x = np.ogrid[float(bbox.minY):bbox.maxY+1, bbox.minX:bbox.maxX+1]
501 x -= (position.x - np.floor(position.x+0.5))
502 y -= (position.y - np.floor(position.y+0.5))
503 rsqr = x**2 + y**2
504 img.array[:] = np.exp(-0.5*rsqr/self.sigma**2)
505 img.array /= np.sum(img.array)
506 img.setXY0(geom.Point2I(
507 img.getX0() + np.floor(position.x+0.5),
508 img.getY0() + np.floor(position.y+0.5)
509 ))
510 return img
512 def _doComputeBBox(self, position=None, color=None):
513 # Variable size bbox for addressing DM-29863
514 dims = self.dimensions
515 if self.varyBBox:
516 if position.x > 20.0:
517 dims = dims + geom.Extent2I(2, 2)
518 return geom.Box2I(geom.Point2I(-dims/2), dims)
520 def _doComputeShape(self, position=None, color=None):
521 return afwGeom.ellipses.Quadrupole(self.sigma**2, self.sigma**2, 0.0)
524class PsfMomentsTestCase(unittest.TestCase):
525 """A test case for PSF moments measurement"""
527 @staticmethod
528 def computeDirectPsfMomentsFromGalSim(
529 psf,
530 center,
531 useSourceCentroidOffset=False
532 ):
533 """Directly from GalSim."""
534 psfBBox = psf.computeImageBBox(center)
535 psfSigma = psf.computeShape(center).getTraceRadius()
536 if useSourceCentroidOffset:
537 psfImage = psf.computeImage(center)
538 centroid = center
539 else:
540 psfImage = psf.computeKernelImage(center)
541 psfImage.setXY0(psfBBox.getMin())
542 centroid = geom.Point2D(psfBBox.getMin() + psfBBox.getDimensions() // 2)
543 bbox = psfImage.getBBox(afwImage.PARENT)
544 bounds = galsim.bounds.BoundsI(bbox.getMinX(), bbox.getMaxX(), bbox.getMinY(), bbox.getMaxY())
545 image = galsim.Image(psfImage.array, bounds=bounds, copy=False)
546 guessCentroid = galsim.PositionD(centroid.x, centroid.y)
547 shape = galsim.hsm.FindAdaptiveMom(
548 image,
549 weight=None,
550 badpix=None,
551 guess_sig=psfSigma,
552 precision=1e-6,
553 guess_centroid=guessCentroid,
554 strict=True,
555 round_moments=False,
556 hsmparams=None,
557 )
558 ellipse = lsst.afw.geom.ellipses.SeparableDistortionDeterminantRadius(
559 e1=shape.observed_shape.e1,
560 e2=shape.observed_shape.e2,
561 radius=shape.moments_sigma,
562 normalize=True, # Fail if |e|>1.
563 )
564 quad = lsst.afw.geom.ellipses.Quadrupole(ellipse)
565 ixx = quad.getIxx()
566 iyy = quad.getIyy()
567 ixy = quad.getIxy()
568 return ixx, iyy, ixy
570 @lsst.utils.tests.methodParameters(
571 # Make Cartesian product of settings to feed to methodParameters
572 **dict(list(zip(
573 (kwargs := dict(
574 # Increasing the width beyond 4.5 leads to noticeable
575 # truncation of the PSF, i.e. a PSF that is too large for the
576 # box. While this truncated state leads to incorrect
577 # measurements, it is necessary for testing purposes to
578 # evaluate the behavior under these extreme conditions.
579 width=(2.0, 3.0, 4.0, 10.0, 40.0, 100.0),
580 useSourceCentroidOffset=(True, False),
581 varyBBox=(True, False),
582 wrongBBox=(True, False),
583 center=(
584 (23.0, 34.0), # various offsets that might cause trouble
585 (23.5, 34.0),
586 (23.5, 34.5),
587 (23.15, 34.25),
588 (22.81, 34.01),
589 (22.81, 33.99),
590 (1.2, 1.3), # psfImage extends outside exposure; that's okay
591 (-100.0, -100.0),
592 (-100.5, -100.0),
593 (-100.5, -100.5),
594 )
595 )).keys(),
596 zip(*itertools.product(*kwargs.values()))
597 )))
598 )
599 def testHsmPsfMoments(
600 self, width, useSourceCentroidOffset, varyBBox, wrongBBox, center
601 ):
602 psf = PyGaussianPsf(
603 35, 35, width,
604 varyBBox=varyBBox,
605 wrongBBox=wrongBBox
606 )
607 exposure = afwImage.ExposureF(45, 56)
608 exposure.getMaskedImage().set(1.0, 0, 1.0)
609 exposure.setPsf(psf)
611 # perform the moment measurement
612 algorithmName = "ext_shapeHSM_HsmPsfMoments"
613 msConfig = base.SingleFrameMeasurementConfig()
614 msConfig.algorithms.names = [algorithmName]
615 control = msConfig.plugins[algorithmName]
616 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
617 self.assertFalse(control.useSourceCentroidOffset)
618 control.useSourceCentroidOffset = useSourceCentroidOffset
619 plugin, cat = makePluginAndCat(
620 alg, algorithmName,
621 centroid="centroid",
622 control=control,
623 metadata=True,
624 )
625 source = cat.addNew()
626 source.set("centroid_x", center[0])
627 source.set("centroid_y", center[1])
628 offset = geom.Point2I(*center)
629 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
630 source.setFootprint(afwDetection.Footprint(tmpSpans))
631 plugin.measure(source, exposure)
632 x = source.get("ext_shapeHSM_HsmPsfMoments_x")
633 y = source.get("ext_shapeHSM_HsmPsfMoments_y")
634 xx = source.get("ext_shapeHSM_HsmPsfMoments_xx")
635 yy = source.get("ext_shapeHSM_HsmPsfMoments_yy")
636 xy = source.get("ext_shapeHSM_HsmPsfMoments_xy")
638 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag"))
639 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag_no_pixels"))
640 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag_not_contained"))
641 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag_parent_source"))
643 if width < 4.5:
644 # i.e., as long as the PSF is not truncated for our 35x35 box.
645 self.assertAlmostEqual(x, 0.0, 3)
646 self.assertAlmostEqual(y, 0.0, 3)
647 expected = afwEll.Quadrupole(afwEll.Axes(width, width, 0.0))
648 self.assertAlmostEqual(xx, expected.getIxx(), SHAPE_DECIMALS)
649 self.assertAlmostEqual(xy, expected.getIxy(), SHAPE_DECIMALS)
650 self.assertAlmostEqual(yy, expected.getIyy(), SHAPE_DECIMALS)
652 # Test schema documentation
653 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy]"):
654 self.assertEqual(cat.schema[fieldName].asField().getDoc(),
655 "Centroid of the PSF via the HSM shape algorithm")
656 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy][xy]*"):
657 self.assertEqual(cat.schema[fieldName].asField().getDoc(),
658 "Adaptive moments of the PSF via the HSM shape algorithm")
660 # Test that the moments are identical to those obtained directly by
661 # GalSim. For `width` > 4.5 where the truncation becomes significant,
662 # the answer might not be 'correct' but should remain 'consistent'.
663 xxDirect, yyDirect, xyDirect = self.computeDirectPsfMomentsFromGalSim(
664 psf,
665 geom.Point2D(*center),
666 useSourceCentroidOffset=useSourceCentroidOffset,
667 )
668 self.assertEqual(xx, xxDirect)
669 self.assertEqual(yy, yyDirect)
670 self.assertEqual(xy, xyDirect)
672 @lsst.utils.tests.methodParameters(
673 # Make Cartesian product of settings to feed to methodParameters
674 **dict(list(zip(
675 (kwargs := dict(
676 width=(2.0, 3.0, 4.0),
677 useSourceCentroidOffset=(True, False),
678 varyBBox=(True, False),
679 wrongBBox=(True, False),
680 center=(
681 (23.0, 34.0), # various offsets that might cause trouble
682 (23.5, 34.0),
683 (23.5, 34.5),
684 (23.15, 34.25),
685 (22.81, 34.01),
686 (22.81, 33.99),
687 )
688 )).keys(),
689 zip(*itertools.product(*kwargs.values()))
690 )))
691 )
692 def testHsmPsfMomentsDebiased(
693 self, width, useSourceCentroidOffset, varyBBox, wrongBBox, center
694 ):
695 # As a note, it's really hard to actually unit test whether we've
696 # succesfully "debiased" these measurements. That would require a
697 # many-object comparison of moments with and without noise. So we just
698 # test similar to the biased moments above.
699 var = 1.2
700 # As we reduce the flux, our deviation from the expected value
701 # increases, so decrease tolerance.
702 for flux, decimals in [
703 (1e6, 3),
704 (1e4, 1),
705 (1e3, 0),
706 ]:
707 psf = PyGaussianPsf(
708 35, 35, width,
709 varyBBox=varyBBox,
710 wrongBBox=wrongBBox
711 )
712 exposure = afwImage.ExposureF(45, 56)
713 exposure.getMaskedImage().set(1.0, 0, var)
714 exposure.setPsf(psf)
716 algorithmName = "ext_shapeHSM_HsmPsfMomentsDebiased"
717 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
719 # perform the shape measurement
720 control = lsst.meas.extensions.shapeHSM.HsmPsfMomentsDebiasedConfig()
721 self.assertTrue(control.useSourceCentroidOffset)
722 self.assertEqual(control.noiseSource, "variance")
723 control.useSourceCentroidOffset = useSourceCentroidOffset
724 plugin, cat = makePluginAndCat(
725 alg,
726 algorithmName,
727 centroid="centroid",
728 psfflux="base_PsfFlux",
729 control=control,
730 metadata=True,
731 )
732 source = cat.addNew()
733 source.set("centroid_x", center[0])
734 source.set("centroid_y", center[1])
735 offset = geom.Point2I(*center)
736 source.set("base_PsfFlux_instFlux", flux)
737 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
738 source.setFootprint(afwDetection.Footprint(tmpSpans))
740 plugin.measure(source, exposure)
741 x = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_x")
742 y = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_y")
743 xx = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xx")
744 yy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_yy")
745 xy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xy")
746 for flag in [
747 "ext_shapeHSM_HsmPsfMomentsDebiased_flag",
748 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_no_pixels",
749 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_not_contained",
750 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_parent_source",
751 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_edge"
752 ]:
753 self.assertFalse(source.get(flag))
755 expected = afwEll.Quadrupole(afwEll.Axes(width, width, 0.0))
757 self.assertAlmostEqual(x, 0.0, decimals)
758 self.assertAlmostEqual(y, 0.0, decimals)
760 T = expected.getIxx() + expected.getIyy()
761 self.assertAlmostEqual((xx-expected.getIxx())/T, 0.0, decimals)
762 self.assertAlmostEqual((xy-expected.getIxy())/T, 0.0, decimals)
763 self.assertAlmostEqual((yy-expected.getIyy())/T, 0.0, decimals)
765 # Repeat using noiseSource='meta'. Should get nearly the same
766 # results if BGMEAN is set to `var` above.
767 exposure2 = afwImage.ExposureF(45, 56)
768 # set the variance plane to something else to ensure we're
769 # ignoring it
770 exposure2.getMaskedImage().set(1.0, 0, 2*var+1.1)
771 exposure2.setPsf(psf)
772 exposure2.getMetadata().set("BGMEAN", var)
774 control2 = shapeHSM.HsmPsfMomentsDebiasedConfig()
775 control2.noiseSource = "meta"
776 control2.useSourceCentroidOffset = useSourceCentroidOffset
777 plugin2, cat2 = makePluginAndCat(
778 alg,
779 algorithmName,
780 centroid="centroid",
781 psfflux="base_PsfFlux",
782 control=control2,
783 metadata=True,
784 )
785 source2 = cat2.addNew()
786 source2.set("centroid_x", center[0])
787 source2.set("centroid_y", center[1])
788 offset2 = geom.Point2I(*center)
789 source2.set("base_PsfFlux_instFlux", flux)
790 tmpSpans2 = afwGeom.SpanSet.fromShape(int(width), offset=offset2)
791 source2.setFootprint(afwDetection.Footprint(tmpSpans2))
793 plugin2.measure(source2, exposure2)
794 x2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_x")
795 y2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_y")
796 xx2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_xx")
797 yy2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_yy")
798 xy2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_xy")
799 for flag in [
800 "ext_shapeHSM_HsmPsfMomentsDebiased_flag",
801 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_no_pixels",
802 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_not_contained",
803 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_parent_source",
804 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_edge"
805 ]:
806 self.assertFalse(source.get(flag))
808 # Would be identically equal, but variance input via "BGMEAN" is
809 # consumed in c++ as a double, where variance from the variance
810 # plane is a c++ float.
811 self.assertAlmostEqual(x, x2, 8)
812 self.assertAlmostEqual(y, y2, 8)
813 self.assertAlmostEqual(xx, xx2, 5)
814 self.assertAlmostEqual(xy, xy2, 5)
815 self.assertAlmostEqual(yy, yy2, 5)
817 # Test schema documentation
818 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy]"):
819 self.assertEqual(cat.schema[fieldName].asField().getDoc(),
820 "Debiased centroid of the PSF via the HSM shape algorithm")
821 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy][xy]*"):
822 self.assertEqual(cat.schema[fieldName].asField().getDoc(),
823 "Debiased adaptive moments of the PSF via the HSM shape algorithm")
825 testHsmPsfMomentsDebiasedEdgeArgs = dict(
826 width=(2.0, 3.0, 4.0),
827 useSourceCentroidOffset=(True, False),
828 center=(
829 (1.2, 1.3),
830 (33.2, 50.1)
831 )
832 )
834 @lsst.utils.tests.methodParameters(
835 # Make Cartesian product of settings to feed to methodParameters
836 **dict(list(zip(
837 (kwargs := dict(
838 width=(2.0, 3.0, 4.0),
839 useSourceCentroidOffset=(True, False),
840 center=[
841 (1.2, 1.3),
842 (33.2, 50.1)
843 ]
844 )).keys(),
845 zip(*itertools.product(*kwargs.values()))
846 )))
847 )
848 def testHsmPsfMomentsDebiasedEdge(self, width, useSourceCentroidOffset, center):
849 # As we reduce the flux, our deviation from the expected value
850 # increases, so decrease tolerance.
851 var = 1.2
852 for flux, decimals in [
853 (1e6, 3),
854 (1e4, 2),
855 (1e3, 1),
856 ]:
857 psf = PyGaussianPsf(35, 35, width)
858 exposure = afwImage.ExposureF(45, 56)
859 exposure.getMaskedImage().set(1.0, 0, 2*var+1.1)
860 exposure.setPsf(psf)
862 algorithmName = "ext_shapeHSM_HsmPsfMomentsDebiased"
863 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass
865 # perform the shape measurement
866 control = shapeHSM.HsmPsfMomentsDebiasedConfig()
867 control.useSourceCentroidOffset = useSourceCentroidOffset
868 self.assertEqual(control.noiseSource, "variance")
869 plugin, cat = makePluginAndCat(
870 alg,
871 algorithmName,
872 centroid="centroid",
873 psfflux="base_PsfFlux",
874 control=control,
875 metadata=True,
876 )
877 source = cat.addNew()
878 source.set("centroid_x", center[0])
879 source.set("centroid_y", center[1])
880 offset = geom.Point2I(*center)
881 source.set("base_PsfFlux_instFlux", flux)
882 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
883 source.setFootprint(afwDetection.Footprint(tmpSpans))
885 # Edge fails when setting noise from var plane
886 with self.assertRaises(base.MeasurementError):
887 plugin.measure(source, exposure)
889 # Succeeds when noise is from meta
890 exposure.getMetadata().set("BGMEAN", var)
891 control.noiseSource = "meta"
892 plugin, cat = makePluginAndCat(
893 alg,
894 algorithmName,
895 centroid="centroid",
896 psfflux="base_PsfFlux",
897 control=control,
898 metadata=True,
899 )
900 source = cat.addNew()
901 source.set("centroid_x", center[0])
902 source.set("centroid_y", center[1])
903 offset = geom.Point2I(*center)
904 source.set("base_PsfFlux_instFlux", flux)
905 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
906 source.setFootprint(afwDetection.Footprint(tmpSpans))
907 plugin.measure(source, exposure)
909 x = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_x")
910 y = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_y")
911 xx = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xx")
912 yy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_yy")
913 xy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xy")
914 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag"))
915 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_no_pixels"))
916 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_not_contained"))
917 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_parent_source"))
918 # but _does_ set EDGE flag in this case
919 self.assertTrue(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_edge"))
921 expected = afwEll.Quadrupole(afwEll.Axes(width, width, 0.0))
923 self.assertAlmostEqual(x, 0.0, decimals)
924 self.assertAlmostEqual(y, 0.0, decimals)
926 T = expected.getIxx() + expected.getIyy()
927 self.assertAlmostEqual((xx-expected.getIxx())/T, 0.0, decimals)
928 self.assertAlmostEqual((xy-expected.getIxy())/T, 0.0, decimals)
929 self.assertAlmostEqual((yy-expected.getIyy())/T, 0.0, decimals)
931 # But fails hard if meta doesn't contain BGMEAN
932 exposure.getMetadata().remove("BGMEAN")
933 plugin, cat = makePluginAndCat(
934 alg,
935 algorithmName,
936 centroid="centroid",
937 psfflux="base_PsfFlux",
938 control=control,
939 metadata=True,
940 )
941 source = cat.addNew()
942 source.set("centroid_x", center[0])
943 source.set("centroid_y", center[1])
944 offset = geom.Point2I(*center)
945 source.set("base_PsfFlux_instFlux", flux)
946 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset)
947 source.setFootprint(afwDetection.Footprint(tmpSpans))
948 with self.assertRaises(base.FatalAlgorithmError):
949 plugin.measure(source, exposure)
951 def testHsmPsfMomentsDebiasedBadNoiseSource(self):
952 control = shapeHSM.HsmPsfMomentsDebiasedConfig()
953 with self.assertRaises(pexConfig.FieldValidationError):
954 control.noiseSource = "ACM"
957class TestMemory(lsst.utils.tests.MemoryTestCase):
958 pass
961def setup_module(module):
962 lsst.utils.tests.init()
965if __name__ == "__main__": 965 ↛ 966line 965 didn't jump to line 966, because the condition on line 965 was never true
966 lsst.utils.tests.init()
967 unittest.main()