Coverage for tests/test_hsm.py: 14%

579 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2024-01-10 13: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/>. 

21 

22import itertools 

23import os 

24import unittest 

25 

26import galsim 

27import lsst.afw.detection as afwDetection 

28import lsst.afw.geom as afwGeom 

29import lsst.afw.geom.ellipses as afwEll 

30import lsst.afw.image as afwImage 

31import lsst.afw.math as afwMath 

32import lsst.afw.table as afwTable 

33import lsst.geom as geom 

34import lsst.meas.algorithms as algorithms 

35import lsst.meas.base as base 

36import lsst.meas.base.tests 

37import lsst.meas.extensions.shapeHSM as shapeHSM 

38import lsst.pex.config as pexConfig 

39import lsst.utils.tests 

40import numpy as np 

41from lsst.daf.base import PropertySet 

42 

43SIZE_DECIMALS = 2 # Number of decimals for equality in sizes 

44SHAPE_DECIMALS = 3 # Number of decimals for equality in shapes 

45 

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 [ 

56 [0.467603106752, 0.381211727, 0.398856937, 0.401755571], 

57 [0.28618443944, 0.199222784, 0.233883543, 0.234257525], 

58 [0.271533794146, 0.158049396, 0.183517068, 0.184893412], 

59 [-0.293754156071, -0.457024541, 0.123946584, -0.609233462], 

60 [0.557720893779, 0.374143023, 0.714147448, 0.435404409], 

61 ] 

62) 

63e2_expected = np.array( 

64 [ 

65 [-0.867225166489, -0.734855778, -0.777027588, -0.774684891], 

66 [-0.469354341577, -0.395520479, -0.502540961, -0.464466257], 

67 [-0.519775291311, -0.471589061, -0.574750641, -0.529664935], 

68 [0.345688365839, -0.342047099, 0.120603755, -0.44609129428863525], 

69 [0.525728304099, 0.370691830, 0.702724807, 0.433999442], 

70 ] 

71) 

72resolution_expected = np.array( 

73 [ 

74 [0.796144249, 0.835624917, 0.835624917, 0.827796187], 

75 [0.685023735, 0.699602704, 0.699602704, 0.659457638], 

76 [0.634736458, 0.651040481, 0.651040481, 0.614663396], 

77 [0.477027015, 0.477210752, 0.477210752, 0.423157447], 

78 [0.595205998, 0.611824797, 0.611824797, 0.563582092], 

79 ] 

80) 

81sigma_e_expected = np.array( 

82 [ 

83 [0.016924826, 0.014637648, 0.014637648, 0.014465546], 

84 [0.075769504, 0.073602324, 0.073602324, 0.064414520], 

85 [0.110253112, 0.106222900, 0.106222900, 0.099357106], 

86 [0.185276702, 0.184300955, 0.184300955, 0.173478300], 

87 [0.073020065, 0.070270966, 0.070270966, 0.061856263], 

88 ] 

89) 

90# End of GalSim's values 

91 

92# These values calculated using GalSim's HSM as part of GalSim 

93galsim_e1 = np.array( 

94 [ 

95 [0.399292618036, 0.381213068962, 0.398856908083, 0.401749581099], 

96 [0.155929282308, 0.199228107929, 0.233882278204, 0.234371587634], 

97 [0.150018423796, 0.158052951097, 0.183515056968, 0.184561833739], 

98 [-2.6984937191, -0.457033962011, 0.123932465911, -0.60886412859], 

99 [0.33959621191, 0.374140143394, 0.713756918907, 0.43560180068], 

100 ] 

101) 

102galsim_e2 = np.array( 

103 [ 

104 [-0.74053555727, -0.734855830669, -0.777024209499, -0.774700462818], 

105 [-0.25573053956, -0.395517915487, -0.50251352787, -0.464388132095], 

106 [-0.287168383598, -0.471584022045, -0.574719130993, -0.5296921134], 

107 [3.1754450798, -0.342054128647, 0.120592080057, -0.446093201637], 

108 [0.320115834475, 0.370669454336, 0.702303349972, 0.433968126774], 

109 ] 

110) 

111galsim_resolution = np.array( 

112 [ 

113 [0.79614430666, 0.835625052452, 0.835625052452, 0.827822327614], 

114 [0.685023903847, 0.699601829052, 0.699601829052, 0.659438848495], 

115 [0.634736537933, 0.651039719582, 0.651039719582, 0.614759743214], 

116 [0.477026551962, 0.47721144557, 0.47721144557, 0.423227936029], 

117 [0.595205545425, 0.611821532249, 0.611821532249, 0.563564240932], 

118 ] 

119) 

120galsim_err = np.array( 

121 [ 

122 [0.0169247947633, 0.0146376201883, 0.0146376201883, 0.0144661813974], 

123 [0.0757696777582, 0.0736026018858, 0.0736026018858, 0.0644160583615], 

124 [0.110252402723, 0.106222368777, 0.106222368777, 0.0993555411696], 

125 [0.185278102756, 0.184301897883, 0.184301897883, 0.17346136272], 

126 [0.0730196461082, 0.0702708885074, 0.0702708885074, 0.0618583671749], 

127 ] 

128) 

129 

130moments_expected = np.array( 

131 [ # sigma, e1, e2 

132 [2.24490427971, 0.336240686301, -0.627372910656], 

133 [1.9031778574, 0.150566105384, -0.245272792302], 

134 [1.77790760994, 0.112286123389, -0.286203939641], 

135 [1.45464873314, -0.155597168978, -0.102008266223], 

136 [1.63144648075, 0.22886961923, 0.228813588897], 

137 ] 

138) 

139centroid_expected = np.array( 

140 [ # x, y 

141 [36.218247328, 20.5678722157], 

142 [20.325744838, 25.4176650386], 

143 [9.54257706283, 12.6134786199], 

144 [20.6407850048, 39.5864802706], 

145 [58.5008586442, 28.2850942049], 

146 ] 

147) 

148 

149round_moments_expected = np.array( 

150 [ # sigma, e1, e2, flux, x, y 

151 [2.40270376205, 0.197810277343, -0.372329413891, 3740.22436523, 36.4032272633, 20.4847916447], 

152 [1.89714717865, 0.046496052295, -0.0987404286861, 776.709594727, 20.2893584046, 25.4230368047], 

153 [1.77995181084, 0.0416346564889, -0.143147706985, 534.59197998, 9.51994111869, 12.6250775205], 

154 [1.46549296379, -0.0831127092242, -0.0628845766187, 348.294403076, 20.6242279632, 39.5941625731], 

155 [1.64031589031, 0.0867517963052, 0.0940798297524, 793.374450684, 58.4728765002, 28.2686937854], 

156 ] 

157) 

158 

159 

160def makePluginAndCat(alg, name, control=None, metadata=False, centroid=None, psfflux=None, addFlux=False): 

161 if control is None: 

162 control = alg.ConfigClass() 

163 if addFlux: 

164 control.addFlux = True 

165 schema = afwTable.SourceTable.makeMinimalSchema() 

166 if centroid: 

167 lsst.afw.table.Point2DKey.addFields(schema, centroid, "centroid", "pixel") 

168 schema.getAliasMap().set("slot_Centroid", centroid) 

169 if psfflux: 

170 base.PsfFluxAlgorithm(base.PsfFluxControl(), psfflux, schema) 

171 schema.getAliasMap().set("slot_PsfFlux", psfflux) 

