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