Coverage for python/lsst/display/matplotlib/matplotlib.py: 54%

429 statements  

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

1# 

2# LSST Data Management System 

3# Copyright 2008, 2009, 2010, 2015 LSST Corporation. 

4# 

5# This product includes software developed by the 

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

7# 

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

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

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

11# (at your option) any later version. 

12# 

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

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

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

16# GNU General Public License for more details. 

17# 

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

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

20# see <http://www.lsstcorp.org/LegalNotices/>. 

21# 

22 

23# 

24# \file 

25# \brief Definitions to talk to matplotlib from python using the "afwDisplay" 

26# interface 

27 

28import math 

29import sys 

30import unicodedata 

31 

32import matplotlib 

33import matplotlib.cm 

34import matplotlib.figure 

35import matplotlib.cbook 

36import matplotlib.colors as mpColors 

37from mpl_toolkits.axes_grid1 import make_axes_locatable 

38 

39import numpy as np 

40import numpy.ma as ma 

41 

42import lsst.afw.display as afwDisplay 

43import lsst.afw.math as afwMath 

44import lsst.afw.display.rgb as afwRgb 

45import lsst.afw.display.interface as interface 

46import lsst.afw.display.virtualDevice as virtualDevice 

47import lsst.afw.display.ds9Regions as ds9Regions 

48import lsst.afw.image as afwImage 

49 

50import lsst.afw.geom as afwGeom 

51import lsst.geom as geom 

52 

53from .wcsAxes import WcsAxesManager, astFrameSetFromWcs 

54 

55# 

56# Set the list of backends which support _getEvent and thus interact() 

57# 

58try: 

59 interactiveBackends 

60except NameError: 

61 # List of backends that support `interact` 

62 interactiveBackends = [ 

63 "Qt4Agg", 

64 "Qt5Agg", 

65 ] 

66 

67try: 

68 matplotlibCtypes 

69except NameError: 

70 matplotlibCtypes = { 

71 afwDisplay.GREEN: "#00FF00", 

72 } 

73 

74 def mapCtype(ctype): 

75 """Map the ctype to a potentially different ctype 

76 

77 Specifically, if matplotlibCtypes[ctype] exists, use it instead 

78 

79 This is used e.g. to map "green" to a brighter shade 

80 """ 

81 return matplotlibCtypes[ctype] if ctype in matplotlibCtypes else ctype 

82 

83 

84class DisplayImpl(virtualDevice.DisplayImpl): 

85 """Provide a matplotlib backend for afwDisplay 

86 

87 Recommended backends in notebooks are: 

88 %matplotlib notebook 

89 or 

90 %matplotlib ipympl 

91 or 

92 %matplotlib qt 

93 %gui qt 

94 or 

95 %matplotlib inline 

96 or 

97 %matplotlib osx 

98 

99 Apparently only qt supports Display.interact(); the list of interactive 

100 backends is given by lsst.display.matplotlib.interactiveBackends 

101 """ 

102 def __init__(self, display, verbose=False, 

103 interpretMaskBits=True, mtvOrigin=afwImage.PARENT, fastMaskDisplay=False, 

104 reopenPlot=False, useSexagesimal=False, dpi=None, 

105 useWcsAxes=True, wcsGrid=False, astPlotOptions="", *args, **kwargs): 

106 """ 

107 Initialise a matplotlib display 

108 

109 @param fastMaskDisplay If True only show the first bitplane that's 

110 set in each pixel 

111 (e.g. if (SATURATED & DETECTED) 

112 ignore DETECTED) 

113 Not really what we want, but a bit faster 

114 @param interpretMaskBits Interpret the mask value under the cursor 

115 @param mtvOrigin Display pixel coordinates with LOCAL origin 

116 (bottom left == 0,0 not XY0) 

117 @param reopenPlot If true, close the plot before opening it. 

118 (useful with e.g. %ipympl) 

119 @param useSexagesimal If True, display coordinates in sexagesimal 

120 E.g. hh:mm:ss.ss (default:False) 

121 May be changed by calling 

122 display.useSexagesimal() 

123 @param dpi Number of dpi (passed to pyplot.figure) 

124 @param useWcsAxes If True (the default) and the data 

125 have a WCS, draw sky coordinate axes 

126 using AST instead of pixel axes 

127 @param wcsGrid If True draw the full curvilinear 

128 coordinate grid across the image 

129 (only used with useWcsAxes) 

130 @param astPlotOptions Extra AST Plot attribute settings, 

131 e.g. "Colour(grid)=2" for a red 

132 grid; these override the defaults. 

133 N.b. AST colours are 1-based grf 

134 colour table indices, not names 

135 (see lsst.display.matplotlib 

136 .wcsAxes.WcsAxesManager) 

137 

138 The `frame` argument to `Display` may be a matplotlib figure; this 

139 permits code such as 

140 fig, axes = plt.subplots(1, 2) 

141 

142 disp = afwDisplay.Display(fig) 

143 disp.scale('asinh', 'zscale', Q=0.5) 

144 

145 for axis, exp in zip(axes, exps): 

146 fig.sca(axis) # make axis active 

147 disp.mtv(exp) 

148 """ 

