Coverage for tests/test_display_matplotlib.py: 99%

307 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-23 07:21 +0000

1# 

2# Copyright 2008-2017 AURA/LSST. 

3# 

4# This product includes software developed by the 

5# LSST Project (http://www.lsst.org/). 

6# 

7# This program is free software: you can redistribute it and/or modify 

8# it under the terms of the GNU General Public License as published by 

9# the Free Software Foundation, either version 3 of the License, or 

10# (at your option) any later version. 

11# 

12# This program is distributed in the hope that it will be useful, 

13# but WITHOUT ANY WARRANTY; without even the implied warranty of 

14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

15# GNU General Public License for more details. 

16# 

17# You should have received a copy of the LSST License Statement and 

18# the GNU General Public License along with this program. If not, 

19# see <https://www.lsstcorp.org/LegalNotices/>. 

20# 

21""" 

22Tests for display_matplotlib 

23""" 

24import unittest 

25 

26import matplotlib 

27matplotlib.use("Agg") 

28import matplotlib.figure # noqa: E402 

29import matplotlib.text # noqa: E402 

30from matplotlib.backends.backend_agg import FigureCanvasAgg # noqa: E402 

31 

32import lsst.utils.tests # noqa: E402 

33import lsst.geom # noqa: E402 

34import lsst.afw.display as afwDisplay # noqa: E402 

35import lsst.afw.geom as afwGeom # noqa: E402 

36import lsst.afw.image as afwImage # noqa: E402 

37 

38 

39def makeTestWcs(): 

40 """Return a rotated TAN SkyWcs for testing.""" 

41 return afwGeom.makeSkyWcs( 

42 crpix=lsst.geom.Point2D(100, 75), 

43 crval=lsst.geom.SpherePoint(30.0, -45.0, lsst.geom.degrees), 

44 cdMatrix=afwGeom.makeCdMatrix(scale=3.0*lsst.geom.arcseconds, 

45 orientation=30*lsst.geom.degrees), 

46 ) 

47 

48 

49def makeFineTestWcs(): 

50 """Return a fine-scale TAN SkyWcs whose decimal labels are wide. 

51 

52 At this scale the numeric labels nearly fill the gap between ticks, 

53 so any mismatch between the axes geometry AST measures against and 

54 the geometry it is finally drawn at makes them overlap. 

55 """ 

56 return afwGeom.makeSkyWcs( 

57 crpix=lsst.geom.Point2D(150, 150), 

58 crval=lsst.geom.SpherePoint(53.0876, -27.5207, lsst.geom.degrees), 

59 cdMatrix=afwGeom.makeCdMatrix(scale=0.2*lsst.geom.arcseconds, 

60 orientation=0*lsst.geom.degrees), 

61 ) 

62 

63 

64class AstFrameSetTestCase(lsst.utils.tests.TestCase): 

65 """Tests for the SkyWcs to pyast FrameSet conversion.""" 

66 

67 def testRoundTrip(self): 

68 """The pyast FrameSet must reproduce pixelToSky exactly.""" 

69 from lsst.display.matplotlib.wcsAxes import astFrameSetFromWcs 

70 

71 wcs = makeTestWcs() 

72 frameSet = astFrameSetFromWcs(wcs) 

73 for x, y in [(0.0, 0.0), (100.0, 75.0), (199.5, 149.5), (-20.0, 300.0)]: 

74 sky = wcs.pixelToSky(x, y) 

75 result = frameSet.tran([[x], [y]]) 

76 self.assertEqual(result[0][0], sky[0].asRadians()) 

77 self.assertEqual(result[1][0], sky[1].asRadians()) 

78 

79 

80def makeWcsAxes(**kwargs): 

81 """Create an Agg figure with pixel-coordinate axes and a manager. 

82 

83 Returns the (axes, manager) pair. 

84 """ 

85 from lsst.display.matplotlib.wcsAxes import WcsAxesManager, astFrameSetFromWcs 

86 

87 fig = matplotlib.figure.Figure(figsize=(8, 6)) 

88 FigureCanvasAgg(fig) 

89 ax = fig.add_subplot(111) 

90 ax.set_xlim(-0.5, 199.5) 

91 ax.set_ylim(-0.5, 149.5) 

