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

391 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-04-25 10:29 -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 

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 fig.sca(axis) # make axis active 

131 disp.mtv(exp) 

132 """ 

133 fig_class = matplotlib.figure.FigureBase 

134 

135 if isinstance(display.frame, fig_class): 

136 figure = display.frame 

137 else: 

138 figure = None 

139 

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

141 

142 if reopenPlot: 

143 import matplotlib.pyplot as pyplot 

144 pyplot.close(display.frame) 

145 

146 if figure is not None: 

147 self._figure = figure 

148 else: 

149 import matplotlib.pyplot as pyplot 

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

151 self._figure.clf() 

152 

153 self._display = display 

154 self._maskTransparency = {None: 0.7} 

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

156 self._fastMaskDisplay = fastMaskDisplay 

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

158 self._mtvOrigin = mtvOrigin 

159 self._mappable_ax = None 

160 self._colorbar_ax = None 

161 self._image_colormap = matplotlib.cm.gray 

162 # 

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

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

165 # 

166 # Support self._scale() 

167 # 

168 self._scaleArgs = dict() 

169 self._normalize = None 

170 # 

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

172 # zscale/minmax; set in mtv 

173 # 

174 self._i_setImage(None) 

175 

176 def _close(self): 

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

178 self._image = None 

179 self._mask = None 

180 self._wcs = None 

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

182 

183 def _show(self): 

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

185 

186 try: 

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

188 except AttributeError: 

189 pass 

190 

191 try: 

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

193 except AttributeError: 

194 pass 

195 

196 try: 

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

198 except AttributeError: 

199 pass 

200 

201 # 

202 # Extensions to the API 

203 # 

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

205 """Defer to figure.savefig() 

206 

207 Parameters 

208 ---------- 

209 args : `list` 

210 Passed through to figure.savefig() 

211 kwargs : `dict` 

212 Passed through to figure.savefig() 

213 """ 

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

215 

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

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

218 

219 Parameters 

220 ---------- 

221 show : `bool` 

222 Should I show the colour bar? 

223 where : `str` 

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

225 axSize : `float` or `str` 

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

227 axPad : `float` or `str` 

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

229 args : `list` 

230 Passed through to colorbar() 

231 kwargs : `dict` 

232 Passed through to colorbar() 

233 

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

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

236 plots with extreme axis ratios. 

237 

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

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

240 """ 

241 if show: 

242 if self._mappable_ax: 

243 if self._colorbar_ax is None: 

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

245 

246 mappable, ax = self._mappable_ax 

247 

248 if where in orientationDict: 

249 orientation = orientationDict[where] 

250 else: 

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

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

253 

254 if axPad is None: 

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

256 

257 divider = make_axes_locatable(ax) 

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

259 

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

261 self._figure.sca(ax) 

262 

263 else: 

264 if self._colorbar_ax is not None: 

265 self._colorbar_ax.remove() 

266 self._colorbar_ax = None 

267 

268 def useSexagesimal(self, useSexagesimal): 

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

270 

271 Parameters 

272 ---------- 

273 useSexagesimal : `bool` 

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

275 

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

277 """ 

278 

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

280 self._useSexagesimal[0] = useSexagesimal 

281 

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

283 """Wait for keyboard input 

284 

285 Parameters 

286 ---------- 

287 prompt : `str` 

288 The prompt string. 

289 allowPdb : `bool` 

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

291 

292 Returns the string you entered 

293 

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

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

296 allowPdb is False) 

297 """ 

298 while True: 

299 s = input(prompt) 

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

301 import pdb 

302 pdb.set_trace() 

303 continue 

304 

305 return s 

306 # 

307 # Defined API 

308 # 

309 

310 def _setMaskTransparency(self, transparency, maskplane): 

311 """Specify mask transparency (percent)""" 

312 

313 self._maskTransparency[maskplane] = 0.01*transparency 

314 

315 def _getMaskTransparency(self, maskplane=None): 

316 """Return the current mask transparency""" 

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

318 

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

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

321 """ 

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

323 

324 # 

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

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

327 # 

328 self._i_setImage(image, mask, wcs) 

329 

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

331 # 'minmax', so do the scaling now 

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

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

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

335 

336 ax = self._figure.gca() 

337 ax.cla() 

338 

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

340 

341 if mask: 

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

343 

344 self.show_colorbar() 

345 

346 if title: 

347 ax.set_title(title) 

348 

349 self._title = title 

350 

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

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

353 _useSexagesimal=self._useSexagesimal): 

354 

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

356 if self._mtvOrigin == afwImage.PARENT: 

357 msg = fmt % (x, y) 

358 else: 

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

360 

361 col = int(x + 0.5) 

362 row = int(y + 0.5) 

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

364 if wcs is not None: 

365 raDec = wcs.pixelToSky(x, y) 

366 ra = raDec[0].asDegrees() 

367 dec = raDec[1].asDegrees() 

368 

369 if _useSexagesimal[0]: 