149 fig_class = matplotlib.figure.FigureBase 

150 

151 if isinstance(display.frame, fig_class): 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true

152 figure = display.frame 

153 else: 

154 figure = None 

155 

156 virtualDevice.DisplayImpl.__init__(self, display, verbose) 

157 

158 if reopenPlot: 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true

159 import matplotlib.pyplot as pyplot 

160 pyplot.close(display.frame) 

161 

162 if figure is not None: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true

163 self._figure = figure 

164 else: 

165 import matplotlib.pyplot as pyplot 

166 self._figure = pyplot.figure(display.frame, dpi=dpi) 

167 self._figure.clf() 

168 

169 self._display = display 

170 self._maskTransparency = {None: 0.7} 

171 self._interpretMaskBits = interpretMaskBits # interpret mask bits in mtv 

172 self._fastMaskDisplay = fastMaskDisplay 

173 self._useSexagesimal = [useSexagesimal] # use an array so we can modify the value in format_coord 

174 self._mtvOrigin = mtvOrigin 

175 self._useWcsAxes = useWcsAxes 

176 self._wcsGrid = wcsGrid 

177 self._astPlotOptions = astPlotOptions 

178 self._wcsAxesManagers = {} # matplotlib Axes -> WcsAxesManager 

179 self._mappable_ax = None 

180 self._colorbar_ax = None 

181 self._image_colormap = matplotlib.cm.gray 

182 # 

183 self.__alpha = unicodedata.lookup("GREEK SMALL LETTER alpha") # used in cursor display string 

184 self.__delta = unicodedata.lookup("GREEK SMALL LETTER delta") # used in cursor display string 

185 # 

186 # Support self._scale() 

187 # 

188 self._scaleArgs = dict() 

189 self._normalize = None 

190 # 

191 # Support self._erase(), reporting pixel/mask values, and 

192 # zscale/minmax; set in mtv 

193 # 

194 self._i_setImage(None) 

195 

196 def _close(self): 

197 """!Close the display, cleaning up any allocated resources""" 

198 for manager in self._wcsAxesManagers.values(): 

199 manager.remove() 

200 self._wcsAxesManagers = {} 

201 self._image = None 

202 self._mask = None 

203 self._wcs = None 

204 self._figure.gca().format_coord = lambda x, y: None # keeps a copy of _wcs 

205 

206 def _show(self): 

207 """Put the plot at the top of the window stacking order""" 

208 

209 try: 

210 self._figure.canvas._tkcanvas._root().lift() # tk 

211 except AttributeError: 

212 pass 

213 

214 try: 

215 self._figure.canvas.manager.window.raise_() # os/x 

216 except AttributeError: 

217 pass 

218 

219 try: 

220 self._figure.canvas.raise_() # qt[45] 

221 except AttributeError: 

222 pass 

223 

224 # 

225 # Extensions to the API 

226 # 

227 def savefig(self, *args, **kwargs): 

228 """Defer to figure.savefig() 

229 

230 Parameters 

231 ---------- 

232 args : `list` 

233 Passed through to figure.savefig() 

234 kwargs : `dict` 

235 Passed through to figure.savefig() 

236 """ 

237 self._figure.savefig(*args, **kwargs) 

238 

239 def show_colorbar(self, show=True, where="right", axSize="5%", axPad=None, **kwargs): 

240 """Show (or hide) the colour bar 

241 

242 Parameters 

243 ---------- 

244 show : `bool` 

245 Should I show the colour bar? 

246 where : `str` 

247 Location of colour bar: "right" or "bottom" 

248 axSize : `float` or `str` 

249 Size of axes to hold the colour bar; fraction of current x-size 

250 axPad : `float` or `str` 

251 Padding between axes and colour bar; fraction of current x-size 

252 args : `list` 

253 Passed through to colorbar() 

254 kwargs : `dict` 

255 Passed through to colorbar() 

256 

257 We set the default padding to put the colourbar in a reasonable 

258 place for roughly square plots, but you may need to fiddle for 

259 plots with extreme axis ratios. 

260 

261 You can only configure the colorbar when it isn't yet visible, but 

262 as you can easily remove it this is not in practice a difficulty. 

263 """ 

264 if show: 264 ↛ 287line 264 didn't jump to line 287 because the condition on line 264 was always true

265 if self._mappable_ax: 265 ↛ exitline 265 didn't return from function 'show_colorbar' because the condition on line 265 was always true

266 if self._colorbar_ax is None: 

267 orientationDict = dict(right="vertical", bottom="horizontal") 

268 

269 mappable, ax = self._mappable_ax 

270 

