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

397 statements  

« prev     ^ index     » next       coverage.py v7.2.3, created at 2023-04-19 04: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 

31import warnings 

32 

33import matplotlib.pyplot as pyplot 

34import matplotlib.cbook 

35import matplotlib.colors as mpColors 

36from matplotlib.blocking_input import BlockingInput 

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 

53# 

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

55# 

56try: 

57 interactiveBackends 

58except NameError: 

59 # List of backends that support `interact` 

60 interactiveBackends = [ 

61 "Qt4Agg", 

62 "Qt5Agg", 

63 ] 

64 

65try: 

66 matplotlibCtypes 

67except NameError: 

68 matplotlibCtypes = { 

69 afwDisplay.GREEN: "#00FF00", 

70 } 

71 

72 def mapCtype(ctype): 

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

74 

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

76 

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

78 """ 

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

80 

81 

82class DisplayImpl(virtualDevice.DisplayImpl): 

83 """Provide a matplotlib backend for afwDisplay 

84 

85 Recommended backends in notebooks are: 

86 %matplotlib notebook 

87 or 

88 %matplotlib ipympl 

89 or 

90 %matplotlib qt 

91 %gui qt 

92 or 

93 %matplotlib inline 

94 or 

95 %matplotlib osx 

96 

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

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

99 """ 

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

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

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

103 """ 

104 Initialise a matplotlib display 

105 

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

107 set in each pixel 

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

109 ignore DETECTED) 

110 Not really what we want, but a bit faster 

111 @param interpretMaskBits Interpret the mask value under the cursor 

112 @param mtvOrigin Display pixel coordinates with LOCAL origin 

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

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

115 (useful with e.g. %ipympl) 

116 @param useSexagesimal If True, display coordinates in sexagesimal 

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

118 May be changed by calling 

119 display.useSexagesimal() 

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

121 

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

123 permits code such as 

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

125 

126 disp = afwDisplay.Display(fig) 

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

128 

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

130 plt.sca(axis) # make axis active 

131 disp.mtv(exp) 

132 """ 

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

134 figure = display.frame 

135 else: 

136 figure = None 

137 

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

139 

140 if reopenPlot: 

141 pyplot.close(display.frame) 

142 

143 if figure is not None: 

144 self._figure = figure 

145 else: 

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

147 self._figure.clf() 

148 

149 self._display = display 

150 self._maskTransparency = {None: 0.7} 

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

152 self._fastMaskDisplay = fastMaskDisplay 

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

154 self._mtvOrigin = mtvOrigin 

155 self._mappable_ax = None 

156 self._colorbar_ax = None 

157 self._image_colormap = pyplot.cm.gray 

158 # 

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

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

161 # 

162 # Support self._scale() 

163 # 

164 self._scaleArgs = dict() 

165 self._normalize = None 

166 # 

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

168 # zscale/minmax; set in mtv 

169 # 

170 self._i_setImage(None) 

171 # 

172 # Ignore warnings due to BlockingKeyInput 

173 # 

174 if not verbose: 

175 warnings.filterwarnings("ignore", category=matplotlib.cbook.mplDeprecation) 

176 

177 def _close(self): 

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

179 self._image = None 

180 self._mask = None 

181 self._wcs = None 

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

183 

184 def _show(self): 

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

186 

187 try: 

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

189 except AttributeError: 

190 pass 

191 

192 try: 

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

194 except AttributeError: 

195 pass 

196 

197 try: 

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

199 except AttributeError: 

200 pass 

201 

202 # 

203 # Extensions to the API 

204 # 

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

206 """Defer to figure.savefig() 

207 

208 Parameters 

209 ---------- 

210 args : `list` 

211 Passed through to figure.savefig() 

212 kwargs : `dict` 

213 Passed through to figure.savefig() 

214 """ 

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

216 

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

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

219 

220 Parameters 

221 ---------- 

222 show : `bool` 

223 Should I show the colour bar? 

224 where : `str` 

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

226 axSize : `float` or `str` 

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

228 axPad : `float` or `str` 

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

230 args : `list` 

231 Passed through to colorbar() 

232 kwargs : `dict` 

233 Passed through to colorbar() 

234 

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

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

237 plots with extreme axis ratios. 

238 

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

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

241 """ 

242 if show: 

243 if self._mappable_ax: 

244 if self._colorbar_ax is None: 

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

246 

247 mappable, ax = self._mappable_ax 

