Coverage for tests/test_psfDetermination.py: 14%
302 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-30 03:11 -0700
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-30 03:11 -0700
1# This file is part of meas_algorithms.
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 math
23import logging
24import numpy as np
25import unittest
27import lsst.geom
28from lsst.afw.cameraGeom.testUtils import DetectorWrapper
29import lsst.afw.detection as afwDetection
30import lsst.afw.geom as afwGeom
31import lsst.afw.image as afwImage
32import lsst.afw.math as afwMath
33import lsst.afw.table as afwTable
34import lsst.daf.base as dafBase
35import lsst.afw.display as afwDisplay
36from lsst.log import Log
37import lsst.meas.algorithms as measAlg
38from lsst.meas.algorithms.pcaPsfDeterminer import numCandidatesToReject
39from lsst.meas.algorithms.utils import showPsfMosaic, showPsf
40import lsst.meas.base as measBase
41import lsst.utils.tests
43try:
44 type(display)
45except NameError:
46 display = False
47else:
48 afwDisplay.setDefaultMaskTransparency(75)
50# Change the level to Log.DEBUG or Log.TRACE to see debug messages
51Log.getLogger("lsst.measurement").setLevel(Log.INFO)
52Log.getLogger("lsst.psfDeterminer").setLevel(Log.TRACE)
55def psfVal(ix, iy, x, y, sigma1, sigma2, b):
56 """Return the value at (ix, iy) of a double Gaussian
57 (N(0, sigma1^2) + b*N(0, sigma2^2))/(1 + b)
58 centered at (x, y)
59 """
60 return (math.exp(-0.5*((ix - x)**2 + (iy - y)**2)/sigma1**2)
61 + b*math.exp(-0.5*((ix - x)**2 + (iy - y)**2)/sigma2**2))/(1 + b)
64class SpatialModelPsfTestCase(lsst.utils.tests.TestCase):
65 """A test case for SpatialModelPsf"""
67 def measure(self, footprintSet, exposure):
68 """Measure a set of Footprints, returning a SourceCatalog."""
69 table = afwTable.SourceCatalog(self.schema)
70 footprintSet.makeSources(table)
72 # Then run the default SFM task. Results not checked
73 self.measureTask.run(table, exposure)
75 if display:
76 afwDisplay.Display(frame=1).mtv(exposure, title=self._testMethodName + ": image")
78 return table
80 def setUp(self):
82 self.schema = afwTable.SourceTable.makeMinimalSchema()
83 config = measBase.SingleFrameMeasurementConfig()
84 config.algorithms.names = ["base_PixelFlags",
85 "base_SdssCentroid",
86 "base_GaussianFlux",
87 "base_SdssShape",
88 "base_CircularApertureFlux",
89 "base_PsfFlux",
90 ]
91 config.algorithms["base_CircularApertureFlux"].radii = [3.0]
92 config.slots.centroid = "base_SdssCentroid"
93 config.slots.psfFlux = "base_PsfFlux"
94 config.slots.apFlux = "base_CircularApertureFlux_3_0"
95 config.slots.modelFlux = None
96 config.slots.gaussianFlux = None
97 config.slots.calibFlux = None
98 config.slots.shape = "base_SdssShape"
100 self.measureTask = measBase.SingleFrameMeasurementTask(self.schema, config=config)
102 width, height = 110, 301
104 self.mi = afwImage.MaskedImageF(lsst.geom.ExtentI(width, height))
105 self.mi.set(0)
106 sd = 3 # standard deviation of image
107 self.mi.getVariance().set(sd*sd)
108 self.mi.getMask().addMaskPlane("DETECTED")
110 self.FWHM = 5
111 self.ksize = 31 # size of desired kernel
113 sigma1 = 1.75
114 sigma2 = 2*sigma1
116 self.exposure = afwImage.makeExposure(self.mi)
117 self.exposure.setPsf(measAlg.DoubleGaussianPsf(self.ksize, self.ksize,
118 1.5*sigma1, 1, 0.1))
119 self.exposure.setDetector(DetectorWrapper().detector)
121 #
122 # Make a kernel with the exactly correct basis functions. Useful for debugging
123 #
124 basisKernelList = []
125 for sigma in (sigma1, sigma2):
126 basisKernel = afwMath.AnalyticKernel(self.ksize, self.ksize,
127 afwMath.GaussianFunction2D(sigma, sigma))
128 basisImage = afwImage.ImageD(basisKernel.getDimensions())
129 basisKernel.computeImage(basisImage, True)
130 basisImage /= np.sum(basisImage.getArray())
132 if sigma == sigma1:
133 basisImage0 = basisImage
134 else:
135 basisImage -= basisImage0
137 basisKernelList.append(afwMath.FixedKernel(basisImage))
139 order = 1 # 1 => up to linear
140 spFunc = afwMath.PolynomialFunction2D(order)
142 exactKernel = afwMath.LinearCombinationKernel(basisKernelList, spFunc)
143 exactKernel.setSpatialParameters([[1.0, 0, 0],
144 [0.0, 0.5*1e-2, 0.2e-2]])
145 self.exactPsf = measAlg.PcaPsf(exactKernel)
147 rand = afwMath.Random() # make these tests repeatable by setting seed
149 addNoise = True
151 if addNoise:
152 im = self.mi.getImage()
153 afwMath.randomGaussianImage(im, rand) # N(0, 1)
154 im *= sd # N(0, sd^2)
155 del im
157 xarr, yarr = [], []
159 for x, y in [(20, 20), (60, 20),
160 (30, 35),
161 (50, 50),
162 (20, 90), (70, 160), (25, 265), (75, 275), (85, 30),
163 (50, 120), (70, 80),
164 (60, 210), (20, 210),
165 ]:
166 xarr.append(x)
167 yarr.append(y)
169 for x, y in zip(xarr, yarr):
170 dx = rand.uniform() - 0.5 # random (centered) offsets
171 dy = rand.uniform() - 0.5
173 k = exactKernel.getSpatialFunction(1)(x, y) # functional variation of Kernel ...
174 b = (k*sigma1**2/((1 - k)*sigma2**2)) # ... converted double Gaussian's "b"
176 # flux = 80000 - 20*x - 10*(y/float(height))**2
177 flux = 80000*(1 + 0.1*(rand.uniform() - 0.5))
178 I0 = flux*(1 + b)/(2*np.pi*(sigma1**2 + b*sigma2**2))
179 for iy in range(y - self.ksize//2, y + self.ksize//2 + 1):
180 if iy < 0 or iy >= self.mi.getHeight():
181 continue
183 for ix in range(x - self.ksize//2, x + self.ksize//2 + 1):
184 if ix < 0 or ix >= self.mi.getWidth():
185 continue
187 intensity = I0*psfVal(ix, iy, x + dx, y + dy, sigma1, sigma2, b)
188 Isample = rand.poisson(intensity) if addNoise else intensity
189 self.mi.image[ix, iy, afwImage.LOCAL] += Isample
190 self.mi.variance[ix, iy, afwImage.LOCAL] += intensity
191 #
192 bbox = lsst.geom.BoxI(lsst.geom.PointI(0, 0), lsst.geom.ExtentI(width, height))
193 self.cellSet = afwMath.SpatialCellSet(bbox, 100)
195 self.footprintSet = afwDetection.FootprintSet(self.mi, afwDetection.Threshold(100), "DETECTED")
196 self.catalog = self.measure(self.footprintSet, self.exposure)
198 for source in self.catalog:
199 try:
200 cand = measAlg.makePsfCandidate(source, self.exposure)
201 self.cellSet.insertCandidate(cand)
203 except Exception as e:
204 print(e)
205 continue
207 def tearDown(self):
208 del self.cellSet
209 del self.exposure
210 del self.mi
211 del self.exactPsf
212 del self.footprintSet
213 del self.catalog
214 del self.schema
215 del self.measureTask
217 def setupDeterminer(self, exposure=None, nEigenComponents=2, starSelectorAlg="objectSize"):
218 """Setup the starSelector and psfDeterminer."""
219 if exposure is None:
220 exposure = self.exposure
222 starSelectorClass = measAlg.sourceSelectorRegistry[starSelectorAlg]
223 starSelectorConfig = starSelectorClass.ConfigClass()
225 if starSelectorAlg == "objectSize":
226 starSelectorConfig.sourceFluxField = "base_GaussianFlux_instFlux"
227 starSelectorConfig.badFlags = ["base_PixelFlags_flag_edge",
228 "base_PixelFlags_flag_interpolatedCenter",
229 "base_PixelFlags_flag_saturatedCenter",
230 "base_PixelFlags_flag_crCenter",
231 ]
232 starSelectorConfig.widthStdAllowed = 0.5
234 self.starSelector = starSelectorClass(config=starSelectorConfig)
236 self.makePsfCandidates = measAlg.MakePsfCandidatesTask()
238 psfDeterminerTask = measAlg.psfDeterminerRegistry["pca"]
239 psfDeterminerConfig = psfDeterminerTask.ConfigClass()
240 width, height = exposure.getMaskedImage().getDimensions()
241 psfDeterminerConfig.sizeCellX = width
242 psfDeterminerConfig.sizeCellY = height//3
243 psfDeterminerConfig.nEigenComponents = nEigenComponents
244 psfDeterminerConfig.spatialOrder = 1
245 psfDeterminerConfig.stampSize = 31
246 psfDeterminerConfig.nStarPerCell = 0
247 psfDeterminerConfig.nStarPerCellSpatialFit = 0 # unlimited
248 self.psfDeterminer = psfDeterminerTask(psfDeterminerConfig)
250 def subtractStars(self, exposure, catalog, chi_lim=-1):
251 """Subtract the exposure's PSF from all the sources in catalog."""
252 mi, psf = exposure.getMaskedImage(), exposure.getPsf()
254 subtracted = mi.Factory(mi, True)
256 for s in catalog:
257 xc, yc = s.getX(), s.getY()
258 bbox = subtracted.getBBox()
259 if bbox.contains(lsst.geom.PointI(int(xc), int(yc))):
260 measAlg.subtractPsf(psf, subtracted, xc, yc)
262 chi = subtracted.Factory(subtracted, True)
263 var = subtracted.getVariance()
264 np.sqrt(var.getArray(), var.getArray()) # inplace sqrt
265 chi /= var
267 if display:
268 afwDisplay.Display(frame=0).mtv(subtracted, title=self._testMethodName + ": Subtracted")
269 afwDisplay.Display(frame=2).mtv(chi, title=self._testMethodName + ": Chi")
270 afwDisplay.Display(frame=3).mtv(psf.computeImage(lsst.geom.Point2D(xc, yc)),
271 title=self._testMethodName + ": Psf")
272 afwDisplay.Display(frame=4).mtv(mi, title=self._testMethodName + ": orig")
273 kern = psf.getKernel()
274 kimg = afwImage.ImageD(kern.getWidth(), kern.getHeight())
275 kern.computeImage(kimg, True, xc, yc)
276 afwDisplay.Display(frame=5).mtv(kimg, title=self._testMethodName + ": kernel")
278 chi_min, chi_max = np.min(chi.getImage().getArray()), np.max(chi.getImage().getArray())
279 if False:
280 print(chi_min, chi_max)
282 if chi_lim > 0:
283 self.assertGreater(chi_min, -chi_lim)
284 self.assertLess(chi_max, chi_lim)
286 def testPsfDeterminerObjectSize(self):
287 self._testPsfDeterminer("objectSize")
289 def _testPsfDeterminer(self, starSelectorAlg):
290 self.setupDeterminer(starSelectorAlg=starSelectorAlg)
291 metadata = dafBase.PropertyList()
293 stars = self.starSelector.run(self.catalog, exposure=self.exposure)
294 psfCandidateList = self.makePsfCandidates.run(stars.sourceCat, self.exposure).psfCandidates
296 psf, cellSet = self.psfDeterminer.determinePsf(self.exposure, psfCandidateList, metadata)
297 self.exposure.setPsf(psf)
299 chi_lim = 5.0
300 self.subtractStars(self.exposure, self.catalog, chi_lim)
302 def testPsfDeterminerSubimageObjectSizeStarSelector(self):
303 """Test the (PCA) psfDeterminer on subImages."""
304 w, h = self.exposure.getDimensions()
305 x0, y0 = int(0.35*w), int(0.45*h)
306 bbox = lsst.geom.BoxI(lsst.geom.PointI(x0, y0), lsst.geom.ExtentI(w-x0, h-y0))
307 subExp = self.exposure.Factory(self.exposure, bbox, afwImage.LOCAL)
309 self.setupDeterminer(subExp, starSelectorAlg="objectSize")
310 metadata = dafBase.PropertyList()
311 #
312 # Only keep the sources that lie within the subregion (avoiding lots of log messages)
313 #
315 def trimCatalogToImage(exp, catalog):
316 trimmedCatalog = afwTable.SourceCatalog(catalog.table.clone())
317 for s in catalog:
318 if exp.getBBox().contains(lsst.geom.PointI(s.getCentroid())):
319 trimmedCatalog.append(trimmedCatalog.table.copyRecord(s))
321 return trimmedCatalog
323 stars = self.starSelector.run(trimCatalogToImage(subExp, self.catalog), exposure=subExp)
324 psfCandidateList = self.makePsfCandidates.run(stars.sourceCat, subExp).psfCandidates
326 psf, cellSet = self.psfDeterminer.determinePsf(subExp, psfCandidateList, metadata)
327 subExp.setPsf(psf)
329 # Test how well we can subtract the PSF model. N.b. using self.exposure is an extrapolation
330 for exp, chi_lim in [(subExp, 4.5),
331 (self.exposure.Factory(self.exposure,
332 lsst.geom.BoxI(lsst.geom.PointI(0, 100),
333 (lsst.geom.PointI(w-1, h-1))),
334 afwImage.LOCAL), 7.5),
335 (self.exposure, 19),
336 ]:
337 cat = trimCatalogToImage(exp, self.catalog)
338 exp.setPsf(psf)
339 self.subtractStars(exp, cat, chi_lim)
341 def testPsfDeterminerNEigenObjectSizeStarSelector(self):
342 """Test the (PCA) psfDeterminer when you ask for more components than acceptable stars."""
343 self.setupDeterminer(nEigenComponents=3, starSelectorAlg="objectSize")
344 metadata = dafBase.PropertyList()
346 stars = self.starSelector.run(self.catalog, exposure=self.exposure)
347 psfCandidateList = self.makePsfCandidates.run(stars.sourceCat, self.exposure).psfCandidates
349 psfCandidateList, nEigen = psfCandidateList[0:4], 2 # only enough stars for 2 eigen-components
350 psf, cellSet = self.psfDeterminer.determinePsf(self.exposure, psfCandidateList, metadata)
352 self.assertEqual(psf.getKernel().getNKernelParameters(), nEigen)
354 def testCandidateList(self):
355 self.assertFalse(self.cellSet.getCellList()[0].empty())
356 self.assertTrue(self.cellSet.getCellList()[1].empty())
357 self.assertFalse(self.cellSet.getCellList()[2].empty())
358 self.assertTrue(self.cellSet.getCellList()[3].empty())
360 stamps = []
361 for cell in self.cellSet.getCellList():
362 for cand in cell:
363 cand = cell[0]
364 width, height = 29, 25
365 cand.setWidth(width)
366 cand.setHeight(height)
368 im = cand.getMaskedImage()
369 stamps.append(im)
371 self.assertEqual(im.getWidth(), width)
372 self.assertEqual(im.getHeight(), height)
374 if False and display:
375 mos = afwDisplay.utils.Mosaic()
376 mos.makeMosaic(stamps, frame=2)
378 def testRejectBlends(self):
379 """Test the PcaPsfDeterminerTask blend removal."""
380 """
381 We give it a single blended source, asking it to remove blends,
382 and check that it barfs in the expected way.
383 """
385 psfDeterminerClass = measAlg.psfDeterminerRegistry["pca"]
386 config = psfDeterminerClass.ConfigClass()
387 config.doRejectBlends = True
388 psfDeterminer = psfDeterminerClass(config=config)
390 schema = afwTable.SourceTable.makeMinimalSchema()
391 # Use The single frame measurement task to populate the schema with standard keys
392 measBase.SingleFrameMeasurementTask(schema)
393 catalog = afwTable.SourceCatalog(schema)
394 source = catalog.addNew()
396 # Make the source blended, with necessary information to calculate pca
397 spanShift = lsst.geom.Point2I(54, 123)
398 spans = afwGeom.SpanSet.fromShape(6, offset=spanShift)
399 foot = afwDetection.Footprint(spans, self.exposure.getBBox())
400 foot.addPeak(45, 123, 6)
401 foot.addPeak(47, 126, 5)
402 source.setFootprint(foot)
403 centerKey = afwTable.Point2DKey(source.schema['slot_Centroid'])
404 shapeKey = afwTable.QuadrupoleKey(schema['slot_Shape'])
405 source.set(centerKey, lsst.geom.Point2D(46, 124))
406 source.set(shapeKey, afwGeom.Quadrupole(1.1, 2.2, 1))
408 candidates = [measAlg.makePsfCandidate(source, self.exposure)]
409 metadata = dafBase.PropertyList()
411 with self.assertRaises(RuntimeError) as cm:
412 psfDeterminer.determinePsf(self.exposure, candidates, metadata)
413 self.assertEqual(str(cm.exception), "All PSF candidates removed as blends")
415 def testShowPsfMosaic(self):
416 """ Test that the showPsfMosaic function works.
418 This function is usually called without display=None, which would activate ds9
419 """
420 testDisplay = display if display else afwDisplay.getDisplay(backend="virtualDevice")
421 mos = showPsfMosaic(self.exposure, showEllipticity=True, showFwhm=True, display=testDisplay)
422 self.assertTrue(len(mos.images) > 0)
424 def testShowPsf(self):
425 """ Test that the showPsfMosaic function works.
427 This function is usually called without display=None, which would activate ds9
428 """
430 # Measure PSF so we have a real PSF to work with
431 self.setupDeterminer()
432 metadata = dafBase.PropertyList()
433 stars = self.starSelector.run(self.catalog, exposure=self.exposure)
434 psfCandidateList = self.makePsfCandidates.run(stars.sourceCat, self.exposure).psfCandidates
435 psf, cellSet = self.psfDeterminer.determinePsf(self.exposure, psfCandidateList, metadata)
436 testDisplay = display if display else afwDisplay.getDisplay(backend="virtualDevice")
437 mos = showPsf(psf, display=testDisplay)
438 self.assertTrue(len(mos.images) > 0)
440 def testDownsampleBase(self):
441 """Test that the downsampleCandidates function works.
442 """
443 self.setupDeterminer()
445 # Note that the downsampleCandidates function is designed to work
446 # with a list of psf candidates, it can work with any list.
447 # For these tests we use a list of integers which allows easier
448 # testing that the sort order is maintained.
450 # Try with no downsampling.
451 inputList = list(np.arange(100))
452 candidateList = self.psfDeterminer.downsampleCandidates(inputList)
453 np.testing.assert_array_equal(candidateList, inputList)
455 # And with downsampling.
456 inputList = list(np.arange(500))
457 with self.assertLogs(level=logging.INFO) as cm:
458 candidateList = self.psfDeterminer.downsampleCandidates(inputList)
459 self.assertIn("Down-sampling from 500 to 300 psf candidates.", cm[0][0].message)
460 self.assertEqual(len(candidateList), self.psfDeterminer.config.maxCandidates)
461 np.testing.assert_array_equal(np.sort(candidateList), candidateList)
462 self.assertEqual(len(np.unique(candidateList)), len(candidateList))
464 def testDownsamplePca(self):
465 """Test PCA determiner with downsampling.
466 """
467 self.setupDeterminer()
468 metadata = dafBase.PropertyList()
470 # Decrease the maximum number of stars.
471 self.psfDeterminer.config.maxCandidates = 10
473 stars = self.starSelector.run(self.catalog, exposure=self.exposure)
474 psfCandidateList = self.makePsfCandidates.run(stars.sourceCat, self.exposure).psfCandidates
475 psf, cellSet = self.psfDeterminer.determinePsf(self.exposure, psfCandidateList, metadata)
477 self.assertEqual(metadata['numAvailStars'], self.psfDeterminer.config.maxCandidates)
478 self.assertLessEqual(metadata['numGoodStars'], self.psfDeterminer.config.maxCandidates)
481class PsfCandidateTestCase(lsst.utils.tests.TestCase):
482 def testNumToReject(self):
483 """Reject the correct number of PSF candidates on each iteration"""
484 # Numerical values correspond to the problem case identified in
485 # DM-8030.
487 numBadCandidates = 5
488 totalIter = 3
490 for numIter, value in [(0, 1), (1, 3), (2, 5)]:
491 self.assertEqual(numCandidatesToReject(numBadCandidates, numIter,
492 totalIter), value)
495class TestMemory(lsst.utils.tests.MemoryTestCase):
496 pass
499def setup_module(module):
500 lsst.utils.tests.init()
503if __name__ == "__main__": 503 ↛ 504line 503 didn't jump to line 504, because the condition on line 503 was never true
504 lsst.utils.tests.init()
505 unittest.main()