271 if where in orientationDict: 271 ↛ 274line 271 didn't jump to line 274 because the condition on line 271 was always true

272 orientation = orientationDict[where] 

273 else: 

274 print(f"Unknown location {where}; " 

275 f"please use one of {', '.join(orientationDict.keys())}") 

276 

277 if axPad is None: 277 ↛ 280line 277 didn't jump to line 280 because the condition on line 277 was always true

278 axPad = 0.1 if orientation == "vertical" else 0.3 

279 

280 divider = make_axes_locatable(ax) 

281 self._colorbar_ax = divider.append_axes(where, size=axSize, pad=axPad) 

282 

283 self._figure.colorbar(mappable, cax=self._colorbar_ax, orientation=orientation, **kwargs) 

284 self._figure.sca(ax) 

285 

286 else: 

287 if self._colorbar_ax is not None: 

288 self._colorbar_ax.remove() 

289 self._colorbar_ax = None 

290 

291 def useSexagesimal(self, useSexagesimal): 

292 """Control the formatting coordinates as HH:MM:SS.ss 

293 

294 Parameters 

295 ---------- 

296 useSexagesimal : `bool` 

297 Print coordinates as e.g. HH:MM:SS.ss iff True 

298 

299 N.b. can also be set in Display's ctor 

300 """ 

301 

302 """Are we formatting coordinates as HH:MM:SS.ss?""" 

303 self._useSexagesimal[0] = useSexagesimal 

304 

305 for manager in self._wcsAxesManagers.values(): 

306 manager.setSexagesimal(useSexagesimal) 

307 self._figure.canvas.draw_idle() 

308 

309 def wait(self, prompt="[c(ontinue) p(db)] :", allowPdb=True): 

310 """Wait for keyboard input 

311 

312 Parameters 

313 ---------- 

314 prompt : `str` 

315 The prompt string. 

316 allowPdb : `bool` 

317 If true, entering a 'p' or 'pdb' puts you into pdb 

318 

319 Returns the string you entered 

320 

321 Useful when plotting from a programme that exits such as a processCcd 

322 Any key except 'p' continues; 'p' puts you into pdb (unless 

323 allowPdb is False) 

324 """ 

325 while True: 

326 s = input(prompt) 

327 if allowPdb and s in ("p", "pdb"): 

328 import pdb 

329 pdb.set_trace() 

330 continue 

331 

332 return s 

333 # 

334 # Defined API 

335 # 

336 

337 def _setMaskTransparency(self, transparency, maskplane): 

338 """Specify mask transparency (percent)""" 

339 

340 self._maskTransparency[maskplane] = 0.01*transparency 

341 

342 def _getMaskTransparency(self, maskplane=None): 

343 """Return the current mask transparency""" 

344 return self._maskTransparency[maskplane if maskplane in self._maskTransparency else None] 

345 

346 def _mtv(self, image, mask=None, wcs=None, title="", metadata=None): 

347 """Display an Image and/or Mask on a matplotlib display 

348 """ 

349 title = str(title) if title else "" 

350 

351 # 

352 # Save a reference to the image as it makes erase() easy and permits 

353 # printing cursor values and minmax/zscale stretches. We also save XY0 

354 # 

355 self._i_setImage(image, mask, wcs) 

356 

357 # We need to know the pixel values to support e.g. 'zscale' and 

358 # 'minmax', so do the scaling now 

359 if self._scaleArgs.get('algorithm'): # someone called self.scale() 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true

360 self._i_scale(self._scaleArgs['algorithm'], self._scaleArgs['minval'], self._scaleArgs['maxval'], 

361 self._scaleArgs['unit'], *self._scaleArgs['args'], **self._scaleArgs['kwargs']) 

362 

363 ax = self._figure.gca() 

364 manager = self._wcsAxesManagers.pop(ax, None) 

365 if manager is not None: 

366 manager.remove() 

367 ax.cla() 

368 

369 self._i_mtv(image, wcs, title, False) 

370 

371 if mask: 

372 self._i_mtv(mask, wcs, title, True) 

373 

374 self.show_colorbar() 

375 

376 if title: 376 ↛ 377line 376 didn't jump to line 377 because the condition on line 376 was never true

377 ax.set_title(title) 

378 

379 self._title = title 

380 

381 if wcs is not None and self._useWcsAxes: 

382 self._drawWcsAxes(ax, wcs) 

383 

384 def format_coord(x, y, wcs=self._wcs, x0=self._xy0[0], y0=self._xy0[1], 

385 origin=afwImage.PARENT, bbox=self._image.getBBox(afwImage.PARENT), 

386 _useSexagesimal=self._useSexagesimal): 

387 

388 fmt = '(%1.2f, %1.2f)' 

389 if self._mtvOrigin == afwImage.PARENT: 

390 msg = fmt % (x, y) 

391 else: 

392 msg = (fmt + "L") % (x - x0, y - y0) 

393 

394 col = int(x + 0.5) 

