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

387 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-20 04:16 -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.pyplot as pyplot 

33import matplotlib.cbook 

34import matplotlib.colors as mpColors 

35from mpl_toolkits.axes_grid1 import make_axes_locatable 

36 

37import numpy as np 

38import numpy.ma as ma 

39 

40import lsst.afw.display as afwDisplay 

41import lsst.afw.math as afwMath 

42import lsst.afw.display.rgb as afwRgb 

43import lsst.afw.display.interface as interface 

44import lsst.afw.display.virtualDevice as virtualDevice 

45import lsst.afw.display.ds9Regions as ds9Regions 

46import lsst.afw.image as afwImage 

47 

48import lsst.afw.geom as afwGeom 

49import lsst.geom as geom 

50 

51# 

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

53# 

54try: 

55 interactiveBackends 

56except NameError: 

57 # List of backends that support `interact` 

58 interactiveBackends = [ 

59 "Qt4Agg", 

60 "Qt5Agg", 

61 ] 

62 

63try: 

64 matplotlibCtypes 

65except NameError: 

66 matplotlibCtypes = { 

67 afwDisplay.GREEN: "#00FF00", 

68 } 

69 

70 def mapCtype(ctype): 

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

72 

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

74 

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

76 """ 

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

78 

79 

80class DisplayImpl(virtualDevice.DisplayImpl): 

81 """Provide a matplotlib backend for afwDisplay 

82 

83 Recommended backends in notebooks are: 

84 %matplotlib notebook 

85 or 

86 %matplotlib ipympl 

87 or 

88 %matplotlib qt 

89 %gui qt 

90 or 

91 %matplotlib inline 

92 or 

93 %matplotlib osx 

94 

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

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

97 """ 

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

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

100 reopenPlot=False, useSexagesimal=False, dpi=None, *args, **kwargs): 

101 """ 

102 Initialise a matplotlib display 

103 

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

105 set in each pixel 

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

107 ignore DETECTED) 

108 Not really what we want, but a bit faster 

109 @param interpretMaskBits Interpret the mask value under the cursor 

110 @param mtvOrigin Display pixel coordinates with LOCAL origin 

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

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

113 (useful with e.g. %ipympl) 

114 @param useSexagesimal If True, display coordinates in sexagesimal 

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

116 May be changed by calling 

117 display.useSexagesimal() 

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

119 

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

121 permits code such as 

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

123 

124 disp = afwDisplay.Display(fig) 

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

126 

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

128 plt.sca(axis) # make axis active 

129 disp.mtv(exp) 

130 """ 

131 if hasattr(display.frame, "number"): # the "display" quacks like a matplotlib figure 

132 figure = display.frame 

133 else: 

134 figure = None 

135 

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

137 

138 if reopenPlot: 

139 pyplot.close(display.frame) 

140 

141 if figure is not None: 

142 self._figure = figure 

143 else: 

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

145 self._figure.clf() 

146 

147 self._display = display 

148 self._maskTransparency = {None: 0.7} 

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

150 self._fastMaskDisplay = fastMaskDisplay 

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

152 self._mtvOrigin = mtvOrigin 

153 self._mappable_ax = None 

154 self._colorbar_ax = None 

155 self._image_colormap = pyplot.cm.gray 

156 # 

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

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

159 # 

160 # Support self._scale() 

161 # 

162 self._scaleArgs = dict() 

163 self._normalize = None 

164 # 

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

166 # zscale/minmax; set in mtv 

167 # 

168 self._i_setImage(None) 

169 

170 def _close(self): 

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

172 self._image = None 

173 self._mask = None 

174 self._wcs = None 

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

176 

177 def _show(self): 

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

179 

180 try: 

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

182 except AttributeError: 

183 pass 

184 

185 try: 

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

187 except AttributeError: 

188 pass 

189 

190 try: 

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

192 except AttributeError: 

193 pass 

194 

195 # 

196 # Extensions to the API 

197 # 

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

199 """Defer to figure.savefig() 

200 

201 Parameters 

202 ---------- 

203 args : `list` 

204 Passed through to figure.savefig() 

205 kwargs : `dict` 

206 Passed through to figure.savefig() 

207 """ 

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

209 

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

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

212 

213 Parameters 

214 ---------- 

215 show : `bool` 

216 Should I show the colour bar? 

217 where : `str` 

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

219 axSize : `float` or `str` 

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

221 axPad : `float` or `str` 

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

223 args : `list` 

224 Passed through to colorbar() 

225 kwargs : `dict` 

226 Passed through to colorbar() 

227 

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

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

230 plots with extreme axis ratios. 

231 

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

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

234 """ 

