99 interpretMaskBits=True, mtvOrigin=afwImage.PARENT, fastMaskDisplay=False,
100 reopenPlot=False, useSexagesimal=False, dpi=None, *args, **kwargs):
102 Initialise a matplotlib display
104 @param fastMaskDisplay If True only show the first bitplane that's
106 (e.g. if (SATURATED & 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)
120 The `frame` argument to `Display` may be a matplotlib figure; this
122 fig, axes = plt.subplots(1, 2)
124 disp = afwDisplay.Display(fig)
125 disp.scale('asinh', 'zscale', Q=0.5)
127 for axis, exp in zip(axes, exps):
128 plt.sca(axis) # make axis active
131 if hasattr(display.frame,
"number"):
132 figure = display.frame
136 virtualDevice.DisplayImpl.__init__(self, display, verbose)
139 pyplot.close(display.frame)
141 if figure
is not None:
144 self.
_figure = pyplot.figure(display.frame, dpi=dpi)
157 self.
__alpha = unicodedata.lookup(
"GREEK SMALL LETTER alpha")
158 self.
__delta = unicodedata.lookup(
"GREEK SMALL LETTER delta")
210 def show_colorbar(self, show=True, where="right", axSize="5%", axPad=None, **kwargs):
211 """Show (or hide) the colour bar
216 Should I show the colour bar?
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
224 Passed through to colorbar()
226 Passed through to colorbar()
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.
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.
236 if self._mappable_ax:
237 if self._colorbar_ax is None:
238 orientationDict = dict(right="vertical", bottom="horizontal")
240 mappable, ax = self._mappable_ax
242 if where in orientationDict:
243 orientation = orientationDict[where]
245 print(f"Unknown location {where}; "
246 f"please use one of {', '.join(orientationDict.keys())}")
249 axPad = 0.1 if orientation == "vertical" else 0.3
251 divider = make_axes_locatable(ax)
252 self._colorbar_ax = divider.append_axes(where, size=axSize, pad=axPad)
254 self._figure.colorbar(mappable, cax=self._colorbar_ax, orientation=orientation, **kwargs)
256 try: # fails with %matplotlib inline
257 pyplot.sca(ax) # make main window active again
261 if self._colorbar_ax is not None:
262 self._colorbar_ax.remove()
263 self._colorbar_ax = None
316 def _mtv(self, image, mask=None, wcs=None, title=""):
317 """Display an Image and/or Mask on a matplotlib display
319 title = str(title) if title else ""
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
325 self._i_setImage(image, mask, wcs)
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'])
333 ax = self._figure.gca()
336 self._i_mtv(image, wcs, title, False)
339 self._i_mtv(mask, wcs, title, True)
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):
352 fmt = '(%1.2f, %1.2f)'
353 if self._mtvOrigin == afwImage.PARENT:
356 msg = (fmt + "L") % (x - x0, y - y0)
360 if bbox.contains(geom.PointI(col, row)):
362 raDec = wcs.pixelToSky(x, y)
363 ra = raDec[0].asDegrees()
364 dec = raDec[1].asDegrees()
366 if _useSexagesimal[0]:
367 from astropy import units as u
368 from astropy.coordinates import Angle as apAngle
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)
377 msg += r" (%s, %s): (%s, %s)" % (self.__alpha, self.__delta, ra, dec)
379 msg += ' %1.3f' % (self._image[col, row])
381 val = self._mask[col, row]
382 if self._interpretMaskBits:
383 msg += " [%s]" % self._mask.interpret(val)
389 ax.format_coord = format_coord
390 # Stop images from reporting their value as we've already
392 for a in ax.get_images():
393 a.get_cursor_data = lambda ev: None # disabled
395 # using tight_layout() is too tight and clips the axes
396 self._figure.canvas.draw_idle()
398 def _i_mtv(self, data, wcs, title, isMask):
399 """Internal routine to display an Image or Mask on a DS9 display"""
401 title = str(title) if title else ""
402 dataArr = data.getArray()
405 maskPlanes = data.getMaskPlaneDict()
406 nMaskPlanes = max(maskPlanes.values()) + 1
408 planes = {} # build inverse dictionary
409 for key in maskPlanes:
410 planes[maskPlanes[key]] = key
412 planeList = range(nMaskPlanes)
414 maskArr = np.zeros_like(dataArr, dtype=np.int32)
416 colorNames = ['black']
417 colorGenerator = self.display.maskColorGenerator(omitBW=True)
419 color = self.display.getMaskPlaneColor(planes[p]) if p in planes else None
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
426 colorNames.append(color)
428 # Convert those colours to RGBA so we can have per-mask-plane
429 # transparency and build a colour map
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
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':
442 alpha = 1 - self._getMaskTransparency(planes[p] if p in planes else None)
444 colors[i + 1][alphaChannel] = alpha
446 cmap = mpColors.ListedColormap(colors)
447 norm = mpColors.NoNorm()
449 cmap = self._image_colormap
450 norm = self._normalize
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)
457 with pyplot.rc_context(dict(interactive=False)):
459 for i, p in reversed(list(enumerate(planeList))):
460 if colors[i + 1][alphaChannel] == 0: # colors[0] is reserved
463 bitIsSet = (dataArr & (1 << p)) != 0
464 if bitIsSet.sum() == 0:
467 maskArr[bitIsSet] = i + 1 # + 1 as we set colorNames[0] to black
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)
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)
478 # If we're playing with subplots and have reset the axis
479 # the cached colorbar axis belongs to the old one, so set
481 if self._mappable_ax and self._mappable_ax[1] != self._figure.gca():
482 self._colorbar_ax = None
484 mappable = ax.imshow(dataArr, origin='lower', interpolation='nearest',
485 extent=extent, cmap=cmap, norm=norm)
486 self._mappable_ax = (mappable, ax)
488 self._figure.canvas.draw_idle()
490 def _i_setImage(self, image, mask=None, wcs=None):
491 """Save the current image, mask, wcs, and XY0"""
495 self._xy0 = self._image.getXY0() if self._image else (0, 0)
498 if self._image is None:
499 self._width, self._height = 0, 0
501 self._width, self._height = self._image.getDimensions()
503 self._xcen = 0.5*self._width
504 self._ycen = 0.5*self._height
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]
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
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).
560 ctype = afwDisplay.GREEN
562 axis = self._figure.gca()
565 if isinstance(symb, afwGeom.ellipses.Axes):
566 from matplotlib.patches import Ellipse
568 # Following matplotlib.patches.Ellipse documentation 'width' and
569 # 'height' are diameters while 'angle' is rotation in degrees
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'))
575 from matplotlib.patches import CirclePolygon as Circle
577 axis.add_artist(Circle((c + x0, r + y0), radius=size, color=mapCtype(ctype), fill=False))
579 from matplotlib.lines import Line2D
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()
585 cmd, args = cmd[0], cmd[1:]
588 args = np.array(args).astype(float) - 1.0
590 x = np.empty(len(args)//2)
592 i = np.arange(len(args), dtype=int)
596 axis.add_line(Line2D(x, y, color=mapCtype(ctype)))
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')
602 raise RuntimeError(ds9Cmd)
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')"""
608 from matplotlib.lines import Line2D
611 ctype = afwDisplay.GREEN
613 points = np.array(points)
614 x = points[:, 0] + self._xy0[0]
615 y = points[:, 1] + self._xy0[1]
617 self._figure.gca().add_line(Line2D(x, y, color=mapCtype(ctype)))
619 def _scale(self, algorithm, minval, maxval, unit, *args, **kwargs):
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
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
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
641 def _i_scale(self, algorithm, minval, maxval, unit, *args, **kwargs):
643 maskedPixels = kwargs.get("maskedPixels", [])
644 if isinstance(maskedPixels, str):
645 maskedPixels = [maskedPixels]
646 bitmask = afwImage.Mask.getPlaneBitMask(maskedPixels)
648 sctrl = afwMath.StatisticsControl()
649 sctrl.setAndMask(bitmask)
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")
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":
661 print("scale(..., 'zscale', maskedPixels=...) is not yet implemented")
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")
670 self._normalize = AsinhZScaleNormalize(image=self._image, Q=kwargs.get("Q", 8.0))
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")
679 self._normalize = ZScaleNormalize(image=self._image,
680 nSamples=kwargs.get("nSamples", 1000),
681 contrast=kwargs.get("contrast", 0.25))
683 self._normalize = LinearNormalize(minimum=minval, maximum=maxval)
685 raise RuntimeError("Unsupported stretch algorithm \"%s\"" % algorithm)
690 def _zoom(self, zoomfac):
691 """Zoom by specified amount"""
693 self._zoomfac = zoomfac
700 size = min(self._width, self._height)
701 if size < self._zoomfac: # avoid min == max
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])
706 ax = self._figure.gca()
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
712 ax.set_xlim(xmin, xmax)
713 ax.set_ylim(ymin, ymax)
714 ax.set_aspect('equal', 'datalim')
716 self._figure.canvas.draw_idle()
726 def _getEvent(self, timeout=-1):
727 """Listen for a key press, returning (key, x, y)"""
730 timeout = 24*3600 # -1 generates complaints in QTimer::singleShot. A day is a long time
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')
740 # We set up a blocking event loop. On receipt of a keypress, the
741 # callback records the event and unblocks the loop.
743 def recordKeypress(keypress):
744 """Matplotlib callback to record keypress and unblock"""
746 event = interface.Event(keypress.key, keypress.xdata, keypress.ydata)
747 self._figure.canvas.stop_event_loop()
749 conn = self._figure.canvas.mpl_connect("key_press_event", recordKeypress)
751 self._figure.canvas.start_event_loop(timeout=timeout) # Blocks on keypress
753 self._figure.canvas.mpl_disconnect(conn)
757# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-