Coverage for tests/test_psf.py: 14%
216 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-01-18 02:29 -0800
« prev ^ index » next coverage.py v6.5.0, created at 2023-01-18 02:29 -0800
1import galsim # noqa: F401
2import unittest
3import numpy as np
4import copy
5from galsim import Lanczos # noqa: F401
7import lsst.utils.tests
8import lsst.afw.detection as afwDetection
9import lsst.afw.geom as afwGeom
10import lsst.afw.image as afwImage
11import lsst.afw.math as afwMath
12import lsst.afw.table as afwTable
13import lsst.daf.base as dafBase
14import lsst.geom as geom
15import lsst.meas.algorithms as measAlg
16from lsst.meas.base import SingleFrameMeasurementTask
17from lsst.meas.extensions.piff.piffPsfDeterminer import PiffPsfDeterminerConfig, PiffPsfDeterminerTask
18from lsst.meas.extensions.piff.piffPsfDeterminer import _validateGalsimInterpolant
19from lsst.pex.config import FieldValidationError
22def psfVal(ix, iy, x, y, sigma1, sigma2, b):
23 """Return the value at (ix, iy) of a double Gaussian
24 (N(0, sigma1^2) + b*N(0, sigma2^2))/(1 + b)
25 centered at (x, y)
26 """
27 dx, dy = x - ix, y - iy
28 theta = np.radians(30)
29 ab = 1.0/0.75 # axis ratio
30 c, s = np.cos(theta), np.sin(theta)
31 u, v = c*dx - s*dy, s*dx + c*dy
33 return (np.exp(-0.5*(u**2 + (v*ab)**2)/sigma1**2)
34 + b*np.exp(-0.5*(u**2 + (v*ab)**2)/sigma2**2))/(1 + b)
37class SpatialModelPsfTestCase(lsst.utils.tests.TestCase):
38 """A test case for SpatialModelPsf"""
40 def measure(self, footprintSet, exposure):
41 """Measure a set of Footprints, returning a SourceCatalog"""
42 catalog = afwTable.SourceCatalog(self.schema)
44 footprintSet.makeSources(catalog)
46 self.measureSources.run(catalog, exposure)
47 return catalog
49 def setUp(self):
50 config = SingleFrameMeasurementTask.ConfigClass()
51 config.slots.apFlux = 'base_CircularApertureFlux_12_0'
52 self.schema = afwTable.SourceTable.makeMinimalSchema()
54 self.measureSources = SingleFrameMeasurementTask(
55 self.schema, config=config
56 )
57 self.usePsfFlag = self.schema.addField("use_psf", type="Flag")
59 width, height = 110, 301
61 self.mi = afwImage.MaskedImageF(geom.ExtentI(width, height))
62 self.mi.set(0)
63 sd = 3 # standard deviation of image
64 self.mi.getVariance().set(sd*sd)
65 self.mi.getMask().addMaskPlane("DETECTED")
67 self.ksize = 31 # size of desired kernel
69 sigma1 = 1.75
70 sigma2 = 2*sigma1
72 self.exposure = afwImage.makeExposure(self.mi)
73 self.exposure.setPsf(measAlg.DoubleGaussianPsf(self.ksize, self.ksize,
74 1.5*sigma1, 1, 0.1))
75 cdMatrix = np.array([1.0, 0.0, 0.0, 1.0])
76 cdMatrix.shape = (2, 2)
77 wcs = afwGeom.makeSkyWcs(crpix=geom.PointD(0, 0),
78 crval=geom.SpherePoint(0.0, 0.0, geom.degrees),
79 cdMatrix=cdMatrix)
80 self.exposure.setWcs(wcs)
82 #
83 # Make a kernel with the exactly correct basis functions.
84 # Useful for debugging
85 #
86 basisKernelList = []
87 for sigma in (sigma1, sigma2):
88 basisKernel = afwMath.AnalyticKernel(
89 self.ksize, self.ksize, afwMath.GaussianFunction2D(sigma, sigma)
90 )
91 basisImage = afwImage.ImageD(basisKernel.getDimensions())
92 basisKernel.computeImage(basisImage, True)
93 basisImage /= np.sum(basisImage.getArray())
95 if sigma == sigma1:
96 basisImage0 = basisImage
97 else:
98 basisImage -= basisImage0
100 basisKernelList.append(afwMath.FixedKernel(basisImage))
102 order = 1 # 1 => up to linear
103 spFunc = afwMath.PolynomialFunction2D(order)
105 exactKernel = afwMath.LinearCombinationKernel(basisKernelList, spFunc)
106 exactKernel.setSpatialParameters(
107 [[1.0, 0, 0],
108 [0.0, 0.5*1e-2, 0.2e-2]]
109 )
111 rand = afwMath.Random() # make these tests repeatable by setting seed
113 im = self.mi.getImage()
114 afwMath.randomGaussianImage(im, rand) # N(0, 1)
115 im *= sd # N(0, sd^2)
117 xarr, yarr = [], []
119 for x, y in [(20, 20), (60, 20),
120 (30, 35),
121 (50, 50),
122 (20, 90), (70, 160), (25, 265), (75, 275), (85, 30),
123 (50, 120), (70, 80),
124 (60, 210), (20, 210),
125 ]:
126 xarr.append(x)
127 yarr.append(y)
129 for x, y in zip(xarr, yarr):
130 dx = rand.uniform() - 0.5 # random (centered) offsets
131 dy = rand.uniform() - 0.5
133 k = exactKernel.getSpatialFunction(1)(x, y)
134 b = (k*sigma1**2/((1 - k)*sigma2**2))
136 flux = 80000*(1 + 0.1*(rand.uniform() - 0.5))
137 I0 = flux*(1 + b)/(2*np.pi*(sigma1**2 + b*sigma2**2))
138 for iy in range(y - self.ksize//2, y + self.ksize//2 + 1):
139 if iy < 0 or iy >= self.mi.getHeight():
140 continue
142 for ix in range(x - self.ksize//2, x + self.ksize//2 + 1):
143 if ix < 0 or ix >= self.mi.getWidth():
144 continue
146 II = I0*psfVal(ix, iy, x + dx, y + dy, sigma1, sigma2, b)
147 Isample = rand.poisson(II)
148 self.mi.image[ix, iy, afwImage.LOCAL] += Isample
149 self.mi.variance[ix, iy, afwImage.LOCAL] += II
151 bbox = geom.BoxI(geom.PointI(0, 0), geom.ExtentI(width, height))
152 self.cellSet = afwMath.SpatialCellSet(bbox, 100)
154 self.footprintSet = afwDetection.FootprintSet(
155 self.mi, afwDetection.Threshold(100), "DETECTED"
156 )
158 self.catalog = self.measure(self.footprintSet, self.exposure)
160 for source in self.catalog:
161 cand = measAlg.makePsfCandidate(source, self.exposure)
162 self.cellSet.insertCandidate(cand)
164 def setupDeterminer(self, stampSize=None, debugStarData=False):
165 """Setup the starSelector and psfDeterminer
167 Parameters
168 ----------
169 stampSize : `int`, optional
170 Set ``config.stampSize`` to this, if not None.
171 """
172 starSelectorClass = measAlg.sourceSelectorRegistry["objectSize"]
173 starSelectorConfig = starSelectorClass.ConfigClass()
174 starSelectorConfig.sourceFluxField = "base_GaussianFlux_instFlux"
175 starSelectorConfig.badFlags = [
176 "base_PixelFlags_flag_edge",
177 "base_PixelFlags_flag_interpolatedCenter",
178 "base_PixelFlags_flag_saturatedCenter",
179 "base_PixelFlags_flag_crCenter",
180 ]
181 # Set to match when the tolerance of the test was set
182 starSelectorConfig.widthStdAllowed = 0.5
184 self.starSelector = starSelectorClass(config=starSelectorConfig)
186 makePsfCandidatesConfig = measAlg.MakePsfCandidatesTask.ConfigClass()
187 if stampSize is not None:
188 makePsfCandidatesConfig.kernelSize = stampSize
189 self.makePsfCandidates = measAlg.MakePsfCandidatesTask(config=makePsfCandidatesConfig)
191 psfDeterminerConfig = PiffPsfDeterminerConfig()
192 psfDeterminerConfig.spatialOrder = 1
193 if stampSize is not None:
194 psfDeterminerConfig.stampSize = stampSize
195 psfDeterminerConfig.debugStarData = debugStarData
197 self.psfDeterminer = PiffPsfDeterminerTask(psfDeterminerConfig)
199 def subtractStars(self, exposure, catalog, chi_lim=-1):
200 """Subtract the exposure's PSF from all the sources in catalog"""
201 mi, psf = exposure.getMaskedImage(), exposure.getPsf()
203 subtracted = mi.Factory(mi, True)
204 for s in catalog:
205 xc, yc = s.getX(), s.getY()
206 bbox = subtracted.getBBox(afwImage.PARENT)
207 if bbox.contains(geom.PointI(int(xc), int(yc))):
208 measAlg.subtractPsf(psf, subtracted, xc, yc)
209 chi = subtracted.Factory(subtracted, True)
210 var = subtracted.getVariance()
211 np.sqrt(var.getArray(), var.getArray()) # inplace sqrt
212 chi /= var
214 chi_min = np.min(chi.getImage().getArray())
215 chi_max = np.max(chi.getImage().getArray())
216 print(chi_min, chi_max)
218 if chi_lim > 0:
219 self.assertGreater(chi_min, -chi_lim)
220 self.assertLess(chi_max, chi_lim)
222 def checkPiffDeterminer(self, stampSize=None, debugStarData=False):
223 """Configure PiffPsfDeterminerTask and run basic tests on it.
225 Parameters
226 ----------
227 stampSize : `int`, optional
228 Set ``config.stampSize`` to this, if not None.
229 """
230 self.setupDeterminer(stampSize=stampSize, debugStarData=debugStarData)
231 metadata = dafBase.PropertyList()
233 stars = self.starSelector.run(self.catalog, exposure=self.exposure)
234 psfCandidateList = self.makePsfCandidates.run(
235 stars.sourceCat,
236 exposure=self.exposure
237 ).psfCandidates
238 psf, cellSet = self.psfDeterminer.determinePsf(
239 self.exposure,
240 psfCandidateList,
241 metadata,
242 flagKey=self.usePsfFlag
243 )
244 self.exposure.setPsf(psf)
246 self.assertEqual(len(psfCandidateList), metadata['numAvailStars'])
247 self.assertEqual(sum(self.catalog['use_psf']), metadata['numGoodStars'])
248 self.assertEqual(
249 psf.getAveragePosition(),
250 geom.Point2D(
251 np.mean([s.x for s in psf._piffResult.stars]),
252 np.mean([s.y for s in psf._piffResult.stars])
253 )
254 )
255 if self.psfDeterminer.config.debugStarData:
256 self.assertIn('image', psf._piffResult.stars[0].data.__dict__)
257 else:
258 self.assertNotIn('image', psf._piffResult.stars[0].data.__dict__)
260 # Test how well we can subtract the PSF model
261 self.subtractStars(self.exposure, self.catalog, chi_lim=6.1)
263 # Test bboxes
264 for point in [
265 psf.getAveragePosition(),
266 geom.Point2D(),
267 geom.Point2D(1, 1)
268 ]:
269 self.assertEqual(
270 psf.computeBBox(point),
271 psf.computeKernelImage(point).getBBox()
272 )
273 self.assertEqual(
274 psf.computeKernelBBox(point),
275 psf.computeKernelImage(point).getBBox()
276 )
277 self.assertEqual(
278 psf.computeImageBBox(point),
279 psf.computeImage(point).getBBox()
280 )
282 # Some roundtrips
283 with lsst.utils.tests.getTempFilePath(".fits") as tmpFile:
284 self.exposure.writeFits(tmpFile)
285 fitsIm = afwImage.ExposureF(tmpFile)
286 copyIm = copy.deepcopy(self.exposure)
288 for newIm in [fitsIm, copyIm]:
289 # Piff doesn't enable __eq__ for its results, so we just check
290 # that some PSF images come out the same.
291 for point in [
292 geom.Point2D(0, 0),
293 geom.Point2D(10, 100),
294 geom.Point2D(-200, 30),
295 geom.Point2D(float("nan")) # "nullPoint"
296 ]:
297 self.assertImagesAlmostEqual(
298 psf.computeImage(point),
299 newIm.getPsf().computeImage(point)
300 )
301 # Also check average position
302 newPsf = newIm.getPsf()
303 self.assertImagesAlmostEqual(
304 psf.computeImage(psf.getAveragePosition()),
305 newPsf.computeImage(newPsf.getAveragePosition())
306 )
308 def testPiffDeterminer_default(self):
309 """Test piff with the default config."""
310 self.checkPiffDeterminer()
312 def testPiffDeterminer_kernelSize27(self):
313 """Test Piff with a psf kernelSize of 27."""
314 self.checkPiffDeterminer(27)
316 def testPiffDeterminer_debugStarData(self):
317 """Test Piff with debugStarData=True."""
318 self.checkPiffDeterminer(debugStarData=True)
320 @lsst.utils.tests.methodParameters(samplingSize=[1.0, 0.9, 1.1])
321 def test_validatePsfCandidates(self, samplingSize):
322 """Test that `_validatePsfCandidates` raises for too-small candidates.
324 This should be independent of the samplingSize parameter.
325 """
326 drawSizeDict = {1.0: 27,
327 0.9: 31,
328 1.1: 25,
329 }
331 makePsfCandidatesConfig = measAlg.MakePsfCandidatesTask.ConfigClass()
332 makePsfCandidatesConfig.kernelSize = 23
333 self.makePsfCandidates = measAlg.MakePsfCandidatesTask(config=makePsfCandidatesConfig)
334 psfCandidateList = self.makePsfCandidates.run(
335 self.catalog,
336 exposure=self.exposure
337 ).psfCandidates
339 psfDeterminerConfig = PiffPsfDeterminerConfig()
340 psfDeterminerConfig.stampSize = drawSizeDict[samplingSize]
341 psfDeterminerConfig.samplingSize = samplingSize
342 self.psfDeterminer = PiffPsfDeterminerTask(psfDeterminerConfig)
344 with self.assertRaisesRegex(RuntimeError,
345 "stampSize=27 "
346 "pixels per side; found 23x23"):
347 self.psfDeterminer._validatePsfCandidates(psfCandidateList, 27)
349 # This should not raise.
350 self.psfDeterminer._validatePsfCandidates(psfCandidateList, 21)
353class PiffConfigTestCase(lsst.utils.tests.TestCase):
354 """A test case to check for valid Piff config"""
355 def testValidateGalsimInterpolant(self):
356 # Check that random strings are not valid interpolants.
357 self.assertFalse(_validateGalsimInterpolant("foo"))
358 # Check that the Lanczos order is an integer
359 self.assertFalse(_validateGalsimInterpolant("Lanczos(3.0"))
360 self.assertFalse(_validateGalsimInterpolant("Lanczos(-5.0"))
361 self.assertFalse(_validateGalsimInterpolant("Lanczos(N)"))
362 # Check for various valid Lanczos interpolants
363 for interp in ("Lanczos(4)", "galsim.Lanczos(7)"):
364 self.assertTrue(_validateGalsimInterpolant(interp))
365 self.assertFalse(_validateGalsimInterpolant(interp.lower()))
366 # Evaluating the string should succeed. This is how Piff does it.
367 self.assertTrue(eval(interp))
368 # Check that interpolation methods are case sensitive.
369 for interp in ("Linear", "Cubic", "Quintic", "Delta", "Nearest", "SincInterpolant"):
370 self.assertFalse(_validateGalsimInterpolant(f"galsim.{interp.lower()}"))
371 self.assertFalse(_validateGalsimInterpolant(interp))
372 self.assertTrue(_validateGalsimInterpolant(f"galsim.{interp}"))
373 self.assertTrue(eval(f"galsim.{interp}"))
375 def testKernelSize(self): # TODO: Remove this test in DM-36311.
376 config = PiffPsfDeterminerConfig()
378 # Setting both stampSize and kernelSize should raise an error.
379 config.stampSize = 27
380 with self.assertWarns(FutureWarning):
381 config.kernelSize = 25
382 self.assertRaises(FieldValidationError, config.validate)
384 # even if they agree with each other
385 config.stampSize = 31
386 with self.assertWarns(FutureWarning):
387 config.kernelSize = 31
388 self.assertRaises(FieldValidationError, config.validate)
390 # Setting stampSize and kernelSize should be valid, because if not
391 # set, stampSize is set to the size of PSF candidates internally.
392 # This is only a temporary behavior and should go away in DM-36311.
393 config.stampSize = None
394 with self.assertWarns(FutureWarning):
395 config.kernelSize = None
396 config.validate()
399class TestMemory(lsst.utils.tests.MemoryTestCase):
400 pass
403def setup_module(module):
404 lsst.utils.tests.init()
407if __name__ == "__main__": 407 ↛ 408line 407 didn't jump to line 408, because the condition on line 407 was never true
408 lsst.utils.tests.init()
409 unittest.main()