172 if metadata: 

173 plugin = alg(control, name, schema, PropertySet()) 

174 else: 

175 plugin = alg(control, name, schema) 

176 cat = afwTable.SourceCatalog(schema) 

177 if centroid: 

178 cat.defineCentroid(centroid) 

179 return plugin, cat 

180 

181 

182class MomentsTestCase(unittest.TestCase): 

183 """A test case for shape measurement""" 

184 

185 def setUp(self): 

186 # load the known values 

187 self.dataDir = os.path.join(os.getenv("MEAS_EXTENSIONS_SHAPEHSM_DIR"), "tests", "data") 

188 self.bkgd = 1000.0 # standard for atlas image 

189 self.offset = geom.Extent2I(1234, 1234) 

190 self.xy0 = geom.Point2I(5678, 9876) 

191 

192 def tearDown(self): 

193 del self.offset 

194 del self.xy0 

195 

196 def runMeasurement(self, algorithmName, imageid, x, y, v, addFlux=False, maskAll=False): 

197 """Run the measurement algorithm on an image""" 

198 # load the test image 

199 imgFile = os.path.join(self.dataDir, "image.%d.fits" % imageid) 

200 img = afwImage.ImageF(imgFile) 

201 img -= self.bkgd 

202 nx, ny = img.getWidth(), img.getHeight() 

203 msk = afwImage.Mask(geom.Extent2I(nx, ny), 0x0) 

204 var = afwImage.ImageF(geom.Extent2I(nx, ny), v) 

205 mimg = afwImage.MaskedImageF(img, msk, var) 

206 msk.getArray()[:] = np.where(np.fabs(img.getArray()) < 1.0e-8, msk.getPlaneBitMask("BAD"), 0) 

207 if maskAll: 

208 msk.array[:] |= msk.getPlaneBitMask("BAD") 

209 

210 # Put it in a bigger image, in case it matters 

211 big = afwImage.MaskedImageF(self.offset + mimg.getDimensions()) 

212 big.getImage().set(0) 

213 big.getMask().set(0) 

214 big.getVariance().set(v) 

215 subBig = afwImage.MaskedImageF(big, geom.Box2I(big.getXY0() + self.offset, mimg.getDimensions())) 

216 subBig.assign(mimg) 

217 mimg = big 

218 mimg.setXY0(self.xy0) 

219 

220 exposure = afwImage.makeExposure(mimg) 

221 cdMatrix = np.array([1.0 / (2.53 * 3600.0), 0.0, 0.0, 1.0 / (2.53 * 3600.0)]) 

222 cdMatrix.shape = (2, 2) 

223 exposure.setWcs( 

224 afwGeom.makeSkyWcs( 

225 crpix=geom.Point2D(1.0, 1.0), crval=geom.SpherePoint(0, 0, geom.degrees), cdMatrix=cdMatrix 

226 ) 

227 ) 

228 

229 # load the corresponding test psf 

230 psfFile = os.path.join(self.dataDir, "psf.%d.fits" % imageid) 

231 psfImg = afwImage.ImageD(psfFile) 

232 psfImg -= self.bkgd 

233 

234 kernel = afwMath.FixedKernel(psfImg) 

235 kernelPsf = algorithms.KernelPsf(kernel) 

236 exposure.setPsf(kernelPsf) 

237 

238 # perform the shape measurement 

239 msConfig = base.SingleFrameMeasurementConfig() 

240 msConfig.plugins.names |= [algorithmName] 

241 control = msConfig.plugins[algorithmName] 

242 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass 

243 # NOTE: It is essential to remove the floating point part of the position for the 

244 # Algorithm._apply. Otherwise, when the PSF is realised it will have been warped 

245 # to account for the sub-pixel offset and we won't get *exactly* this PSF. 

246 plugin, table = makePluginAndCat( 

247 alg, algorithmName, control, centroid="centroid", metadata=True, addFlux=addFlux 

248 ) 

249 center = geom.Point2D(int(x), int(y)) + geom.Extent2D(self.offset + geom.Extent2I(self.xy0)) 

250 source = table.makeRecord() 

251 source.set("centroid_x", center.getX()) 

252 source.set("centroid_y", center.getY()) 

253 source.setFootprint(afwDetection.Footprint(afwGeom.SpanSet(exposure.getBBox(afwImage.PARENT)))) 

254 plugin.measure(source, exposure) 

255 

256 return source 

257 

258 def testHsmSourceMoments(self): 

259 for i, imageid in enumerate(file_indices): 

260 source = self.runMeasurement( 

261 "ext_shapeHSM_HsmSourceMoments", imageid, x_centroid[i], y_centroid[i], sky_var[i] 

262 ) 

263 x = source.get("ext_shapeHSM_HsmSourceMoments_x") 

264 y = source.get("ext_shapeHSM_HsmSourceMoments_y") 

265 xx = source.get("ext_shapeHSM_HsmSourceMoments_xx") 

266 yy = source.get("ext_shapeHSM_HsmSourceMoments_yy") 

267 xy = source.get("ext_shapeHSM_HsmSourceMoments_xy") 

268 

269 # Centroids from GalSim use the FITS lower-left corner of 1,1 

270 offset = self.xy0 + self.offset 

271 self.assertAlmostEqual(x - offset.getX(), centroid_expected[i][0] - 1, 3) 

272 self.assertAlmostEqual(y - offset.getY(), centroid_expected[i][1] - 1, 3) 

273 

274 expected = afwEll.Quadrupole( 

275 afwEll.SeparableDistortionDeterminantRadius( 

276 moments_expected[i][1], moments_expected[i][2], moments_expected[i][0] 

277 ) 

278 ) 

279 

280 self.assertAlmostEqual(xx, expected.getIxx(), SHAPE_DECIMALS) 

281 self.assertAlmostEqual(xy, expected.getIxy(), SHAPE_DECIMALS) 

282 self.assertAlmostEqual(yy, expected.getIyy(), SHAPE_DECIMALS) 

283 

284 def testHsmSourceMomentsRound(self): 

285 for i, imageid in enumerate(file_indices): 

286 source = self.runMeasurement( 

287 "ext_shapeHSM_HsmSourceMomentsRound", 

288 imageid, 

289 x_centroid[i], 

290 y_centroid[i], 

291 sky_var[i], 

292 addFlux=True, 

293 ) 

294 x = source.get("ext_shapeHSM_HsmSourceMomentsRound_x") 

295 y = source.get("ext_shapeHSM_HsmSourceMomentsRound_y") 

296 xx = source.get("ext_shapeHSM_HsmSourceMomentsRound_xx") 

297 yy = source.get("ext_shapeHSM_HsmSourceMomentsRound_yy") 

298 xy = source.get("ext_shapeHSM_HsmSourceMomentsRound_xy") 

299 flux = source.get("ext_shapeHSM_HsmSourceMomentsRound_Flux") 

300 

301 # Centroids from GalSim use the FITS lower-left corner of 1,1 

302 offset = self.xy0 + self.offset 

303 self.assertAlmostEqual(x - offset.getX(), round_moments_expected[i][4] - 1, 3) 

304 self.assertAlmostEqual(y - offset.getY(), round_moments_expected[i][5] - 1, 3) 

305 

306 expected = afwEll.Quadrupole( 

307 afwEll.SeparableDistortionDeterminantRadius( 

308 round_moments_expected[i][1], round_moments_expected[i][2], round_moments_expected[i][0] 

309 ) 

310 ) 

