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