Coverage for tests/test_psf.py: 14%
210 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-10-30 02:27 -0700
« prev ^ index » next coverage.py v6.5.0, created at 2022-10-30 02:27 -0700
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):
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
196 self.psfDeterminer = PiffPsfDeterminerTask(psfDeterminerConfig)
198 def subtractStars(self, exposure, catalog, chi_lim=-1):
199 """Subtract the exposure's PSF from all the sources in catalog"""
200 mi, psf = exposure.getMaskedImage(), exposure.getPsf()
202 subtracted = mi.Factory(mi, True)
203 for s in catalog:
204 xc, yc = s.getX(), s.getY()
205 bbox = subtracted.getBBox(afwImage.PARENT)
206 if bbox.contains(geom.PointI(int(xc), int(yc))):
207 measAlg.subtractPsf(psf, subtracted, xc, yc)
208 chi = subtracted.Factory(subtracted, True)
209 var = subtracted.getVariance()
210 np.sqrt(var.getArray(), var.getArray()) # inplace sqrt
211 chi /= var
213 chi_min = np.min(chi.getImage().getArray())
214 chi_max = np.max(chi.getImage().getArray())
215 print(chi_min, chi_max)
217 if chi_lim > 0:
218 self.assertGreater(chi_min, -chi_lim)
219 self.assertLess(chi_max, chi_lim)
221 def checkPiffDeterminer(self, stampSize=None):
222 """Configure PiffPsfDeterminerTask and run basic tests on it.
224 Parameters
225 ----------
226 stampSize : `int`, optional
227 Set ``config.stampSize`` to this, if not None.
228 """
229 self.setupDeterminer(stampSize=stampSize)
230 metadata = dafBase.PropertyList()
232 stars = self.starSelector.run(self.catalog, exposure=self.exposure)
233 psfCandidateList = self.makePsfCandidates.run(
234 stars.sourceCat,
235 exposure=self.exposure
236 ).psfCandidates
237 psf, cellSet = self.psfDeterminer.determinePsf(
238 self.exposure,
239 psfCandidateList,
240 metadata,
241 flagKey=self.usePsfFlag
242 )
243 self.exposure.setPsf(psf)
245 self.assertEqual(len(psfCandidateList), metadata['numAvailStars'])
246 self.assertEqual(sum(self.catalog['use_psf']), metadata['numGoodStars'])
247 self.assertEqual(
248 psf.getAveragePosition(),
249 geom.Point2D(
250 np.mean([s.x for s in psf._piffResult.stars]),
251 np.mean([s.y for s in psf._piffResult.stars])
252 )
253 )
255 # Test how well we can subtract the PSF model
256 self.subtractStars(self.exposure, self.catalog, chi_lim=6.1)
258 # Test bboxes
259 for point in [
260 psf.getAveragePosition(),
261 geom.Point2D(),
262 geom.Point2D(1, 1)
263 ]:
264 self.assertEqual(
265 psf.computeBBox(point),
266 psf.computeKernelImage(point).getBBox()
267 )
268 self.assertEqual(
269 psf.computeKernelBBox(point),
270 psf.computeKernelImage(point).getBBox()
271 )
272 self.assertEqual(
273 psf.computeImageBBox(point),
274 psf.computeImage(point).getBBox()
275 )
277 # Some roundtrips
278 with lsst.utils.tests.getTempFilePath(".fits") as tmpFile:
279 self.exposure.writeFits(tmpFile)
280 fitsIm = afwImage.ExposureF(tmpFile)
281 copyIm = copy.deepcopy(self.exposure)
283 for newIm in [fitsIm, copyIm]:
284 # Piff doesn't enable __eq__ for its results, so we just check
285 # that some PSF images come out the same.
286 for point in [
287 geom.Point2D(0, 0),
288 geom.Point2D(10, 100),
289 geom.Point2D(-200, 30),
290 geom.Point2D(float("nan")) # "nullPoint"
291 ]:
292 self.assertImagesAlmostEqual(
293 psf.computeImage(point),
294 newIm.getPsf().computeImage(point)
295 )
296 # Also check average position
297 newPsf = newIm.getPsf()
298 self.assertImagesAlmostEqual(
299 psf.computeImage(psf.getAveragePosition()),
300 newPsf.computeImage(newPsf.getAveragePosition())
301 )
303 def testPiffDeterminer_default(self):
304 """Test piff with the default config."""
305 self.checkPiffDeterminer()
307 def testPiffDeterminer_kernelSize27(self):
308 """Test Piff with a psf kernelSize of 27."""
309 self.checkPiffDeterminer(27)
311 @lsst.utils.tests.methodParameters(samplingSize=[1.0, 0.9, 1.1])
312 def test_validatePsfCandidates(self, samplingSize):
313 """Test that `_validatePsfCandidates` raises for too-small candidates.
315 This should be independent of the samplingSize parameter.
316 """
317 drawSizeDict = {1.0: 27,
318 0.9: 31,
319 1.1: 25,
320 }
322 makePsfCandidatesConfig = measAlg.MakePsfCandidatesTask.ConfigClass()
323 makePsfCandidatesConfig.kernelSize = 23
324 self.makePsfCandidates = measAlg.MakePsfCandidatesTask(config=makePsfCandidatesConfig)
325 psfCandidateList = self.makePsfCandidates.run(
326 self.catalog,
327 exposure=self.exposure
328 ).psfCandidates
330 psfDeterminerConfig = PiffPsfDeterminerConfig()
331 psfDeterminerConfig.stampSize = drawSizeDict[samplingSize]
332 psfDeterminerConfig.samplingSize = samplingSize
333 self.psfDeterminer = PiffPsfDeterminerTask(psfDeterminerConfig)
335 with self.assertRaisesRegex(RuntimeError,
336 "stampSize=27 "
337 "pixels per side; found 23x23"):
338 self.psfDeterminer._validatePsfCandidates(psfCandidateList, 27)
340 # This should not raise.
341 self.psfDeterminer._validatePsfCandidates(psfCandidateList, 21)
344class PiffConfigTestCase(lsst.utils.tests.TestCase):
345 """A test case to check for valid Piff config"""
346 def testValidateGalsimInterpolant(self):
347 # Check that random strings are not valid interpolants.
348 self.assertFalse(_validateGalsimInterpolant("foo"))
349 # Check that the Lanczos order is an integer
350 self.assertFalse(_validateGalsimInterpolant("Lanczos(3.0"))
351 self.assertFalse(_validateGalsimInterpolant("Lanczos(-5.0"))
352 self.assertFalse(_validateGalsimInterpolant("Lanczos(N)"))
353 # Check for various valid Lanczos interpolants
354 for interp in ("Lanczos(4)", "galsim.Lanczos(7)"):
355 self.assertTrue(_validateGalsimInterpolant(interp))
356 self.assertFalse(_validateGalsimInterpolant(interp.lower()))
357 # Evaluating the string should succeed. This is how Piff does it.
358 self.assertTrue(eval(interp))
359 # Check that interpolation methods are case sensitive.
360 for interp in ("Linear", "Cubic", "Quintic", "Delta", "Nearest", "SincInterpolant"):
361 self.assertFalse(_validateGalsimInterpolant(f"galsim.{interp.lower()}"))
362 self.assertFalse(_validateGalsimInterpolant(interp))
363 self.assertTrue(_validateGalsimInterpolant(f"galsim.{interp}"))
364 self.assertTrue(eval(f"galsim.{interp}"))
366 def testKernelSize(self): # TODO: Remove this test in DM-36311.
367 config = PiffPsfDeterminerConfig()
369 # Setting both stampSize and kernelSize should raise an error.
370 config.stampSize = 27
371 with self.assertWarns(FutureWarning):
372 config.kernelSize = 25
373 self.assertRaises(FieldValidationError, config.validate)
375 # even if they agree with each other
376 config.stampSize = 31
377 with self.assertWarns(FutureWarning):
378 config.kernelSize = 31
379 self.assertRaises(FieldValidationError, config.validate)
381 # Setting stampSize and kernelSize should be valid, because if not
382 # set, stampSize is set to the size of PSF candidates internally.
383 # This is only a temporary behavior and should go away in DM-36311.
384 config.stampSize = None
385 with self.assertWarns(FutureWarning):
386 config.kernelSize = None
387 config.validate()
390class TestMemory(lsst.utils.tests.MemoryTestCase):
391 pass
394def setup_module(module):
395 lsst.utils.tests.init()
398if __name__ == "__main__": 398 ↛ 399line 398 didn't jump to line 399, because the condition on line 398 was never true
399 lsst.utils.tests.init()
400 unittest.main()