Coverage for tests/test_cloughTocher2DInterpolate.py: 20%

138 statements  

« prev     ^ index     » next       coverage.py v7.4.3, created at 2024-03-13 10:34 +0000

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/>. 

21 

22 

23import unittest 

24 

25from typing import Iterable 

26from itertools import product 

27import numpy as np 

28 

29import lsst.utils.tests 

30import lsst.geom 

31import lsst.afw.image as afwImage 

32from lsst.meas.algorithms.cloughTocher2DInterpolator import ( 

33 CloughTocher2DInterpolateTask, 

34) 

35from lsst.meas.algorithms import CloughTocher2DInterpolatorUtils as ctUtils 

36 

37 

38class CloughTocher2DInterpolateTestCase(lsst.utils.tests.TestCase): 

39 """Test the CloughTocher2DInterpolateTask.""" 

40 

41 def setUp(self): 

42 super().setUp() 

43 

44 self.maskedimage = afwImage.MaskedImageF(100, 121) 

45 for x in range(100): 

46 for y in range(121): 

47 self.maskedimage[x, y] = (3 * y + x * 5, 0, 1.0) 

48 

49 # Clone the maskedimage so we can compare it after running the task. 

50 self.reference = self.maskedimage.clone() 

51 

52 # Set some central pixels as SAT 

53 sliceX, sliceY = slice(30, 35), slice(40, 45) 

54 self.maskedimage.mask[sliceX, sliceY] = afwImage.Mask.getPlaneBitMask("SAT") 

55 self.maskedimage.image[sliceX, sliceY] = np.nan 

56 # Put nans here to make sure interp is done ok 

57 

58 # Set an entire column as BAD 

59 self.maskedimage.mask[54:55, :] = afwImage.Mask.getPlaneBitMask("BAD") 

60 self.maskedimage.image[54:55, :] = np.nan 

61 

62 # Set an entire row as BAD 

63 self.maskedimage.mask[:, 110:111] = afwImage.Mask.getPlaneBitMask("BAD") 

64 self.maskedimage.image[:, 110:111] = np.nan 

65 

66 # Set a diagonal set of pixels as CR 

67 for i in range(74, 78): 

68 self.maskedimage.mask[i, i] = afwImage.Mask.getPlaneBitMask("CR") 

69 self.maskedimage.image[i, i] = np.nan 

70 

71 # Set one of the edges as EDGE 

72 self.maskedimage.mask[0:1, :] = afwImage.Mask.getPlaneBitMask("EDGE") 

73 self.maskedimage.image[0:1, :] = np.nan 

74 

75 # Set a smaller streak at the edge 

76 self.maskedimage.mask[25:28, 0:1] = afwImage.Mask.getPlaneBitMask("EDGE") 

77 self.maskedimage.image[25:28, 0:1] = np.nan 

78 

79 # Update the reference image's mask alone, so we can compare them after 

80 # running the task. 

81 self.reference.mask.array[:, :] = self.maskedimage.mask.array 

82 

83 # Create a noise image 

84 self.noise = self.maskedimage.clone() 

85 np.random.seed(12345) 

86 self.noise.image.array[:, :] = np.random.normal(size=self.noise.image.array.shape) 

87 

88 @lsst.utils.tests.methodParameters(n_runs=(1, 2)) 

89 def test_interpolation(self, n_runs: int): 

90 """Test that the interpolation is done correctly. 

91 

92 Parameters 

93 ---------- 

94 n_runs : `int` 

95 Number of times to run the task. Running the task more than once 

96 should have no effect. 

97 """ 

98 config = CloughTocher2DInterpolateTask.ConfigClass() 

99 config.badMaskPlanes = ( 

100 "BAD", 

101 "SAT", 

102 "CR", 

103 "EDGE", 

104 ) 

105 config.fillValue = 0.5 

106 task = CloughTocher2DInterpolateTask(config) 

107 for n in range(n_runs): 

108 task.run(self.maskedimage) 

109 

110 # Assert that the mask and the variance planes remain unchanged. 

111 self.assertImagesEqual(self.maskedimage.variance, self.reference.variance) 

112 self.assertMasksEqual(self.maskedimage.mask, self.reference.mask) 

113 

114 # Check that the long streak of bad pixels have been replaced with the 

115 # fillValue, but not the short streak. 

116 np.testing.assert_array_equal(self.maskedimage.image[0:1, :].array, config.fillValue) 

