Coverage for python/lsst/display/matplotlib/matplotlib.py: 54%
429 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 09:00 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 09:00 +0000
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
53from .wcsAxes import WcsAxesManager, astFrameSetFromWcs
55#
56# Set the list of backends which support _getEvent and thus interact()
57#
58try:
59 interactiveBackends
60except NameError:
61 # List of backends that support `interact`
62 interactiveBackends = [
63 "Qt4Agg",
64 "Qt5Agg",
65 ]
67try:
68 matplotlibCtypes
69except NameError:
70 matplotlibCtypes = {
71 afwDisplay.GREEN: "#00FF00",
72 }
74 def mapCtype(ctype):
75 """Map the ctype to a potentially different ctype
77 Specifically, if matplotlibCtypes[ctype] exists, use it instead
79 This is used e.g. to map "green" to a brighter shade
80 """
81 return matplotlibCtypes[ctype] if ctype in matplotlibCtypes else ctype
84class DisplayImpl(virtualDevice.DisplayImpl):
85 """Provide a matplotlib backend for afwDisplay
87 Recommended backends in notebooks are:
88 %matplotlib notebook
89 or
90 %matplotlib ipympl
91 or
92 %matplotlib qt
93 %gui qt
94 or
95 %matplotlib inline
96 or
97 %matplotlib osx
99 Apparently only qt supports Display.interact(); the list of interactive
100 backends is given by lsst.display.matplotlib.interactiveBackends
101 """
102 def __init__(self, display, verbose=False,
103 interpretMaskBits=True, mtvOrigin=afwImage.PARENT, fastMaskDisplay=False,
104 reopenPlot=False, useSexagesimal=False, dpi=None,
105 useWcsAxes=True, wcsGrid=False, astPlotOptions="", *args, **kwargs):
106 """
107 Initialise a matplotlib display
109 @param fastMaskDisplay If True only show the first bitplane that's
110 set in each pixel
111 (e.g. if (SATURATED & DETECTED)
112 ignore DETECTED)
113 Not really what we want, but a bit faster
114 @param interpretMaskBits Interpret the mask value under the cursor
115 @param mtvOrigin Display pixel coordinates with LOCAL origin
116 (bottom left == 0,0 not XY0)
117 @param reopenPlot If true, close the plot before opening it.
118 (useful with e.g. %ipympl)
119 @param useSexagesimal If True, display coordinates in sexagesimal
120 E.g. hh:mm:ss.ss (default:False)
121 May be changed by calling
122 display.useSexagesimal()
123 @param dpi Number of dpi (passed to pyplot.figure)
124 @param useWcsAxes If True (the default) and the data
125 have a WCS, draw sky coordinate axes
126 using AST instead of pixel axes
127 @param wcsGrid If True draw the full curvilinear
128 coordinate grid across the image
129 (only used with useWcsAxes)
130 @param astPlotOptions Extra AST Plot attribute settings,
131 e.g. "Colour(grid)=2" for a red
132 grid; these override the defaults.
133 N.b. AST colours are 1-based grf
134 colour table indices, not names
135 (see lsst.display.matplotlib
136 .wcsAxes.WcsAxesManager)
138 The `frame` argument to `Display` may be a matplotlib figure; this
139 permits code such as
140 fig, axes = plt.subplots(1, 2)
142 disp = afwDisplay.Display(fig)
143 disp.scale('asinh', 'zscale', Q=0.5)
145 for axis, exp in zip(axes, exps):
146 fig.sca(axis) # make axis active
147 disp.mtv(exp)
148 """
149 fig_class = matplotlib.figure.FigureBase
151 if isinstance(display.frame, fig_class): 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true
152 figure = display.frame
153 else:
154 figure = None
156 virtualDevice.DisplayImpl.__init__(self, display, verbose)
158 if reopenPlot: 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true
159 import matplotlib.pyplot as pyplot
160 pyplot.close(display.frame)
162 if figure is not None: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true
163 self._figure = figure
164 else:
165 import matplotlib.pyplot as pyplot
166 self._figure = pyplot.figure(display.frame, dpi=dpi)
167 self._figure.clf()
169 self._display = display
170 self._maskTransparency = {None: 0.7}
171 self._interpretMaskBits = interpretMaskBits # interpret mask bits in mtv
172 self._fastMaskDisplay = fastMaskDisplay
173 self._useSexagesimal = [useSexagesimal] # use an array so we can modify the value in format_coord
174 self._mtvOrigin = mtvOrigin
175 self._useWcsAxes = useWcsAxes
176 self._wcsGrid = wcsGrid
177 self._astPlotOptions = astPlotOptions
178 self._wcsAxesManagers = {} # matplotlib Axes -> WcsAxesManager
179 self._mappable_ax = None
180 self._colorbar_ax = None
181 self._image_colormap = matplotlib.cm.gray
182 #
183 self.__alpha = unicodedata.lookup("GREEK SMALL LETTER alpha") # used in cursor display string
184 self.__delta = unicodedata.lookup("GREEK SMALL LETTER delta") # used in cursor display string
185 #
186 # Support self._scale()
187 #
188 self._scaleArgs = dict()
189 self._normalize = None
190 #
191 # Support self._erase(), reporting pixel/mask values, and
192 # zscale/minmax; set in mtv
193 #
194 self._i_setImage(None)
196 def _close(self):
197 """!Close the display, cleaning up any allocated resources"""
198 for manager in self._wcsAxesManagers.values():
199 manager.remove()
200 self._wcsAxesManagers = {}
201 self._image = None
202 self._mask = None
203 self._wcs = None
204 self._figure.gca().format_coord = lambda x, y: None # keeps a copy of _wcs
206 def _show(self):
207 """Put the plot at the top of the window stacking order"""
209 try:
210 self._figure.canvas._tkcanvas._root().lift() # tk
211 except AttributeError:
212 pass
214 try:
215 self._figure.canvas.manager.window.raise_() # os/x
216 except AttributeError:
217 pass
219 try:
220 self._figure.canvas.raise_() # qt[45]
221 except AttributeError:
222 pass
224 #
225 # Extensions to the API
226 #
227 def savefig(self, *args, **kwargs):
228 """Defer to figure.savefig()
230 Parameters
231 ----------
232 args : `list`
233 Passed through to figure.savefig()
234 kwargs : `dict`
235 Passed through to figure.savefig()
236 """
237 self._figure.savefig(*args, **kwargs)
239 def show_colorbar(self, show=True, where="right", axSize="5%", axPad=None, **kwargs):
240 """Show (or hide) the colour bar
242 Parameters
243 ----------
244 show : `bool`
245 Should I show the colour bar?
246 where : `str`
247 Location of colour bar: "right" or "bottom"
248 axSize : `float` or `str`
249 Size of axes to hold the colour bar; fraction of current x-size
250 axPad : `float` or `str`
251 Padding between axes and colour bar; fraction of current x-size
252 args : `list`
253 Passed through to colorbar()
254 kwargs : `dict`
255 Passed through to colorbar()
257 We set the default padding to put the colourbar in a reasonable
258 place for roughly square plots, but you may need to fiddle for
259 plots with extreme axis ratios.
261 You can only configure the colorbar when it isn't yet visible, but
262 as you can easily remove it this is not in practice a difficulty.
263 """
264 if show: 264 ↛ 287line 264 didn't jump to line 287 because the condition on line 264 was always true
265 if self._mappable_ax: 265 ↛ exitline 265 didn't return from function 'show_colorbar' because the condition on line 265 was always true
266 if self._colorbar_ax is None:
267 orientationDict = dict(right="vertical", bottom="horizontal")
269 mappable, ax = self._mappable_ax
271 if where in orientationDict: 271 ↛ 274line 271 didn't jump to line 274 because the condition on line 271 was always true
272 orientation = orientationDict[where]
273 else:
274 print(f"Unknown location {where}; "
275 f"please use one of {', '.join(orientationDict.keys())}")
277 if axPad is None: 277 ↛ 280line 277 didn't jump to line 280 because the condition on line 277 was always true
278 axPad = 0.1 if orientation == "vertical" else 0.3
280 divider = make_axes_locatable(ax)
281 self._colorbar_ax = divider.append_axes(where, size=axSize, pad=axPad)
283 self._figure.colorbar(mappable, cax=self._colorbar_ax, orientation=orientation, **kwargs)
284 self._figure.sca(ax)
286 else:
287 if self._colorbar_ax is not None:
288 self._colorbar_ax.remove()
289 self._colorbar_ax = None
291 def useSexagesimal(self, useSexagesimal):
292 """Control the formatting coordinates as HH:MM:SS.ss
294 Parameters
295 ----------
296 useSexagesimal : `bool`
297 Print coordinates as e.g. HH:MM:SS.ss iff True
299 N.b. can also be set in Display's ctor
300 """
302 """Are we formatting coordinates as HH:MM:SS.ss?"""
303 self._useSexagesimal[0] = useSexagesimal
305 for manager in self._wcsAxesManagers.values():
306 manager.setSexagesimal(useSexagesimal)
307 self._figure.canvas.draw_idle()
309 def wait(self, prompt="[c(ontinue) p(db)] :", allowPdb=True):
310 """Wait for keyboard input
312 Parameters
313 ----------
314 prompt : `str`
315 The prompt string.
316 allowPdb : `bool`
317 If true, entering a 'p' or 'pdb' puts you into pdb
319 Returns the string you entered
321 Useful when plotting from a programme that exits such as a processCcd
322 Any key except 'p' continues; 'p' puts you into pdb (unless
323 allowPdb is False)
324 """
325 while True:
326 s = input(prompt)
327 if allowPdb and s in ("p", "pdb"):
328 import pdb
329 pdb.set_trace()
330 continue
332 return s
333 #
334 # Defined API
335 #
337 def _setMaskTransparency(self, transparency, maskplane):
338 """Specify mask transparency (percent)"""
340 self._maskTransparency[maskplane] = 0.01*transparency
342 def _getMaskTransparency(self, maskplane=None):
343 """Return the current mask transparency"""
344 return self._maskTransparency[maskplane if maskplane in self._maskTransparency else None]
346 def _mtv(self, image, mask=None, wcs=None, title="", metadata=None):
347 """Display an Image and/or Mask on a matplotlib display
348 """
349 title = str(title) if title else ""
351 #
352 # Save a reference to the image as it makes erase() easy and permits
353 # printing cursor values and minmax/zscale stretches. We also save XY0
354 #
355 self._i_setImage(image, mask, wcs)
357 # We need to know the pixel values to support e.g. 'zscale' and
358 # 'minmax', so do the scaling now
359 if self._scaleArgs.get('algorithm'): # someone called self.scale() 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true
360 self._i_scale(self._scaleArgs['algorithm'], self._scaleArgs['minval'], self._scaleArgs['maxval'],
361 self._scaleArgs['unit'], *self._scaleArgs['args'], **self._scaleArgs['kwargs'])
363 ax = self._figure.gca()
364 manager = self._wcsAxesManagers.pop(ax, None)
365 if manager is not None:
366 manager.remove()
367 ax.cla()
369 self._i_mtv(image, wcs, title, False)
371 if mask:
372 self._i_mtv(mask, wcs, title, True)
374 self.show_colorbar()
376 if title: 376 ↛ 377line 376 didn't jump to line 377 because the condition on line 376 was never true
377 ax.set_title(title)
379 self._title = title
381 if wcs is not None and self._useWcsAxes:
382 self._drawWcsAxes(ax, wcs)
384 def format_coord(x, y, wcs=self._wcs, x0=self._xy0[0], y0=self._xy0[1],
385 origin=afwImage.PARENT, bbox=self._image.getBBox(afwImage.PARENT),
386 _useSexagesimal=self._useSexagesimal):
388 fmt = '(%1.2f, %1.2f)'
389 if self._mtvOrigin == afwImage.PARENT:
390 msg = fmt % (x, y)
391 else:
392 msg = (fmt + "L") % (x - x0, y - y0)
394 col = int(x + 0.5)
395 row = int(y + 0.5)
396 if bbox.contains(geom.PointI(col, row)):
397 if wcs is not None:
398 raDec = wcs.pixelToSky(x, y)
399 ra = raDec[0].asDegrees()
400 dec = raDec[1].asDegrees()
402 if _useSexagesimal[0]:
403 from astropy import units as u
404 from astropy.coordinates import Angle as apAngle
406 kwargs = dict(sep=':', pad=True, precision=2)
407 ra = apAngle(ra*u.deg).to_string(unit=u.hour, **kwargs)
408 dec = apAngle(dec*u.deg).to_string(unit=u.deg, **kwargs)
409 else:
410 ra = "%9.4f" % ra
411 dec = "%9.4f" % dec
413 msg += r" (%s, %s): (%s, %s)" % (self.__alpha, self.__delta, ra, dec)
415 msg += ' %1.3f' % (self._image[col, row])
416 if self._mask:
417 val = self._mask[col, row]
418 if self._interpretMaskBits:
419 msg += " [%s]" % self._mask.interpret(val)
420 else:
421 msg += " 0x%x" % val
423 return msg
425 ax.format_coord = format_coord
426 # Stop images from reporting their value as we've already
427 # printed it nicely
428 for a in ax.get_images():
429 a.get_cursor_data = lambda ev: None # disabled
431 # using tight_layout() is too tight and clips the axes
432 self._figure.canvas.draw_idle()
434 def _i_mtv(self, data, wcs, title, isMask):
435 """Internal routine to display an Image or Mask on a matplotlib
436 display.
438 Does not trigger a ``canvas.draw_idle`` command since it is assumed
439 that the caller will do that once all the layers have been added.
440 """
442 title = str(title) if title else ""
443 dataArr = data.getArray()
445 if isMask:
446 maskPlanes = data.getMaskPlaneDict()
447 nMaskPlanes = max(maskPlanes.values()) + 1
449 planes = {} # build inverse dictionary
450 for key in maskPlanes:
451 planes[maskPlanes[key]] = key
453 planeList = range(nMaskPlanes)
455 colorNames = ['black']
456 colorGenerator = self.display.maskColorGenerator(omitBW=True)
457 for p in planeList:
458 color = self.display.getMaskPlaneColor(planes[p]) if p in planes else None
460 if not color: # none was specified 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true
461 color = next(colorGenerator)
462 elif color.lower() == afwDisplay.IGNORE: 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true
463 color = 'black' # we'll set alpha = 0 anyway
465 colorNames.append(color)
466 #
467 # Convert those colours to RGBA so we can have per-mask-plane
468 # transparency and build a colour map
469 #
470 # Pixels equal to 0 don't get set (as no bits are set), so leave
471 # them transparent and start our colours at [1] --
472 # hence "i + 1" below
473 #
474 colors = mpColors.to_rgba_array(colorNames)
475 alphaChannel = 3 # the alpha channel; the A in RGBA
476 colors[0][alphaChannel] = 0.0 # it's black anyway
477 for i, p in enumerate(planeList):
478 if colorNames[i + 1] == 'black': 478 ↛ 479line 478 didn't jump to line 479 because the condition on line 478 was never true
479 alpha = 0.0
480 else:
481 alpha = 1 - self._getMaskTransparency(planes[p] if p in planes else None)
483 colors[i + 1][alphaChannel] = alpha
485 cmap = mpColors.ListedColormap(colors)
486 norm = mpColors.NoNorm()
487 else:
488 cmap = self._image_colormap
489 norm = self._normalize
491 ax = self._figure.gca()
492 bbox = data.getBBox()
493 extent = (bbox.getBeginX() - 0.5, bbox.getEndX() - 0.5,
494 bbox.getBeginY() - 0.5, bbox.getEndY() - 0.5)
496 with matplotlib.rc_context(dict(interactive=False)):
497 if isMask:
498 if self._fastMaskDisplay: 498 ↛ 501line 498 didn't jump to line 501 because the condition on line 498 was never true
499 # Show only the lowest set bitplane in each pixel; a single
500 # imshow of the plane indices suffices.
501 maskArr = np.zeros_like(dataArr, dtype=np.int32)
502 for i, p in reversed(list(enumerate(planeList))):
503 if colors[i + 1][alphaChannel] == 0: # colors[0] reserved
504 continue
505 bitIsSet = (dataArr & (1 << p)) != 0
506 if not bitIsSet.any():
507 continue
508 maskArr[bitIsSet] = i + 1 # + 1 as colorNames[0] is black
509 ax.imshow(maskArr, origin='lower', interpolation='nearest',
510 extent=extent, cmap=cmap, norm=norm)
511 else:
512 # Composite every visible bitplane into a single
513 # full-resolution RGBA image and draw it as one layer.
514 # The Porter-Duff "over" operator is associative, so
515 # blending the planes here and drawing the result over the
516 # image is identical to drawing each plane as its own
517 # imshow, but matplotlib then colour-maps and resamples one
518 # layer instead of one per set plane. The planes are
519 # composited in the same order they would be drawn
520 # (highest plane first, so the lowest ends up on top).
521 composite = np.zeros(dataArr.shape + (4,), dtype=np.float32)
522 for i, p in reversed(list(enumerate(planeList))):
523 alpha = colors[i + 1][alphaChannel]
524 if alpha == 0: # colors[0] is reserved 524 ↛ 525line 524 didn't jump to line 525 because the condition on line 524 was never true
525 continue
526 bitIsSet = (dataArr & (1 << p)) != 0
527 if not bitIsSet.any(): 527 ↛ 531line 527 didn't jump to line 531 because the condition on line 527 was always true
528 continue
529 # Place this plane's flat colour over the pixels
530 # accumulated from higher planes (straight-alpha over).
531 dst = composite[bitIsSet]
532 dstAlpha = dst[:, 3:4]
533 outAlpha = alpha + dstAlpha*(1.0 - alpha)
534 composite[bitIsSet, :3] = \
535 (colors[i + 1][:3]*alpha +
536 dst[:, :3]*dstAlpha*(1.0 - alpha))/outAlpha
537 composite[bitIsSet, 3] = outAlpha[:, 0]
538 ax.imshow(composite, origin='lower', interpolation='nearest',
539 extent=extent)
540 else:
541 # If we're playing with subplots and have reset the axis
542 # the cached colorbar axis belongs to the old one, so set
543 # it to None
544 if self._mappable_ax and self._mappable_ax[1] != self._figure.gca(): 544 ↛ 545line 544 didn't jump to line 545 because the condition on line 544 was never true
545 self._colorbar_ax = None
547 mappable = ax.imshow(dataArr, origin='lower', interpolation='nearest',
548 extent=extent, cmap=cmap, norm=norm)
549 self._mappable_ax = (mappable, ax)
551 def _drawWcsAxes(self, ax, wcs):
552 """Draw sky coordinate axes with AST, hiding the pixel axes.
554 Falls back to ordinary pixel axes with a warning if AST cannot
555 draw the given WCS.
556 """
557 # Freeze the view at the image extent; autoscaling in response
558 # to AST artists drawn outside the view would change the limits
559 # and retrigger drawing.
560 ax.set_xlim(*ax.get_xlim())
561 ax.set_ylim(*ax.get_ylim())
563 # The colorbar is attached with an axes divider that only resizes
564 # this axes on the next draw. AST spaces the labels using the
565 # axes geometry as it stands when it measures them, so realise the
566 # pending resize first; otherwise the labels are laid out for the
567 # wider, pre-colorbar axes and collide once the axes shrinks.
568 self._figure.draw_without_rendering()
570 try:
571 frameSet = astFrameSetFromWcs(wcs)
572 self._wcsAxesManagers[ax] = WcsAxesManager(
573 ax, frameSet,
574 grid=self._wcsGrid,
575 useSexagesimal=self._useSexagesimal[0],
576 extraOptions=self._astPlotOptions)
577 except Exception as e:
578 print(f"Unable to draw WCS axes ({e}); using pixel coordinates",
579 file=sys.stderr)
581 def _i_setImage(self, image, mask=None, wcs=None):
582 """Save the current image, mask, wcs, and XY0"""
583 self._image = image
584 self._mask = mask
585 self._wcs = wcs
586 self._xy0 = self._image.getXY0() if self._image else (0, 0)
588 self._zoomfac = None
589 if self._image is None:
590 self._width, self._height = 0, 0
591 else:
592 self._width, self._height = self._image.getDimensions()
594 self._xcen = 0.5*self._width
595 self._ycen = 0.5*self._height
597 def _setImageColormap(self, cmap):
598 """Set the colormap used for the image
600 cmap should be either the name of an attribute of matplotlib.cm or an
601 mpColors.Colormap (e.g. "gray" or matplotlib.cm.gray)
603 """
604 if not isinstance(cmap, mpColors.Colormap): 604 ↛ 607line 604 didn't jump to line 607 because the condition on line 604 was always true
605 cmap = matplotlib.colormaps[cmap]
607 self._image_colormap = cmap
609 #
610 # Graphics commands
611 #
613 def _buffer(self, enable=True):
614 if sys.modules.get('matplotlib.pyplot') is not None:
615 import matplotlib.pyplot as pyplot
616 if enable:
617 pyplot.ioff()
618 else:
619 pyplot.ion()
620 self._figure.show()
622 def _flush(self):
623 pass
625 def _erase(self):
626 """Erase the display, keeping any WCS axes"""
628 for axis in self._figure.axes:
629 manager = self._wcsAxesManagers.get(axis)
630 keep = set(manager.artists) if manager is not None else set()
631 for artist in list(axis.lines) + list(axis.texts) + list(axis.patches):
632 if artist not in keep:
633 artist.remove()
635 self._figure.canvas.draw_idle()
637 def _dot(self, symb, c, r, size, ctype,
638 fontFamily="helvetica", textAngle=None):
639 """Draw a symbol at (col,row) = (c,r) [0-based coordinates]
640 Possible values are:
641 + Draw a +
642 x Draw an x
643 * Draw a *
644 o Draw a circle
645 @:Mxx,Mxy,Myy Draw an ellipse with moments
646 (Mxx, Mxy, Myy) (argument size is ignored)
647 An afwGeom.ellipses.Axes Draw the ellipse (argument size is
648 ignored)
650 Any other value is interpreted as a string to be drawn. Strings obey the
651 fontFamily (which may be extended with other characteristics, e.g.
652 "times bold italic". Text will be drawn rotated by textAngle
653 (textAngle is ignored otherwise).
654 """
655 if not ctype: 655 ↛ 658line 655 didn't jump to line 658 because the condition on line 655 was always true
656 ctype = afwDisplay.GREEN
658 axis = self._figure.gca()
659 x0, y0 = self._xy0
661 if isinstance(symb, afwGeom.ellipses.Axes): 661 ↛ 662line 661 didn't jump to line 662 because the condition on line 661 was never true
662 from matplotlib.patches import Ellipse
664 # Following matplotlib.patches.Ellipse documentation 'width' and
665 # 'height' are diameters while 'angle' is rotation in degrees
666 # (anti-clockwise)
667 axis.add_artist(Ellipse((c + x0, r + y0), height=2*symb.getA(), width=2*symb.getB(),
668 angle=90.0 + math.degrees(symb.getTheta()),
669 edgecolor=mapCtype(ctype), facecolor='none'))
670 elif symb == 'o':
671 from matplotlib.patches import CirclePolygon as Circle
673 axis.add_artist(Circle((c + x0, r + y0), radius=size, color=mapCtype(ctype), fill=False))
674 else:
675 from matplotlib.lines import Line2D
677 for ds9Cmd in ds9Regions.dot(symb, c + x0, r + y0, size, fontFamily="helvetica", textAngle=None):
678 tmp = ds9Cmd.split('#')
679 cmd = tmp.pop(0).split()
681 cmd, args = cmd[0], cmd[1:]
683 if cmd == "line": 683 ↛ 693line 683 didn't jump to line 693 because the condition on line 683 was always true
684 args = np.array(args).astype(float) - 1.0
686 x = np.empty(len(args)//2)
687 y = np.empty_like(x)
688 i = np.arange(len(args), dtype=int)
689 x = args[i%2 == 0]
690 y = args[i%2 == 1]
692 axis.add_line(Line2D(x, y, color=mapCtype(ctype)))
693 elif cmd == "text":
694 x, y = np.array(args[0:2]).astype(float) - 1.0
695 axis.text(x, y, symb, color=mapCtype(ctype),
696 horizontalalignment='center', verticalalignment='center')
697 else:
698 raise RuntimeError(ds9Cmd)
700 def _drawLines(self, points, ctype):
701 """Connect the points, a list of (col,row)
702 Ctype is the name of a colour (e.g. 'red')"""
704 from matplotlib.lines import Line2D
706 if not ctype:
707 ctype = afwDisplay.GREEN
709 points = np.array(points)
710 x = points[:, 0] + self._xy0[0]
711 y = points[:, 1] + self._xy0[1]
713 self._figure.gca().add_line(Line2D(x, y, color=mapCtype(ctype)))
715 def _scale(self, algorithm, minval, maxval, unit, *args, **kwargs):
716 """
717 Set gray scale
719 N.b. Supports extra arguments:
720 @param maskedPixels List of names of mask bits to ignore
721 E.g. ["BAD", "INTERP"].
722 A single name is also supported
723 """
724 self._scaleArgs['algorithm'] = algorithm
725 self._scaleArgs['minval'] = minval
726 self._scaleArgs['maxval'] = maxval
727 self._scaleArgs['unit'] = unit
728 self._scaleArgs['args'] = args
729 self._scaleArgs['kwargs'] = kwargs
731 try:
732 self._i_scale(algorithm, minval, maxval, unit, *args, **kwargs)
733 except (AttributeError, RuntimeError):
734 # Unable to access self._image; we'll try again when we run mtv
735 pass
737 def _i_scale(self, algorithm, minval, maxval, unit, *args, **kwargs):
739 maskedPixels = kwargs.get("maskedPixels", [])
740 if isinstance(maskedPixels, str):
741 maskedPixels = [maskedPixels]
742 bitmask = afwImage.Mask.getPlaneBitMask(maskedPixels)
744 sctrl = afwMath.StatisticsControl()
745 sctrl.setAndMask(bitmask)
747 if minval == "minmax":
748 if self._image is None:
749 raise RuntimeError("You may only use minmax if an image is loaded into the display")
751 mi = afwImage.makeMaskedImage(self._image, self._mask)
752 stats = afwMath.makeStatistics(mi, afwMath.MIN | afwMath.MAX, sctrl)
753 minval = stats.getValue(afwMath.MIN)
754 maxval = stats.getValue(afwMath.MAX)
755 elif minval == "zscale":
756 if bitmask:
757 print("scale(..., 'zscale', maskedPixels=...) is not yet implemented")
759 if algorithm is None:
760 self._normalize = None
761 elif algorithm == "asinh":
762 if minval == "zscale":
763 if self._image is None:
764 raise RuntimeError("You may only use zscale if an image is loaded into the display")
766 self._normalize = AsinhZScaleNormalize(image=self._image, Q=kwargs.get("Q", 8.0))
767 else:
768 self._normalize = AsinhNormalize(minimum=minval,
769 dataRange=maxval - minval, Q=kwargs.get("Q", 8.0))
770 elif algorithm == "linear":
771 if minval == "zscale":
772 if self._image is None:
773 raise RuntimeError("You may only use zscale if an image is loaded into the display")
775 self._normalize = ZScaleNormalize(image=self._image,
776 nSamples=kwargs.get("nSamples", 1000),
777 contrast=kwargs.get("contrast", 0.25))
778 else:
779 self._normalize = LinearNormalize(minimum=minval, maximum=maxval)
780 else:
781 raise RuntimeError("Unsupported stretch algorithm \"%s\"" % algorithm)
782 #
783 # Zoom and Pan
784 #
786 def _zoom(self, zoomfac):
787 """Zoom by specified amount"""
789 self._zoomfac = zoomfac
791 if zoomfac is None: 791 ↛ 792line 791 didn't jump to line 792 because the condition on line 791 was never true
792 return
794 x0, y0 = self._xy0
796 size = min(self._width, self._height)
797 if size < self._zoomfac: # avoid min == max 797 ↛ 798line 797 didn't jump to line 798 because the condition on line 797 was never true
798 size = self._zoomfac
799 xmin, xmax = self._xcen + x0 + size/self._zoomfac*np.array([-1, 1])
800 ymin, ymax = self._ycen + y0 + size/self._zoomfac*np.array([-1, 1])
802 ax = self._figure.gca()
804 tb = self._figure.canvas.toolbar
805 if tb is not None: # It's None for e.g. %matplotlib inline in jupyter 805 ↛ 806line 805 didn't jump to line 806 because the condition on line 805 was never true
806 tb.push_current() # save the current zoom in the view stack
808 ax.set_xlim(xmin, xmax)
809 ax.set_ylim(ymin, ymax)
810 ax.set_aspect('equal', 'datalim')
812 self._figure.canvas.draw_idle()
814 def _pan(self, colc, rowc):
815 """Pan to (colc, rowc)"""
817 self._xcen = colc
818 self._ycen = rowc
820 self._zoom(self._zoomfac)
822 def _getEvent(self, timeout=-1):
823 """Listen for a key press, returning (key, x, y)"""
825 if timeout < 0:
826 timeout = 24*3600 # -1 generates complaints in QTimer::singleShot. A day is a long time
828 mpBackend = matplotlib.get_backend()
829 if mpBackend not in interactiveBackends:
830 print("The %s matplotlib backend doesn't support display._getEvent()" %
831 (matplotlib.get_backend(),), file=sys.stderr)
832 return interface.Event('q')
834 event = None
836 # We set up a blocking event loop. On receipt of a keypress, the
837 # callback records the event and unblocks the loop.
839 def recordKeypress(keypress):
840 """Matplotlib callback to record keypress and unblock"""
841 nonlocal event
842 event = interface.Event(keypress.key, keypress.xdata, keypress.ydata)
843 self._figure.canvas.stop_event_loop()
845 conn = self._figure.canvas.mpl_connect("key_press_event", recordKeypress)
846 try:
847 self._figure.canvas.start_event_loop(timeout=timeout) # Blocks on keypress
848 finally:
849 self._figure.canvas.mpl_disconnect(conn)
850 return event
853# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
856class Normalize(mpColors.Normalize):
857 """Class to support stretches for mtv()"""
859 def __call__(self, value, clip=None):
860 """
861 Return a MaskedArray with value mapped to [0, 255]
863 @param value Input pixel value or array to be mapped
864 """
865 if isinstance(value, np.ndarray):
866 data = value
867 else:
868 data = value.data
870 data = data - self.mapping.minimum[0]
871 return ma.array(data*self.mapping.mapIntensityToUint8(data)/255.0)
874class AsinhNormalize(Normalize):
875 """Provide an asinh stretch for mtv()"""
876 def __init__(self, minimum=0, dataRange=1, Q=8):
877 """Initialise an object able to carry out an asinh mapping
879 @param minimum Minimum pixel value (default: 0)
880 @param dataRange Range of values for stretch if Q=0; roughly the
881 linear part (default: 1)
882 @param Q Softening parameter (default: 8)
884 See Lupton et al., PASP 116, 133
885 """
886 # The object used to perform the desired mapping
887 self.mapping = afwRgb.AsinhMapping(minimum, dataRange, Q)
889 vmin, vmax = self._getMinMaxQ()[0:2]
890 if vmax*Q > vmin:
891 vmax *= Q
892 super().__init__(vmin, vmax)
894 def _getMinMaxQ(self):
895 """Return an asinh mapping's minimum and maximum value, and Q
897 Regrettably this information is not preserved by AsinhMapping
898 so we have to reverse engineer it
899 """
901 frac = 0.1 # magic number in AsinhMapping
902 Q = np.sinh((frac*self.mapping._uint8Max)/self.mapping._slope)/frac
903 dataRange = Q/self.mapping._soften
905 vmin = self.mapping.minimum[0]
906 return vmin, vmin + dataRange, Q
909class AsinhZScaleNormalize(AsinhNormalize):
910 """Provide an asinh stretch using zscale to set limits for mtv()"""
911 def __init__(self, image=None, Q=8):
912 """Initialise an object able to carry out an asinh mapping
914 @param image image to use estimate minimum and dataRange using zscale
915 (see AsinhNormalize)
916 @param Q Softening parameter (default: 8)
918 See Lupton et al., PASP 116, 133
919 """
921 # The object used to perform the desired mapping
922 self.mapping = afwRgb.AsinhZScaleMapping(image, Q)
924 vmin, vmax = self._getMinMaxQ()[0:2]
925 # n.b. super() would call AsinhNormalize,
926 # and I want to pass min/max to the baseclass
927 Normalize.__init__(self, vmin, vmax)
930class ZScaleNormalize(Normalize):
931 """Provide a zscale stretch for mtv()"""
932 def __init__(self, image=None, nSamples=1000, contrast=0.25):
933 """Initialise an object able to carry out a zscale mapping
935 @param image to be used to estimate the stretch
936 @param nSamples Number of data points to use (default: 1000)
937 @param contrast Control the range of pixels to display around the
938 median (default: 0.25)
939 """
941 # The object used to perform the desired mapping
942 self.mapping = afwRgb.ZScaleMapping(image, nSamples, contrast)
944 super().__init__(self.mapping.minimum[0], self.mapping.maximum)
947class LinearNormalize(Normalize):
948 """Provide a linear stretch for mtv()"""
949 def __init__(self, minimum=0, maximum=1):
950 """Initialise an object able to carry out a linear mapping
952 @param minimum Minimum value to display
953 @param maximum Maximum value to display
954 """
955 # The object used to perform the desired mapping
956 self.mapping = afwRgb.LinearMapping(minimum, maximum)
958 super().__init__(self.mapping.minimum[0], self.mapping.maximum)