370 from astropy import units as u 

371 from astropy.coordinates import Angle as apAngle 

372 

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

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

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

376 else: 

377 ra = "%9.4f" % ra 

378 dec = "%9.4f" % dec 

379 

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

381 

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

383 if self._mask: 

384 val = self._mask[col, row] 

385 if self._interpretMaskBits: 

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

387 else: 

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

389 

390 return msg 

391 

392 ax.format_coord = format_coord 

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

394 # printed it nicely 

395 for a in ax.get_images(): 

396 a.get_cursor_data = lambda ev: None # disabled 

397 

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

399 self._figure.canvas.draw_idle() 

400 

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

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

403 

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

405 dataArr = data.getArray() 

406 

407 if isMask: 

408 maskPlanes = data.getMaskPlaneDict() 

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

410 

411 planes = {} # build inverse dictionary 

412 for key in maskPlanes: 

413 planes[maskPlanes[key]] = key 

414 

415 planeList = range(nMaskPlanes) 

416 

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

418 

419 colorNames = ['black'] 

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

421 for p in planeList: 

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

423 

424 if not color: # none was specified 

425 color = next(colorGenerator) 

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

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

428 

429 colorNames.append(color) 

430 # 

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

432 # transparency and build a colour map 

433 # 

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

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

436 # hence "i + 1" below 

437 # 

438 colors = mpColors.to_rgba_array(colorNames) 

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

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

441 for i, p in enumerate(planeList): 

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

443 alpha = 0.0 

444 else: 

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

446 

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

448 

449 cmap = mpColors.ListedColormap(colors) 

450 norm = mpColors.NoNorm() 

451 else: 

452 cmap = self._image_colormap 

453 norm = self._normalize 

454 

455 ax = self._figure.gca() 

456 bbox = data.getBBox() 

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

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

459 

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

461 if isMask: 

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

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

464 continue 

465 

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

467 if bitIsSet.sum() == 0: 

468 continue 

469 

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

471 

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

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

474 extent=extent, cmap=cmap, norm=norm) 

475 maskArr[:] = 0 

476 

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

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

479 extent=extent, cmap=cmap, norm=norm) 

480 else: 

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

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

483 # it to None 

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

485 self._colorbar_ax = None 

486 

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

488 extent=extent, cmap=cmap, norm=norm) 

489 self._mappable_ax = (mappable, ax) 

490 

491 self._figure.canvas.draw_idle() 

492 

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

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

495 self._image = image 

496 self._mask = mask 

497 self._wcs = wcs 

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

499 

500 self._zoomfac = None 

501 if self._image is None: 

502 self._width, self._height = 0, 0 

503 else: 

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

505 

506 self._xcen = 0.5*self._width 

507 self._ycen = 0.5*self._height 

508 

509 def _setImageColormap(self, cmap): 

510 """Set the colormap used for the image 

511 

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

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

514 

515 """ 

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

517 cmap = matplotlib.colormaps[cmap] 

518 

519 self._image_colormap = cmap 

520 

521 # 

522 # Graphics commands 

523 # 

524 

525 def _buffer(self, enable=True): 

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

527 import matplotlib.pyplot as pyplot 

528 if enable: 

529 pyplot.ioff() 

530 else: 

531 pyplot.ion() 

532 self._figure.show() 

533 

534 def _flush(self): 

535 pass 

536 

537 def _erase(self): 

538 """Erase the display""" 

539 

540 for axis in self._figure.axes: 

541 axis.lines = [] 

542 axis.texts = [] 

543 

544 self._figure.canvas.draw_idle() 

545 

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

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

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

549 Possible values are: 

550 + Draw a + 

551 x Draw an x 

552 * Draw a * 

553 o Draw a circle 

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

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

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

557 ignored) 

558 

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

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

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

562 (textAngle is ignored otherwise). 

563 """ 

564 if not ctype: 

565 ctype = afwDisplay.GREEN 

566 

567 axis = self._figure.gca() 

568 x0, y0 = self._xy0 

569 

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

571 from matplotlib.patches import Ellipse 

572 

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

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

575 # (anti-clockwise) 

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

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

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

579 elif symb == 'o': 

580 from matplotlib.patches import CirclePolygon as Circle 

581 

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

583 else: 

584 from matplotlib.lines import Line2D 

585 

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

587 tmp = ds9Cmd.split('#') 

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

589 

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

591 

592 if cmd == "line": 

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

594 

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

596 y = np.empty_like(x) 

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

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

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

600 

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

602 elif cmd == "text": 

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

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

605 horizontalalignment='center', verticalalignment='center') 

606 else: 

607 raise RuntimeError(ds9Cmd) 

608 

609 def _drawLines(self, points, ctype): 

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

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

612 

613 from matplotlib.lines import Line2D 

614 

615 if not ctype: 

616 ctype = afwDisplay.GREEN 

617 

618 points = np.array(points) 

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

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

621 

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

623 

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

625 """ 

626 Set gray scale 

