Coverage for tests/test_psf.py: 14%

216 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2024-01-17 19:37 +0000

1import galsim # noqa: F401 

2import unittest 

3import numpy as np 

4import copy 

5from galsim import Lanczos # noqa: F401 

6 

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 

20 

21 

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 

32 

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) 

35 

36 

37class SpatialModelPsfTestCase(lsst.utils.tests.TestCase): 

38 """A test case for SpatialModelPsf""" 

39 

40 def measure(self, footprintSet, exposure): 

41 """Measure a set of Footprints, returning a SourceCatalog""" 

42 catalog = afwTable.SourceCatalog(self.schema) 

43 

44 footprintSet.makeSources(catalog) 

45 

46 self.measureSources.run(catalog, exposure) 

47 return catalog 

48 

49 def setUp(self): 

50 config = SingleFrameMeasurementTask.ConfigClass() 

51 config.slots.apFlux = 'base_CircularApertureFlux_12_0' 

52 self.schema = afwTable.SourceTable.makeMinimalSchema() 

53 

54 self.measureSources = SingleFrameMeasurementTask( 

55 self.schema, config=config 

56 ) 

57 self.usePsfFlag = self.schema.addField("use_psf", type="Flag") 

58 

59 width, height = 110, 301 

60 

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") 

66 

67 self.ksize = 31 # size of desired kernel 

68 

69 sigma1 = 1.75 

70 sigma2 = 2*sigma1 

71 

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) 

81 

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()) 

94 

95 if sigma == sigma1: 

96 basisImage0 = basisImage 

97 else: 

98 basisImage -= basisImage0 

99 

100 basisKernelList.append(afwMath.FixedKernel(basisImage)) 

101 

102 order = 1 # 1 => up to linear 

103 spFunc = afwMath.PolynomialFunction2D(order) 

104 

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 ) 

110 

111 rand = afwMath.Random() # make these tests repeatable by setting seed 

112 

113 im = self.mi.getImage() 

114 afwMath.randomGaussianImage(im, rand) # N(0, 1) 

115 im *= sd # N(0, sd^2) 

116 

117 xarr, yarr = [], [] 

118 

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) 

128 

129 for x, y in zip(xarr, yarr): 

130 dx = rand.uniform() - 0.5 # random (centered) offsets 

131 dy = rand.uniform() - 0.5 

132 

133 k = exactKernel.getSpatialFunction(1)(x, y) 

134 b = (k*sigma1**2/((1 - k)*sigma2**2)) 

135 

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 

141 

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 

145 

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 

150 

151 bbox = geom.BoxI(geom.PointI(0, 0), geom.ExtentI(width, height)) 

152 self.cellSet = afwMath.SpatialCellSet(bbox, 100) 

153 

154 self.footprintSet = afwDetection.FootprintSet( 

155 self.mi, afwDetection.Threshold(100), "DETECTED" 

156 ) 

157 

158 self.catalog = self.measure(self.footprintSet, self.exposure) 

159 

160 for source in self.catalog: 

161 cand = measAlg.makePsfCandidate(source, self.exposure) 

162 self.cellSet.insertCandidate(cand) 

163 

164 def setupDeterminer(self, stampSize=None, debugStarData=False): 

165 """Setup the starSelector and psfDeterminer 

166 

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 

183 

184 self.starSelector = starSelectorClass(config=starSelectorConfig) 

185 

186 makePsfCandidatesConfig = measAlg.MakePsfCandidatesTask.ConfigClass() 

187 if stampSize is not None: 

188 makePsfCandidatesConfig.kernelSize = stampSize 

189 self.makePsfCandidates = measAlg.MakePsfCandidatesTask(config=makePsfCandidatesConfig) 

190 

191 psfDeterminerConfig = PiffPsfDeterminerConfig() 

192 psfDeterminerConfig.spatialOrder = 1 

193 if stampSize is not None: 

194 psfDeterminerConfig.stampSize = stampSize 

195 psfDeterminerConfig.debugStarData = debugStarData 

196 

197 self.psfDeterminer = PiffPsfDeterminerTask(psfDeterminerConfig) 

198 

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() 

202 

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 

213 

214 chi_min = np.min(chi.getImage().getArray()) 

215 chi_max = np.max(chi.getImage().getArray()) 

216 print(chi_min, chi_max) 

217 

218 if chi_lim > 0: 

219 self.assertGreater(chi_min, -chi_lim) 

220 self.assertLess(chi_max, chi_lim) 

221 

222 def checkPiffDeterminer(self, stampSize=None, debugStarData=False): 

223 """Configure PiffPsfDeterminerTask and run basic tests on it. 

224 

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() 

232 

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) 

245 

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__) 

259 

260 # Test how well we can subtract the PSF model 

261 self.subtractStars(self.exposure, self.catalog, chi_lim=6.1) 

262 

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 ) 

281 

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) 

287 

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 ) 

307 

308 def testPiffDeterminer_default(self): 

309 """Test piff with the default config.""" 

310 self.checkPiffDeterminer() 

311 

312 def testPiffDeterminer_kernelSize27(self): 

313 """Test Piff with a psf kernelSize of 27.""" 

314 self.checkPiffDeterminer(27) 

315 

316 def testPiffDeterminer_debugStarData(self): 

317 """Test Piff with debugStarData=True.""" 

318 self.checkPiffDeterminer(debugStarData=True) 

319 

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. 

323 

324 This should be independent of the samplingSize parameter. 

325 """ 

326 drawSizeDict = {1.0: 27, 

327 0.9: 31, 

328 1.1: 25, 

329 } 

330 

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 

338 

339 psfDeterminerConfig = PiffPsfDeterminerConfig() 

340 psfDeterminerConfig.stampSize = drawSizeDict[samplingSize] 

341 psfDeterminerConfig.samplingSize = samplingSize 

342 self.psfDeterminer = PiffPsfDeterminerTask(psfDeterminerConfig) 

343 

344 with self.assertRaisesRegex(RuntimeError, 

345 "stampSize=27 " 

346 "pixels per side; found 23x23"): 

347 self.psfDeterminer._validatePsfCandidates(psfCandidateList, 27) 

348 

349 # This should not raise. 

350 self.psfDeterminer._validatePsfCandidates(psfCandidateList, 21) 

351 

352 

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}")) 

374 

375 def testKernelSize(self): # TODO: Remove this test in DM-36311. 

376 config = PiffPsfDeterminerConfig() 

377 

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) 

383 

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) 

389 

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() 

397 

398 

399class TestMemory(lsst.utils.tests.MemoryTestCase): 

400 pass 

401 

402 

403def setup_module(module): 

404 lsst.utils.tests.init() 

405 

406 

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()