101 interpretMaskBits=True, mtvOrigin=afwImage.PARENT, fastMaskDisplay=False,
102 reopenPlot=False, useSexagesimal=False, dpi=None, *args, **kwargs):
104 Initialise a matplotlib display
106 @param fastMaskDisplay If True only show the first bitplane that's
108 (e.g. if (SATURATED & 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)
122 The `frame` argument to `Display` may be a matplotlib figure; this
124 fig, axes = plt.subplots(1, 2)
126 disp = afwDisplay.Display(fig)
127 disp.scale('asinh', 'zscale', Q=0.5)
129 for axis, exp in zip(axes, exps):
130 fig.sca(axis) # make axis active
133 fig_class = matplotlib.figure.FigureBase
135 if isinstance(display.frame, fig_class):
136 figure = display.frame
140 virtualDevice.DisplayImpl.__init__(self, display, verbose)
143 import matplotlib.pyplot
as pyplot
144 pyplot.close(display.frame)
146 if figure
is not None:
149 import matplotlib.pyplot
as pyplot
150 self.
_figure = pyplot.figure(display.frame, dpi=dpi)
163 self.
__alpha = unicodedata.lookup(
"GREEK SMALL LETTER alpha")
164 self.
__delta = unicodedata.lookup(
"GREEK SMALL LETTER delta")
216 def show_colorbar(self, show=True, where="right", axSize="5%", axPad=None, **kwargs):
217 """Show (or hide) the colour bar
222 Should I show the colour bar?
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
230 Passed through to colorbar()
232 Passed through to colorbar()
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.
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.
242 if self._mappable_ax:
243 if self._colorbar_ax is None:
244 orientationDict = dict(right="vertical", bottom="horizontal")
246 mappable, ax = self._mappable_ax
248 if where in orientationDict:
249 orientation = orientationDict[where]
251 print(f"Unknown location {where}; "
252 f"please use one of {', '.join(orientationDict.keys())}")
255 axPad = 0.1 if orientation == "vertical" else 0.3
257 divider = make_axes_locatable(ax)
258 self._colorbar_ax = divider.append_axes(where, size=axSize, pad=axPad)
260 self._figure.colorbar(mappable, cax=self._colorbar_ax, orientation=orientation, **kwargs)
264 if self._colorbar_ax is not None:
265 self._colorbar_ax.remove()
266 self._colorbar_ax = None
319 def _mtv(self, image, mask=None, wcs=None, title=""):
320 """Display an Image and/or Mask on a matplotlib display
322 title = str(title) if title else ""
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
328 self._i_setImage(image, mask, wcs)
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'])
336 ax = self._figure.gca()
339 self._i_mtv(image, wcs, title, False)
342 self._i_mtv(mask, wcs, title, True)
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):
355 fmt = '(%1.2f, %1.2f)'
356 if self._mtvOrigin == afwImage.PARENT:
359 msg = (fmt + "L") % (x - x0, y - y0)
363 if bbox.contains(geom.PointI(col, row)):
365 raDec = wcs.pixelToSky(x, y)
366 ra = raDec[0].asDegrees()
367 dec = raDec[1].asDegrees()
369 if _useSexagesimal[0]:
370 from astropy import units as u
371 from astropy.coordinates import Angle as apAngle
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)
380 msg += r" (%s, %s): (%s, %s)" % (self.__alpha, self.__delta, ra, dec)
382 msg += ' %1.3f' % (self._image[col, row])
384 val = self._mask[col, row]
385 if self._interpretMaskBits:
386 msg += " [%s]" % self._mask.interpret(val)
392 ax.format_coord = format_coord
393 # Stop images from reporting their value as we've already
395 for a in ax.get_images():
396 a.get_cursor_data = lambda ev: None # disabled
398 # using tight_layout() is too tight and clips the axes
399 self._figure.canvas.draw_idle()
401 def _i_mtv(self, data, wcs, title, isMask):
402 """Internal routine to display an Image or Mask on a DS9 display"""
404 title = str(title) if title else ""
405 dataArr = data.getArray()
408 maskPlanes = data.getMaskPlaneDict()
409 nMaskPlanes = max(maskPlanes.values()) + 1
411 planes = {} # build inverse dictionary
412 for key in maskPlanes:
413 planes[maskPlanes[key]] = key
415 planeList = range(nMaskPlanes)
417 maskArr = np.zeros_like(dataArr, dtype=np.int32)
419 colorNames = ['black']
420 colorGenerator = self.display.maskColorGenerator(omitBW=True)
422 color = self.display.getMaskPlaneColor(planes[p]) if p in planes else None
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
429 colorNames.append(color)
431 # Convert those colours to RGBA so we can have per-mask-plane
432 # transparency and build a colour map
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
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':
445 alpha = 1 - self._getMaskTransparency(planes[p] if p in planes else None)
447 colors[i + 1][alphaChannel] = alpha
449 cmap = mpColors.ListedColormap(colors)
450 norm = mpColors.NoNorm()
452 cmap = self._image_colormap
453 norm = self._normalize
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)
460 with matplotlib.rc_context(dict(interactive=False)):
462 for i, p in reversed(list(enumerate(planeList))):
463 if colors[i + 1][alphaChannel] == 0: # colors[0] is reserved
466 bitIsSet = (dataArr & (1 << p)) != 0
467 if bitIsSet.sum() == 0:
470 maskArr[bitIsSet] = i + 1 # + 1 as we set colorNames[0] to black
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)
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)
481 # If we're playing with subplots and have reset the axis
482 # the cached colorbar axis belongs to the old one, so set
484 if self._mappable_ax and self._mappable_ax[1] != self._figure.gca():
485 self._colorbar_ax = None
487 mappable = ax.imshow(dataArr, origin='lower', interpolation='nearest',
488 extent=extent, cmap=cmap, norm=norm)
489 self._mappable_ax = (mappable, ax)
491 self._figure.canvas.draw_idle()
493 def _i_setImage(self, image, mask=None, wcs=None):
494 """Save the current image, mask, wcs, and XY0"""
498 self._xy0 = self._image.getXY0() if self._image else (0, 0)
501 if self._image is None:
502 self._width, self._height = 0, 0
504 self._width, self._height = self._image.getDimensions()
506 self._xcen = 0.5*self._width
507 self._ycen = 0.5*self._height
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]
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
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).
565 ctype = afwDisplay.GREEN
567 axis = self._figure.gca()
570 if isinstance(symb, afwGeom.ellipses.Axes):
571 from matplotlib.patches import Ellipse
573 # Following matplotlib.patches.Ellipse documentation 'width' and
574 # 'height' are diameters while 'angle' is rotation in degrees
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'))
580 from matplotlib.patches import CirclePolygon as Circle
582 axis.add_artist(Circle((c + x0, r + y0), radius=size, color=mapCtype(ctype), fill=False))
584 from matplotlib.lines import Line2D
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()
590 cmd, args = cmd[0], cmd[1:]
593 args = np.array(args).astype(float) - 1.0
595 x = np.empty(len(args)//2)
597 i = np.arange(len(args), dtype=int)
601 axis.add_line(Line2D(x, y, color=mapCtype(ctype)))
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')
607 raise RuntimeError(ds9Cmd)
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')"""
613 from matplotlib.lines import Line2D
616 ctype = afwDisplay.GREEN
618 points = np.array(points)
619 x = points[:, 0] + self._xy0[0]
620 y = points[:, 1] + self._xy0[1]
622 self._figure.gca().add_line(Line2D(x, y, color=mapCtype(ctype)))
624 def _scale(self, algorithm, minval, maxval, unit, *args, **kwargs):
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
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
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
646 def _i_scale(self, algorithm, minval, maxval, unit, *args, **kwargs):
648 maskedPixels = kwargs.get("maskedPixels", [])
649 if isinstance(maskedPixels, str):
650 maskedPixels = [maskedPixels]
651 bitmask = afwImage.Mask.getPlaneBitMask(maskedPixels)
653 sctrl = afwMath.StatisticsControl()
654 sctrl.setAndMask(bitmask)
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")
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":
666 print("scale(..., 'zscale', maskedPixels=...) is not yet implemented")
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")
675 self._normalize = AsinhZScaleNormalize(image=self._image, Q=kwargs.get("Q", 8.0))
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")
684 self._normalize = ZScaleNormalize(image=self._image,
685 nSamples=kwargs.get("nSamples", 1000),
686 contrast=kwargs.get("contrast", 0.25))
688 self._normalize = LinearNormalize(minimum=minval, maximum=maxval)
690 raise RuntimeError("Unsupported stretch algorithm \"%s\"" % algorithm)
695 def _zoom(self, zoomfac):
696 """Zoom by specified amount"""
698 self._zoomfac = zoomfac
705 size = min(self._width, self._height)
706 if size < self._zoomfac: # avoid min == max
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])
711 ax = self._figure.gca()
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
717 ax.set_xlim(xmin, xmax)
718 ax.set_ylim(ymin, ymax)
719 ax.set_aspect('equal', 'datalim')
721 self._figure.canvas.draw_idle()
731 def _getEvent(self, timeout=-1):
732 """Listen for a key press, returning (key, x, y)"""
735 timeout = 24*3600 # -1 generates complaints in QTimer::singleShot. A day is a long time
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')
745 # We set up a blocking event loop. On receipt of a keypress, the
746 # callback records the event and unblocks the loop.
748 def recordKeypress(keypress):
749 """Matplotlib callback to record keypress and unblock"""
751 event = interface.Event(keypress.key, keypress.xdata, keypress.ydata)
752 self._figure.canvas.stop_event_loop()
754 conn = self._figure.canvas.mpl_connect("key_press_event", recordKeypress)
756 self._figure.canvas.start_event_loop(timeout=timeout) # Blocks on keypress
758 self._figure.canvas.mpl_disconnect(conn)
762# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-