395 row = int(y + 0.5) 

396 if bbox.contains(geom.PointI(col, row)): 

397 if wcs is not None: 

398 raDec = wcs.pixelToSky(x, y) 

399 ra = raDec[0].asDegrees() 

400 dec = raDec[1].asDegrees() 

401 

402 if _useSexagesimal[0]: 

403 from astropy import units as u 

404 from astropy.coordinates import Angle as apAngle 

405 

406 kwargs = dict(sep=':', pad=True, precision=2) 

407 ra = apAngle(ra*u.deg).to_string(unit=u.hour, **kwargs) 

408 dec = apAngle(dec*u.deg).to_string(unit=u.deg, **kwargs) 

409 else: 

410 ra = "%9.4f" % ra 

411 dec = "%9.4f" % dec 

412 

413 msg += r" (%s, %s): (%s, %s)" % (self.__alpha, self.__delta, ra, dec) 

414 

415 msg += ' %1.3f' % (self._image[col, row]) 

416 if self._mask: 

417 val = self._mask[col, row] 

418 if self._interpretMaskBits: 

419 msg += " [%s]" % self._mask.interpret(val) 

420 else: 

421 msg += " 0x%x" % val 

422 

423 return msg 

424 

425 ax.format_coord = format_coord 

426 # Stop images from reporting their value as we've already 

427 # printed it nicely 

428 for a in ax.get_images(): 

429 a.get_cursor_data = lambda ev: None # disabled 

430 

431 # using tight_layout() is too tight and clips the axes 

432 self._figure.canvas.draw_idle() 

433 

434 def _i_mtv(self, data, wcs, title, isMask): 

435 """Internal routine to display an Image or Mask on a matplotlib 

436 display. 

437 

438 Does not trigger a ``canvas.draw_idle`` command since it is assumed 

439 that the caller will do that once all the layers have been added. 

440 """ 

441 

442 title = str(title) if title else "" 

443 dataArr = data.getArray() 

444 

445 if isMask: 

446 maskPlanes = data.getMaskPlaneDict() 

447 nMaskPlanes = max(maskPlanes.values()) + 1 

448 

449 planes = {} # build inverse dictionary 

450 for key in maskPlanes: 

451 planes[maskPlanes[key]] = key 

452 

453 planeList = range(nMaskPlanes) 

454 

455 colorNames = ['black'] 

456 colorGenerator = self.display.maskColorGenerator(omitBW=True) 

457 for p in planeList: 

458 color = self.display.getMaskPlaneColor(planes[p]) if p in planes else None 

459 

460 if not color: # none was specified 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true

461 color = next(colorGenerator) 

462 elif color.lower() == afwDisplay.IGNORE: 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true

463 color = 'black' # we'll set alpha = 0 anyway 

464 

465 colorNames.append(color) 

466 # 

467 # Convert those colours to RGBA so we can have per-mask-plane 

468 # transparency and build a colour map 

469 # 

470 # Pixels equal to 0 don't get set (as no bits are set), so leave 

471 # them transparent and start our colours at [1] -- 

472 # hence "i + 1" below 

473 # 

474 colors = mpColors.to_rgba_array(colorNames) 

475 alphaChannel = 3 # the alpha channel; the A in RGBA 

476 colors[0][alphaChannel] = 0.0 # it's black anyway 

477 for i, p in enumerate(planeList): 

478 if colorNames[i + 1] == 'black': 478 ↛ 479line 478 didn't jump to line 479 because the condition on line 478 was never true

479 alpha = 0.0 

480 else: 

481 alpha = 1 - self._getMaskTransparency(planes[p] if p in planes else None) 

482 

483 colors[i + 1][alphaChannel] = alpha 

484 

485 cmap = mpColors.ListedColormap(colors) 

486 norm = mpColors.NoNorm() 

487 else: 

488 cmap = self._image_colormap 

489 norm = self._normalize 

490 

491 ax = self._figure.gca() 

492 bbox = data.getBBox() 

493 extent = (bbox.getBeginX() - 0.5, bbox.getEndX() - 0.5, 

494 bbox.getBeginY() - 0.5, bbox.getEndY() - 0.5) 

495 

496 with matplotlib.rc_context(dict(interactive=False)): 

497 if isMask: 

498 if self._fastMaskDisplay: 498 ↛ 501line 498 didn't jump to line 501 because the condition on line 498 was never true

499 # Show only the lowest set bitplane in each pixel; a single 

500 # imshow of the plane indices suffices. 

501 maskArr = np.zeros_like(dataArr, dtype=np.int32) 

502 for i, p in reversed(list(enumerate(planeList))): 

503 if colors[i + 1][alphaChannel] == 0: # colors[0] reserved 

504 continue 

505 bitIsSet = (dataArr & (1 << p)) != 0 

506 if not bitIsSet.any(): 

507 continue 

508 maskArr[bitIsSet] = i + 1 # + 1 as colorNames[0] is black 