92 wcs = makeTestWcs() 

93 manager = WcsAxesManager(ax, astFrameSetFromWcs(wcs), **kwargs) 

94 return ax, manager 

95 

96 

97class WcsAxesManagerTestCase(lsst.utils.tests.TestCase): 

98 """Tests for WcsAxesManager drawing and artist lifecycle.""" 

99 

100 def testDrawCreatesArtists(self): 

101 ax, manager = makeWcsAxes() 

102 self.assertGreater(len(manager.artists), 0) 

103 # All tracked artists really are in the axes. 

104 axesArtists = set(ax.lines) | set(ax.texts) 

105 for artist in manager.artists: 

106 self.assertIn(artist, axesArtists) 

107 # Sky axis labels come from the AST SkyFrame. 

108 texts = [a.get_text() for a in manager.artists 

109 if isinstance(a, matplotlib.text.Text)] 

110 self.assertIn("Right ascension", texts) 

111 self.assertIn("Declination", texts) 

112 

113 def testFurnitureHidden(self): 

114 ax, manager = makeWcsAxes() 

115 self.assertFalse(ax.xaxis.get_visible()) 

116 self.assertFalse(ax.yaxis.get_visible()) 

117 for spine in ax.spines.values(): 

118 self.assertFalse(spine.get_visible()) 

119 

120 def testTextNotClipped(self): 

121 ax, manager = makeWcsAxes() 

122 for artist in manager.artists: 

123 if isinstance(artist, matplotlib.text.Text): 

124 self.assertFalse(artist.get_clip_on()) 

125 

126 def testDecimalLabelsByDefault(self): 

127 ax, manager = makeWcsAxes() 

128 tickLabels = [a.get_text() for a in manager.artists 

129 if isinstance(a, matplotlib.text.Text) and 

130 a.get_text() not in ("Right ascension", "Declination")] 

131 self.assertGreater(len(tickLabels), 0) 

132 for label in tickLabels: 

133 self.assertNotIn(":", label) 

134 # AST chooses the precision; the labels must be decimal degrees 

135 # and adjacent labels must be distinct. 

136 self.assertTrue(any("." in label for label in tickLabels)) 

137 self.assertEqual(len(tickLabels), len(set(tickLabels))) 

138 

139 def testSexagesimalLabels(self): 

140 ax, manager = makeWcsAxes(useSexagesimal=True) 

141 tickLabels = [a.get_text() for a in manager.artists 

142 if isinstance(a, matplotlib.text.Text)] 

143 self.assertTrue(any(":" in label for label in tickLabels)) 

144 

145 def testRedrawOnLimitChange(self): 

146 ax, manager = makeWcsAxes() 

147 oldArtists = list(manager.artists) 

148 ax.set_xlim(50, 150) 

149 ax.set_ylim(40, 110) 

150 self.assertGreater(len(manager.artists), 0) 

151 self.assertNotEqual(manager.artists, oldArtists) 

152 # The old artists must be gone from the axes. 

153 axesArtists = set(ax.lines) | set(ax.texts) 

154 for artist in oldArtists: 

155 self.assertNotIn(artist, axesArtists) 

156 

157 def testRedrawOnResize(self): 

158 """Resizing the figure re-spaces the labels for the new geometry.""" 

159 from matplotlib.backend_bases import ResizeEvent 

160 

161 ax, manager = makeWcsAxes() 

162 oldArtists = list(manager.artists) 

163 fig = ax.get_figure() 

164 fig.set_size_inches(3, 3) 

165 ResizeEvent("resize_event", fig.canvas)._process() 

166 self.assertGreater(len(manager.artists), 0) 

167 self.assertNotEqual(manager.artists, oldArtists) 

168 # The old artists must be gone from the axes. 

169 axesArtists = set(ax.lines) | set(ax.texts) 

170 for artist in oldArtists: 

171 self.assertNotIn(artist, axesArtists) 

172 

173 def testResizeIgnoresUnchangedSize(self): 

174 """A resize event at the current size must not trigger a rebuild. 

175 

176 Interactive web backends (e.g. ipympl) emit resize events 

177 repeatedly at the size the figure already has, and a rebuild 

178 repaints the canvas and prompts yet another event; acting on each 

179 one drives an unbounded repaint loop. 

180 """ 