235 if show: 

236 if self._mappable_ax: 

237 if self._colorbar_ax is None: 

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

239 

240 mappable, ax = self._mappable_ax 

241 

242 if where in orientationDict: 

243 orientation = orientationDict[where] 

244 else: 

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

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

247 

248 if axPad is None: 

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

250 

251 divider = make_axes_locatable(ax) 

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

253 

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

255 

256 try: # fails with %matplotlib inline 

257 pyplot.sca(ax) # make main window active again 

258 except ValueError: 

259 pass 

260 else: 

261 if self._colorbar_ax is not None: 

262 self._colorbar_ax.remove() 

263 self._colorbar_ax = None 

264 

265 def useSexagesimal(self, useSexagesimal): 

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

267 

268 Parameters 

269 ---------- 

270 useSexagesimal : `bool` 

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

272 

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

274 """ 

275 

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

277 self._useSexagesimal[0] = useSexagesimal 

278 

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

280 """Wait for keyboard input 

281 

282 Parameters 

283 ---------- 

284 prompt : `str` 

285 The prompt string. 

286 allowPdb : `bool` 

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

288 

289 Returns the string you entered 

290 

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

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

293 allowPdb is False) 

294 """ 

295 while True: 

296 s = input(prompt) 

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

298 import pdb 

299 pdb.set_trace() 

300 continue 

301 

302 return s 

303 # 

304 # Defined API 

305 # 

306 

307 def _setMaskTransparency(self, transparency, maskplane): 

308 """Specify mask transparency (percent)""" 

309 

310 self._maskTransparency[maskplane] = 0.01*transparency 

311 

312 def _getMaskTransparency(self, maskplane=None): 

313 """Return the current mask transparency""" 

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

315 

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

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

318 """ 

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

320 

321 # 

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

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

324 # 

325 self._i_setImage(image, mask, wcs) 

326 

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

328 # 'minmax', so do the scaling now 

329 if self._scaleArgs.get('algorithm'): # someone called self.scale() 

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

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

332 

333 ax = self._figure.gca() 

334 ax.cla() 

335 

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

337 

338 if mask: 

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

340 

341 self.show_colorbar() 

342 

343 if title: 

344 ax.set_title(title) 

345 

346 self._title = title 

347 

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

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

350 _useSexagesimal=self._useSexagesimal): 

351 

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

353 if self._mtvOrigin == afwImage.PARENT: 

354 msg = fmt % (x, y) 

355 else: 

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

357 

358 col = int(x + 0.5) 

359 row = int(y + 0.5) 

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

361 if wcs is not None: 

362 raDec = wcs.pixelToSky(x, y) 

363 ra = raDec[0].asDegrees() 

364 dec = raDec[1].asDegrees() 

365 

366 if _useSexagesimal[0]: 

367 from astropy import units as u 

368 from astropy.coordinates import Angle as apAngle 

369 

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

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

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

373 else: 

374 ra = "%9.4f" % ra 

375 dec = "%9.4f" % dec 

376 

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

378 

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

380 if self._mask: 

381 val = self._mask[col, row] 

382 if self._interpretMaskBits: 

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

384 else: 

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

386 

387 return msg 

388 

389 ax.format_coord = format_coord 

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

391 # printed it nicely 

392 for a in ax.get_images(): 

393 a.get_cursor_data = lambda ev: None # disabled 

394 

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

396 self._figure.canvas.draw_idle() 

397 

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

399 """Internal routine to display an Image or Mask on a DS9 display""" 

400 

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

402 dataArr = data.getArray() 

403 

404 if isMask: 

405 maskPlanes = data.getMaskPlaneDict() 

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

407 

408 planes = {} # build inverse dictionary 

409 for key in maskPlanes: 

410 planes[maskPlanes[key]] = key 

411 

412 planeList = range(nMaskPlanes) 

413 

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

415 

416 colorNames = ['black'] 

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

418 for p in planeList: 

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

420 

421 if not color: # none was specified 

422 color = next(colorGenerator) 

423 elif color.lower() == afwDisplay.IGNORE: 

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

425 

426 colorNames.append(color) 

427 # 

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

429 # transparency and build a colour map 

430 # 

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

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

433 # hence "i + 1" below 

434 # 

435 colors = mpColors.to_rgba_array(colorNames) 

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

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

438 for i, p in enumerate(planeList): 

439 if colorNames[i + 1] == 'black': 