509 ax.imshow(maskArr, origin='lower', interpolation='nearest', 

510 extent=extent, cmap=cmap, norm=norm) 

511 else: 

512 # Composite every visible bitplane into a single 

513 # full-resolution RGBA image and draw it as one layer. 

514 # The Porter-Duff "over" operator is associative, so 

515 # blending the planes here and drawing the result over the 

516 # image is identical to drawing each plane as its own 

517 # imshow, but matplotlib then colour-maps and resamples one 

518 # layer instead of one per set plane. The planes are 

519 # composited in the same order they would be drawn 

520 # (highest plane first, so the lowest ends up on top). 

521 composite = np.zeros(dataArr.shape + (4,), dtype=np.float32) 

522 for i, p in reversed(list(enumerate(planeList))): 

523 alpha = colors[i + 1][alphaChannel] 

524 if alpha == 0: # colors[0] is reserved 524 ↛ 525line 524 didn't jump to line 525 because the condition on line 524 was never true

525 continue 

526 bitIsSet = (dataArr & (1 << p)) != 0 

527 if not bitIsSet.any(): 527 ↛ 531line 527 didn't jump to line 531 because the condition on line 527 was always true

528 continue 

529 # Place this plane's flat colour over the pixels 

530 # accumulated from higher planes (straight-alpha over). 

531 dst = composite[bitIsSet] 

532 dstAlpha = dst[:, 3:4] 

533 outAlpha = alpha + dstAlpha*(1.0 - alpha) 

534 composite[bitIsSet, :3] = \ 

535 (colors[i + 1][:3]*alpha + 

536 dst[:, :3]*dstAlpha*(1.0 - alpha))/outAlpha 

537 composite[bitIsSet, 3] = outAlpha[:, 0] 

538 ax.imshow(composite, origin='lower', interpolation='nearest', 

539 extent=extent) 

540 else: 

541 # If we're playing with subplots and have reset the axis 

542 # the cached colorbar axis belongs to the old one, so set 

543 # it to None 

544 if self._mappable_ax and self._mappable_ax[1] != self._figure.gca(): 544 ↛ 545line 544 didn't jump to line 545 because the condition on line 544 was never true

545 self._colorbar_ax = None 

546 

547 mappable = ax.imshow(dataArr, origin='lower', interpolation='nearest', 

548 extent=extent, cmap=cmap, norm=norm) 

549 self._mappable_ax = (mappable, ax) 

550 

551 def _drawWcsAxes(self, ax, wcs): 

552 """Draw sky coordinate axes with AST, hiding the pixel axes. 

553 

554 Falls back to ordinary pixel axes with a warning if AST cannot 

555 draw the given WCS. 

556 """ 

557 # Freeze the view at the image extent; autoscaling in response 

558 # to AST artists drawn outside the view would change the limits 

559 # and retrigger drawing. 

560 ax.set_xlim(*ax.get_xlim()) 

561 ax.set_ylim(*ax.get_ylim()) 

562 

563 # The colorbar is attached with an axes divider that only resizes 

564 # this axes on the next draw. AST spaces the labels using the 

565 # axes geometry as it stands when it measures them, so realise the 

566 # pending resize first; otherwise the labels are laid out for the 

567 # wider, pre-colorbar axes and collide once the axes shrinks. 

568 self._figure.draw_without_rendering() 

569 

570 try: 

571 frameSet = astFrameSetFromWcs(wcs) 

572 self._wcsAxesManagers[ax] = WcsAxesManager( 

573 ax, frameSet, 

574 grid=self._wcsGrid, 

575 useSexagesimal=self._useSexagesimal[0], 

576 extraOptions=self._astPlotOptions) 

577 except Exception as e: 

578 print(f"Unable to draw WCS axes ({e}); using pixel coordinates", 

579 file=sys.stderr) 

580 

581 def _i_setImage(self, image, mask=None, wcs=None): 

582 """Save the current image, mask, wcs, and XY0""" 

583 self._image = image 

584 self._mask = mask 

585 self._wcs = wcs 

586 self._xy0 = self._image.getXY0() if self._image else (0, 0) 

587 

588 self._zoomfac = None 

589 if self._image is None: 

590 self._width, self._height = 0, 0 

591 else: 

592 self._width, self._height = self._image.getDimensions() 

593 

594 self._xcen = 0.5*self._width 

595 self._ycen = 0.5*self._height 

596 

597 def _setImageColormap(self, cmap): 

598 """Set the colormap used for the image 

599 

600 cmap should be either the name of an attribute of matplotlib.cm or an 

601 mpColors.Colormap (e.g. "gray" or matplotlib.cm.gray) 

602 

603 """ 

604 if not isinstance(cmap, mpColors.Colormap): 604 ↛ 607line 604 didn't jump to line 607 because the condition on line 604 was always true

605 cmap = matplotlib.colormaps[cmap] 