311 self.assertAlmostEqual(xx, expected.getIxx(), SHAPE_DECIMALS) 

312 self.assertAlmostEqual(xy, expected.getIxy(), SHAPE_DECIMALS) 

313 self.assertAlmostEqual(yy, expected.getIyy(), SHAPE_DECIMALS) 

314 

315 self.assertAlmostEqual(flux, round_moments_expected[i][3], SHAPE_DECIMALS) 

316 

317 def testHsmSourceMomentsVsSdssShape(self): 

318 # Initialize a config and activate the plugins. 

319 sfmConfig = base.SingleFrameMeasurementConfig() 

320 sfmConfig.plugins.names |= ["ext_shapeHSM_HsmSourceMoments", "base_SdssShape"] 

321 

322 # Create a minimal schema (columns). 

323 schema = lsst.meas.base.tests.TestDataset.makeMinimalSchema() 

324 

325 # Instantiate the task. 

326 sfmTask = base.SingleFrameMeasurementTask(config=sfmConfig, schema=schema) 

327 

328 # Create a simple, test dataset. 

329 bbox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Extent2I(100, 100)) 

330 dataset = lsst.meas.base.tests.TestDataset(bbox) 

331 

332 # First source is a point. 

333 dataset.addSource(100000.0, lsst.geom.Point2D(49.5, 49.5)) 

334 

335 # Second source is a galaxy. 

336 dataset.addSource(300000.0, lsst.geom.Point2D(76.3, 79.2), afwGeom.Quadrupole(2.0, 3.0, 0.5)) 

337 

338 # Third source is also a galaxy. 

339 dataset.addSource(250000.0, lsst.geom.Point2D(28.9, 41.35), afwGeom.Quadrupole(1.8, 3.5, 0.4)) 

340 

341 # Get the exposure and catalog. 

342 exposure, catalog = dataset.realize(10.0, sfmTask.schema, randomSeed=0) 

343 

344 # Run the measurement task to get the output catalog. 

345 sfmTask.run(catalog, exposure) 

346 cat = catalog.asAstropy() 

347 

348 # Get the moments from the catalog. 

349 xSdss, ySdss = cat["base_SdssShape_x"], cat["base_SdssShape_y"] 

350 xxSdss, xySdss, yySdss = cat["base_SdssShape_xx"], cat["base_SdssShape_xy"], cat["base_SdssShape_yy"] 

351 xHsm, yHsm = cat["ext_shapeHSM_HsmSourceMoments_x"], cat["ext_shapeHSM_HsmSourceMoments_y"] 

352 xxHsm, xyHsm, yyHsm = ( 

353 cat["ext_shapeHSM_HsmSourceMoments_xx"], 

354 cat["ext_shapeHSM_HsmSourceMoments_xy"], 

355 cat["ext_shapeHSM_HsmSourceMoments_yy"], 

356 ) 

357 

358 # Loop over the sources and check that the moments are the same. 

359 for i in range(3): 

360 self.assertAlmostEqual(xSdss[i], xHsm[i], 2) 

361 self.assertAlmostEqual(ySdss[i], yHsm[i], 2) 

362 self.assertAlmostEqual(xxSdss[i], xxHsm[i], SHAPE_DECIMALS) 

363 self.assertAlmostEqual(xySdss[i], xyHsm[i], SHAPE_DECIMALS) 

364 self.assertAlmostEqual(yySdss[i], yyHsm[i], SHAPE_DECIMALS) 

365 

366 def testHsmSourceMomentsAllMasked(self): 

367 i = 0 

368 imageid = file_indices[0] 

369 with self.assertRaises(base.MeasurementError): 

370 _ = self.runMeasurement( 

371 "ext_shapeHSM_HsmSourceMoments", 

372 imageid, 

373 x_centroid[i], 

374 y_centroid[i], 

375 sky_var[i], 

376 maskAll=True, 

377 ) 

378 

379 

380class ShapeTestCase(unittest.TestCase): 

381 """A test case for shape measurement""" 

382 

383 def setUp(self): 

384 # load the known values 

385 self.dataDir = os.path.join(os.getenv("MEAS_EXTENSIONS_SHAPEHSM_DIR"), "tests", "data") 

386 self.bkgd = 1000.0 # standard for atlas image 

387 self.offset = geom.Extent2I(1234, 1234) 

388 self.xy0 = geom.Point2I(5678, 9876) 

389 

390 def tearDown(self): 

391 del self.offset 

392 del self.xy0 

393 

394 @staticmethod 

395 def computeDirectShapeFromGalSim(record, exposure, config): 

396 """ 

397 Retrieve the shape as estimated directly by GalSim for comparison 

398 purposes. 

399 

400 Parameters 

401 ---------- 

402 record : `~lsst.afw.table.SourceRecord` 

403 The record containing the center and footprint of the source which 

404 needs measurement. 

405 exposure : `~lsst.afw.image.Exposure` 

406 The exposure containing the source which needs measurement. 

407 config : `~lsst.meas.extensions.shapeHSM._hsm_shape.\ 

408 HsmShapeConfig` 

409 The configuration object containing parameters and settings for 

410 this measurement. This needs to be a subclass in the format 

411 HsmShape<Method>Config, where <Method> represents the name of the 

412 correction method being utilized (e.g., Ksb, Regauss, etc.). 

413 

414 Returns 

415 ------- 

416 shapeDirect : `~galsim.hsm.ShapeData` 

417 An object containing the results of shape measurement. 

418 """ 

419 

420 # Get the center of the source as a Point2D. 

421 center = geom.Point2D(record.get("centroid_x"), record.get("centroid_y")) 

422 

423 # Get the PSF image evaluated at the source centroid. 

424 psfImage = exposure.getPsf().computeImage(center) 

425 psfImage.setXY0(0, 0) 

426 

427 # Get the GalSim images to use in the EstimateShear call. 

428 bbox = record.getFootprint().getBBox() 

429 bounds = galsim.BoundsI(bbox.getMinX(), bbox.getMaxX(), bbox.getMinY(), bbox.getMaxY()) 

430 image = galsim.Image(exposure.image[bbox].array, bounds=bounds) 

431 psfBBox = psfImage.getBBox(afwImage.PARENT) 

432 psfBounds = galsim.BoundsI(psfBBox.getMinX(), psfBBox.getMaxX(), psfBBox.getMinY(), psfBBox.getMaxY()) 

433 psf = galsim.Image(psfImage.array, bounds=psfBounds) 

434 

435 # Get the mask of bad pixels. 

436 subMask = exposure.mask[bbox] 

437 badpix = subMask.array.copy() # Copy it since badpix gets modified. 

438 bitValue = exposure.mask.getPlaneBitMask(config.badMaskPlanes) 

439 badpix &= bitValue 

440 badpix = galsim.Image(badpix, bounds=bounds) 

441 

442 # Estimate the sky variance. 

443 sctrl = afwMath.StatisticsControl() 

444 sctrl.setAndMask(bitValue) 

445 variance = afwImage.Image( 

446 exposure.variance[bbox], 

447 dtype=exposure.variance.dtype, 

448 deep=True, 

449 ) 

450 stat = afwMath.makeStatistics(variance, subMask, afwMath.MEDIAN, sctrl) 

451 skyvar = stat.getValue(afwMath.MEDIAN) 