248 

249 if where in orientationDict: 

250 orientation = orientationDict[where] 

251 else: 

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

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

254 

255 if axPad is None: 

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

257 

258 divider = make_axes_locatable(ax) 

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

260 

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

262 

263 try: # fails with %matplotlib inline 

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

265 except ValueError: 

266 pass 

267 else: 

268 if self._colorbar_ax is not None: 

269 self._colorbar_ax.remove() 

270 self._colorbar_ax = None 

271 

272 def useSexagesimal(self, useSexagesimal): 

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

274 

275 Parameters 

276 ---------- 

277 useSexagesimal : `bool` 

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

279 

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

281 """ 

282 

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

284 self._useSexagesimal[0] = useSexagesimal 

285 

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

287 """Wait for keyboard input 

288 

289 Parameters 

290 ---------- 

291 prompt : `str` 

292 The prompt string. 

293 allowPdb : `bool` 

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

295 

296 Returns the string you entered 

297 

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

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

300 allowPdb is False) 

301 """ 

302 while True: 

303 s = input(prompt) 

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

305 import pdb 

306 pdb.set_trace() 

307 continue 

308 

309 return s 

310 # 

311 # Defined API 

312 # 

313 

314 def _setMaskTransparency(self, transparency, maskplane): 

315 """Specify mask transparency (percent)""" 

316 

317 self._maskTransparency[maskplane] = 0.01*transparency 

318 

319 def _getMaskTransparency(self, maskplane=None): 

320 """Return the current mask transparency""" 

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

322 

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

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

325 """ 

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

327 

328 # 

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

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

331 # 

332 self._i_setImage(image, mask, wcs) 

333 

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

335 # 'minmax', so do the scaling now 

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

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

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

339 

340 ax = self._figure.gca() 

341 ax.cla() 

342 

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

344 

345 if mask: 

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

347 

348 self.show_colorbar() 

349 

350 if title: 

351 ax.set_title(title) 

352 

353 self._title = title 

354 

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

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

357 _useSexagesimal=self._useSexagesimal): 

358 

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

360 if self._mtvOrigin == afwImage.PARENT: 

361 msg = fmt % (x, y) 

362 else: 

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

364 

365 col = int(x + 0.5) 

366 row = int(y + 0.5) 

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

368 if wcs is not None: 

369 raDec = wcs.pixelToSky(x, y) 

370 ra = raDec[0].asDegrees() 

371 dec = raDec[1].asDegrees() 

372 

373 if _useSexagesimal[0]: 

374 from astropy import units as u 

375 from astropy.coordinates import Angle as apAngle 

376 

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

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

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

380 else: 

381 ra = "%9.4f" % ra 

382 dec = "%9.4f" % dec 

383 

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

385 

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

387 if self._mask: 

388 val = self._mask[col, row] 

389 if self._interpretMaskBits: 

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

391 else: 

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

393 

394 return msg 

395 

396 ax.format_coord = format_coord 

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

398 # printed it nicely 

399 for a in ax.get_images(): 

400 a.get_cursor_data = lambda ev: None # disabled 

401 

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

403 self._figure.canvas.draw_idle() 

404 

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

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

407 

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

409 dataArr = data.getArray() 

410 

411 if isMask: 

412 maskPlanes = data.getMaskPlaneDict() 

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

414 

415 planes = {} # build inverse dictionary 

416 for key in maskPlanes: 

417 planes[maskPlanes[key]] = key 

418 

419 planeList = range(nMaskPlanes) 

420 

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

422 

423 colorNames = ['black'] 

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

425 for p in planeList: 

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

427 

428 if not color: # none was specified 

429 color = next(colorGenerator) 

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

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

432 

433 colorNames.append(color) 

434 # 

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

436 # transparency and build a colour map 

437 # 

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

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

440 # hence "i + 1" below 

441 # 

442 colors = mpColors.to_rgba_array(colorNames) 

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

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

445 for i, p in enumerate(planeList): 

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

447 alpha = 0.0 

448 else: 

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

450 

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

452 

453 cmap = mpColors.ListedColormap(colors) 

454 norm = mpColors.NoNorm() 

455 else: 

456 cmap = self._image_colormap 

457 norm = self._normalize 

458 

459 ax = self._figure.gca() 

460 bbox = data.getBBox() 

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

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

463 

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

465 if isMask: 

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

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

468 continue 

469 

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

471 if bitIsSet.sum() == 0: 

