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

418 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-09 02:07 -0700

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 DS9 display""" 

436 

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

438 dataArr = data.getArray() 

439 

440 if isMask: 

441 maskPlanes = data.getMaskPlaneDict() 

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

443 

444 planes = {} # build inverse dictionary 

445 for key in maskPlanes: 

446 planes[maskPlanes[key]] = key 

447 

448 planeList = range(nMaskPlanes) 

449 

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

451 

452 colorNames = ['black'] 

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

454 for p in planeList: 

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

456 

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

458 color = next(colorGenerator) 

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

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

461 

462 colorNames.append(color) 

463 # 

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

465 # transparency and build a colour map 

466 # 

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

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

469 # hence "i + 1" below 

470 # 

471 colors = mpColors.to_rgba_array(colorNames) 

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

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

474 for i, p in enumerate(planeList): 

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

476 alpha = 0.0 

477 else: 

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

479 

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

481 

482 cmap = mpColors.ListedColormap(colors) 

483 norm = mpColors.NoNorm() 

484 else: 

485 cmap = self._image_colormap 

486 norm = self._normalize 

487 

488 ax = self._figure.gca() 

489 bbox = data.getBBox() 

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

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

492 

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

494 if isMask: 

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

496 if colors[i + 1][alphaChannel] == 0: # colors[0] is reserved 496 ↛ 497line 496 didn't jump to line 497 because the condition on line 496 was never true

497 continue 

498 

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

500 if bitIsSet.sum() == 0: 500 ↛ 503line 500 didn't jump to line 503 because the condition on line 500 was always true

501 continue 

502 

503 maskArr[bitIsSet] = i + 1 # + 1 as we set colorNames[0] to black 

504 

505 if not self._fastMaskDisplay: # we draw each bitplane separately 

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

507 extent=extent, cmap=cmap, norm=norm) 

508 maskArr[:] = 0 

509 

510 if self._fastMaskDisplay: # we only draw the lowest bitplane 510 ↛ 511line 510 didn't jump to line 511 because the condition on line 510 was never true

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

512 extent=extent, cmap=cmap, norm=norm) 

513 else: 

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

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

516 # it to None 

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

518 self._colorbar_ax = None 

519 

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

521 extent=extent, cmap=cmap, norm=norm) 

522 self._mappable_ax = (mappable, ax) 

523 

524 self._figure.canvas.draw_idle() 

525 

526 def _drawWcsAxes(self, ax, wcs): 

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

528 

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

530 draw the given WCS. 

531 """ 

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

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

534 # and retrigger drawing. 

535 ax.set_xlim(*ax.get_xlim()) 

536 ax.set_ylim(*ax.get_ylim()) 

537 

538 try: 

539 frameSet = astFrameSetFromWcs(wcs) 

540 self._wcsAxesManagers[ax] = WcsAxesManager( 

541 ax, frameSet, 

542 grid=self._wcsGrid, 

543 useSexagesimal=self._useSexagesimal[0], 

544 extraOptions=self._astPlotOptions) 

545 except Exception as e: 

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

547 file=sys.stderr) 

548 

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

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

551 self._image = image 

552 self._mask = mask 

553 self._wcs = wcs 

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

555 

556 self._zoomfac = None 

557 if self._image is None: 

558 self._width, self._height = 0, 0 

559 else: 

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

561 

562 self._xcen = 0.5*self._width 

563 self._ycen = 0.5*self._height 

564 

565 def _setImageColormap(self, cmap): 

566 """Set the colormap used for the image 

567 

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

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

570 

571 """ 

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

573 cmap = matplotlib.colormaps[cmap] 

574 

575 self._image_colormap = cmap 

576 

577 # 

578 # Graphics commands 

579 # 

580 

581 def _buffer(self, enable=True): 

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

583 import matplotlib.pyplot as pyplot 

584 if enable: 

585 pyplot.ioff() 

586 else: 

587 pyplot.ion() 

588 self._figure.show() 

589 

590 def _flush(self): 

591 pass 

592 

593 def _erase(self): 

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

595 

596 for axis in self._figure.axes: 

597 manager = self._wcsAxesManagers.get(axis) 

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

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

600 if artist not in keep: 

601 artist.remove() 

602 

603 self._figure.canvas.draw_idle() 

604 

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

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

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

608 Possible values are: 

609 + Draw a + 

610 x Draw an x 

611 * Draw a * 

612 o Draw a circle 

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

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

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

616 ignored) 

617 

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

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

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

621 (textAngle is ignored otherwise). 

622 """ 

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

624 ctype = afwDisplay.GREEN 

625 

626 axis = self._figure.gca() 

627 x0, y0 = self._xy0 

628 

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

630 from matplotlib.patches import Ellipse 

631 

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

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

634 # (anti-clockwise) 

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

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

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

638 elif symb == 'o': 

639 from matplotlib.patches import CirclePolygon as Circle 

640 

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

642 else: 

643 from matplotlib.lines import Line2D 

644 

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

646 tmp = ds9Cmd.split('#') 

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

648 

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

650 

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

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

653 

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

655 y = np.empty_like(x) 

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

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

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

659 

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

661 elif cmd == "text": 

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

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

664 horizontalalignment='center', verticalalignment='center') 

665 else: 

666 raise RuntimeError(ds9Cmd) 

667 

668 def _drawLines(self, points, ctype): 

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

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

671 

672 from matplotlib.lines import Line2D 

673 

674 if not ctype: 

675 ctype = afwDisplay.GREEN 

676 

677 points = np.array(points) 

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

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

680 

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

682 

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

684 """ 