452 

453 # Prepare various values for GalSim's EstimateShear. 

454 recomputeFlux = "FIT" 

455 precision = 1.0e-6 

456 psfSigma = exposure.getPsf().computeShape(center).getTraceRadius() 

457 guessCentroid = galsim.PositionD(center.x, center.y) 

458 

459 # Estimate the shape using GalSim's Python interface. 

460 shapeDirect = galsim.hsm.EstimateShear( 

461 gal_image=image, 

462 PSF_image=psf, 

463 weight=None, 

464 badpix=badpix, 

465 sky_var=skyvar, 

466 shear_est=config.shearType.upper(), 

467 recompute_flux=recomputeFlux.upper(), 

468 guess_sig_gal=2.5 * psfSigma, 

469 guess_sig_PSF=psfSigma, 

470 precision=precision, 

471 guess_centroid=guessCentroid, 

472 hsmparams=None, 

473 ) 

474 return shapeDirect 

475 

476 def runMeasurement(self, algorithmName, imageid, x, y, v): 

477 """Run the measurement algorithm on an image""" 

478 # load the test image 

479 imgFile = os.path.join(self.dataDir, "image.%d.fits" % imageid) 

480 img = afwImage.ImageF(imgFile) 

481 img -= self.bkgd 

482 nx, ny = img.getWidth(), img.getHeight() 

483 msk = afwImage.Mask(geom.Extent2I(nx, ny), 0x0) 

484 var = afwImage.ImageF(geom.Extent2I(nx, ny), v) 

485 mimg = afwImage.MaskedImageF(img, msk, var) 

486 msk.getArray()[:] = np.where(np.fabs(img.getArray()) < 1.0e-8, msk.getPlaneBitMask("BAD"), 0) 

487 

488 # Put it in a bigger image, in case it matters 

489 big = afwImage.MaskedImageF(self.offset + mimg.getDimensions()) 

490 big.getImage().set(0) 

491 big.getMask().set(0) 

492 big.getVariance().set(v) 

493 subBig = afwImage.MaskedImageF(big, geom.Box2I(big.getXY0() + self.offset, mimg.getDimensions())) 

494 subBig.assign(mimg) 

495 mimg = big 

496 mimg.setXY0(self.xy0) 

497 

498 exposure = afwImage.makeExposure(mimg) 

499 cdMatrix = np.array([1.0 / (2.53 * 3600.0), 0.0, 0.0, 1.0 / (2.53 * 3600.0)]) 

500 cdMatrix.shape = (2, 2) 

501 exposure.setWcs( 

502 afwGeom.makeSkyWcs( 

503 crpix=geom.Point2D(1.0, 1.0), crval=geom.SpherePoint(0, 0, geom.degrees), cdMatrix=cdMatrix 

504 ) 

505 ) 

506 

507 # load the corresponding test psf 

508 psfFile = os.path.join(self.dataDir, "psf.%d.fits" % imageid) 

509 psfImg = afwImage.ImageD(psfFile) 

510 psfImg -= self.bkgd 

511 

512 kernel = afwMath.FixedKernel(psfImg) 

513 kernelPsf = algorithms.KernelPsf(kernel) 

514 exposure.setPsf(kernelPsf) 

515 

516 # perform the shape measurement 

517 msConfig = base.SingleFrameMeasurementConfig() 

518 msConfig.plugins.names |= [algorithmName] 

519 control = msConfig.plugins[algorithmName] 

520 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass 

521 # NOTE: It is essential to remove the floating point part of the position for the 

522 # Algorithm._apply. Otherwise, when the PSF is realised it will have been warped 

523 # to account for the sub-pixel offset and we won't get *exactly* this PSF. 

524 plugin, table = makePluginAndCat(alg, algorithmName, control, centroid="centroid", metadata=True) 

525 center = geom.Point2D(int(x), int(y)) + geom.Extent2D(self.offset + geom.Extent2I(self.xy0)) 

526 source = table.makeRecord() 

527 source.set("centroid_x", center.getX()) 

528 source.set("centroid_y", center.getY()) 

529 source.setFootprint(afwDetection.Footprint(afwGeom.SpanSet(exposure.getBBox(afwImage.PARENT)))) 

530 plugin.measure(source, exposure) 

531 

532 shapeDirect = self.computeDirectShapeFromGalSim(source, exposure, control) 

533 

534 return source, alg.measTypeSymbol, shapeDirect 

535 

536 def testHsmShape(self): 

537 """Test that we can instantiate and play with a measureShape""" 

538 

539 nFail = 0 

540 msg = "" 

541 

542 for (algNum, algName), (i, imageid) in itertools.product( 

543 enumerate(correction_methods), enumerate(file_indices) 

544 ): 

545 algorithmName = "ext_shapeHSM_HsmShape" + algName[0:1].upper() + algName[1:].lower() 

546 

547 source, preEstimationMeasType, shapeDirect = self.runMeasurement( 

548 algorithmName, imageid, x_centroid[i], y_centroid[i], sky_var[i] 

549 ) 

550 

551 postEstimationMeasType = shapeDirect.meas_type 

552 

553 # Check consistency with GalSim output 

554 self.assertEqual( 

555 preEstimationMeasType, 

556 postEstimationMeasType, 

557 "The plugin setup is incompatible with GalSim output.", 

558 ) 

559 

560 ########################################## 

561 # see how we did 

562 if algName in ("KSB"): 

563 # Need to convert g1,g2 --> e1,e2 because GalSim has done that 

564 # for the expected values ("for consistency") 

565 g1 = source.get(algorithmName + "_g1") 

566 g2 = source.get(algorithmName + "_g2") 

567 scale = 2.0 / (1.0 + g1**2 + g2**2) 

568 e1 = g1 * scale 

569 e2 = g2 * scale 

570 sigma = source.get(algorithmName + "_sigma") 

571 # Ensure the values calculated are identical to those obtained 

572 # from GalSim. 

573 self.assertEqual(g1, shapeDirect.corrected_g1) 

574 self.assertEqual(g2, shapeDirect.corrected_g2) 

575 else: 

576 e1 = source.get(algorithmName + "_e1") 

577 e2 = source.get(algorithmName + "_e2") 

578 sigma = 0.5 * source.get(algorithmName + "_sigma") 

579 # Ensure the values calculated are identical to those obtained 

580 # from GalSim. 

581 self.assertEqual(e1, shapeDirect.corrected_e1) 

582 self.assertEqual(e2, shapeDirect.corrected_e2) 

583 

584 resolution = source.get(algorithmName + "_resolution") 

585 flags = source.get(algorithmName + "_flag") 

586 

587 # Check that the shape error and the resolution factor are the same 

588 # as GalSim's. 

589 self.assertEqual(sigma, shapeDirect.corrected_shape_err) 

590 self.assertEqual(resolution, shapeDirect.resolution_factor) 

591 

592 tests = [ 

593 # label, known-value, measured, tolerance 

594 ["e1", float(e1_expected[i][algNum]), e1, 0.5 * 10**-SHAPE_DECIMALS], 

595 ["e2", float(e2_expected[i][algNum]), e2, 0.5 * 10**-SHAPE_DECIMALS], 

596 ["resolution", float(resolution_expected[i][algNum]), resolution, 0.5 * 10**-SIZE_DECIMALS], 

597 # sigma won't match exactly because 

598 # we're using skyvar=mean(var) instead of measured value ... expected a difference 

599 ["sigma", float(sigma_e_expected[i][algNum]), sigma, 0.07], 

600 ["shapeStatus", 0, flags, 0], 

601 ] 