181 from matplotlib.backend_bases import ResizeEvent 

182 

183 ax, manager = makeWcsAxes() 

184 oldArtists = list(manager.artists) 

185 ResizeEvent("resize_event", ax.get_figure().canvas)._process() 

186 self.assertEqual(manager.artists, oldArtists) 

187 

188 def testDebouncedRedraw(self): 

189 """With a working timer, drag events coalesce into one redraw.""" 

190 from matplotlib.backend_bases import TimerBase 

191 

192 starts = [] 

193 

194 class FakeTimer(TimerBase): 

195 def _timer_start(self): 

196 starts.append(self) 

197 

198 def _timer_stop(self): 

199 pass 

200 

201 ax, manager = makeWcsAxes() 

202 canvas = ax.get_figure().canvas 

203 canvas.new_timer = lambda interval=None: FakeTimer(interval=interval) 

204 

205 oldArtists = list(manager.artists) 

206 for i in range(5): 

207 ax.set_xlim(-0.5 + i, 199.5 + i) 

208 ax.set_ylim(-0.5 + i, 149.5 + i) 

209 # No redraw yet: the debounce timer is pending. 

210 self.assertEqual(manager.artists, oldArtists) 

211 self.assertGreater(len(starts), 0) 

212 # Fire the pending timer: exactly one rebuild for the new view. 

213 manager._redrawTimer._on_timer() 

214 self.assertNotEqual(manager.artists, oldArtists) 

215 axesArtists = set(ax.lines) | set(ax.texts) 

216 for artist in oldArtists: 

217 self.assertNotIn(artist, axesArtists) 

218 

219 def testNoRedrawAfterRemove(self): 

220 ax, manager = makeWcsAxes() 

221 manager.remove() 

222 ax.set_xlim(50, 150) 

223 self.assertEqual(manager.artists, []) 

224 self.assertEqual(len(ax.lines), 0) 

225 

226 def testSetSexagesimal(self): 

227 ax, manager = makeWcsAxes() 

228 

229 def tickLabels(): 

230 return [a.get_text() for a in manager.artists 

231 if isinstance(a, matplotlib.text.Text)] 

232 

233 self.assertFalse(any(":" in label for label in tickLabels())) 

234 manager.setSexagesimal(True) 

235 self.assertTrue(any(":" in label for label in tickLabels())) 

236 manager.setSexagesimal(False) 

237 self.assertFalse(any(":" in label for label in tickLabels())) 

238 

239 def testConstructorFailureRestoresFurniture(self): 

240 from lsst.display.matplotlib.wcsAxes import WcsAxesManager, astFrameSetFromWcs 

241 

242 fig = matplotlib.figure.Figure(figsize=(8, 6)) 

243 FigureCanvasAgg(fig) 

244 ax = fig.add_subplot(111) 

245 ax.set_xlim(-0.5, 199.5) 

246 ax.set_ylim(-0.5, 149.5) 

247 with self.assertRaises(Exception): 

248 WcsAxesManager(ax, astFrameSetFromWcs(makeTestWcs()), 

249 extraOptions="NoSuchAttr=1") 

250 self.assertTrue(ax.xaxis.get_visible()) 

251 self.assertTrue(ax.yaxis.get_visible()) 

252 for spine in ax.spines.values(): 

253 self.assertTrue(spine.get_visible()) 

254 self.assertEqual(len(ax.lines) + len(ax.texts), 0) 

255 

256 def testRemove(self): 

257 ax, manager = makeWcsAxes() 

258 manager.remove() 

259 self.assertEqual(manager.artists, []) 

260 self.assertEqual(len(ax.lines), 0) 

261 self.assertEqual(len(ax.texts), 0) 

262 self.assertTrue(ax.xaxis.get_visible()) 

263 self.assertTrue(ax.yaxis.get_visible()) 

264 for spine in ax.spines.values(): 

265 self.assertTrue(spine.get_visible()) 

266 

267 

268class WcsAxesDisplayTestCase(lsst.utils.tests.TestCase): 

269 """Tests for WCS axes at the afwDisplay level.""" 

270 

271 frame = 100 # keep test figures separate from any other test 

272 