627 

628 N.b. Supports extra arguments: 

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

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

631 A single name is also supported 

632 """ 

633 self._scaleArgs['algorithm'] = algorithm 

634 self._scaleArgs['minval'] = minval 

635 self._scaleArgs['maxval'] = maxval 

636 self._scaleArgs['unit'] = unit 

637 self._scaleArgs['args'] = args 

638 self._scaleArgs['kwargs'] = kwargs 

639 

640 try: 

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

642 except (AttributeError, RuntimeError): 

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

644 pass 

645 

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

647 

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

649 if isinstance(maskedPixels, str): 

650 maskedPixels = [maskedPixels] 

651 bitmask = afwImage.Mask.getPlaneBitMask(maskedPixels) 

652 

653 sctrl = afwMath.StatisticsControl() 

654 sctrl.setAndMask(bitmask) 

655 

656 if minval == "minmax": 

657 if self._image is None: 

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

659 

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

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

662 minval = stats.getValue(afwMath.MIN) 

663 maxval = stats.getValue(afwMath.MAX) 

664 elif minval == "zscale": 

665 if bitmask: 

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

667 

668 if algorithm is None: 

669 self._normalize = None 

670 elif algorithm == "asinh": 

671 if minval == "zscale": 

672 if self._image is None: 

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

674 

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

676 else: 

677 self._normalize = AsinhNormalize(minimum=minval, 

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

679 elif algorithm == "linear": 

680 if minval == "zscale": 

681 if self._image is None: 

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

683 

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

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

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

687 else: 

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

689 else: 

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

691 # 

692 # Zoom and Pan 

693 # 

694 

695 def _zoom(self, zoomfac): 

696 """Zoom by specified amount""" 

697 

698 self._zoomfac = zoomfac 

699 

700 if zoomfac is None: 

701 return 

702 

703 x0, y0 = self._xy0 

704 

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

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

707 size = self._zoomfac 

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

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

710 

711 ax = self._figure.gca() 

712 

713 tb = self._figure.canvas.toolbar 

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

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

716 

717 ax.set_xlim(xmin, xmax) 

718 ax.set_ylim(ymin, ymax) 

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

720 

721 self._figure.canvas.draw_idle() 

722 

723 def _pan(self, colc, rowc): 

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

725 

726 self._xcen = colc 

727 self._ycen = rowc 

728 

729 self._zoom(self._zoomfac) 

730 

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

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

733 

734 if timeout < 0: 

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

736 

737 mpBackend = matplotlib.get_backend() 

738 if mpBackend not in interactiveBackends: 

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

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

741 return interface.Event('q') 

742 

743 event = None 

744 

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

746 # callback records the event and unblocks the loop. 

747 

748 def recordKeypress(keypress): 

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

750 nonlocal event 

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

752 self._figure.canvas.stop_event_loop() 

753 

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

755 try: 

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

757 finally: 

758 self._figure.canvas.mpl_disconnect(conn) 

759 return event 

760 

761 

762# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 

763 

764 

765class Normalize(mpColors.Normalize): 

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

767 

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

769 """ 

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

771 

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

773 """ 

774 if isinstance(value, np.ndarray): 

775 data = value 

776 else: 

777 data = value.data 

778 

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

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

781 

782 

783class AsinhNormalize(Normalize): 

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

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

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

787 

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

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

790 linear part (default: 1) 

791 @param Q Softening parameter (default: 8) 

792 

793 See Lupton et al., PASP 116, 133 

794 """ 

795 # The object used to perform the desired mapping 

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

797 

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

799 if vmax*Q > vmin: 

800 vmax *= Q 

801 super().__init__(vmin, vmax) 

802 

803 def _getMinMaxQ(self): 

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

805 

806 Regrettably this information is not preserved by AsinhMapping 

807 so we have to reverse engineer it 

808 """ 

809 

810 frac = 0.1 # magic number in AsinhMapping 

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

812 dataRange = Q/self.mapping._soften 

813 

814 vmin = self.mapping.minimum[0] 

815 return vmin, vmin + dataRange, Q 

816 

817 

818class AsinhZScaleNormalize(AsinhNormalize): 

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

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

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

822 

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

824 (see AsinhNormalize) 

825 @param Q Softening parameter (default: 8) 

826 

827 See Lupton et al., PASP 116, 133 

828 """ 

829 

830 # The object used to perform the desired mapping 

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

832 

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

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

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

836 Normalize.__init__(self, vmin, vmax) 

837 

838 

839class ZScaleNormalize(Normalize): 

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

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

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

843 

844 @param image to be used to estimate the stretch 

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

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

847 median (default: 0.25) 

848 """ 

849 

850 # The object used to perform the desired mapping 

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

852 

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

854 

855 

856class LinearNormalize(Normalize): 

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

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

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

860 

861 @param minimum Minimum value to display 

862 @param maximum Maximum value to display 

863 """ 

864 # The object used to perform the desired mapping 

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

866 

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