606 

607 self._image_colormap = cmap 

608 

609 # 

610 # Graphics commands 

611 # 

612 

613 def _buffer(self, enable=True): 

614 if sys.modules.get('matplotlib.pyplot') is not None: 

615 import matplotlib.pyplot as pyplot 

616 if enable: 

617 pyplot.ioff() 

618 else: 

619 pyplot.ion() 

620 self._figure.show() 

621 

622 def _flush(self): 

623 pass 

624 

625 def _erase(self): 

626 """Erase the display, keeping any WCS axes""" 

627 

628 for axis in self._figure.axes: 

629 manager = self._wcsAxesManagers.get(axis) 

630 keep = set(manager.artists) if manager is not None else set() 

631 for artist in list(axis.lines) + list(axis.texts) + list(axis.patches): 

632 if artist not in keep: 

633 artist.remove() 

634 

635 self._figure.canvas.draw_idle() 

636 

637 def _dot(self, symb, c, r, size, ctype, 

638 fontFamily="helvetica", textAngle=None): 

639 """Draw a symbol at (col,row) = (c,r) [0-based coordinates] 

640 Possible values are: 

641 + Draw a + 

642 x Draw an x 

643 * Draw a * 

644 o Draw a circle 

645 @:Mxx,Mxy,Myy Draw an ellipse with moments 

646 (Mxx, Mxy, Myy) (argument size is ignored) 

647 An afwGeom.ellipses.Axes Draw the ellipse (argument size is 

648 ignored) 

649 

650 Any other value is interpreted as a string to be drawn. Strings obey the 

651 fontFamily (which may be extended with other characteristics, e.g. 

652 "times bold italic". Text will be drawn rotated by textAngle 

653 (textAngle is ignored otherwise). 

654 """ 

655 if not ctype: 655 ↛ 658line 655 didn't jump to line 658 because the condition on line 655 was always true

656 ctype = afwDisplay.GREEN 

657 

658 axis = self._figure.gca() 

659 x0, y0 = self._xy0 

660 

661 if isinstance(symb, afwGeom.ellipses.Axes): 661 ↛ 662line 661 didn't jump to line 662 because the condition on line 661 was never true

662 from matplotlib.patches import Ellipse 

663 

664 # Following matplotlib.patches.Ellipse documentation 'width' and 

665 # 'height' are diameters while 'angle' is rotation in degrees 

666 # (anti-clockwise) 

667 axis.add_artist(Ellipse((c + x0, r + y0), height=2*symb.getA(), width=2*symb.getB(), 

668 angle=90.0 + math.degrees(symb.getTheta()), 

669 edgecolor=mapCtype(ctype), facecolor='none')) 

670 elif symb == 'o': 

671 from matplotlib.patches import CirclePolygon as Circle 

672 

673 axis.add_artist(Circle((c + x0, r + y0), radius=size, color=mapCtype(ctype), fill=False)) 

674 else: 

675 from matplotlib.lines import Line2D 

676 

677 for ds9Cmd in ds9Regions.dot(symb, c + x0, r + y0, size, fontFamily="helvetica", textAngle=None): 

678 tmp = ds9Cmd.split('#') 

679 cmd = tmp.pop(0).split() 

680 

681 cmd, args = cmd[0], cmd[1:] 

682 

683 if cmd == "line": 683 ↛ 693line 683 didn't jump to line 693 because the condition on line 683 was always true

684 args = np.array(args).astype(float) - 1.0 

685 