273 def setUp(self): 

274 afwDisplay.setDefaultBackend("matplotlib") 

275 # A distinct frame per test so each test gets a fresh figure. 

276 WcsAxesDisplayTestCase.frame += 1 

277 self.frame = WcsAxesDisplayTestCase.frame 

278 

279 def tearDown(self): 

280 afwDisplay.delAllDisplays() 

281 

282 @staticmethod 

283 def makeExposure(): 

284 exposure = afwImage.ExposureF(200, 150) 

285 exposure.image.array[:] = 1.0 

286 exposure.setWcs(makeTestWcs()) 

287 return exposure 

288 

289 def testWcsAxesByDefault(self): 

290 display = afwDisplay.Display(frame=self.frame) 

291 display.mtv(self.makeExposure()) 

292 impl = display._impl 

293 ax = impl._figure.gca() 

294 self.assertIn(ax, impl._wcsAxesManagers) 

295 self.assertGreater(len(impl._wcsAxesManagers[ax].artists), 0) 

296 self.assertFalse(ax.xaxis.get_visible()) 

297 

298 def testUseWcsAxesFalse(self): 

299 display = afwDisplay.Display(frame=self.frame, useWcsAxes=False) 

300 display.mtv(self.makeExposure()) 

301 impl = display._impl 

302 ax = impl._figure.gca() 

303 self.assertEqual(impl._wcsAxesManagers, {}) 

304 self.assertTrue(ax.xaxis.get_visible()) 

305 

306 def testNoWcsMeansPixelAxes(self): 

307 display = afwDisplay.Display(frame=self.frame) 

308 display.mtv(afwImage.ImageF(200, 150)) 

309 impl = display._impl 

310 ax = impl._figure.gca() 

311 self.assertEqual(impl._wcsAxesManagers, {}) 

312 self.assertTrue(ax.xaxis.get_visible()) 

313 

314 def testWcsGridOption(self): 

315 display = afwDisplay.Display(frame=self.frame, wcsGrid=True) 

316 display.mtv(self.makeExposure()) 

317 impl = display._impl 

318 ax = impl._figure.gca() 

319 manager = impl._wcsAxesManagers[ax] 

320 self.assertIn("Grid=1", manager._plotOptions()) 

321 

322 def testLabelsDoNotOverlapColorbar(self): 

323 """Numeric labels must be spaced for the axes as finally drawn. 

324 

325 The colorbar resizes the image axes, and AST must space the 

326 labels for that final size rather than the full-width axes it 

327 starts from, or wide decimal labels overlap. 

328 """ 

329 exposure = afwImage.ExposureF(300, 300) 

330 exposure.image.array[:] = 1.0 

331 exposure.setWcs(makeFineTestWcs()) 

332 

333 display = afwDisplay.Display(frame=self.frame) 

334 display.mtv(exposure) 

335 fig = display._impl._figure 

336 ax = fig.gca() 

337 fig.canvas.draw() 

338 renderer = fig.canvas.get_renderer() 

339 

340 # Numeric tick labels drawn horizontally (the bottom, RA axis). 

341 boxes = [] 

342 for artist in ax.texts: 

343 text = artist.get_text() 

344 if text in ("Right ascension", "Declination"): 

345 continue 

346 if any(c.isdigit() for c in text) and artist.get_rotation() % 180 == 0: 346 ↛ 342line 346 didn't jump to line 342 because the condition on line 346 was always true

347 boxes.append(artist.get_window_extent(renderer)) 

348 # Keep the row nearest the bottom of the axes. 

349 yBottom = min(box.y0 for box in boxes) 

350 bottom = sorted((box for box in boxes if box.y0 < yBottom + 20), 

351 key=lambda box: box.x0) 

352 self.assertGreater(len(bottom), 1) 

353 for left, right in zip(bottom, bottom[1:]): 

354 self.assertLessEqual(left.x1, right.x0, 

355 "adjacent RA labels overlap") 

356 

357 def testZoomRedraws(self): 

358 display = afwDisplay.Display(frame=self.frame) 

359 display.mtv(self.makeExposure()) 

360 impl = display._impl 

361 ax = impl._figure.gca() 

362 manager = impl._wcsAxesManagers[ax] 

