LSST Applications g2079a07aa2+86d27d4dc4,g2305ad1205+a659bff248,g2bbee38e9b+3c60f8fe34,g337abbeb29+3c60f8fe34,g33d1c0ed96+3c60f8fe34,g3502564af9+d77d6d1350,g3a166c0a6a+3c60f8fe34,g487adcacf7+25d9892218,g4be5004598+d77d6d1350,g50ff169b8f+96c6868917,g52b1c1532d+585e252eca,g591dd9f2cf+4d81263f9a,g5cd07815a0+980d2b1c3b,g607f77f49a+d77d6d1350,g858d7b2824+d77d6d1350,g88963caddf+83e433e629,g99cad8db69+a4d3c48eeb,g9ddcbc5298+9a081db1e4,ga1e77700b3+bcf1af89ad,ga57fefb910+9a39d7b2d7,gae0086650b+585e252eca,gb065fddaf9+4f9fd82a2c,gb0e22166c9+60f28cb32d,gb363559e06+d84b1d3d07,gb3b7280ab2+4563d032e1,gb4b16eec92+babe958938,gba4ed39666+c2a2e4ac27,gbb8dafda3b+ed6854b564,gc120e1dc64+b72d212f87,gc28159a63d+3c60f8fe34,gc3e9b769f7+921dbcd359,gcf0d15dbbd+9a39d7b2d7,gdaeeff99f8+f9a426f77a,gddc38dedce+585e252eca,ge79ae78c31+3c60f8fe34,w.2024.21
LSST Data Management Base Package
Loading...
Searching...
No Matches
matplotlib.py
Go to the documentation of this file.
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#
22
23#
24# \file
25# \brief Definitions to talk to matplotlib from python using the "afwDisplay"
26# interface
27
28import math
29import sys
30import unicodedata
31
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
38
39import numpy as np
40import numpy.ma as ma
41
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
49
50import lsst.afw.geom as afwGeom
51import lsst.geom as geom
52
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 ]
64
65try:
66 matplotlibCtypes
67except NameError:
68 matplotlibCtypes = {
69 afwDisplay.GREEN: "#00FF00",
70 }
71
72 def mapCtype(ctype):
73 """Map the ctype to a potentially different ctype
74
75 Specifically, if matplotlibCtypes[ctype] exists, use it instead
76
77 This is used e.g. to map "green" to a brighter shade
78 """
79 return matplotlibCtypes[ctype] if ctype in matplotlibCtypes else ctype
80
81
82class DisplayImpl(virtualDevice.DisplayImpl):
83 """Provide a matplotlib backend for afwDisplay
84
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
96
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
105
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)
121
122 The `frame` argument to `Display` may be a matplotlib figure; this
123 permits code such as
124 fig, axes = plt.subplots(1, 2)
125
126 disp = afwDisplay.Display(fig)
127 disp.scale('asinh', 'zscale', Q=0.5)
128
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
134
135 if isinstance(display.frame, fig_class):
136 figure = display.frame
137 else:
138 figure = None
139
140 virtualDevice.DisplayImpl.__init__(self, display, verbose)
141
142 if reopenPlot:
143 import matplotlib.pyplot as pyplot
144 pyplot.close(display.frame)
145
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()
152
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)
175
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
182
183 def _show(self):
184 """Put the plot at the top of the window stacking order"""
185
186 try:
187 self._figure.canvas._tkcanvas._root().lift() # tk
188 except AttributeError:
189 pass
190
191 try:
192 self._figure.canvas.manager.window.raise_() # os/x
193 except AttributeError:
194 pass
195
196 try:
197 self._figure.canvas.raise_() # qt[45]
198 except AttributeError:
199 pass
200
201 #
202 # Extensions to the API
203 #
204 def savefig(self, *args, **kwargs):
205 """Defer to figure.savefig()
206
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)
215
216 def show_colorbar(self, show=True, where="right", axSize="5%", axPad=None, **kwargs):
217 """Show (or hide) the colour bar
218
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()
233
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.
237
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")
245
246 mappable, ax = self._mappable_ax
247
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())}")
253
254 if axPad is None:
255 axPad = 0.1 if orientation == "vertical" else 0.3
256
257 divider = make_axes_locatable(ax)
258 self._colorbar_ax = divider.append_axes(where, size=axSize, pad=axPad)
259
260 self._figure.colorbar(mappable, cax=self._colorbar_ax, orientation=orientation, **kwargs)
261 self._figure.sca(ax)
262
263 else:
264 if self._colorbar_ax is not None:
265 self._colorbar_ax.remove()
266 self._colorbar_ax = None
267
268 def useSexagesimal(self, useSexagesimal):
269 """Control the formatting coordinates as HH:MM:SS.ss
270
271 Parameters
272 ----------
273 useSexagesimal : `bool`
274 Print coordinates as e.g. HH:MM:SS.ss iff True
275
276 N.b. can also be set in Display's ctor
277 """
278
279 """Are we formatting coordinates as HH:MM:SS.ss?"""
280 self._useSexagesimal[0] = useSexagesimal
281
282 def wait(self, prompt="[c(ontinue) p(db)] :", allowPdb=True):
283 """Wait for keyboard input
284
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
291
292 Returns the string you entered
293
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
304
305 return s
306 #
307 # Defined API
308 #
309
310 def _setMaskTransparency(self, transparency, maskplane):
311 """Specify mask transparency (percent)"""
312
313 self._maskTransparency[maskplane] = 0.01*transparency
314
315 def _getMaskTransparency(self, maskplane=None):
316 """Return the current mask transparency"""
317 return self._maskTransparency[maskplane if maskplane in self._maskTransparency else None]
318
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 ""
323
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)
329
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'])
335
336 ax = self._figure.gca()
337 ax.cla()
338
339 self._i_mtv(image, wcs, title, False)
340
341 if mask:
342 self._i_mtv(mask, wcs, title, True)
343
344 self.show_colorbar()
345
346 if title:
347 ax.set_title(title)
348
349 self._title = title
350
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):
354
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)
360
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()
368
369 if _useSexagesimal[0]:
370 from astropy import units as u
371 from astropy.coordinates import Angle as apAngle
372
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
379
380 msg += r" (%s, %s): (%s, %s)" % (self.__alpha, self.__delta, ra, dec)
381
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
389
390 return msg
391
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
397
398 # using tight_layout() is too tight and clips the axes
399 self._figure.canvas.draw_idle()
400
401 def _i_mtv(self, data, wcs, title, isMask):
402 """Internal routine to display an Image or Mask on a DS9 display"""
403
404 title = str(title) if title else ""
405 dataArr = data.getArray()
406
407 if isMask:
408 maskPlanes = data.getMaskPlaneDict()
409 nMaskPlanes = max(maskPlanes.values()) + 1
410
411 planes = {} # build inverse dictionary
412 for key in maskPlanes:
413 planes[maskPlanes[key]] = key
414
415 planeList = range(nMaskPlanes)
416
417 maskArr = np.zeros_like(dataArr, dtype=np.int32)
418
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
423
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
428
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)
446
447 colors[i + 1][alphaChannel] = alpha
448
449 cmap = mpColors.ListedColormap(colors)
450 norm = mpColors.NoNorm()
451 else:
452 cmap = self._image_colormap
453 norm = self._normalize
454
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)
459
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
465
466 bitIsSet = (dataArr & (1 << p)) != 0
467 if bitIsSet.sum() == 0:
468 continue
469
470 maskArr[bitIsSet] = i + 1 # + 1 as we set colorNames[0] to black
471
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
476
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
486
487 mappable = ax.imshow(dataArr, origin='lower', interpolation='nearest',
488 extent=extent, cmap=cmap, norm=norm)
489 self._mappable_ax = (mappable, ax)
490
491 self._figure.canvas.draw_idle()
492
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)
499
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()
505
506 self._xcen = 0.5*self._width
507 self._ycen = 0.5*self._height
508
509 def _setImageColormap(self, cmap):
510 """Set the colormap used for the image
511
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)
514
515 """
516 if not isinstance(cmap, mpColors.Colormap):
517 cmap = matplotlib.colormaps[cmap]
518
519 self._image_colormap = cmap
520
521 #
522 # Graphics commands
523 #
524
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()
533
534 def _flush(self):
535 pass
536
537 def _erase(self):
538 """Erase the display"""
539
540 for axis in self._figure.axes:
541 axis.lines = []
542 axis.texts = []
543
544 self._figure.canvas.draw_idle()
545
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)
558
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
566
567 axis = self._figure.gca()
568 x0, y0 = self._xy0
569
570 if isinstance(symb, afwGeom.ellipses.Axes):
571 from matplotlib.patches import Ellipse
572
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
581
582 axis.add_artist(Circle((c + x0, r + y0), radius=size, color=mapCtype(ctype), fill=False))
583 else:
584 from matplotlib.lines import Line2D
585
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()
589
590 cmd, args = cmd[0], cmd[1:]
591
592 if cmd == "line":
593 args = np.array(args).astype(float) - 1.0
594
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]
600
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)
608
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')"""
612
613 from matplotlib.lines import Line2D
614
615 if not ctype:
616 ctype = afwDisplay.GREEN
617
618 points = np.array(points)
619 x = points[:, 0] + self._xy0[0]
620 y = points[:, 1] + self._xy0[1]
621
622 self._figure.gca().add_line(Line2D(x, y, color=mapCtype(ctype)))
623
624 def _scale(self, algorithm, minval, maxval, unit, *args, **kwargs):
625 """
626 Set gray scale
627
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
639
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
645
646 def _i_scale(self, algorithm, minval, maxval, unit, *args, **kwargs):
647
648 maskedPixels = kwargs.get("maskedPixels", [])
649 if isinstance(maskedPixels, str):
650 maskedPixels = [maskedPixels]
651 bitmask = afwImage.Mask.getPlaneBitMask(maskedPixels)
652
653 sctrl = afwMath.StatisticsControl()
654 sctrl.setAndMask(bitmask)
655
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")
659
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")
667
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")
674
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")
683
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 #
694
695 def _zoom(self, zoomfac):
696 """Zoom by specified amount"""
697
698 self._zoomfac = zoomfac
699
700 if zoomfac is None:
701 return
702
703 x0, y0 = self._xy0
704
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])
710
711 ax = self._figure.gca()
712
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
716
717 ax.set_xlim(xmin, xmax)
718 ax.set_ylim(ymin, ymax)
719 ax.set_aspect('equal', 'datalim')
720
721 self._figure.canvas.draw_idle()
722
723 def _pan(self, colc, rowc):
724 """Pan to (colc, rowc)"""
725
726 self._xcen = colc
727 self._ycen = rowc
728
729 self._zoom(self._zoomfac)
730
731 def _getEvent(self, timeout=-1):
732 """Listen for a key press, returning (key, x, y)"""
733
734 if timeout < 0:
735 timeout = 24*3600 # -1 generates complaints in QTimer::singleShot. A day is a long time
736
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')
742
743 event = None
744
745 # We set up a blocking event loop. On receipt of a keypress, the
746 # callback records the event and unblocks the loop.
747
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()
753
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
760
761
762# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
763
764
765class Normalize(mpColors.Normalize):
766 """Class to support stretches for mtv()"""
767
768 def __call__(self, value, clip=None):
769 """
770 Return a MaskedArray with value mapped to [0, 255]
771
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
778
779 data = data - self.mapping.minimum[0]
780 return ma.array(data*self.mapping.mapIntensityToUint8(data)/255.0)
781
782
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
787
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)
792
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)
797
798 vmin, vmax = self._getMinMaxQ()[0:2]
799 if vmax*Q > vmin:
800 vmax *= Q
801 super().__init__(vmin, vmax)
802
803 def _getMinMaxQ(self):
804 """Return an asinh mapping's minimum and maximum value, and Q
805
806 Regrettably this information is not preserved by AsinhMapping
807 so we have to reverse engineer it
808 """
809
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
813
814 vmin = self.mapping.minimum[0]
815 return vmin, vmin + dataRange, Q
816
817
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
822
823 @param image image to use estimate minimum and dataRange using zscale
824 (see AsinhNormalize)
825 @param Q Softening parameter (default: 8)
826
827 See Lupton et al., PASP 116, 133
828 """
829
830 # The object used to perform the desired mapping
831 self.mapping = afwRgb.AsinhZScaleMapping(image, Q)
832
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)
837
838
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
843
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 """
849
850 # The object used to perform the desired mapping
851 self.mapping = afwRgb.ZScaleMapping(image, nSamples, contrast)
852
853 super().__init__(self.mapping.minimum[0], self.mapping.maximum)
854
855
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
860
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)
866
867 super().__init__(self.mapping.minimum[0], self.mapping.maximum)
_close(self)
Close the display, cleaning up any allocated resources.
_i_setImage(self, image, mask=None, wcs=None)
__init__(self, display, verbose=False, interpretMaskBits=True, mtvOrigin=afwImage.PARENT, fastMaskDisplay=False, reopenPlot=False, useSexagesimal=False, dpi=None, *args, **kwargs)