686 x = np.empty(len(args)//2) 

687 y = np.empty_like(x) 

688 i = np.arange(len(args), dtype=int) 

689 x = args[i%2 == 0] 

690 y = args[i%2 == 1] 

691 

692 axis.add_line(Line2D(x, y, color=mapCtype(ctype))) 

693 elif cmd == "text": 

694 x, y = np.array(args[0:2]).astype(float) - 1.0 

695 axis.text(x, y, symb, color=mapCtype(ctype), 

696 horizontalalignment='center', verticalalignment='center') 

697 else: 

698 raise RuntimeError(ds9Cmd) 

699 

700 def _drawLines(self, points, ctype): 

701 """Connect the points, a list of (col,row) 

702 Ctype is the name of a colour (e.g. 'red')""" 

703 

704 from matplotlib.lines import Line2D 

705 

706 if not ctype: 

707 ctype = afwDisplay.GREEN 

708 

709 points = np.array(points) 

710 x = points[:, 0] + self._xy0[0] 

711 y = points[:, 1] + self._xy0[1] 

712 

713 self._figure.gca().add_line(Line2D(x, y, color=mapCtype(ctype))) 

714 

715 def _scale(self, algorithm, minval, maxval, unit, *args, **kwargs): 

716 """ 

717 Set gray scale 

718 

719 N.b. Supports extra arguments: 

720 @param maskedPixels List of names of mask bits to ignore 

721 E.g. ["BAD", "INTERP"]. 

722 A single name is also supported 

723 """ 

724 self._scaleArgs['algorithm'] = algorithm 

725 self._scaleArgs['minval'] = minval 

726 self._scaleArgs['maxval'] = maxval 

727 self._scaleArgs['unit'] = unit 

728 self._scaleArgs['args'] = args 

729 self._scaleArgs['kwargs'] = kwargs 

730 

731 try: 

732 self._i_scale(algorithm, minval, maxval, unit, *args, **kwargs) 

733 except (AttributeError, RuntimeError): 

734 # Unable to access self._image; we'll try again when we run mtv 

735 pass 

736 

737 def _i_scale(self, algorithm, minval, maxval, unit, *args, **kwargs): 

738 

739 maskedPixels = kwargs.get("maskedPixels", []) 

740 if isinstance(maskedPixels, str): 

741 maskedPixels = [maskedPixels] 

742 bitmask = afwImage.Mask.getPlaneBitMask(maskedPixels) 

743 

744 sctrl = afwMath.StatisticsControl() 

745 sctrl.setAndMask(bitmask) 

746 

747 if minval == "minmax": 

748 if self._image is None: 

749 raise RuntimeError("You may only use minmax if an image is loaded into the display") 

750 

751 mi = afwImage.makeMaskedImage(self._image, self._mask) 

752 stats = afwMath.makeStatistics(mi, afwMath.MIN | afwMath.MAX, sctrl) 

753 minval = stats.getValue(afwMath.MIN) 

754 maxval = stats.getValue(afwMath.MAX) 

755 elif minval == "zscale": 

756 if bitmask: 

757 print("scale(..., 'zscale', maskedPixels=...) is not yet implemented") 

758 

759 if algorithm is None: 

760 self._normalize = None 

761 elif algorithm == "asinh": 

762 if minval == "zscale": 

763 if self._image is None: 

764 raise RuntimeError("You may only use zscale if an image is loaded into the display") 

765 

766 self._normalize = AsinhZScaleNormalize(image=self._image, Q=kwargs.get("Q", 8.0)) 

767 else: 

768 self._normalize = AsinhNormalize(minimum=minval, 

769 dataRange=maxval - minval, Q=kwargs.get("Q", 8.0)) 

770 elif algorithm == "linear": 

771 if minval == "zscale": 

772 if self._image is None: 

773 raise RuntimeError("You may only use zscale if an image is loaded into the display") 

774 

775 self._normalize = ZScaleNormalize(image=self._image, 

776 nSamples=kwargs.get("nSamples", 1000), 

777 contrast=kwargs.get("contrast", 0.25)) 

778 else: 

779 self._normalize = LinearNormalize(minimum=minval, maximum=maxval) 

780 else: 

781 raise RuntimeError("Unsupported stretch algorithm \"%s\"" % algorithm) 

782 # 

783 # Zoom and Pan 

784 # 

785 

786 def _zoom(self, zoomfac): 

787 """Zoom by specified amount""" 

788 

789 self._zoomfac = zoomfac 

790 

791 if zoomfac is None: 791 ↛ 792line 791 didn't jump to line 792 because the condition on line 791 was never true

792 return 

793 

794 x0, y0 = self._xy0 

795 

796 size = min(self._width, self._height) 

797 if size < self._zoomfac: # avoid min == max 797 ↛ 798line 797 didn't jump to line 798 because the condition on line 797 was never true

798 size = self._zoomfac 

799 xmin, xmax = self._xcen + x0 + size/self._zoomfac*np.array([-1, 1]) 

800 ymin, ymax = self._ycen + y0 + size/self._zoomfac*np.array([-1, 1]) 

801 

802 ax = self._figure.gca() 

803 

804 tb = self._figure.canvas.toolbar 

805 if tb is not None: # It's None for e.g. %matplotlib inline in jupyter 805 ↛ 806line 805 didn't jump to line 806 because the condition on line 805 was never true

806 tb.push_current() # save the current zoom in the view stack 

807 

808 ax.set_xlim(xmin, xmax) 

809 ax.set_ylim(ymin, ymax) 

810 ax.set_aspect('equal', 'datalim') 

811 

812 self._figure.canvas.draw_idle() 

813 

814 def _pan(self, colc, rowc): 

815 """Pan to (colc, rowc)""" 

816 

817 self._xcen = colc 

818 self._ycen = rowc 

819 

820 self._zoom(self._zoomfac) 

821 

822 def _getEvent(self, timeout=-1): 

823 """Listen for a key press, returning (key, x, y)""" 

824 

825 if timeout < 0: 

826 timeout = 24*3600 # -1 generates complaints in QTimer::singleShot. A day is a long time 

827 

828 mpBackend = matplotlib.get_backend() 

829 if mpBackend not in interactiveBackends: 

830 print("The %s matplotlib backend doesn't support display._getEvent()" % 

831 (matplotlib.get_backend(),), file=sys.stderr) 

832 return interface.Event('q') 

833 

834 event = None 

835 

836 # We set up a blocking event loop. On receipt of a keypress, the 

837 # callback records the event and unblocks the loop. 

838 

839 def recordKeypress(keypress): 

840 """Matplotlib callback to record keypress and unblock""" 

841 nonlocal event 

842 event = interface.Event(keypress.key, keypress.xdata, keypress.ydata) 

843 self._figure.canvas.stop_event_loop() 

844 

845 conn = self._figure.canvas.mpl_connect("key_press_event", recordKeypress) 

846 try: 

847 self._figure.canvas.start_event_loop(timeout=timeout) # Blocks on keypress 

848 finally: 

849 self._figure.canvas.mpl_disconnect(conn) 

850 return event 

851 

852 

853# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 

854 

855 

856class Normalize(mpColors.Normalize): 

857 """Class to support stretches for mtv()""" 

858 

859 def __call__(self, value, clip=None): 

860 """ 

861 Return a MaskedArray with value mapped to [0, 255] 

862 

863 @param value Input pixel value or array to be mapped 

864 """ 

865 if isinstance(value, np.ndarray): 

866 data = value 

867 else: 

868 data = value.data 

869 

870 data = data - self.mapping.minimum[0] 

871 return ma.array(data*self.mapping.mapIntensityToUint8(data)/255.0) 

872 

873 

874class AsinhNormalize(Normalize): 

875 """Provide an asinh stretch for mtv()""" 

876 def __init__(self, minimum=0, dataRange=1, Q=8): 

877 """Initialise an object able to carry out an asinh mapping 

878 

879 @param minimum Minimum pixel value (default: 0) 

880 @param dataRange Range of values for stretch if Q=0; roughly the 

881 linear part (default: 1) 

882 @param Q Softening parameter (default: 8) 

883 

884 See Lupton et al., PASP 116, 133 

885 """ 

886 # The object used to perform the desired mapping 

887 self.mapping = afwRgb.AsinhMapping(minimum, dataRange, Q) 

888 

889 vmin, vmax = self._getMinMaxQ()[0:2] 

890 if vmax*Q > vmin: 

891 vmax *= Q 

892 super().__init__(vmin, vmax) 

893 

894 def _getMinMaxQ(self): 

895 """Return an asinh mapping's minimum and maximum value, and Q 

896 

897 Regrettably this information is not preserved by AsinhMapping 

898 so we have to reverse engineer it 

899 """ 

900 

901 frac = 0.1 # magic number in AsinhMapping 

902 Q = np.sinh((frac*self.mapping._uint8Max)/self.mapping._slope)/frac 

903 dataRange = Q/self.mapping._soften 

904 

905 vmin = self.mapping.minimum[0] 

906 return vmin, vmin + dataRange, Q 

907 

908 

909class AsinhZScaleNormalize(AsinhNormalize): 

910 """Provide an asinh stretch using zscale to set limits for mtv()""" 

911 def __init__(self, image=None, Q=8): 

912 """Initialise an object able to carry out an asinh mapping 

913 

914 @param image image to use estimate minimum and dataRange using zscale 

915 (see AsinhNormalize) 

916 @param Q Softening parameter (default: 8) 

917 

918 See Lupton et al., PASP 116, 133 

919 """ 

920 

921 # The object used to perform the desired mapping 

922 self.mapping = afwRgb.AsinhZScaleMapping(image, Q) 

923 

924 vmin, vmax = self._getMinMaxQ()[0:2] 

925 # n.b. super() would call AsinhNormalize, 

926 # and I want to pass min/max to the baseclass 

927 Normalize.__init__(self, vmin, vmax) 

928 

929 

930class ZScaleNormalize(Normalize): 

931 """Provide a zscale stretch for mtv()""" 

932 def __init__(self, image=None, nSamples=1000, contrast=0.25): 

933 """Initialise an object able to carry out a zscale mapping 

934 

935 @param image to be used to estimate the stretch 

936 @param nSamples Number of data points to use (default: 1000) 

937 @param contrast Control the range of pixels to display around the 

938 median (default: 0.25) 

939 """ 

940 

941 # The object used to perform the desired mapping 

942 self.mapping = afwRgb.ZScaleMapping(image, nSamples, contrast) 

943 

944 super().__init__(self.mapping.minimum[0], self.mapping.maximum) 

945 

946 

947class LinearNormalize(Normalize): 

948 """Provide a linear stretch for mtv()""" 

949 def __init__(self, minimum=0, maximum=1): 

950 """Initialise an object able to carry out a linear mapping 

951 

952 @param minimum Minimum value to display 

953 @param maximum Maximum value to display 

954 """ 

955 # The object used to perform the desired mapping 

956 self.mapping = afwRgb.LinearMapping(minimum, maximum) 

957 

958 super().__init__(self.mapping.minimum[0], self.mapping.maximum)