685 Set gray scale 

686 

687 N.b. Supports extra arguments: 

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

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

690 A single name is also supported 

691 """ 

692 self._scaleArgs['algorithm'] = algorithm 

693 self._scaleArgs['minval'] = minval 

694 self._scaleArgs['maxval'] = maxval 

695 self._scaleArgs['unit'] = unit 

696 self._scaleArgs['args'] = args 

697 self._scaleArgs['kwargs'] = kwargs 

698 

699 try: 

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

701 except (AttributeError, RuntimeError): 

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

703 pass 

704 

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

706 

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

708 if isinstance(maskedPixels, str): 

709 maskedPixels = [maskedPixels] 

710 bitmask = afwImage.Mask.getPlaneBitMask(maskedPixels) 

711 

712 sctrl = afwMath.StatisticsControl() 

713 sctrl.setAndMask(bitmask) 

714 

715 if minval == "minmax": 

716 if self._image is None: 

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

718 

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

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

721 minval = stats.getValue(afwMath.MIN) 

722 maxval = stats.getValue(afwMath.MAX) 

723 elif minval == "zscale": 

724 if bitmask: 

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

726 

727 if algorithm is None: 

728 self._normalize = None 

729 elif algorithm == "asinh": 

730 if minval == "zscale": 

731 if self._image is None: 

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

733 

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

735 else: 

736 self._normalize = AsinhNormalize(minimum=minval, 

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

738 elif algorithm == "linear": 

739 if minval == "zscale": 

740 if self._image is None: 

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

742 

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

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

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

746 else: 

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

748 else: 

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

750 # 

751 # Zoom and Pan 

752 # 

753 

754 def _zoom(self, zoomfac): 

755 """Zoom by specified amount""" 

756 

757 self._zoomfac = zoomfac 

758 

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

760 return 

761 

762 x0, y0 = self._xy0 

763 

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

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

766 size = self._zoomfac 

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

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

769 

770 ax = self._figure.gca() 

771 

772 tb = self._figure.canvas.toolbar 

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

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

775 

776 ax.set_xlim(xmin, xmax) 

777 ax.set_ylim(ymin, ymax) 

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

779 

780 self._figure.canvas.draw_idle() 

781 

782 def _pan(self, colc, rowc): 

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

784 

785 self._xcen = colc 

786 self._ycen = rowc 

787 

788 self._zoom(self._zoomfac) 

789 

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

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

792 

793 if timeout < 0: 

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

795 

796 mpBackend = matplotlib.get_backend() 

797 if mpBackend not in interactiveBackends: 

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

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

800 return interface.Event('q') 

801 

802 event = None 

803 

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

805 # callback records the event and unblocks the loop. 

806 

807 def recordKeypress(keypress): 

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

809 nonlocal event 

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

811 self._figure.canvas.stop_event_loop() 

812 

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

814 try: 

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

816 finally: 

817 self._figure.canvas.mpl_disconnect(conn) 

818 return event 

819 

820 

821# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 

822 

823 

824class Normalize(mpColors.Normalize): 

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

826 

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

828 """ 

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

830 

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

832 """ 

833 if isinstance(value, np.ndarray): 

834 data = value 

835 else: 

836 data = value.data 

837 

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

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

840 

841 

842class AsinhNormalize(Normalize): 

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

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

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

846 

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

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

849 linear part (default: 1) 

850 @param Q Softening parameter (default: 8) 

851 

852 See Lupton et al., PASP 116, 133 

853 """ 

854 # The object used to perform the desired mapping 

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

856 

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

858 if vmax*Q > vmin: 

859 vmax *= Q 

860 super().__init__(vmin, vmax) 

861 

862 def _getMinMaxQ(self): 

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

864 

865 Regrettably this information is not preserved by AsinhMapping 

866 so we have to reverse engineer it 

867 """ 

868 

869 frac = 0.1 # magic number in AsinhMapping 

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

871 dataRange = Q/self.mapping._soften 

872 

873 vmin = self.mapping.minimum[0] 

874 return vmin, vmin + dataRange, Q 

875 

876 

877class AsinhZScaleNormalize(AsinhNormalize): 

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

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

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

881 

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

883 (see AsinhNormalize) 

884 @param Q Softening parameter (default: 8) 

885 

886 See Lupton et al., PASP 116, 133 

887 """ 

888 

889 # The object used to perform the desired mapping 

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

891 

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

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

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

895 Normalize.__init__(self, vmin, vmax) 

896 

897 

898class ZScaleNormalize(Normalize): 

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

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

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

902 

903 @param image to be used to estimate the stretch 

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

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

906 median (default: 0.25) 

907 """ 

908 

909 # The object used to perform the desired mapping 

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

911 

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

913 

914 

915class LinearNormalize(Normalize): 

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

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

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

919 

920 @param minimum Minimum value to display 

921 @param maximum Maximum value to display 

922 """ 

923 # The object used to perform the desired mapping 

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

925 

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