472 continue 

473 

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

475 

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

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

478 extent=extent, cmap=cmap, norm=norm) 

479 maskArr[:] = 0 

480 

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

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

483 extent=extent, cmap=cmap, norm=norm) 

484 else: 

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

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

487 # it to None 

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

489 self._colorbar_ax = None 

490 

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

492 extent=extent, cmap=cmap, norm=norm) 

493 self._mappable_ax = (mappable, ax) 

494 

495 self._figure.canvas.draw_idle() 

496 

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

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

499 self._image = image 

500 self._mask = mask 

501 self._wcs = wcs 

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

503 

504 self._zoomfac = None 

505 if self._image is None: 

506 self._width, self._height = 0, 0 

507 else: 

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

509 

510 self._xcen = 0.5*self._width 

511 self._ycen = 0.5*self._height 

512 

513 def _setImageColormap(self, cmap): 

514 """Set the colormap used for the image 

515 

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

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

518 

519 """ 

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

521 cmap = getattr(pyplot.cm, cmap) 

522 

523 self._image_colormap = cmap 

524 

525 # 

526 # Graphics commands 

527 # 

528 

529 def _buffer(self, enable=True): 

530 if enable: 

531 pyplot.ioff() 

532 else: 

533 pyplot.ion() 

534 self._figure.show() 

535 

536 def _flush(self): 

537 pass 

538 

539 def _erase(self): 

540 """Erase the display""" 

541 

542 for axis in self._figure.axes: 

543 axis.lines = [] 

544 axis.texts = [] 

545 

546 self._figure.canvas.draw_idle() 

547 

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

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

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

551 Possible values are: 

552 + Draw a + 

553 x Draw an x 

554 * Draw a * 

555 o Draw a circle 

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

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

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

559 ignored) 

560 

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

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

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

564 (textAngle is ignored otherwise). 

565 """ 

566 if not ctype: 

567 ctype = afwDisplay.GREEN 

568 

569 axis = self._figure.gca() 

570 x0, y0 = self._xy0 

571 

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

573 from matplotlib.patches import Ellipse 

574 

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

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

577 # (anti-clockwise) 

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

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

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

581 elif symb == 'o': 

582 from matplotlib.patches import CirclePolygon as Circle 

583 

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

585 else: 

586 from matplotlib.lines import Line2D 

587 

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

589 tmp = ds9Cmd.split('#') 

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

591 

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

593 

594 if cmd == "line": 

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

596 

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

598 y = np.empty_like(x) 

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

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

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

602 

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

604 elif cmd == "text": 

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

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

607 horizontalalignment='center', verticalalignment='center') 

608 else: 

609 raise RuntimeError(ds9Cmd) 

610 

611 def _drawLines(self, points, ctype): 

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

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

614 

615 from matplotlib.lines import Line2D 

616 

617 if not ctype: 

618 ctype = afwDisplay.GREEN 

619 

620 points = np.array(points) 

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

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

623 

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

625 

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

627 """ 

628 Set gray scale 

629 

630 N.b. Supports extra arguments: 

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

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

633 A single name is also supported 