602 

603 for test in tests: 

604 label, know, hsm, limit = test 

605 err = hsm - know 

606 msgTmp = "%-12s %s %5s: %6.6f %6.6f (val-known) = %.3g\n" % ( 

607 algName, 

608 imageid, 

609 label, 

610 know, 

611 hsm, 

612 err, 

613 ) 

614 if not np.isfinite(err) or abs(err) > limit: 

615 msg += msgTmp 

616 nFail += 1 

617 

618 self.assertAlmostEqual(g1 if algName in ("KSB") else e1, galsim_e1[i][algNum], SHAPE_DECIMALS) 

619 self.assertAlmostEqual(g2 if algName in ("KSB") else e2, galsim_e2[i][algNum], SHAPE_DECIMALS) 

620 self.assertAlmostEqual(resolution, galsim_resolution[i][algNum], SIZE_DECIMALS) 

621 self.assertAlmostEqual(sigma, galsim_err[i][algNum], delta=0.07) 

622 

623 self.assertEqual(nFail, 0, "\n" + msg) 

624 

625 @lsst.utils.tests.methodParametersProduct( 

626 # Increasing the width beyond 4.5 leads to noticeable 

627 # truncation of the PSF, i.e. a PSF that is too large for the 

628 # box. While this truncated state leads to incorrect 

629 # measurements, it is necessary for testing purposes to 

630 # evaluate the behavior under these extreme conditions. 

631 # Increasing the width beyond 41.3 fails to converge for this 

632 # particular test dataset. 

633 width=(2.0, 3.0, 4.0, 10.0, 40.0), 

634 varyBBox=(True, False), 

635 wrongBBox=(True, False), 

636 algName=correction_methods, 

637 ) 

638 def testHsmShapeWithVariousPsfsVsDirectGalsim(self, width, varyBBox, wrongBBox, algName): 

639 # Set the full algorithm name. 

640 algorithmName = "ext_shapeHSM_HsmShape" + algName[0:1].upper() + algName[1:].lower() 

641 

642 # Initialize a config and activate the plugins. 

643 sfmConfig = base.SingleFrameMeasurementConfig() 

644 sfmConfig.plugins.names |= [algorithmName] 

645 

646 # Create a minimal schema (columns). 

647 schema = lsst.meas.base.tests.TestDataset.makeMinimalSchema() 

648 

649 # Create a simple, test dataset. 

650 bbox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), lsst.geom.Extent2I(60, 60)) 

651 dataset = lsst.meas.base.tests.TestDataset(bbox) 

652 

653 # Add a galaxy. 

654 center = lsst.geom.Point2D(24.9, 32.5) 

655 dataset.addSource(150000.0, center, afwGeom.Quadrupole(3.0, 4.0, 0.5)) 

656 

657 # Get the exposure. 

658 exposure, _ = dataset.realize(noise=10.0, schema=schema, randomSeed=1746) 

659 

660 # Create and set the PSF for the exposure. 

661 psf = PyGaussianPsf(35, 35, width, varyBBox=varyBBox, wrongBBox=wrongBBox) 

662 exposure.getMaskedImage().set(1.0, 0, 1.0) 

663 exposure.setPsf(psf) 

664 

665 # Conduct the measurement directly by GalSim. 

666 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass 

667 plugin, table = makePluginAndCat(alg, algorithmName, centroid="centroid", metadata=True) 

668 record = table.makeRecord() 

669 record.set("centroid_x", center.x) 

670 record.set("centroid_y", center.y) 

671 record.setFootprint(afwDetection.Footprint(afwGeom.SpanSet(exposure.getBBox(afwImage.PARENT)))) 

672 shapeDirect = self.computeDirectShapeFromGalSim(record, exposure, plugin.config) 

673 

674 # Run the shapeHSM measurement task and update the record. 

675 plugin.measure(record, exposure) 

676 

677 if algName in ("KSB"): 

678 g1Direct, g2Direct = shapeDirect.corrected_g1, shapeDirect.corrected_g2 

679 sigmaDirect = shapeDirect.corrected_shape_err 

680 g1Hsm, g2Hsm = record[algorithmName + "_g1"], record[algorithmName + "_g2"] 

681 sigmaHsm = record[algorithmName + "_sigma"] 

682 # Check that the answers are "identical" between the two methods. 

683 self.assertEqual(g1Direct, g1Hsm) 

684 self.assertEqual(g2Direct, g2Hsm) 

685 self.assertEqual(sigmaDirect, sigmaHsm) 

686 else: 

687 e1Direct, e2Direct = shapeDirect.corrected_e1, shapeDirect.corrected_e2 

688 sigmaDirect = shapeDirect.corrected_shape_err 

689 e1Hsm, e2Hsm = record[algorithmName + "_e1"], record[algorithmName + "_e2"] 

690 # The factor of 0.5 is because shapeHSM returns 

691 # `2 * corrected_shape_err` as sigma for e-type distortions. 

692 sigmaHsm = 0.5 * record[algorithmName + "_sigma"] 

693 # Check that the answers are "identical" between the two methods. 

694 self.assertEqual(e1Direct, e1Hsm) 

695 self.assertEqual(e2Direct, e2Hsm) 

696 self.assertEqual(sigmaDirect, sigmaHsm) 

697 

698 resolutionDirect = shapeDirect.resolution_factor 

699 flagsDirect = shapeDirect.correction_status 

700 resolutionHSM = record[algorithmName + "_resolution"] 

701 flagsHSM = record[algorithmName + "_flag"] 

702 

703 # Check that the resolution factor and the correction status are 

704 # exactly the same as when using GalSim directly. 

705 self.assertEqual(resolutionDirect, resolutionHSM) 

706 self.assertEqual(flagsDirect, flagsHSM) 

707 

708 def testValidate(self): 

709 for algName in correction_methods: 

710 with self.assertRaises(pexConfig.FieldValidationError): 

711 algorithmName = "ext_shapeHSM_HsmShape" + algName[0:1].upper() + algName[1:].lower() 

712 msConfig = base.SingleFrameMeasurementConfig() 

713 msConfig.plugins.names |= [algorithmName] 

714 control = msConfig.plugins[algorithmName] 

715 control.shearType = "WRONG" 

716 control.validate() 

717 

718 

719class PyGaussianPsf(afwDetection.Psf): 

720 # Like afwDetection.GaussianPsf, but handles computeImage exactly instead of 

721 # via interpolation. This is a subminimal implementation. It works for the 

722 # tests here but isn't fully functional as a Psf class. 

723 

724 def __init__(self, width, height, sigma, varyBBox=False, wrongBBox=False): 

725 afwDetection.Psf.__init__(self, isFixed=not varyBBox) 

726 self.dimensions = geom.Extent2I(width, height) 

727 self.sigma = sigma 

728 self.varyBBox = varyBBox # To address DM-29863 

729 self.wrongBBox = wrongBBox # To address DM-30426 

730 

731 def _doComputeKernelImage(self, position=None, color=None): 

732 bbox = self.computeBBox(position, color) 

733 img = afwImage.Image(bbox, dtype=np.float64) 

734 x, y = np.ogrid[bbox.minY : bbox.maxY + 1, bbox.minX : bbox.maxX + 1] 