363 oldArtists = list(manager.artists) 

364 display.zoom(4) 

365 display.pan(100, 75) 

366 self.assertGreater(len(manager.artists), 0) 

367 self.assertNotEqual(manager.artists, oldArtists) 

368 

369 def testErasePreservesWcsAxes(self): 

370 display = afwDisplay.Display(frame=self.frame) 

371 display.mtv(self.makeExposure()) 

372 impl = display._impl 

373 ax = impl._figure.gca() 

374 manager = impl._wcsAxesManagers[ax] 

375 nAstLines = sum(1 for a in manager.artists if a in set(ax.lines)) 

376 display.dot("+", 100, 75) 

377 self.assertGreater(len(ax.lines), nAstLines) 

378 display.erase() 

379 self.assertEqual(len(ax.lines), nAstLines) 

380 axesArtists = set(ax.lines) | set(ax.texts) 

381 for artist in manager.artists: 

382 self.assertIn(artist, axesArtists) 

383 

384 def testFallbackRestoresPixelAxes(self): 

385 display = afwDisplay.Display(frame=self.frame, astPlotOptions="NoSuchAttr=1") 

386 display.mtv(self.makeExposure()) 

387 impl = display._impl 

388 ax = impl._figure.gca() 

389 self.assertEqual(impl._wcsAxesManagers, {}) 

390 self.assertTrue(ax.xaxis.get_visible()) 

391 for spine in ax.spines.values(): 

392 self.assertTrue(spine.get_visible()) 

393 

394 def testEraseRemovesPatches(self): 

395 display = afwDisplay.Display(frame=self.frame) 

396 display.mtv(self.makeExposure()) 

397 ax = display._impl._figure.gca() 

398 display.dot("o", 100, 75, size=10) 

399 self.assertEqual(len(ax.patches), 1) 

400 display.erase() 

401 self.assertEqual(len(ax.patches), 0) 

402 

403 def testRepeatedMtvDoesNotLeak(self): 

404 display = afwDisplay.Display(frame=self.frame) 

405 display.mtv(self.makeExposure()) 

406 impl = display._impl 

407 ax = impl._figure.gca() 

408 nTexts = len(ax.texts) 

409 display.mtv(self.makeExposure()) 

410 ax = impl._figure.gca() 

411 self.assertEqual(len(impl._wcsAxesManagers), 1) 

412 self.assertEqual(len(ax.texts), nTexts) 

413 

414 def testUseSexagesimalRedraws(self): 

415 display = afwDisplay.Display(frame=self.frame) 

416 display.mtv(self.makeExposure()) 

417 impl = display._impl 

418 ax = impl._figure.gca() 

419 manager = impl._wcsAxesManagers[ax] 

420 

421 def hasColon(): 

422 return any(":" in a.get_text() for a in manager.artists 

423 if isinstance(a, matplotlib.text.Text)) 

424 

425 self.assertFalse(hasColon()) 

426 display.useSexagesimal(True) 

427 self.assertTrue(hasColon()) 

428 

429 def testCloseReleasesManagers(self): 

430 display = afwDisplay.Display(frame=self.frame) 

431 display.mtv(self.makeExposure()) 

432 impl = display._impl 

433 impl._close() 

434 self.assertEqual(impl._wcsAxesManagers, {}) 

435 

436 

437class DisplayMatplotlibTestCase(unittest.TestCase): 

438 

439 def setUp(self): 

440 pass 

441 

442 def tearDown(self): 

443 pass 

444 

445 def testSetAfwDisplayBackend(self): 

446 """Set the default backend to this package""" 

447 afwDisplay.setDefaultBackend("matplotlib") 

448 

449 def testSetImageColormap(self): 

450 """This is a stand-in for an eventual testcase for changing image 

451 colormap. 

452 

453 The basic outline should look something like: 

454 

455 afwDisplay.setDefaultBackend("matplotlib") 

456 display = afwDisplay.Display() 

457 display.setImageColormap('viridis') 

458 assert display._image_colormap == 'viridis' 

459 """ 

460 pass 

461 

462 

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

464 pass 

465 

466 

467def setup_module(module): 

468 lsst.utils.tests.init() 

469 

470 

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

472 lsst.utils.tests.init() 

473 unittest.main()