634 """ 

635 self._scaleArgs['algorithm'] = algorithm 

636 self._scaleArgs['minval'] = minval 

637 self._scaleArgs['maxval'] = maxval 

638 self._scaleArgs['unit'] = unit 

639 self._scaleArgs['args'] = args 

640 self._scaleArgs['kwargs'] = kwargs 

641 

642 try: 

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

644 except (AttributeError, RuntimeError): 

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

646 pass 

647 

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

649 

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

651 if isinstance(maskedPixels, str): 

652 maskedPixels = [maskedPixels] 

653 bitmask = afwImage.Mask.getPlaneBitMask(maskedPixels) 

654 

655 sctrl = afwMath.StatisticsControl() 

656 sctrl.setAndMask(bitmask) 

657 

658 if minval == "minmax": 

659 if self._image is None: 

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

661 

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

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

664 minval = stats.getValue(afwMath.MIN) 

665 maxval = stats.getValue(afwMath.MAX) 

666 elif minval == "zscale": 

667 if bitmask: 

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

669 

670 if algorithm is None: 

671 self._normalize = None 

672 elif algorithm == "asinh": 

673 if minval == "zscale": 

674 if self._image is None: 

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

676 

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

678 else: 

679 self._normalize = AsinhNormalize(minimum=minval, 

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

681 elif algorithm == "linear": 

682 if minval == "zscale": 

683 if self._image is None: 

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

685 

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

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

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

689 else: 

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

691 else: 

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

693 # 

694 # Zoom and Pan 

695 # 

696 

697 def _zoom(self, zoomfac): 

698 """Zoom by specified amount""" 

699 

700 self._zoomfac = zoomfac 

701 

702 if zoomfac is None: 

703 return 

704 

705 x0, y0 = self._xy0 

706 

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

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

709 size = self._zoomfac 

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

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

712 

713 ax = self._figure.gca() 

714 

715 tb = self._figure.canvas.toolbar 

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

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

718 

719 ax.set_xlim(xmin, xmax) 

720 ax.set_ylim(ymin, ymax) 

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

722 

723 self._figure.canvas.draw_idle() 

724 

725 def _pan(self, colc, rowc): 

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

727 

728 self._xcen = colc 

729 self._ycen = rowc 

730 

731 self._zoom(self._zoomfac) 

732 

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

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

735 

736 if timeout < 0: 

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

738 

739 mpBackend = matplotlib.get_backend() 

740 if mpBackend not in interactiveBackends: 

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

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

743 return interface.Event('q') 

744 

745 blocking_input = BlockingKeyInput(self._figure) 

746 return blocking_input(timeout=timeout) 

747 

748# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 

749 

750 

751class BlockingKeyInput(BlockingInput): 

752 """ 

753 Callable class to retrieve a single keyboard click 

754 """ 

755 def __init__(self, fig): 

756 """Create a BlockingKeyInput 

757 

758 @param fig The figure to monitor for keyboard events 

759 """ 

760 BlockingInput.__init__(self, fig=fig, eventslist=('key_press_event',)) 

761 

762 def post_event(self): 

763 """ 

764 Return the event containing the key and (x, y) 

765 """ 

766 try: 

767 event = self.events[-1] 

768 except IndexError: 

769 # details of the event to pass back to the display 

770 self.ev = None 

771 else: 

772 self.ev = interface.Event(event.key, event.xdata, event.ydata) 

773 

774 def __call__(self, timeout=-1): 

775 """ 

776 Blocking call to retrieve a single key click 

777 Returns key or None if timeout (-1: never timeout) 

778 """ 

779 self.ev = None 

780 

781 BlockingInput.__call__(self, n=1, timeout=timeout) 

782 

783 return self.ev 

784 

785# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 

786 

787 

788class Normalize(mpColors.Normalize): 

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

790 

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

792 """ 

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

794 

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

796 """ 

797 if isinstance(value, np.ndarray): 

798 data = value 

799 else: 

800 data = value.data 

801 

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

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

804 

805 

806class AsinhNormalize(Normalize): 

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

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

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

810 

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

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

813 linear part (default: 1) 

814 @param Q Softening parameter (default: 8) 

815 

816 See Lupton et al., PASP 116, 133 

817 """ 

818 # The object used to perform the desired mapping 

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

820 

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

822 if vmax*Q > vmin: 

823 vmax *= Q 

824 super().__init__(vmin, vmax) 

825 

826 def _getMinMaxQ(self): 

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

828 

829 Regrettably this information is not preserved by AsinhMapping 

830 so we have to reverse engineer it 

831 """ 

832 

833 frac = 0.1 # magic number in AsinhMapping 

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

835 dataRange = Q/self.mapping._soften 

836 

837 vmin = self.mapping.minimum[0] 

838 return vmin, vmin + dataRange, Q 

839 

840 

841class AsinhZScaleNormalize(AsinhNormalize): 

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

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

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

845 

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

847 (see AsinhNormalize) 

848 @param Q Softening parameter (default: 8) 

849 

850 See Lupton et al., PASP 116, 133 

851 """ 

852 

853 # The object used to perform the desired mapping 

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

855 

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

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

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

859 Normalize.__init__(self, vmin, vmax) 

860 

861 

862class ZScaleNormalize(Normalize): 

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

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

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

866 

867 @param image to be used to estimate the stretch 

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

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

870 median (default: 0.25) 

871 """ 

872 

873 # The object used to perform the desired mapping 

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

875 

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

877 

878 

879class LinearNormalize(Normalize): 

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

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

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

883 

884 @param minimum Minimum value to display 

885 @param maximum Maximum value to display 

886 """ 

887 # The object used to perform the desired mapping 

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

889 

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