735 rsqr = x**2 + y**2 

736 img.array[:] = np.exp(-0.5 * rsqr / self.sigma**2) 

737 img.array /= np.sum(img.array) 

738 return img 

739 

740 def _doComputeImage(self, position=None, color=None): 

741 bbox = self.computeBBox(position, color) 

742 if self.wrongBBox: 

743 # For DM-30426: 

744 # Purposely make computeImage.getBBox() and computeBBox() 

745 # inconsistent. Old shapeHSM code attempted to infer the former 

746 # from the latter, but was unreliable. New code infers the former 

747 # directly, so this inconsistency no longer breaks things. 

748 bbox.shift(geom.Extent2I(1, 2)) 

749 img = afwImage.Image(bbox, dtype=np.float64) 

750 y, x = np.ogrid[float(bbox.minY) : bbox.maxY + 1, bbox.minX : bbox.maxX + 1] 

751 x -= position.x - np.floor(position.x + 0.5) 

752 y -= position.y - np.floor(position.y + 0.5) 

753 rsqr = x**2 + y**2 

754 img.array[:] = np.exp(-0.5 * rsqr / self.sigma**2) 

755 img.array /= np.sum(img.array) 

756 img.setXY0( 

757 geom.Point2I(img.getX0() + np.floor(position.x + 0.5), img.getY0() + np.floor(position.y + 0.5)) 

758 ) 

759 return img 

760 

761 def _doComputeBBox(self, position=None, color=None): 

762 # Variable size bbox for addressing DM-29863 

763 dims = self.dimensions 

764 if self.varyBBox: 

765 if position.x > 20.0: 

766 dims = dims + geom.Extent2I(2, 2) 

767 return geom.Box2I(geom.Point2I(-dims / 2), dims) 

768 

769 def _doComputeShape(self, position=None, color=None): 

770 return afwGeom.ellipses.Quadrupole(self.sigma**2, self.sigma**2, 0.0) 

771 

772 

773class PsfMomentsTestCase(unittest.TestCase): 

774 """A test case for PSF moments measurement""" 

775 

776 @staticmethod 

777 def computeDirectPsfMomentsFromGalSim(psf, center, useSourceCentroidOffset=False): 

778 """Directly from GalSim.""" 

779 psfBBox = psf.computeImageBBox(center) 

780 psfSigma = psf.computeShape(center).getTraceRadius() 

781 if useSourceCentroidOffset: 

782 psfImage = psf.computeImage(center) 

783 centroid = center 

784 else: 

785 psfImage = psf.computeKernelImage(center) 

786 psfImage.setXY0(psfBBox.getMin()) 

787 centroid = geom.Point2D(psfBBox.getMin() + psfBBox.getDimensions() // 2) 

788 bbox = psfImage.getBBox(afwImage.PARENT) 

789 bounds = galsim.BoundsI(bbox.getMinX(), bbox.getMaxX(), bbox.getMinY(), bbox.getMaxY()) 

790 image = galsim.Image(psfImage.array, bounds=bounds) 

791 guessCentroid = galsim.PositionD(centroid.x, centroid.y) 

792 shape = galsim.hsm.FindAdaptiveMom( 

793 image, 

794 weight=None, 

795 badpix=None, 

796 guess_sig=psfSigma, 

797 precision=1e-6, 

798 guess_centroid=guessCentroid, 

799 strict=True, 

800 round_moments=False, 

801 hsmparams=None, 

802 ) 

803 ellipse = lsst.afw.geom.ellipses.SeparableDistortionDeterminantRadius( 

804 e1=shape.observed_shape.e1, 

805 e2=shape.observed_shape.e2, 

806 radius=shape.moments_sigma, 

807 normalize=True, # Fail if |e|>1. 

808 ) 

809 quad = lsst.afw.geom.ellipses.Quadrupole(ellipse) 

810 ixx = quad.getIxx() 

811 iyy = quad.getIyy() 

812 ixy = quad.getIxy() 

813 return ixx, iyy, ixy 

814 

815 @lsst.utils.tests.methodParameters( 

816 # Make Cartesian product of settings to feed to methodParameters 

817 **dict( 

818 list( 

819 zip( 

820 ( 

821 kwargs := dict( 

822 # Increasing the width beyond 4.5 leads to noticeable 

823 # truncation of the PSF, i.e. a PSF that is too large for the 

824 # box. While this truncated state leads to incorrect 

825 # measurements, it is necessary for testing purposes to 

826 # evaluate the behavior under these extreme conditions. 

827 width=(2.0, 3.0, 4.0, 10.0, 40.0, 100.0), 

828 useSourceCentroidOffset=(True, False), 

829 varyBBox=(True, False), 

830 wrongBBox=(True, False), 

831 center=( 

832 (23.0, 34.0), # various offsets that might cause trouble 

833 (23.5, 34.0), 

834 (23.5, 34.5), 

835 (23.15, 34.25), 

836 (22.81, 34.01), 

837 (22.81, 33.99), 

838 (1.2, 1.3), # psfImage extends outside exposure; that's okay 

839 (-100.0, -100.0), 

840 (-100.5, -100.0), 

841 (-100.5, -100.5), 

842 ), 

843 ) 

844 ).keys(), 

845 zip(*itertools.product(*kwargs.values())), 

846 ) 

847 ) 

848 ) 

849 ) 

850 def testHsmPsfMoments(self, width, useSourceCentroidOffset, varyBBox, wrongBBox, center): 

851 psf = PyGaussianPsf(35, 35, width, varyBBox=varyBBox, wrongBBox=wrongBBox) 

852 exposure = afwImage.ExposureF(45, 56) 

853 exposure.getMaskedImage().set(1.0, 0, 1.0) 

854 exposure.setPsf(psf) 

855 

856 # perform the moment measurement 

857 algorithmName = "ext_shapeHSM_HsmPsfMoments" 

858 msConfig = base.SingleFrameMeasurementConfig() 

859 msConfig.algorithms.names = [algorithmName] 

860 control = msConfig.plugins[algorithmName] 

861 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass 

862 self.assertFalse(control.useSourceCentroidOffset) 

863 control.useSourceCentroidOffset = useSourceCentroidOffset 

864 plugin, cat = makePluginAndCat( 

865 alg, 

866 algorithmName, 

867 centroid="centroid", 

868 control=control, 

869 metadata=True, 

870 ) 

871 source = cat.addNew() 

872 source.set("centroid_x", center[0]) 

873 source.set("centroid_y", center[1]) 

874 offset = geom.Point2I(*center) 

875 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset) 

876 source.setFootprint(afwDetection.Footprint(tmpSpans)) 

877 plugin.measure(source, exposure) 

878 x = source.get("ext_shapeHSM_HsmPsfMoments_x") 

879 y = source.get("ext_shapeHSM_HsmPsfMoments_y") 

880 xx = source.get("ext_shapeHSM_HsmPsfMoments_xx") 

881 yy = source.get("ext_shapeHSM_HsmPsfMoments_yy") 

882 xy = source.get("ext_shapeHSM_HsmPsfMoments_xy") 

883 

884 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag")) 

885 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag_no_pixels")) 

886 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag_not_contained")) 

887 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMoments_flag_parent_source")) 

888 

889 if width < 4.5: 

890 # i.e., as long as the PSF is not truncated for our 35x35 box. 

891 self.assertAlmostEqual(x, 0.0, 3) 