117 with self.assertRaises(AssertionError): 

118 np.testing.assert_array_equal(self.maskedimage.image[25:28, 0:1].array, config.fillValue) 

119 

120 # Check that interpolated pixels are close to the reference (original), 

121 # and that none of them is still NaN. 

122 self.assertTrue(np.isfinite(self.maskedimage.image.array).all()) 

123 self.assertImagesAlmostEqual( 

124 self.maskedimage.image[1:, :], 

125 self.reference.image[1:, :], 

126 rtol=1e-05, 

127 atol=1e-08, 

128 ) 

129 

130 @lsst.utils.tests.methodParametersProduct(pass_badpix=(True, False), pass_goodpix=(True, False)) 

131 def test_interpolation_with_noise(self, pass_badpix: bool = True, pass_goodpix: bool = True): 

132 """Test that we can reuse the badpix and goodpix. 

133 

134 Parameters 

135 ---------- 

136 pass_badpix : `bool` 

137 Whether to pass the badpix to the task? 

138 pass_goodpix : `bool` 

139 Whether to pass the goodpix to the task? 

140 """ 

141 

142 config = CloughTocher2DInterpolateTask.ConfigClass() 

143 config.badMaskPlanes = ( 

144 "BAD", 

145 "SAT", 

146 "CR", 

147 "EDGE", 

148 ) 

149 task = CloughTocher2DInterpolateTask(config) 

150 

151 badpix, goodpix = task.run(self.noise) 

152 task.run( 

153 self.maskedimage, 

154 badpix=(badpix if pass_badpix else None), 

155 goodpix=(goodpix if pass_goodpix else None), 

156 ) 

157 

158 # Check that the long streak of bad pixels by the edge have been 

159 # replaced with fillValue, but not the short streak. 

160 np.testing.assert_array_equal(self.maskedimage.image[0:1, :].array, config.fillValue) 

161 with self.assertRaises(AssertionError): 

162 np.testing.assert_array_equal(self.maskedimage.image[25:28, 0:1].array, config.fillValue) 

163 

164 # Check that interpolated pixels are close to the reference (original), 

165 # and that none of them is still NaN. 

166 self.assertTrue(np.isfinite(self.maskedimage.image.array).all()) 

167 self.assertImagesAlmostEqual( 

168 self.maskedimage.image[1:, :], 

169 self.reference.image[1:, :], 

170 rtol=1e-05, 

171 atol=1e-08, 

172 ) 

173 

174 

175class CloughTocher2DInterpolatorUtilsTestCase(CloughTocher2DInterpolateTestCase): 

176 """Test the CloughTocher2DInterpolatorUtils.""" 

177 

178 @classmethod 

179 def find_good_pixels_around_bad_pixels( 

180 cls, 

181 image: afwImage.MaskedImage, 

182 maskPlanes: Iterable[str], 

183 *, 

184 max_window_extent: lsst.geom.Extent2I, 

185 badpix: set | None = None, 

186 goodpix: dict | None = None, 

187 ): 

188 """Find the location of bad pixels, and neighboring good pixels. 

189 

190 Parameters 

191 ---------- 

192 image : `~lsst.afw.image.MaskedImage` 

193 Image from which to find the bad and the good pixels. 

194 maskPlanes : `list` [`str`] 

195 List of mask planes to consider as bad pixels. 

196 max_window_extent : `lsst.geom.Extent2I` 

197 Maximum extent of the window around a bad pixel to consider when 

198 looking for good pixels. 

199 badpix : `list` [`tuple` [`int`, `int`]], optional 

200 A known list of bad pixels. If provided, the function does not look for 

201 any additional bad pixels, but it verifies that the provided 

202 coordinates correspond to bad pixels. If an input``badpix`` is not 

203 found to be bad as specified by ``maskPlanes``, an exception is raised. 

204 goodpix : `dict` [`tuple` [`int`, `int`], `float`], optional 

205 A known mapping of the coordinates of good pixels to their values, to 

206 which any newly found good pixels locations will be added, and the 

207 values (even for existing items) will be updated. 

208 

209 Returns 

210 ------- 

211 badpix : `list` [`tuple` [`int`, `int`]] 

212 The coordinates of the bad pixels. If ``badpix`` was provided as an 

213 input argument, the returned quantity is the same as the input. 

214 goodpix : `dict` [`tuple` [`int`, `int`], `float`] 

215 Updated mapping of the coordinates of good pixels to their values. 

216 

217 Raises 

218 ------ 

219 RuntimeError 

220 If a pixel passed in as ``goodpix`` is found to be bad as specified by 

221 ``maskPlanes``. 

222 ValueError 

223 If an input ``badpix`` is not found to be bad as specified by 

224 ``maskPlanes``. 

225 """ 

