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
« 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#
23#
24# \file
25# \brief Definitions to talk to matplotlib from python using the "afwDisplay"
26# interface
28import math
29import sys
30import unicodedata
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
39import numpy as np
40import numpy.ma as ma
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
50import lsst.afw.geom as afwGeom
51import lsst.geom as geom
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 ]
65try:
66 matplotlibCtypes
67except NameError:
68 matplotlibCtypes = {
69 afwDisplay.GREEN: "#00FF00",
70 }
72 def mapCtype(ctype):
73 """Map the ctype to a potentially different ctype
75 Specifically, if matplotlibCtypes[ctype] exists, use it instead
77 This is used e.g. to map "green" to a brighter shade
78 """
79 return matplotlibCtypes[ctype] if ctype in matplotlibCtypes else ctype
82class DisplayImpl(virtualDevice.DisplayImpl):
83 """Provide a matplotlib backend for afwDisplay
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
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
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)
122 The `frame` argument to `Display` may be a matplotlib figure; this
123 permits code such as
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
131 disp.mtv(exp)
132 """
133 fig_class = matplotlib.figure.FigureBase
135 if isinstance(display.frame, fig_class):
136 figure = display.frame
137 else:
138 figure = None
140 virtualDevice.DisplayImpl.__init__(self, display, verbose)
142 if reopenPlot:
143 import matplotlib.pyplot as pyplot
144 pyplot.close(display.frame)
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()
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)
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
183 def _show(self):
184 """Put the plot at the top of the window stacking order"""
186 try:
187 self._figure.canvas._tkcanvas._root().lift() # tk
188 except AttributeError:
189 pass
191 try:
192 self._figure.canvas.manager.window.raise_() # os/x
193 except AttributeError:
194 pass
196 try:
197 self._figure.canvas.raise_() # qt[45]
198 except AttributeError:
199 pass
201 #
202 # Extensions to the API
203 #
204 def savefig(self, *args, **kwargs):
205 """Defer to figure.savefig()
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)
216 def show_colorbar(self, show=True, where="right", axSize="5%", axPad=None, **kwargs):
217 """Show (or hide) the colour bar
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()
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.
240 """
241 if show:
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]
250 else:
251 print(f"Unknown location {where}; "
252 f"please use one of {', '.join(orientationDict.keys())}")
254 if axPad is None:
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)
261 self._figure.sca(ax)
263 else:
264 if self._colorbar_ax is not None:
265 self._colorbar_ax.remove()
266 self._colorbar_ax = None
268 def useSexagesimal(self, useSexagesimal):
269 """Control the formatting coordinates as HH:MM:SS.ss
271 Parameters
272 ----------
273 useSexagesimal : `bool`
274 Print coordinates as e.g. HH:MM:SS.ss iff True
276 N.b. can also be set in Display's ctor
277 """
279 """Are we formatting coordinates as HH:MM:SS.ss?"""
280 self._useSexagesimal[0] = useSexagesimal
282 def wait(self, prompt="[c(ontinue) p(db)] :", allowPdb=True):
283 """Wait for keyboard input
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
292 Returns the string you entered
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
305 return s
306 #
307 # Defined API
308 #
310 def _setMaskTransparency(self, transparency, maskplane):
311 """Specify mask transparency (percent)"""
313 self._maskTransparency[maskplane] = 0.01*transparency
315 def _getMaskTransparency(self, maskplane=None):
316 """Return the current mask transparency"""
317 return self._maskTransparency[maskplane if maskplane in self._maskTransparency else None]
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 ""
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)
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()
337 ax.cla()
339 self._i_mtv(image, wcs, title, False)
341 if mask:
342 self._i_mtv(mask, wcs, title, True)
344 self.show_colorbar()
346 if title:
347 ax.set_title(title)
349 self._title = title
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:
357 msg = fmt % (x, y)
358 else:
359 msg = (fmt + "L") % (x - x0, y - y0)
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()
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)
376 else:
377 ra = "%9.4f" % ra
378 dec = "%9.4f" % dec
380 msg += r" (%s, %s): (%s, %s)" % (self.__alpha, self.__delta, ra, dec)
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
390 return msg
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
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()
407 if isMask:
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)
421 for p in planeList:
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)
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)
447 colors[i + 1][alphaChannel] = alpha
449 cmap = mpColors.ListedColormap(colors)
450 norm = mpColors.NoNorm()
451 else:
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)):
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
466 bitIsSet = (dataArr & (1 << p)) != 0
467 if bitIsSet.sum() == 0:
468 continue
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)
475 maskArr[:] = 0
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
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"""
495 self._image = image
496 self._mask = mask
497 self._wcs = wcs
498 self._xy0 = self._image.getXY0() if self._image else (0, 0)
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()
506 self._xcen = 0.5*self._width
507 self._ycen = 0.5*self._height
509 def _setImageColormap(self, cmap):
510 """Set the colormap used for the image
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)
515 """
516 if not isinstance(cmap, mpColors.Colormap):
517 cmap = matplotlib.colormaps[cmap]
519 self._image_colormap = cmap
521 #
522 # Graphics commands
523 #
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()
534 def _flush(self):
535 pass
537 def _erase(self):
538 """Erase the display"""
540 for axis in self._figure.axes:
541 axis.lines = []
542 axis.texts = []
544 self._figure.canvas.draw_idle()
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)
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
567 axis = self._figure.gca()
568 x0, y0 = self._xy0
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
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
582 axis.add_artist(Circle((c + x0, r + y0), radius=size, color=mapCtype(ctype), fill=False))
583 else:
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:]
592 if cmd == "line":
593 args = np.array(args).astype(float) - 1.0
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]
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)
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
615 if not ctype:
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):
625 """
626 Set gray scale
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
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
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":
665 if bitmask:
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))
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")
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 #
695 def _zoom(self, zoomfac):
696 """Zoom by specified amount"""
698 self._zoomfac = zoomfac
700 if zoomfac is None:
701 return
703 x0, y0 = self._xy0
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])
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()
723 def _pan(self, colc, rowc):
724 """Pan to (colc, rowc)"""
726 self._xcen = colc
727 self._ycen = rowc
729 self._zoom(self._zoomfac)
731 def _getEvent(self, timeout=-1):
732 """Listen for a key press, returning (key, x, y)"""
734 if timeout < 0:
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')
743 event = None
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"""
750 nonlocal event
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)
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
762# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
765class Normalize(mpColors.Normalize):
766 """Class to support stretches for mtv()"""
768 def __call__(self, value, clip=None):
769 """
770 Return a MaskedArray with value mapped to [0, 255]
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
779 data = data - self.mapping.minimum[0]
780 return ma.array(data*self.mapping.mapIntensityToUint8(data)/255.0)
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
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)
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)
798 vmin, vmax = self._getMinMaxQ()[0:2]
799 if vmax*Q > vmin:
800 vmax *= Q
801 super().__init__(vmin, vmax)
803 def _getMinMaxQ(self):
804 """Return an asinh mapping's minimum and maximum value, and Q
806 Regrettably this information is not preserved by AsinhMapping
807 so we have to reverse engineer it
808 """
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
814 vmin = self.mapping.minimum[0]
815 return vmin, vmin + dataRange, Q
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
823 @param image image to use estimate minimum and dataRange using zscale
824 (see AsinhNormalize)
825 @param Q Softening parameter (default: 8)
827 See Lupton et al., PASP 116, 133
828 """
830 # The object used to perform the desired mapping
831 self.mapping = afwRgb.AsinhZScaleMapping(image, Q)
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)
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
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 """
850 # The object used to perform the desired mapping
851 self.mapping = afwRgb.ZScaleMapping(image, nSamples, contrast)
853 super().__init__(self.mapping.minimum[0], self.mapping.maximum)
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
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)
867 super().__init__(self.mapping.minimum[0], self.mapping.maximum)