892 self.assertAlmostEqual(y, 0.0, 3) 

893 expected = afwEll.Quadrupole(afwEll.Axes(width, width, 0.0)) 

894 self.assertAlmostEqual(xx, expected.getIxx(), SHAPE_DECIMALS) 

895 self.assertAlmostEqual(xy, expected.getIxy(), SHAPE_DECIMALS) 

896 self.assertAlmostEqual(yy, expected.getIyy(), SHAPE_DECIMALS) 

897 

898 # Test schema documentation 

899 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy]"): 

900 self.assertEqual( 

901 cat.schema[fieldName].asField().getDoc(), "Centroid of the PSF via the HSM shape algorithm" 

902 ) 

903 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy][xy]*"): 

904 self.assertEqual( 

905 cat.schema[fieldName].asField().getDoc(), 

906 "Adaptive moments of the PSF via the HSM shape algorithm", 

907 ) 

908 

909 # Test that the moments are identical to those obtained directly by 

910 # GalSim. For `width` > 4.5 where the truncation becomes significant, 

911 # the answer might not be 'correct' but should remain 'consistent'. 

912 xxDirect, yyDirect, xyDirect = self.computeDirectPsfMomentsFromGalSim( 

913 psf, 

914 geom.Point2D(*center), 

915 useSourceCentroidOffset=useSourceCentroidOffset, 

916 ) 

917 self.assertEqual(xx, xxDirect) 

918 self.assertEqual(yy, yyDirect) 

919 self.assertEqual(xy, xyDirect) 

920 

921 @lsst.utils.tests.methodParameters( 

922 # Make Cartesian product of settings to feed to methodParameters 

923 **dict( 

924 list( 

925 zip( 

926 ( 

927 kwargs := dict( 

928 width=(2.0, 3.0, 4.0), 

929 useSourceCentroidOffset=(True, False), 

930 varyBBox=(True, False), 

931 wrongBBox=(True, False), 

932 center=( 

933 (23.0, 34.0), # various offsets that might cause trouble 

934 (23.5, 34.0), 

935 (23.5, 34.5), 

936 (23.15, 34.25), 

937 (22.81, 34.01), 

938 (22.81, 33.99), 

939 ), 

940 ) 

941 ).keys(), 

942 zip(*itertools.product(*kwargs.values())), 

943 ) 

944 ) 

945 ) 

946 ) 

947 def testHsmPsfMomentsDebiased(self, width, useSourceCentroidOffset, varyBBox, wrongBBox, center): 

948 # As a note, it's really hard to actually unit test whether we've 

949 # succesfully "debiased" these measurements. That would require a 

950 # many-object comparison of moments with and without noise. So we just 

951 # test similar to the biased moments above. 

952 var = 1.2 

953 # As we reduce the flux, our deviation from the expected value 

954 # increases, so decrease tolerance. 

955 for flux, decimals in [ 

956 (1e6, 3), 

957 (1e4, 1), 

958 (1e3, 0), 

959 ]: 

960 psf = PyGaussianPsf(35, 35, width, varyBBox=varyBBox, wrongBBox=wrongBBox) 

961 exposure = afwImage.ExposureF(45, 56) 

962 exposure.getMaskedImage().set(1.0, 0, var) 

963 exposure.setPsf(psf) 

964 

965 algorithmName = "ext_shapeHSM_HsmPsfMomentsDebiased" 

966 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass 

967 

968 # perform the shape measurement 

969 control = lsst.meas.extensions.shapeHSM.HsmPsfMomentsDebiasedConfig() 

970 self.assertTrue(control.useSourceCentroidOffset) 

971 self.assertEqual(control.noiseSource, "variance") 

972 control.useSourceCentroidOffset = useSourceCentroidOffset 

973 plugin, cat = makePluginAndCat( 

974 alg, 

975 algorithmName, 

976 centroid="centroid", 

977 psfflux="base_PsfFlux", 

978 control=control, 

979 metadata=True, 

980 ) 

981 source = cat.addNew() 

982 source.set("centroid_x", center[0]) 

983 source.set("centroid_y", center[1]) 

984 offset = geom.Point2I(*center) 

985 source.set("base_PsfFlux_instFlux", flux) 

986 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset) 

987 source.setFootprint(afwDetection.Footprint(tmpSpans)) 

988 

989 plugin.measure(source, exposure) 

990 x = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_x") 

991 y = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_y") 

992 xx = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xx") 

993 yy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_yy") 

994 xy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xy") 

995 for flag in [ 

996 "ext_shapeHSM_HsmPsfMomentsDebiased_flag", 

997 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_no_pixels", 

998 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_not_contained", 

999 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_parent_source", 

1000 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_edge", 

1001 ]: 

1002 self.assertFalse(source.get(flag)) 

1003 

1004 expected = afwEll.Quadrupole(afwEll.Axes(width, width, 0.0)) 

1005 

1006 self.assertAlmostEqual(x, 0.0, decimals) 

1007 self.assertAlmostEqual(y, 0.0, decimals) 

1008 

1009 T = expected.getIxx() + expected.getIyy() 

1010 self.assertAlmostEqual((xx - expected.getIxx()) / T, 0.0, decimals) 

1011 self.assertAlmostEqual((xy - expected.getIxy()) / T, 0.0, decimals) 

1012 self.assertAlmostEqual((yy - expected.getIyy()) / T, 0.0, decimals) 

1013 

1014 # Repeat using noiseSource='meta'. Should get nearly the same 

1015 # results if BGMEAN is set to `var` above. 

1016 exposure2 = afwImage.ExposureF(45, 56) 

1017 # set the variance plane to something else to ensure we're 

1018 # ignoring it 

1019 exposure2.getMaskedImage().set(1.0, 0, 2 * var + 1.1) 

1020 exposure2.setPsf(psf) 

1021 exposure2.getMetadata().set("BGMEAN", var) 

1022 

1023 control2 = shapeHSM.HsmPsfMomentsDebiasedConfig() 

1024 control2.noiseSource = "meta" 

1025 control2.useSourceCentroidOffset = useSourceCentroidOffset 

1026 plugin2, cat2 = makePluginAndCat( 

1027 alg, 

1028 algorithmName, 

1029 centroid="centroid", 

1030 psfflux="base_PsfFlux", 

1031 control=control2, 

1032 metadata=True, 

1033 ) 

1034 source2 = cat2.addNew() 

1035 source2.set("centroid_x", center[0]) 

1036 source2.set("centroid_y", center[1]) 

1037 offset2 = geom.Point2I(*center) 

1038 source2.set("base_PsfFlux_instFlux", flux) 

1039 tmpSpans2 = afwGeom.SpanSet.fromShape(int(width), offset=offset2) 

1040 source2.setFootprint(afwDetection.Footprint(tmpSpans2)) 

1041 

1042 plugin2.measure(source2, exposure2) 

1043 x2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_x") 

1044 y2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_y") 

1045 xx2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_xx") 

1046 yy2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_yy") 

1047 xy2 = source2.get("ext_shapeHSM_HsmPsfMomentsDebiased_xy") 

1048 for flag in [ 

1049 "ext_shapeHSM_HsmPsfMomentsDebiased_flag", 

1050 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_no_pixels", 

1051 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_not_contained", 

1052 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_parent_source", 

1053 "ext_shapeHSM_HsmPsfMomentsDebiased_flag_edge", 

1054 ]: 