226 

227 bbox = image.getBBox() 

228 if badpix is None: 

229 iterator = product(range(bbox.minX, bbox.maxX + 1), range(bbox.minY, bbox.maxY + 1)) 

230 badpix = set() 

231 else: 

232 iterator = badpix 

233 

234 if goodpix is None: 

235 goodpix = {} 

236 

237 for x, y in iterator: 

238 if image.mask[x, y] & afwImage.Mask.getPlaneBitMask(maskPlanes): 

239 if (x, y) in goodpix: 

240 raise RuntimeError( 

241 f"Pixel ({x}, {y}) is bad as specified by maskPlanes {maskPlanes} but " 

242 "passed in as goodpix" 

243 ) 

244 badpix.add((x, y)) 

245 window = lsst.geom.Box2I.makeCenteredBox( 

246 center=lsst.geom.Point2D(x, y), # center has to be a Point2D instance. 

247 size=max_window_extent, 

248 ) 

249 # Restrict to the bounding box of the image. 

250 window.clip(bbox) 

251 

252 for xx, yy in product( 

253 range(window.minX, window.maxX + 1), 

254 range(window.minY, window.maxY + 1), 

255 ): 

256 if not (image.mask[xx, yy] & afwImage.Mask.getPlaneBitMask(maskPlanes)): 

257 goodpix[(xx, yy)] = image.image[xx, yy] 

258 elif (x, y) in badpix: 

259 # If (x, y) is in badpix, but did not get flagged as bad, 

260 # raise an exception. 

261 raise ValueError(f"Pixel ({x}, {y}) is not bad as specified by maskPlanes {maskPlanes}") 

262 

263 return badpix, goodpix 

264 

265 def test_parity(self, buffer=4): 

266 """Test that the C++ implementation gives the same results as the 

267 pure-Python implementation. 

268 

269 Parameters 

270 ---------- 

271 buffer : `int`, optional 

272 Same as the buffer parameter in `findGoodPixelsAroundBadPixels`. 

273 """ 

274 bpix, gpix = ctUtils.findGoodPixelsAroundBadPixels( 

275 self.maskedimage, ["BAD", "SAT", "CR", "EDGE"], buffer=buffer 

276 ) 

277 badpix, goodpix = self.find_good_pixels_around_bad_pixels( 

278 self.maskedimage, 

279 ["BAD", "SAT", "CR", "EDGE"], 

280 max_window_extent=lsst.geom.Extent2I(2 * buffer + 1, 2 * buffer + 1), 

281 ) 

282 

283 self.assertEqual(len(goodpix), gpix.shape[0]) 

284 for row in gpix: 

285 x, y, val = int(row[0]), int(row[1]), row[2] 

286 self.assertEqual(goodpix[(x, y)], val) 

287 

288 self.assertEqual(set(zip(bpix[:, 0], bpix[:, 1])), badpix) 

289 

290 def test_findGoodPixelsAroundBadPixels(self): 

291 """Test the findGoodPixelsAroundBadPixels utility functino.""" 

292 badpix, goodpix = ctUtils.findGoodPixelsAroundBadPixels( 

293 self.maskedimage, 

294 ["BAD", "SAT", "CR", "EDGE"], 

295 buffer=4, 

296 ) 

297 

298 # Check that badpix and goodpix have no overlaps 

299 badSet = set(zip(badpix[:, 0], badpix[:, 1])) 

300 goodSet = set(zip(goodpix[:, 0], goodpix[:, 1])) 

301 self.assertEqual(len(badSet & goodSet), 0) 

302 

303 # buffer = 0 should give no goodpix, but same badpix 

304 badpix0, goodpix0 = ctUtils.findGoodPixelsAroundBadPixels( 

305 self.maskedimage, 

306 ["BAD", "SAT", "CR", "EDGE"], 

307 buffer=0, 

308 ) 

309 

310 self.assertEqual(len(goodpix0), 0) 

311 np.testing.assert_array_equal(badpix0, badpix) 

312 

313 # For large enough buffer, badpix and goodpix should be mutually 

314 # exclusive and complete. This also checks that edges are handled. 