440 alpha = 0.0 

441 else: 

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

443 

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

445 

446 cmap = mpColors.ListedColormap(colors) 

447 norm = mpColors.NoNorm() 

448 else: 

449 cmap = self._image_colormap 

450 norm = self._normalize 

451 

452 ax = self._figure.gca() 

453 bbox = data.getBBox() 

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

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

456 

457 with pyplot.rc_context(dict(interactive=False)): 

458 if isMask: 

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

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

461 continue 

462 

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

464 if bitIsSet.sum() == 0: 

465 continue 

466 

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

468 

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

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

471 extent=extent, cmap=cmap, norm=norm) 

472 maskArr[:] = 0 

473 

474 if self._fastMaskDisplay: # we only draw the lowest bitplane 

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

476 extent=extent, cmap=cmap, norm=norm) 

477 else: 

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

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

480 # it to None 

481 if self._mappable_ax and self._mappable_ax[1] != self._figure.gca(): 

482 self._colorbar_ax = None 

483 

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

485 extent=extent, cmap=cmap, norm=norm) 

486 self._mappable_ax = (mappable, ax) 

487 

488 self._figure.canvas.draw_idle() 

489 

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

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

492 self._image = image 

493 self._mask = mask 

494 self._wcs = wcs 

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

496 

497 self._zoomfac = None 

498 if self._image is None: 

499 self._width, self._height = 0, 0 

500 else: 

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

502 

503 self._xcen = 0.5*self._width 

504 self._ycen = 0.5*self._height 

505 

506 def _setImageColormap(self, cmap): 

507 """Set the colormap used for the image 

508 

509 cmap should be either the name of an attribute of pyplot.cm or an 

510 mpColors.Colormap (e.g. "gray" or pyplot.cm.gray) 

511 

512 """ 

513 if not isinstance(cmap, mpColors.Colormap): 

514 cmap = getattr(pyplot.cm, cmap) 

515 

516 self._image_colormap = cmap 

517 

518 # 

519 # Graphics commands 

520 # 

521 

522 def _buffer(self, enable=True): 

523 if enable: 

524 pyplot.ioff() 

525 else: 

526 pyplot.ion() 

527 self._figure.show() 

528 

529 def _flush(self): 

530 pass 

531 

532 def _erase(self): 

533 """Erase the display""" 

534 

535 for axis in self._figure.axes: 

536 axis.lines = [] 

537 axis.texts = [] 

538 

539 self._figure.canvas.draw_idle() 

540 

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

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

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

544 Possible values are: 

545 + Draw a + 

546 x Draw an x 

547 * Draw a * 

548 o Draw a circle 

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

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

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

552 ignored) 

553 

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

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

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

557 (textAngle is ignored otherwise). 

558 """ 

559 if not ctype: 

560 ctype = afwDisplay.GREEN 

561 

562 axis = self._figure.gca() 

563 x0, y0 = self._xy0 

564 

565 if isinstance(symb, afwGeom.ellipses.Axes): 

566 from matplotlib.patches import Ellipse 

567 

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

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

570 # (anti-clockwise) 

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

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

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

574 elif symb == 'o': 

575 from matplotlib.patches import CirclePolygon as Circle 

576 

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

578 else: 

579 from matplotlib.lines import Line2D 

580 

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

582 tmp = ds9Cmd.split('#') 

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

584 

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

586 

587 if cmd == "line": 

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

589 

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

591 y = np.empty_like(x) 

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

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

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

595 

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

597 elif cmd == "text": 

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

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

600 horizontalalignment='center', verticalalignment='center') 

601 else: 

602 raise RuntimeError(ds9Cmd) 

603 

604 def _drawLines(self, points, ctype): 

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

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

607 

608 from matplotlib.lines import Line2D 

609 

610 if not ctype: 

611 ctype = afwDisplay.GREEN 

612 

613 points = np.array(points) 

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

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

616 

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

618 

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

620 """ 

621 Set gray scale 

622 

623 N.b. Supports extra arguments: 

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

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

626 A single name is also supported 