1055 self.assertFalse(source.get(flag)) 

1056 

1057 # Would be identically equal, but variance input via "BGMEAN" is 

1058 # consumed in c++ as a double, where variance from the variance 

1059 # plane is a c++ float. 

1060 self.assertAlmostEqual(x, x2, 8) 

1061 self.assertAlmostEqual(y, y2, 8) 

1062 self.assertAlmostEqual(xx, xx2, 5) 

1063 self.assertAlmostEqual(xy, xy2, 5) 

1064 self.assertAlmostEqual(yy, yy2, 5) 

1065 

1066 # Test schema documentation 

1067 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy]"): 

1068 self.assertEqual( 

1069 cat.schema[fieldName].asField().getDoc(), 

1070 "Debiased centroid of the PSF via the HSM shape algorithm", 

1071 ) 

1072 for fieldName in cat.schema.extract("*HsmPsfMoments_[xy][xy]*"): 

1073 self.assertEqual( 

1074 cat.schema[fieldName].asField().getDoc(), 

1075 "Debiased adaptive moments of the PSF via the HSM shape algorithm", 

1076 ) 

1077 

1078 testHsmPsfMomentsDebiasedEdgeArgs = dict( 

1079 width=(2.0, 3.0, 4.0), useSourceCentroidOffset=(True, False), center=((1.2, 1.3), (33.2, 50.1)) 

1080 ) 

1081 

1082 @lsst.utils.tests.methodParameters( 

1083 # Make Cartesian product of settings to feed to methodParameters 

1084 **dict( 

1085 list( 

1086 zip( 

1087 ( 

1088 kwargs := dict( 

1089 width=(2.0, 3.0, 4.0), 

1090 useSourceCentroidOffset=(True, False), 

1091 center=[(1.2, 1.3), (33.2, 50.1)], 

1092 ) 

1093 ).keys(), 

1094 zip(*itertools.product(*kwargs.values())), 

1095 ) 

1096 ) 

1097 ) 

1098 ) 

1099 def testHsmPsfMomentsDebiasedEdge(self, width, useSourceCentroidOffset, center): 

1100 # As we reduce the flux, our deviation from the expected value 

1101 # increases, so decrease tolerance. 

1102 var = 1.2 

1103 for flux, decimals in [ 

1104 (1e6, 3), 

1105 (1e4, 2), 

1106 (1e3, 1), 

1107 ]: 

1108 psf = PyGaussianPsf(35, 35, width) 

1109 exposure = afwImage.ExposureF(45, 56) 

1110 exposure.getMaskedImage().set(1.0, 0, 2 * var + 1.1) 

1111 exposure.setPsf(psf) 

1112 

1113 algorithmName = "ext_shapeHSM_HsmPsfMomentsDebiased" 

1114 alg = base.SingleFramePlugin.registry[algorithmName].PluginClass 

1115 

1116 # perform the shape measurement 

1117 control = shapeHSM.HsmPsfMomentsDebiasedConfig() 

1118 control.useSourceCentroidOffset = useSourceCentroidOffset 

1119 self.assertEqual(control.noiseSource, "variance") 

1120 plugin, cat = makePluginAndCat( 

1121 alg, 

1122 algorithmName, 

1123 centroid="centroid", 

1124 psfflux="base_PsfFlux", 

1125 control=control, 

1126 metadata=True, 

1127 ) 

1128 source = cat.addNew() 

1129 source.set("centroid_x", center[0]) 

1130 source.set("centroid_y", center[1]) 

1131 offset = geom.Point2I(*center) 

1132 source.set("base_PsfFlux_instFlux", flux) 

1133 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset) 

1134 source.setFootprint(afwDetection.Footprint(tmpSpans)) 

1135 

1136 # Edge fails when setting noise from var plane 

1137 with self.assertRaises(base.MeasurementError): 

1138 plugin.measure(source, exposure) 

1139 

1140 # Succeeds when noise is from meta 

1141 exposure.getMetadata().set("BGMEAN", var) 

1142 control.noiseSource = "meta" 

1143 plugin, cat = makePluginAndCat( 

1144 alg, 

1145 algorithmName, 

1146 centroid="centroid", 

1147 psfflux="base_PsfFlux", 

1148 control=control, 

1149 metadata=True, 

1150 ) 

1151 source = cat.addNew() 

1152 source.set("centroid_x", center[0]) 

1153 source.set("centroid_y", center[1]) 

1154 offset = geom.Point2I(*center) 

1155 source.set("base_PsfFlux_instFlux", flux) 

1156 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset) 

1157 source.setFootprint(afwDetection.Footprint(tmpSpans)) 

1158 plugin.measure(source, exposure) 

1159 

1160 x = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_x") 

1161 y = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_y") 

1162 xx = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xx") 

1163 yy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_yy") 

1164 xy = source.get("ext_shapeHSM_HsmPsfMomentsDebiased_xy") 

1165 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag")) 

1166 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_no_pixels")) 

1167 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_not_contained")) 

1168 self.assertFalse(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_parent_source")) 

1169 # but _does_ set EDGE flag in this case 

1170 self.assertTrue(source.get("ext_shapeHSM_HsmPsfMomentsDebiased_flag_edge")) 

1171 

1172 expected = afwEll.Quadrupole(afwEll.Axes(width, width, 0.0)) 

1173 

1174 self.assertAlmostEqual(x, 0.0, decimals) 

1175 self.assertAlmostEqual(y, 0.0, decimals) 

1176 

1177 T = expected.getIxx() + expected.getIyy() 

1178 self.assertAlmostEqual((xx - expected.getIxx()) / T, 0.0, decimals) 

1179 self.assertAlmostEqual((xy - expected.getIxy()) / T, 0.0, decimals) 

1180 self.assertAlmostEqual((yy - expected.getIyy()) / T, 0.0, decimals) 

1181 

1182 # But fails hard if meta doesn't contain BGMEAN 

1183 exposure.getMetadata().remove("BGMEAN") 

1184 plugin, cat = makePluginAndCat( 

1185 alg, 

1186 algorithmName, 

1187 centroid="centroid", 

1188 psfflux="base_PsfFlux", 

1189 control=control, 

1190 metadata=True, 

1191 ) 

1192 source = cat.addNew() 

1193 source.set("centroid_x", center[0]) 

1194 source.set("centroid_y", center[1]) 

1195 offset = geom.Point2I(*center) 

1196 source.set("base_PsfFlux_instFlux", flux) 

1197 tmpSpans = afwGeom.SpanSet.fromShape(int(width), offset=offset) 

1198 source.setFootprint(afwDetection.Footprint(tmpSpans)) 

1199 with self.assertRaises(base.FatalAlgorithmError): 

1200 plugin.measure(source, exposure) 

1201 

1202 def testHsmPsfMomentsDebiasedBadNoiseSource(self): 

1203 control = shapeHSM.HsmPsfMomentsDebiasedConfig() 

1204 with self.assertRaises(pexConfig.FieldValidationError): 

1205 control.noiseSource = "ACM" 

1206 

1207 

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

1209 pass 

1210 

1211 

1212def setup_module(module): 

1213 lsst.utils.tests.init() 

1214 

1215 

1216if __name__ == "__main__": 1216 ↛ 1217line 1216 didn't jump to line 1217, because the condition on line 1216 was never true

1217 lsst.utils.tests.init() 

1218 unittest.main()