lsst.display.ds9  16.0-2-g9d5294e+22
ds9.py
Go to the documentation of this file.
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 #
22 
23 __all__ = ["Ds9Error", "getXpaAccessPoint", "ds9Version", "Buffer",
24  "selectFrame", "ds9Cmd", "initDS9", "Ds9Event", "DisplayImpl"]
25 
26 import os
27 import re
28 import sys
29 import time
30 
31 import numpy as np
32 
33 import lsst.afw.display.interface as interface
34 import lsst.afw.display.virtualDevice as virtualDevice
35 import lsst.afw.display.ds9Regions as ds9Regions
36 
37 try:
38  from . import xpa as xpa
39 except ImportError as e:
40  print("Cannot import xpa: %s" % (e), file=sys.stderr)
41 
42 import lsst.afw.display.displayLib as displayLib
43 import lsst.afw.math as afwMath
44 
45 try:
46  needShow
47 except NameError:
48  needShow = True # Used to avoid a bug in ds9 5.4
49 
50 # An error talking to ds9
51 
52 
53 class Ds9Error(IOError):
54  """Some problem talking to ds9"""
55 
56 
57 try:
58  _maskTransparency
59 except NameError:
60  _maskTransparency = None
61 
62 
64  """Parse XPA_PORT and send return an identifier to send ds9 commands there, instead of "ds9"
65  If you don't have XPA_PORT set, the usual xpans tricks will be played when we return "ds9".
66  """
67  xpa_port = os.environ.get("XPA_PORT")
68  if xpa_port:
69  mat = re.search(r"^DS9:ds9\s+(\d+)\s+(\d+)", xpa_port)
70  if mat:
71  port1, port2 = mat.groups()
72 
73  return "127.0.0.1:%s" % (port1)
74  else:
75  print("Failed to parse XPA_PORT=%s" % xpa_port, file=sys.stderr)
76 
77  return "ds9"
78 
79 
80 def ds9Version():
81  """Return the version of ds9 in use, as a string"""
82  try:
83  v = ds9Cmd("about", get=True)
84  return v.splitlines()[1].split()[1]
85  except Exception as e:
86  print("Error reading version: %s" % e, file=sys.stderr)
87  return "0.0.0"
88 
89 
90 try:
91  cmdBuffer
92 except NameError:
93  XPA_SZ_LINE = 4096 - 100 # internal buffersize in xpa. Sigh; esp. as the 100 is some needed slop
94 
95  class Buffer(object):
96  """Control buffering the sending of commands to ds9;
97  annoying but necessary for anything resembling performance
98 
99  The usual usage pattern (from a module importing this file, ds9.py) is:
100 
101  with ds9.Buffering():
102  # bunches of ds9.{dot,line} commands
103  ds9.flush()
104  # bunches more ds9.{dot,line} commands
105  """
106 
107  def __init__(self, size=0):
108  """Create a command buffer, with a maximum depth of size"""
109  self._commands = "" # list of pending commands
110  self._lenCommands = len(self._commands)
111  self._bufsize = [] # stack of bufsizes
112 
113  self._bufsize.append(size) # don't call self.size() as ds9Cmd isn't defined yet
114 
115  def set(self, size, silent=True):
116  """Set the ds9 buffer size to size"""
117  if size < 0:
118  size = XPA_SZ_LINE - 5
119 
120  if size > XPA_SZ_LINE:
121  print("xpa silently hardcodes a limit of %d for buffer sizes (you asked for %d) " %
122  (XPA_SZ_LINE, size), file=sys.stderr)
123  self.set(-1) # use max buffersize
124  return
125 
126  if self._bufsize:
127  self._bufsize[-1] = size # change current value
128  else:
129  self._bufsize.append(size) # there is no current value; set one
130 
131  self.flush(silent=silent)
132 
133  def _getSize(self):
134  """Get the current ds9 buffer size"""
135  return self._bufsize[-1]
136 
137  def pushSize(self, size=-1):
138  """Replace current ds9 command buffer size with size (see also popSize)
139  @param: Size of buffer (-1: largest possible given bugs in xpa)"""
140  self.flush(silent=True)
141  self._bufsize.append(0)
142  self.set(size, silent=True)
143 
144  def popSize(self):
145  """Switch back to the previous command buffer size (see also pushSize)"""
146  self.flush(silent=True)
147 
148  if len(self._bufsize) > 1:
149  self._bufsize.pop()
150 
151  def flush(self, silent=True):
152  """Flush the pending commands"""
153  ds9Cmd(flush=True, silent=silent)
154 
155  cmdBuffer = Buffer(0)
156 
157 
158 def selectFrame(frame):
159  return "frame %d" % (frame)
160 
161 
162 def ds9Cmd(cmd=None, trap=True, flush=False, silent=True, frame=None, get=False):
163  """Issue a ds9 command, raising errors as appropriate"""
164 
165  global cmdBuffer
166  if cmd:
167  if frame is not None:
168  cmd = "%s;" % selectFrame(frame) + cmd
169 
170  if get:
171  return xpa.get(None, getXpaAccessPoint(), cmd, "").strip()
172 
173  # Work around xpa's habit of silently truncating long lines
174  # 5 to handle newlines and such like
175  if cmdBuffer._lenCommands + len(cmd) > XPA_SZ_LINE - 5:
176  ds9Cmd(flush=True, silent=silent)
177 
178  cmdBuffer._commands += ";" + cmd
179  cmdBuffer._lenCommands += 1 + len(cmd)
180 
181  if flush or cmdBuffer._lenCommands >= cmdBuffer._getSize():
182  cmd = (cmdBuffer._commands + "\n")
183  cmdBuffer._commands = ""
184  cmdBuffer._lenCommands = 0
185  else:
186  return
187 
188  cmd = cmd.rstrip()
189  if not cmd:
190  return
191 
192  try:
193  ret = xpa.set(None, getXpaAccessPoint(), cmd, "", "", 0)
194  if ret:
195  raise IOError(ret)
196  except IOError as e:
197  if not trap:
198  raise Ds9Error("XPA: %s, (%s)" % (e, cmd))
199  elif not silent:
200  print("Caught ds9 exception processing command \"%s\": %s" % (cmd, e), file=sys.stderr)
201 
202 
203 def initDS9(execDs9=True):
204  try:
205  xpa.reset()
206  ds9Cmd("iconify no; raise", False)
207  ds9Cmd("wcs wcsa", False) # include the pixel coordinates WCS (WCSA)
208 
209  v0, v1 = ds9Version().split('.')[0:2]
210  global needShow
211  needShow = False
212  try:
213  if int(v0) == 5:
214  needShow = (int(v1) <= 4)
215  except Exception:
216  pass
217  except Ds9Error as e:
218  if not re.search('xpa', os.environ['PATH']):
219  raise Ds9Error('You need the xpa binaries in your path to use ds9 with python')
220 
221  if not execDs9:
222  raise Ds9Error
223 
224  import distutils.spawn
225  if not distutils.spawn.find_executable("ds9"):
226  raise NameError("ds9 doesn't appear to be on your path")
227  if "DISPLAY" not in os.environ:
228  raise RuntimeError("$DISPLAY isn't set, so I won't be able to start ds9 for you")
229 
230  print("ds9 doesn't appear to be running (%s), I'll try to exec it for you" % e)
231 
232  os.system('ds9 &')
233  for i in range(10):
234  try:
235  ds9Cmd(selectFrame(1), False)
236  break
237  except Ds9Error:
238  print("waiting for ds9...\r", end="")
239  sys.stdout.flush()
240  time.sleep(0.5)
241  else:
242  print(" \r", end="")
243  break
244 
245  sys.stdout.flush()
246 
247  raise Ds9Error
248 
249 
250 class Ds9Event(interface.Event):
251  """An event generated by a mouse or key click on the display"""
252 
253  def __init__(self, k, x, y):
254  interface.Event.__init__(self, k, x, y)
255 
256 
257 class DisplayImpl(virtualDevice.DisplayImpl):
258 
259  def __init__(self, display, verbose=False, *args, **kwargs):
260  virtualDevice.DisplayImpl.__init__(self, display, verbose)
261 
262  def _close(self):
263  """Called when the device is closed"""
264  pass
265 
266  def _setMaskTransparency(self, transparency, maskplane):
267  """Specify ds9's mask transparency (percent); or None to not set it when loading masks"""
268  if maskplane is not None:
269  print("ds9 is unable to set transparency for individual maskplanes" % maskplane,
270  file=sys.stderr)
271  return
272  ds9Cmd("mask transparency %d" % transparency, frame=self.display.frame)
273 
274  def _getMaskTransparency(self, maskplane):
275  """Return the current ds9's mask transparency"""
276 
277  selectFrame(self.display.frame)
278  return float(ds9Cmd("mask transparency", get=True))
279 
280  def _show(self):
281  """Uniconify and Raise ds9. N.b. throws an exception if frame doesn't exit"""
282  ds9Cmd("raise", trap=False, frame=self.display.frame)
283 
284  def _mtv(self, image, mask=None, wcs=None, title=""):
285  """Display an Image and/or Mask on a DS9 display
286  """
287 
288  for i in range(3):
289  try:
290  initDS9(i == 0)
291  except Ds9Error:
292  print("waiting for ds9...\r", end="")
293  sys.stdout.flush()
294  time.sleep(0.5)
295  else:
296  if i > 0:
297  print(" \r", end="")
298  sys.stdout.flush()
299  break
300 
301  ds9Cmd(selectFrame(self.display.frame))
302  ds9Cmd("smooth no")
303  self._erase()
304 
305  if image:
306  _i_mtv(image, wcs, title, False)
307 
308  if mask:
309  maskPlanes = mask.getMaskPlaneDict()
310  nMaskPlanes = max(maskPlanes.values()) + 1
311 
312  planes = {} # build inverse dictionary
313  for key in maskPlanes:
314  planes[maskPlanes[key]] = key
315 
316  planeList = range(nMaskPlanes)
317  usedPlanes = int(afwMath.makeStatistics(mask, afwMath.SUM).getValue())
318  mask1 = mask.Factory(mask.getDimensions()) # Mask containing just one bitplane
319 
320  colorGenerator = self.display.maskColorGenerator(omitBW=True)
321  for p in planeList:
322  if planes.get(p):
323  pname = planes[p]
324 
325  if not ((1 << p) & usedPlanes): # no pixels have this bitplane set
326  continue
327 
328  mask1[:] = mask
329  mask1 &= (1 << p)
330 
331  color = self.display.getMaskPlaneColor(pname)
332 
333  if not color: # none was specified
334  color = next(colorGenerator)
335  elif color.lower() == "ignore":
336  continue
337 
338  ds9Cmd("mask color %s" % color)
339  _i_mtv(mask1, wcs, title, True)
340  #
341  # Graphics commands
342  #
343 
344  def _buffer(self, enable=True):
345  if enable:
346  cmdBuffer.pushSize()
347  else:
348  cmdBuffer.popSize()
349 
350  def _flush(self):
351  cmdBuffer.flush()
352 
353  def _erase(self):
354  """Erase the specified DS9 frame"""
355  ds9Cmd("regions delete all", flush=True, frame=self.display.frame)
356 
357  def _dot(self, symb, c, r, size, ctype, fontFamily="helvetica", textAngle=None):
358  """Draw a symbol onto the specified DS9 frame at (col,row) = (c,r) [0-based coordinates]
359  Possible values are:
360  + Draw a +
361  x Draw an x
362  * Draw a *
363  o Draw a circle
364  @:Mxx,Mxy,Myy Draw an ellipse with moments (Mxx, Mxy, Myy) (argument size is ignored)
365  An object derived from afwGeom.ellipses.BaseCore Draw the ellipse (argument size is ignored)
366  Any other value is interpreted as a string to be drawn. Strings obey the fontFamily (which may be extended
367  with other characteristics, e.g. "times bold italic". Text will be drawn rotated by textAngle
368  (textAngle is ignored otherwise).
369 
370  N.b. objects derived from BaseCore include Axes and Quadrupole.
371  """
372  cmd = selectFrame(self.display.frame) + "; "
373  for region in ds9Regions.dot(symb, c, r, size, ctype, fontFamily, textAngle):
374  cmd += 'regions command {%s}; ' % region
375 
376  ds9Cmd(cmd, silent=True)
377 
378  def _drawLines(self, points, ctype):
379  """Connect the points, a list of (col,row)
380  Ctype is the name of a colour (e.g. 'red')"""
381 
382  cmd = selectFrame(self.display.frame) + "; "
383  for region in ds9Regions.drawLines(points, ctype):
384  cmd += 'regions command {%s}; ' % region
385 
386  ds9Cmd(cmd)
387  #
388  # Set gray scale
389  #
390 
391  def _scale(self, algorithm, min, max, unit, *args, **kwargs):
392  if algorithm:
393  ds9Cmd("scale %s" % algorithm, frame=self.display.frame)
394 
395  if min in ("minmax", "zscale"):
396  ds9Cmd("scale mode %s" % (min))
397  else:
398  if unit:
399  print("ds9: ignoring scale unit %s" % unit)
400 
401  ds9Cmd("scale limits %g %g" % (min, max), frame=self.display.frame)
402  #
403  # Zoom and Pan
404  #
405 
406  def _zoom(self, zoomfac):
407  """Zoom frame by specified amount"""
408 
409  cmd = selectFrame(self.display.frame) + "; "
410  cmd += "zoom to %d; " % zoomfac
411 
412  ds9Cmd(cmd, flush=True)
413 
414  def _pan(self, colc, rowc):
415  """Pan frame to (colc, rowc)"""
416 
417  cmd = selectFrame(self.display.frame) + "; "
418  # ds9 is 1-indexed. Grrr
419  cmd += "pan to %g %g physical; " % (colc + 1, rowc + 1)
420 
421  ds9Cmd(cmd, flush=True)
422 
423  def _getEvent(self):
424  """Listen for a key press on frame in ds9, returning (key, x, y)"""
425 
426  vals = ds9Cmd("imexam key coordinate", get=True).split()
427  if vals[0] == "XPA$ERROR":
428  if vals[1:4] == ['unknown', 'option', '"-state"']:
429  pass # a ds9 bug --- you get this by hitting TAB
430  else:
431  print("Error return from imexam:", " ".join(vals), file=sys.stderr)
432  return None
433 
434  k = vals.pop(0)
435  try:
436  x = float(vals[0])
437  y = float(vals[1])
438  except Exception:
439  x = float("NaN")
440  y = float("NaN")
441 
442  return Ds9Event(k, x, y)
443 
444 
445 try:
446  haveGzip
447 except NameError:
448  # does gzip work?
449  haveGzip = not os.system("gzip < /dev/null > /dev/null 2>&1")
450 
451 
452 def _i_mtv(data, wcs, title, isMask):
453  """Internal routine to display an Image or Mask on a DS9 display"""
454 
455  title = str(title) if title else ""
456 
457  if True:
458  if isMask:
459  xpa_cmd = "xpaset %s fits mask" % getXpaAccessPoint()
460  # ds9 mis-handles BZERO/BSCALE in uint16 data.
461  # The following hack works around this.
462  # This is a copy we're modifying
463  if data.getArray().dtype == np.uint16:
464  data |= 0x8000
465  else:
466  xpa_cmd = "xpaset %s fits" % getXpaAccessPoint()
467 
468  if haveGzip:
469  xpa_cmd = "gzip | " + xpa_cmd
470 
471  pfd = os.popen(xpa_cmd, "w")
472  else:
473  pfd = open("foo.fits", "w")
474 
475  ds9Cmd(flush=True, silent=True)
476 
477  try:
478  displayLib.writeFitsImage(pfd.fileno(), data, wcs, title)
479  except Exception as e:
480  try:
481  pfd.close()
482  except Exception:
483  pass
484 
485  raise e
486 
487  try:
488  pfd.close()
489  except Exception:
490  pass
491 
492 
493 if False:
494  try:
495  definedCallbacks
496  except NameError:
497  definedCallbacks = True
498 
499  for k in ('XPA$ERROR',):
500  interface.setCallback(k)
def initDS9(execDs9=True)
Definition: ds9.py:203
def set(self, size, silent=True)
Definition: ds9.py:115
def ds9Version()
Definition: ds9.py:80
def ds9Cmd(cmd=None, trap=True, flush=False, silent=True, frame=None, get=False)
Definition: ds9.py:162
def __init__(self, size=0)
Definition: ds9.py:107
def pushSize(self, size=-1)
Definition: ds9.py:137
def __init__(self, k, x, y)
Definition: ds9.py:253
def selectFrame(frame)
Definition: ds9.py:158
def __init__(self, display, verbose=False, args, kwargs)
Definition: ds9.py:259
def flush(self, silent=True)
Definition: ds9.py:151
def getXpaAccessPoint()
Definition: ds9.py:63