627 """ 

628 self._scaleArgs['algorithm'] = algorithm 

629 self._scaleArgs['minval'] = minval 

630 self._scaleArgs['maxval'] = maxval 

631 self._scaleArgs['unit'] = unit 

632 self._scaleArgs['args'] = args 

633 self._scaleArgs['kwargs'] = kwargs 

634 

635 try: 

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

637 except (AttributeError, RuntimeError): 

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

639 pass 

640 

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

642 

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

644 if isinstance(maskedPixels, str): 

645 maskedPixels = [maskedPixels] 

646 bitmask = afwImage.Mask.getPlaneBitMask(maskedPixels) 

647 

648 sctrl = afwMath.StatisticsControl() 

649 sctrl.setAndMask(bitmask) 

650 

651 if minval == "minmax": 

652 if self._image is None: 

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

654 

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

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

657 minval = stats.getValue(afwMath.MIN) 

658 maxval = stats.getValue(afwMath.MAX) 

659 elif minval == "zscale": 

660 if bitmask: 

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

662 

663 if algorithm is None: 

664 self._normalize = None 

665 elif algorithm == "asinh": 

666 if minval == "zscale": 

667 if self._image is None: 

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

669 

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

671 else: 

672 self._normalize = AsinhNormalize(minimum=minval, 

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

674 elif algorithm == "linear": 

675 if minval == "zscale": 

676 if self._image is None: 

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

678 

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

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

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

682 else: 

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

684 else: 

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

686 # 

687 # Zoom and Pan 

688 # 

689 

690 def _zoom(self, zoomfac): 

691 """Zoom by specified amount""" 

692 

693 self._zoomfac = zoomfac 

694 

695 if zoomfac is None: 

696 return 

697 

698 x0, y0 = self._xy0 

699 

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

701 if size < self._zoomfac: # avoid min == max 

702 size = self._zoomfac 

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

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

705 

706 ax = self._figure.gca() 

707 

708 tb = self._figure.canvas.toolbar 

709 if tb is not None: # It's None for e.g. %matplotlib inline in jupyter 

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

711 

712 ax.set_xlim(xmin, xmax) 

713 ax.set_ylim(ymin, ymax) 

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

715 

716 self._figure.canvas.draw_idle() 

717 

718 def _pan(self, colc, rowc): 

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

720 

721 self._xcen = colc 

722 self._ycen = rowc 

723 

724 self._zoom(self._zoomfac) 

725 

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

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

728 

729 if timeout < 0: 

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

731 

732 mpBackend = matplotlib.get_backend() 

733 if mpBackend not in interactiveBackends: 

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

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

736 return interface.Event('q') 

737 

738 event = None 

739 

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

741 # callback records the event and unblocks the loop. 

742 

743 def recordKeypress(keypress): 

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

745 nonlocal event 

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

747 self._figure.canvas.stop_event_loop() 

748 

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

750 try: 

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

752 finally: 

753 self._figure.canvas.mpl_disconnect(conn) 

754 return event 

755 

756 

757# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 

758 

759 

760class Normalize(mpColors.Normalize): 

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

762 

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

764 """ 

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

766 

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

768 """ 

769 if isinstance(value, np.ndarray): 

770 data = value 

771 else: 

772 data = value.data 

773 

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

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

776 

777 

778class AsinhNormalize(Normalize): 

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

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

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

782 

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

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

785 linear part (default: 1) 

786 @param Q Softening parameter (default: 8) 

787 

788 See Lupton et al., PASP 116, 133 

789 """ 

790 # The object used to perform the desired mapping 

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

792 

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

794 if vmax*Q > vmin: 

795 vmax *= Q 

796 super().__init__(vmin, vmax) 

797 

798 def _getMinMaxQ(self): 

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

800 

801 Regrettably this information is not preserved by AsinhMapping 

802 so we have to reverse engineer it 

803 """ 

804 

805 frac = 0.1 # magic number in AsinhMapping 

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

807 dataRange = Q/self.mapping._soften 

808 

809 vmin = self.mapping.minimum[0] 

810 return vmin, vmin + dataRange, Q 

811 

812 

813class AsinhZScaleNormalize(AsinhNormalize): 

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

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

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

817 

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

819 (see AsinhNormalize) 

820 @param Q Softening parameter (default: 8) 

821 

822 See Lupton et al., PASP 116, 133 

823 """ 

824 

825 # The object used to perform the desired mapping 

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

827 

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

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

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

831 Normalize.__init__(self, vmin, vmax) 

832 

833 

834class ZScaleNormalize(Normalize): 

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

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

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

838 

839 @param image to be used to estimate the stretch 

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

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

842 median (default: 0.25) 

843 """ 

844 

845 # The object used to perform the desired mapping 

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

847 

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

849 

850 

851class LinearNormalize(Normalize): 

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

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

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

855 

856 @param minimum Minimum value to display 

857 @param maximum Maximum value to display 

858 """ 

859 # The object used to perform the desired mapping 

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

861 

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