Coverage for python/lsst/display/matplotlib/matplotlib.py: 55%
418 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 02:07 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 02:07 -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
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 DS9 display"""
437 title = str(title) if title else ""
438 dataArr = data.getArray()
440 if isMask:
441 maskPlanes = data.getMaskPlaneDict()
442 nMaskPlanes = max(maskPlanes.values()) + 1
444 planes = {} # build inverse dictionary
445 for key in maskPlanes:
446 planes[maskPlanes[key]] = key
448 planeList = range(nMaskPlanes)
450 maskArr = np.zeros_like(dataArr, dtype=np.int32)
452 colorNames = ['black']
453 colorGenerator = self.display.maskColorGenerator(omitBW=True)
454 for p in planeList:
455 color = self.display.getMaskPlaneColor(planes[p]) if p in planes else None
457 if not color: # none was specified 457 ↛ 458line 457 didn't jump to line 458 because the condition on line 457 was never true
458 color = next(colorGenerator)
459 elif color.lower() == afwDisplay.IGNORE: 459 ↛ 460line 459 didn't jump to line 460 because the condition on line 459 was never true
460 color = 'black' # we'll set alpha = 0 anyway
462 colorNames.append(color)
463 #
464 # Convert those colours to RGBA so we can have per-mask-plane
465 # transparency and build a colour map
466 #
467 # Pixels equal to 0 don't get set (as no bits are set), so leave
468 # them transparent and start our colours at [1] --
469 # hence "i + 1" below
470 #
471 colors = mpColors.to_rgba_array(colorNames)
472 alphaChannel = 3 # the alpha channel; the A in RGBA
473 colors[0][alphaChannel] = 0.0 # it's black anyway
474 for i, p in enumerate(planeList):
475 if colorNames[i + 1] == 'black': 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true
476 alpha = 0.0
477 else:
478 alpha = 1 - self._getMaskTransparency(planes[p] if p in planes else None)
480 colors[i + 1][alphaChannel] = alpha
482 cmap = mpColors.ListedColormap(colors)
483 norm = mpColors.NoNorm()
484 else:
485 cmap = self._image_colormap
486 norm = self._normalize
488 ax = self._figure.gca()
489 bbox = data.getBBox()
490 extent = (bbox.getBeginX() - 0.5, bbox.getEndX() - 0.5,
491 bbox.getBeginY() - 0.5, bbox.getEndY() - 0.5)
493 with matplotlib.rc_context(dict(interactive=False)):
494 if isMask:
495 for i, p in reversed(list(enumerate(planeList))):
496 if colors[i + 1][alphaChannel] == 0: # colors[0] is reserved 496 ↛ 497line 496 didn't jump to line 497 because the condition on line 496 was never true
497 continue
499 bitIsSet = (dataArr & (1 << p)) != 0
500 if bitIsSet.sum() == 0: 500 ↛ 503line 500 didn't jump to line 503 because the condition on line 500 was always true
501 continue
503 maskArr[bitIsSet] = i + 1 # + 1 as we set colorNames[0] to black
505 if not self._fastMaskDisplay: # we draw each bitplane separately
506 ax.imshow(maskArr, origin='lower', interpolation='nearest',
507 extent=extent, cmap=cmap, norm=norm)
508 maskArr[:] = 0
510 if self._fastMaskDisplay: # we only draw the lowest bitplane 510 ↛ 511line 510 didn't jump to line 511 because the condition on line 510 was never true
511 ax.imshow(maskArr, origin='lower', interpolation='nearest',
512 extent=extent, cmap=cmap, norm=norm)
513 else:
514 # If we're playing with subplots and have reset the axis
515 # the cached colorbar axis belongs to the old one, so set
516 # it to None
517 if self._mappable_ax and self._mappable_ax[1] != self._figure.gca(): 517 ↛ 518line 517 didn't jump to line 518 because the condition on line 517 was never true
518 self._colorbar_ax = None
520 mappable = ax.imshow(dataArr, origin='lower', interpolation='nearest',
521 extent=extent, cmap=cmap, norm=norm)
522 self._mappable_ax = (mappable, ax)
524 self._figure.canvas.draw_idle()
526 def _drawWcsAxes(self, ax, wcs):
527 """Draw sky coordinate axes with AST, hiding the pixel axes.
529 Falls back to ordinary pixel axes with a warning if AST cannot
530 draw the given WCS.
531 """
532 # Freeze the view at the image extent; autoscaling in response
533 # to AST artists drawn outside the view would change the limits
534 # and retrigger drawing.
535 ax.set_xlim(*ax.get_xlim())
536 ax.set_ylim(*ax.get_ylim())
538 try:
539 frameSet = astFrameSetFromWcs(wcs)
540 self._wcsAxesManagers[ax] = WcsAxesManager(
541 ax, frameSet,
542 grid=self._wcsGrid,
543 useSexagesimal=self._useSexagesimal[0],
544 extraOptions=self._astPlotOptions)
545 except Exception as e:
546 print(f"Unable to draw WCS axes ({e}); using pixel coordinates",
547 file=sys.stderr)
549 def _i_setImage(self, image, mask=None, wcs=None):
550 """Save the current image, mask, wcs, and XY0"""
551 self._image = image
552 self._mask = mask
553 self._wcs = wcs
554 self._xy0 = self._image.getXY0() if self._image else (0, 0)
556 self._zoomfac = None
557 if self._image is None:
558 self._width, self._height = 0, 0
559 else:
560 self._width, self._height = self._image.getDimensions()
562 self._xcen = 0.5*self._width
563 self._ycen = 0.5*self._height
565 def _setImageColormap(self, cmap):
566 """Set the colormap used for the image
568 cmap should be either the name of an attribute of matplotlib.cm or an
569 mpColors.Colormap (e.g. "gray" or matplotlib.cm.gray)
571 """
572 if not isinstance(cmap, mpColors.Colormap): 572 ↛ 575line 572 didn't jump to line 575 because the condition on line 572 was always true
573 cmap = matplotlib.colormaps[cmap]
575 self._image_colormap = cmap
577 #
578 # Graphics commands
579 #
581 def _buffer(self, enable=True):
582 if sys.modules.get('matplotlib.pyplot') is not None:
583 import matplotlib.pyplot as pyplot
584 if enable:
585 pyplot.ioff()
586 else:
587 pyplot.ion()
588 self._figure.show()
590 def _flush(self):
591 pass
593 def _erase(self):
594 """Erase the display, keeping any WCS axes"""
596 for axis in self._figure.axes:
597 manager = self._wcsAxesManagers.get(axis)
598 keep = set(manager.artists) if manager is not None else set()
599 for artist in list(axis.lines) + list(axis.texts) + list(axis.patches):
600 if artist not in keep:
601 artist.remove()
603 self._figure.canvas.draw_idle()
605 def _dot(self, symb, c, r, size, ctype,
606 fontFamily="helvetica", textAngle=None):
607 """Draw a symbol at (col,row) = (c,r) [0-based coordinates]
608 Possible values are:
609 + Draw a +
610 x Draw an x
611 * Draw a *
612 o Draw a circle
613 @:Mxx,Mxy,Myy Draw an ellipse with moments
614 (Mxx, Mxy, Myy) (argument size is ignored)
615 An afwGeom.ellipses.Axes Draw the ellipse (argument size is
616 ignored)
618 Any other value is interpreted as a string to be drawn. Strings obey the
619 fontFamily (which may be extended with other characteristics, e.g.
620 "times bold italic". Text will be drawn rotated by textAngle
621 (textAngle is ignored otherwise).
622 """
623 if not ctype: 623 ↛ 626line 623 didn't jump to line 626 because the condition on line 623 was always true
624 ctype = afwDisplay.GREEN
626 axis = self._figure.gca()
627 x0, y0 = self._xy0
629 if isinstance(symb, afwGeom.ellipses.Axes): 629 ↛ 630line 629 didn't jump to line 630 because the condition on line 629 was never true
630 from matplotlib.patches import Ellipse
632 # Following matplotlib.patches.Ellipse documentation 'width' and
633 # 'height' are diameters while 'angle' is rotation in degrees
634 # (anti-clockwise)
635 axis.add_artist(Ellipse((c + x0, r + y0), height=2*symb.getA(), width=2*symb.getB(),
636 angle=90.0 + math.degrees(symb.getTheta()),
637 edgecolor=mapCtype(ctype), facecolor='none'))
638 elif symb == 'o':
639 from matplotlib.patches import CirclePolygon as Circle
641 axis.add_artist(Circle((c + x0, r + y0), radius=size, color=mapCtype(ctype), fill=False))
642 else:
643 from matplotlib.lines import Line2D
645 for ds9Cmd in ds9Regions.dot(symb, c + x0, r + y0, size, fontFamily="helvetica", textAngle=None):
646 tmp = ds9Cmd.split('#')
647 cmd = tmp.pop(0).split()
649 cmd, args = cmd[0], cmd[1:]
651 if cmd == "line": 651 ↛ 661line 651 didn't jump to line 661 because the condition on line 651 was always true
652 args = np.array(args).astype(float) - 1.0
654 x = np.empty(len(args)//2)
655 y = np.empty_like(x)
656 i = np.arange(len(args), dtype=int)
657 x = args[i%2 == 0]
658 y = args[i%2 == 1]
660 axis.add_line(Line2D(x, y, color=mapCtype(ctype)))
661 elif cmd == "text":
662 x, y = np.array(args[0:2]).astype(float) - 1.0
663 axis.text(x, y, symb, color=mapCtype(ctype),
664 horizontalalignment='center', verticalalignment='center')
665 else:
666 raise RuntimeError(ds9Cmd)
668 def _drawLines(self, points, ctype):
669 """Connect the points, a list of (col,row)
670 Ctype is the name of a colour (e.g. 'red')"""
672 from matplotlib.lines import Line2D
674 if not ctype:
675 ctype = afwDisplay.GREEN
677 points = np.array(points)
678 x = points[:, 0] + self._xy0[0]
679 y = points[:, 1] + self._xy0[1]
681 self._figure.gca().add_line(Line2D(x, y, color=mapCtype(ctype)))
683 def _scale(self, algorithm, minval, maxval, unit, *args, **kwargs):
684 """
685 Set gray scale
687 N.b. Supports extra arguments:
688 @param maskedPixels List of names of mask bits to ignore
689 E.g. ["BAD", "INTERP"].
690 A single name is also supported
691 """
692 self._scaleArgs['algorithm'] = algorithm
693 self._scaleArgs['minval'] = minval
694 self._scaleArgs['maxval'] = maxval
695 self._scaleArgs['unit'] = unit
696 self._scaleArgs['args'] = args
697 self._scaleArgs['kwargs'] = kwargs
699 try:
700 self._i_scale(algorithm, minval, maxval, unit, *args, **kwargs)
701 except (AttributeError, RuntimeError):
702 # Unable to access self._image; we'll try again when we run mtv
703 pass
705 def _i_scale(self, algorithm, minval, maxval, unit, *args, **kwargs):
707 maskedPixels = kwargs.get("maskedPixels", [])
708 if isinstance(maskedPixels, str):
709 maskedPixels = [maskedPixels]
710 bitmask = afwImage.Mask.getPlaneBitMask(maskedPixels)
712 sctrl = afwMath.StatisticsControl()
713 sctrl.setAndMask(bitmask)
715 if minval == "minmax":
716 if self._image is None:
717 raise RuntimeError("You may only use minmax if an image is loaded into the display")
719 mi = afwImage.makeMaskedImage(self._image, self._mask)
720 stats = afwMath.makeStatistics(mi, afwMath.MIN | afwMath.MAX, sctrl)
721 minval = stats.getValue(afwMath.MIN)
722 maxval = stats.getValue(afwMath.MAX)
723 elif minval == "zscale":
724 if bitmask:
725 print("scale(..., 'zscale', maskedPixels=...) is not yet implemented")
727 if algorithm is None:
728 self._normalize = None
729 elif algorithm == "asinh":
730 if minval == "zscale":
731 if self._image is None:
732 raise RuntimeError("You may only use zscale if an image is loaded into the display")
734 self._normalize = AsinhZScaleNormalize(image=self._image, Q=kwargs.get("Q", 8.0))
735 else:
736 self._normalize = AsinhNormalize(minimum=minval,
737 dataRange=maxval - minval, Q=kwargs.get("Q", 8.0))
738 elif algorithm == "linear":
739 if minval == "zscale":
740 if self._image is None:
741 raise RuntimeError("You may only use zscale if an image is loaded into the display")
743 self._normalize = ZScaleNormalize(image=self._image,
744 nSamples=kwargs.get("nSamples", 1000),
745 contrast=kwargs.get("contrast", 0.25))
746 else:
747 self._normalize = LinearNormalize(minimum=minval, maximum=maxval)
748 else:
749 raise RuntimeError("Unsupported stretch algorithm \"%s\"" % algorithm)
750 #
751 # Zoom and Pan
752 #
754 def _zoom(self, zoomfac):
755 """Zoom by specified amount"""
757 self._zoomfac = zoomfac
759 if zoomfac is None: 759 ↛ 760line 759 didn't jump to line 760 because the condition on line 759 was never true
760 return
762 x0, y0 = self._xy0
764 size = min(self._width, self._height)
765 if size < self._zoomfac: # avoid min == max 765 ↛ 766line 765 didn't jump to line 766 because the condition on line 765 was never true
766 size = self._zoomfac
767 xmin, xmax = self._xcen + x0 + size/self._zoomfac*np.array([-1, 1])
768 ymin, ymax = self._ycen + y0 + size/self._zoomfac*np.array([-1, 1])
770 ax = self._figure.gca()
772 tb = self._figure.canvas.toolbar
773 if tb is not None: # It's None for e.g. %matplotlib inline in jupyter 773 ↛ 774line 773 didn't jump to line 774 because the condition on line 773 was never true
774 tb.push_current() # save the current zoom in the view stack
776 ax.set_xlim(xmin, xmax)
777 ax.set_ylim(ymin, ymax)
778 ax.set_aspect('equal', 'datalim')
780 self._figure.canvas.draw_idle()
782 def _pan(self, colc, rowc):
783 """Pan to (colc, rowc)"""
785 self._xcen = colc
786 self._ycen = rowc
788 self._zoom(self._zoomfac)
790 def _getEvent(self, timeout=-1):
791 """Listen for a key press, returning (key, x, y)"""
793 if timeout < 0:
794 timeout = 24*3600 # -1 generates complaints in QTimer::singleShot. A day is a long time
796 mpBackend = matplotlib.get_backend()
797 if mpBackend not in interactiveBackends:
798 print("The %s matplotlib backend doesn't support display._getEvent()" %
799 (matplotlib.get_backend(),), file=sys.stderr)
800 return interface.Event('q')
802 event = None
804 # We set up a blocking event loop. On receipt of a keypress, the
805 # callback records the event and unblocks the loop.
807 def recordKeypress(keypress):
808 """Matplotlib callback to record keypress and unblock"""
809 nonlocal event
810 event = interface.Event(keypress.key, keypress.xdata, keypress.ydata)
811 self._figure.canvas.stop_event_loop()
813 conn = self._figure.canvas.mpl_connect("key_press_event", recordKeypress)
814 try:
815 self._figure.canvas.start_event_loop(timeout=timeout) # Blocks on keypress
816 finally:
817 self._figure.canvas.mpl_disconnect(conn)
818 return event
821# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
824class Normalize(mpColors.Normalize):
825 """Class to support stretches for mtv()"""
827 def __call__(self, value, clip=None):
828 """
829 Return a MaskedArray with value mapped to [0, 255]
831 @param value Input pixel value or array to be mapped
832 """
833 if isinstance(value, np.ndarray):
834 data = value
835 else:
836 data = value.data
838 data = data - self.mapping.minimum[0]
839 return ma.array(data*self.mapping.mapIntensityToUint8(data)/255.0)
842class AsinhNormalize(Normalize):
843 """Provide an asinh stretch for mtv()"""
844 def __init__(self, minimum=0, dataRange=1, Q=8):
845 """Initialise an object able to carry out an asinh mapping
847 @param minimum Minimum pixel value (default: 0)
848 @param dataRange Range of values for stretch if Q=0; roughly the
849 linear part (default: 1)
850 @param Q Softening parameter (default: 8)
852 See Lupton et al., PASP 116, 133
853 """
854 # The object used to perform the desired mapping
855 self.mapping = afwRgb.AsinhMapping(minimum, dataRange, Q)
857 vmin, vmax = self._getMinMaxQ()[0:2]
858 if vmax*Q > vmin:
859 vmax *= Q
860 super().__init__(vmin, vmax)
862 def _getMinMaxQ(self):
863 """Return an asinh mapping's minimum and maximum value, and Q
865 Regrettably this information is not preserved by AsinhMapping
866 so we have to reverse engineer it
867 """
869 frac = 0.1 # magic number in AsinhMapping
870 Q = np.sinh((frac*self.mapping._uint8Max)/self.mapping._slope)/frac
871 dataRange = Q/self.mapping._soften
873 vmin = self.mapping.minimum[0]
874 return vmin, vmin + dataRange, Q
877class AsinhZScaleNormalize(AsinhNormalize):
878 """Provide an asinh stretch using zscale to set limits for mtv()"""
879 def __init__(self, image=None, Q=8):
880 """Initialise an object able to carry out an asinh mapping
882 @param image image to use estimate minimum and dataRange using zscale
883 (see AsinhNormalize)
884 @param Q Softening parameter (default: 8)
886 See Lupton et al., PASP 116, 133
887 """
889 # The object used to perform the desired mapping
890 self.mapping = afwRgb.AsinhZScaleMapping(image, Q)
892 vmin, vmax = self._getMinMaxQ()[0:2]
893 # n.b. super() would call AsinhNormalize,
894 # and I want to pass min/max to the baseclass
895 Normalize.__init__(self, vmin, vmax)
898class ZScaleNormalize(Normalize):
899 """Provide a zscale stretch for mtv()"""
900 def __init__(self, image=None, nSamples=1000, contrast=0.25):
901 """Initialise an object able to carry out a zscale mapping
903 @param image to be used to estimate the stretch
904 @param nSamples Number of data points to use (default: 1000)
905 @param contrast Control the range of pixels to display around the
906 median (default: 0.25)
907 """
909 # The object used to perform the desired mapping
910 self.mapping = afwRgb.ZScaleMapping(image, nSamples, contrast)
912 super().__init__(self.mapping.minimum[0], self.mapping.maximum)
915class LinearNormalize(Normalize):
916 """Provide a linear stretch for mtv()"""
917 def __init__(self, minimum=0, maximum=1):
918 """Initialise an object able to carry out a linear mapping
920 @param minimum Minimum value to display
921 @param maximum Maximum value to display
922 """
923 # The object used to perform the desired mapping
924 self.mapping = afwRgb.LinearMapping(minimum, maximum)
926 super().__init__(self.mapping.minimum[0], self.mapping.maximum)