315 badpix, goodpix = ctUtils.findGoodPixelsAroundBadPixels( 

316 self.maskedimage, 

317 ["BAD", "SAT", "CR", "EDGE"], 

318 buffer=251, 

319 ) 

320 

321 self.assertEqual( 

322 len(badpix) + len(goodpix), 

323 self.maskedimage.getWidth() * self.maskedimage.getHeight(), 

324 ) 

325 

326 def test_update_functions(self): 

327 """Test updateArrayFromImage and updateImageFromArray behave as 

328 expected. 

329 """ 

330 badpix, _ = ctUtils.findGoodPixelsAroundBadPixels( 

331 self.maskedimage, 

332 ["BAD", "SAT", "CR", "EDGE"], 

333 buffer=3, 

334 ) 

335 

336 # Ensure that maskedimage and reference are not the same initially. 

337 with self.assertRaises(AssertionError): 

338 self.assertImagesEqual(self.maskedimage.image, self.reference.image) 

339 

340 # Update badpix values from the reference image 

341 ctUtils.updateArrayFromImage(badpix, self.reference.image) 

342 

343 # Update maskedimage from badpix values 

344 ctUtils.updateImageFromArray(self.maskedimage.image, badpix) 

345 

346 # maskedimage and reference image should now to be identifical 

347 self.assertImagesEqual(self.maskedimage.image, self.reference.image) 

348 

349 @lsst.utils.tests.methodParametersProduct(x0=(0, 23, -53), y0=(0, 47, -31)) 

350 def test_origin(self, x0=23, y0=47): 

351 """Test that we get consistent results with arbitrary image origins. 

352 

353 Parameters 

354 ---------- 

355 x0 : `int` 

356 The origin of the image along the horizontal axis. 

357 y0 : `int` 

358 The origin of the image along the vertical axis. 

359 """ 

360 # Calling setUp explicitly becomes necessary, as we change in the image 

361 # in-place and need to reset to the original state when running with 

362 # a different set of parameters. 

363 self.setUp() 

364 badpix0, goodpix0 = ctUtils.findGoodPixelsAroundBadPixels( 

365 self.maskedimage, 

366 ["BAD", "SAT", "CR", "EDGE"], 

367 buffer=4, 

368 ) 

369 

370 # Check that badpix and goodpix have no overlaps 

371 badSet = set(zip(badpix0[:, 0], badpix0[:, 1])) 

372 goodSet = set(zip(goodpix0[:, 0], goodpix0[:, 1])) 

373 self.assertEqual(len(badSet & goodSet), 0) 

374 

375 # Set a non-trivial xy0 for the maskedimage 

376 self.maskedimage.setXY0(lsst.geom.Point2I(x0, y0)) 

377 badpix, goodpix = ctUtils.findGoodPixelsAroundBadPixels( 

378 self.maskedimage, 

379 ["BAD", "SAT", "CR", "EDGE"], 

380 buffer=4, 

381 ) 

382 

383 # Adjust the x and y columns with origin, so we can compare them. 

384 badpix0[:, 0] += x0 

385 goodpix0[:, 0] += x0 

386 badpix0[:, 1] += y0 

387 goodpix0[:, 1] += y0 

388 

389 # The third column (pixel values) must match exactly if the 

390 # corresponding pixel values are read, regardless of the coordinate. 

391 np.testing.assert_array_equal(goodpix, goodpix0) 

392 np.testing.assert_array_equal(badpix, badpix0) 

393 

394 # Update one of the goodpix arrays from image and check that it is 

395 # invariant. It would be invariant if it handles the pixel coordinates 

396 # consistently. 

397 ctUtils.updateArrayFromImage(goodpix0, self.maskedimage.image) 

398 np.testing.assert_array_equal(goodpix, goodpix0) 

399 

400 # There should be some nan values right now. 

401 self.assertFalse(np.isfinite(self.maskedimage.image.array).all()) 

402 

403 # There should not be any nan values if the image is updated correctly. 

404 badpix[:, 2] = -99 

405 ctUtils.updateImageFromArray(self.maskedimage.image, badpix) 

406 self.assertTrue(np.isfinite(self.maskedimage.image.array).all()) 

407 

408 

409def setup_module(module): 

410 lsst.utils.tests.init() 

411 

412 

413class MemoryTestCase(lsst.utils.tests.MemoryTestCase): 

414 pass 

415 

416 

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

418 lsst.utils.tests.init() 

419 unittest.main()