Coverage for python/lsst/display/ds9/ds9.py: 19%

274 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-01-31 02:31 -0800

1# This file is part of display_ds9. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22__all__ = ["Ds9Error", "getXpaAccessPoint", "ds9Version", "Buffer", 

23 "selectFrame", "ds9Cmd", "initDS9", "Ds9Event", "DisplayImpl"] 

24 

25import os 

26import re 

27import sys 

28import time 

29 

30import numpy as np 

31 

32import lsst.afw.display.interface as interface 

33import lsst.afw.display.virtualDevice as virtualDevice 

34import lsst.afw.display.ds9Regions as ds9Regions 

35 

36try: 

37 from . import xpa as xpa 

38except ImportError as e: 

39 print("Cannot import xpa: %s" % (e), file=sys.stderr) 

40 

41import lsst.afw.display as afwDisplay 

42import lsst.afw.math as afwMath 

43 

44try: 

45 needShow 

46except NameError: 

47 needShow = True # Used to avoid a bug in ds9 5.4 

48 

49 

50class Ds9Error(IOError): 

51 """Represents an error communicating with DS9. 

52 """ 

53 

54 

55try: 

56 _maskTransparency 

57except NameError: 

58 _maskTransparency = None 

59 

60 

61def getXpaAccessPoint(): 

62 """Parse XPA_PORT if set and return an identifier to send DS9 commands. 

63 

64 Returns 

65 ------- 

66 

67 xpaAccessPoint : `str` 

68 Either a reference to the local host with the configured port, or the 

69 string ``"ds9"``. 

70 

71 Notes 

72 ----- 

73 If you don't have XPA_PORT set, the usual xpans tricks will be played 

74 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 

89def ds9Version(): 

90 """Get the version of DS9 in use. 

91 

92 Returns 

93 ------- 

94 version : `str` 

95 Version of DS9 in use. 

96 """ 

97 try: 

98 v = ds9Cmd("about", get=True) 

99 return v.splitlines()[1].split()[1] 

100 except Exception as e: 

101 print("Error reading version: %s" % e, file=sys.stderr) 

102 return "0.0.0" 

103 

104 

105try: 

106 cmdBuffer 

107except NameError: 

108 # internal buffersize in xpa. Sigh; esp. as the 100 is some needed slop 

109 XPA_SZ_LINE = 4096 - 100 

110 

111 class Buffer(object): 

112 """Buffer to control sending commands to DS9. 

113 

114 Notes 

115 ----- 

116 The usual usage pattern is: 

117 

118 >>> with ds9.Buffering(): 

119 ... # bunches of ds9.{dot,line} commands 

120 ... ds9.flush() 

121 ... # bunches more ds9.{dot,line} commands 

122 """ 

123 

124 def __init__(self, size=0): 

125 self._commands = "" # list of pending commands 

126 self._lenCommands = len(self._commands) 

127 self._bufsize = [] # stack of bufsizes 

128 

129 self._bufsize.append(size) # don't call self.size() as ds9Cmd isn't defined yet 

130 

131 def set(self, size, silent=True): 

132 """Set the ds9 buffer size to size. 

133 

134 Parameters 

135 ---------- 

136 size : `int` 

137 Size of buffer. Requesting a negative size provides the 

138 largest possible buffer given bugs in xpa. 

139 silent : `bool`, optional 

140 Do not print error messages (default `True`). 

141 """ 

142 if size < 0: 

143 size = XPA_SZ_LINE - 5 

144 

145 if size > XPA_SZ_LINE: 

146 print("xpa silently hardcodes a limit of %d for buffer sizes (you asked for %d) " % 

147 (XPA_SZ_LINE, size), file=sys.stderr) 

148 self.set(-1) # use max buffersize 

149 return 

150 

151 if self._bufsize: 

152 self._bufsize[-1] = size # change current value 

153 else: 

154 self._bufsize.append(size) # there is no current value; set one 

155 

156 self.flush(silent=silent) 

157 

158 def _getSize(self): 

159 """Get the current DS9 buffer size. 

160 

161 Returns 

162 ------- 

163 size : `int` 

164 Size of buffer. 

165 """ 

166 return self._bufsize[-1] 

167 

168 def pushSize(self, size=-1): 

169 """Replace current DS9 command buffer size. 

170 

171 Parameters 

172 ---------- 

173 size : `int`, optional 

174 Size of buffer. A negative value sets the largest possible 

175 buffer. 

176 

177 Notes 

178 ----- 

179 See also `popSize`. 

180 """ 

181 self.flush(silent=True) 

182 self._bufsize.append(0) 

183 self.set(size, silent=True) 

184 

185 def popSize(self): 

186 """Switch back to the previous command buffer size. 

187 

188 Notes 

189 ----- 

190 See also `pushSize`. 

191 """ 

192 self.flush(silent=True) 

193 

194 if len(self._bufsize) > 1: 

195 self._bufsize.pop() 

196 

197 def flush(self, silent=True): 

198 """Flush the pending commands. 

199 

200 Parameters 

201 ---------- 

202 silent : `bool`, optional 

203 Do not print error messages. 

204 """ 

205 ds9Cmd(flush=True, silent=silent) 

206 

207 cmdBuffer = Buffer(0) 

208 

209 

210def selectFrame(frame): 

211 """Convert integer frame number to DS9 command syntax. 

212 

213 Parameters 

214 ---------- 

215 frame : `int` 

216 Frame number 

217 

218 Returns 

219 ------- 

220 frameString : `str` 

221 """ 

222 return "frame %d" % (frame) 

223 

224 

225def ds9Cmd(cmd=None, trap=True, flush=False, silent=True, frame=None, get=False): 

226 """Issue a DS9 command, raising errors as appropriate. 

227 

228 Parameters 

229 ---------- 

230 cmd : `str`, optional 

231 Command to execute. 

232 trap : `bool`, optional 

233 Trap errors. 

234 flush : `bool`, optional 

235 Flush the output. 

236 silent : `bool`, optional 

237 Do not print trapped error messages. 

238 frame : `int`, optional 

239 Frame number on which to execute command. 

240 get : `bool`, optional 

241 Return xpa response. 

242 """ 

243 

244 global cmdBuffer 

245 if cmd: 

246 if frame is not None: 

247 cmd = "%s;" % selectFrame(frame) + cmd 

248 

249 if get: 

250 return xpa.get(None, getXpaAccessPoint(), cmd, "").strip() 

251 

252 # Work around xpa's habit of silently truncating long lines; the value 

253 # ``5`` provides some margin to handle new lines and the like. 

254 if cmdBuffer._lenCommands + len(cmd) > XPA_SZ_LINE - 5: 

255 ds9Cmd(flush=True, silent=silent) 

256 

257 cmdBuffer._commands += ";" + cmd 

258 cmdBuffer._lenCommands += 1 + len(cmd) 

259 

260 if flush or cmdBuffer._lenCommands >= cmdBuffer._getSize(): 

261 cmd = (cmdBuffer._commands + "\n") 

262 cmdBuffer._commands = "" 

263 cmdBuffer._lenCommands = 0 

264 else: 

265 return 

266 

267 cmd = cmd.rstrip() 

268 if not cmd: 

269 return 

270 

271 try: 

272 ret = xpa.set(None, getXpaAccessPoint(), cmd, "", "", 0) 

273 if ret: 

274 raise IOError(ret) 

275 except IOError as e: 

276 if not trap: 

277 raise Ds9Error("XPA: %s, (%s)" % (e, cmd)) 

278 elif not silent: 

279 print("Caught ds9 exception processing command \"%s\": %s" % (cmd, e), file=sys.stderr) 

280 

281 

282def initDS9(execDs9=True): 

283 """Initialize DS9. 

284 

285 Parameters 

286 ---------- 

287 execDs9 : `bool`, optional 

288 If DS9 is not running, attempt to execute it. 

289 """ 

290 try: 

291 xpa.reset() 

292 ds9Cmd("iconify no; raise", False) 

293 ds9Cmd("wcs wcsa", False) # include the pixel coordinates WCS (WCSA) 

294 

295 v0, v1 = ds9Version().split('.')[0:2] 

296 global needShow 

297 needShow = False 

298 try: 

299 if int(v0) == 5: 

300 needShow = (int(v1) <= 4) 

301 except Exception: 

302 pass 

303 except Ds9Error as e: 

304 if not re.search('xpa', os.environ['PATH']): 

305 raise Ds9Error('You need the xpa binaries in your path to use ds9 with python') 

306 

307 if not execDs9: 

308 raise Ds9Error 

309 

310 import distutils.spawn 

311 if not distutils.spawn.find_executable("ds9"): 

312 raise NameError("ds9 doesn't appear to be on your path") 

313 if "DISPLAY" not in os.environ: 

314 raise RuntimeError("$DISPLAY isn't set, so I won't be able to start ds9 for you") 

315 

316 print("ds9 doesn't appear to be running (%s), I'll try to exec it for you" % e) 

317 

318 os.system('ds9 &') 

319 for i in range(10): 

320 try: 

321 ds9Cmd(selectFrame(1), False) 

322 break 

323 except Ds9Error: 

324 print("waiting for ds9...\r", end="") 

325 sys.stdout.flush() 

326 time.sleep(0.5) 

327 else: 

328 print(" \r", end="") 

329 break 

330 

331 sys.stdout.flush() 

332 

333 raise Ds9Error 

334 

335 

336class Ds9Event(interface.Event): 

337 """An event generated by a mouse or key click on the display. 

338 """ 

339 

340 def __init__(self, k, x, y): 

341 interface.Event.__init__(self, k, x, y) 

342 

343 

344class DisplayImpl(virtualDevice.DisplayImpl): 

345 """Virtual device display implementation. 

346 """ 

347 

348 def __init__(self, display, verbose=False, *args, **kwargs): 

349 virtualDevice.DisplayImpl.__init__(self, display, verbose) 

350 

351 def _close(self): 

352 """Called when the device is closed. 

353 """ 

354 pass 

355 

356 def _setMaskTransparency(self, transparency, maskplane): 

357 """Specify DS9's mask transparency. 

358 

359 Parameters 

360 ---------- 

361 transparency : `int` 

362 Percent transparency. 

363 maskplane : `NoneType` 

364 If `None`, transparency is enabled. Otherwise, this parameter is 

365 ignored. 

366 """ 

367 if maskplane is not None: 

368 print("ds9 is unable to set transparency for individual maskplanes" % maskplane, 

369 file=sys.stderr) 

370 return 

371 ds9Cmd("mask transparency %d" % transparency, frame=self.display.frame) 

372 

373 def _getMaskTransparency(self, maskplane): 

374 """Return the current DS9's mask transparency. 

375 

376 Parameters 

377 ---------- 

378 maskplane : unused 

379 This parameter does nothing. 

380 """ 

381 selectFrame(self.display.frame) 

382 return float(ds9Cmd("mask transparency", get=True)) 

383 

384 def _show(self): 

385 """Uniconify and raise DS9. 

386 

387 Notes 

388 ----- 

389 Raises if ``self.display.frame`` doesn't exist. 

390 """ 

391 ds9Cmd("raise", trap=False, frame=self.display.frame) 

392 

393 def _mtv(self, image, mask=None, wcs=None, title=""): 

394 """Display an Image and/or Mask on a DS9 display. 

395 

396 Parameters 

397 ---------- 

398 image : subclass of `lsst.afw.image.Image` 

399 Image to display. 

400 mask : subclass of `lsst.afw.image.Mask`, optional 

401 Mask. 

402 wcs : `lsst.afw.geom.SkyWcs`, optional 

403 WCS of data 

404 title : `str`, optional 

405 Title of image. 

406 """ 

407 

408 for i in range(3): 

409 try: 

410 initDS9(i == 0) 

411 except Ds9Error: 

412 print("waiting for ds9...\r", end="") 

413 sys.stdout.flush() 

414 time.sleep(0.5) 

415 else: 

416 if i > 0: 

417 print(" \r", end="") 

418 sys.stdout.flush() 

419 break 

420 

421 ds9Cmd(selectFrame(self.display.frame)) 

422 ds9Cmd("smooth no") 

423 self._erase() 

424 

425 if image: 

426 _i_mtv(image, wcs, title, False) 

427 

428 if mask: 

429 maskPlanes = mask.getMaskPlaneDict() 

430 nMaskPlanes = max(maskPlanes.values()) + 1 

431 

432 planes = {} # build inverse dictionary 

433 for key in maskPlanes: 

434 planes[maskPlanes[key]] = key 

435 

436 planeList = range(nMaskPlanes) 

437 usedPlanes = int(afwMath.makeStatistics(mask, afwMath.SUM).getValue()) 

438 mask1 = mask.Factory(mask.getDimensions()) # Mask containing just one bitplane 

439 

440 colorGenerator = self.display.maskColorGenerator(omitBW=True) 

441 for p in planeList: 

442 if planes.get(p): 

443 pname = planes[p] 

444 

445 if not ((1 << p) & usedPlanes): # no pixels have this bitplane set 

446 continue 

447 

448 mask1[:] = mask 

449 mask1 &= (1 << p) 

450 

451 color = self.display.getMaskPlaneColor(pname) 

452 

453 if not color: # none was specified 

454 color = next(colorGenerator) 

455 elif color.lower() == "ignore": 

456 continue 

457 

458 ds9Cmd("mask color %s" % color) 

459 _i_mtv(mask1, wcs, title, True) 

460 # 

461 # Graphics commands 

462 # 

463 

464 def _buffer(self, enable=True): 

465 """Push and pop buffer size. 

466 

467 Parameters 

468 ---------- 

469 enable : `bool`, optional 

470 If `True` (default), push size; else pop it. 

471 """ 

472 if enable: 

473 cmdBuffer.pushSize() 

474 else: 

475 cmdBuffer.popSize() 

476 

477 def _flush(self): 

478 """Flush buffer. 

479 """ 

480 cmdBuffer.flush() 

481 

482 def _erase(self): 

483 """Erase all regions in current frame. 

484 """ 

485 ds9Cmd("regions delete all", flush=True, frame=self.display.frame) 

486 

487 def _dot(self, symb, c, r, size, ctype, fontFamily="helvetica", textAngle=None): 

488 """Draw a symbol onto the specified DS9 frame. 

489 

490 Parameters 

491 ---------- 

492 symb : `str`, or subclass of `lsst.afw.geom.ellipses.BaseCore` 

493 Symbol to be drawn. Possible values are: 

494 

495 - ``"+"``: Draw a "+" 

496 - ``"x"``: Draw an "x" 

497 - ``"*"``: Draw a "*" 

498 - ``"o"``: Draw a circle 

499 - ``"@:Mxx,Mxy,Myy"``: Draw an ellipse with moments (Mxx, Mxy, 

500 Myy);(the ``size`` parameter is ignored) 

501 - An object derived from `lsst.afw.geom.ellipses.BaseCore`: Draw 

502 the ellipse (argument size is ignored) 

503 

504 Any other value is interpreted as a string to be drawn. 

505 c : `int` 

506 Column to draw symbol [0-based coordinates]. 

507 r : `int` 

508 Row to draw symbol [0-based coordinates]. 

509 size : `float` 

510 Size of symbol. 

511 ctype : `str` 

512 the name of a colour (e.g. ``"red"``) 

513 fontFamily : `str`, optional 

514 String font. May be extended with other characteristics, 

515 e.g. ``"times bold italic"``. 

516 textAngle: `float`, optional 

517 Text will be drawn rotated by ``textAngle``. 

518 

519 Notes 

520 ----- 

521 Objects derived from `lsst.afw.geom.ellipses.BaseCore` include 

522 `~lsst.afw.geom.ellipses.Axes` and `lsst.afw.geom.ellipses.Quadrupole`. 

523 """ 

524 cmd = selectFrame(self.display.frame) + "; " 

525 for region in ds9Regions.dot(symb, c, r, size, ctype, fontFamily, textAngle): 

526 cmd += 'regions command {%s}; ' % region 

527 

528 ds9Cmd(cmd, silent=True) 

529 

530 def _drawLines(self, points, ctype): 

531 """Connect the points. 

532 

533 Parameters 

534 ----------- 

535 points : `list` of (`int`, `int`) 

536 A list of points specified as (col, row). 

537 ctype : `str` 

538 The name of a colour (e.g. ``"red"``). 

539 """ 

540 cmd = selectFrame(self.display.frame) + "; " 

541 for region in ds9Regions.drawLines(points, ctype): 

542 cmd += 'regions command {%s}; ' % region 

543 

544 ds9Cmd(cmd) 

545 

546 def _scale(self, algorithm, min, max, unit, *args, **kwargs): 

547 """Set image color scale. 

548 

549 Parameters 

550 ---------- 

551 algorithm : {``"linear"``, ``"log"``, ``"pow"``, ``"sqrt"``, ``"squared"``, ``"asinh"``, ``"sinh"``, ``"histequ"``} # noqa: E501 

552 Scaling algorithm. May be any value supported by DS9. 

553 min : `float` 

554 Minimum value for scale. 

555 max : `float` 

556 Maximum value for scale. 

557 unit : `str` 

558 Ignored. 

559 *args 

560 Ignored. 

561 **kwargs 

562 Ignored 

563 """ 

564 if algorithm: 

565 ds9Cmd("scale %s" % algorithm, frame=self.display.frame) 

566 

567 if min in ("minmax", "zscale"): 

568 ds9Cmd("scale mode %s" % (min)) 

569 else: 

570 if unit: 

571 print("ds9: ignoring scale unit %s" % unit) 

572 

573 ds9Cmd("scale limits %g %g" % (min, max), frame=self.display.frame) 

574 # 

575 # Zoom and Pan 

576 # 

577 

578 def _zoom(self, zoomfac): 

579 """Zoom frame by specified amount. 

580 

581 Parameters 

582 ---------- 

583 zoomfac : `int` 

584 DS9 zoom factor. 

585 """ 

586 cmd = selectFrame(self.display.frame) + "; " 

587 cmd += "zoom to %d; " % zoomfac 

588 

589 ds9Cmd(cmd, flush=True) 

590 

591 def _pan(self, colc, rowc): 

592 """Pan frame. 

593 

594 Parameters 

595 ---------- 

596 colc : `int` 

597 Physical column to which to pan. 

598 rowc : `int` 

599 Physical row to which to pan. 

600 """ 

601 cmd = selectFrame(self.display.frame) + "; " 

602 # ds9 is 1-indexed. Grrr 

603 cmd += "pan to %g %g physical; " % (colc + 1, rowc + 1) 

604 

605 ds9Cmd(cmd, flush=True) 

606 

607 def _getEvent(self): 

608 """Listen for a key press on a frame in DS9 and return an event. 

609 

610 Returns 

611 ------- 

612 event : `Ds9Event` 

613 Event with (key, x, y). 

614 """ 

615 vals = ds9Cmd("imexam key coordinate", get=True).split() 

616 if vals[0] == "XPA$ERROR": 

617 if vals[1:4] == ['unknown', 'option', '"-state"']: 

618 pass # a ds9 bug --- you get this by hitting TAB 

619 else: 

620 print("Error return from imexam:", " ".join(vals), file=sys.stderr) 

621 return None 

622 

623 k = vals.pop(0) 

624 try: 

625 x = float(vals[0]) 

626 y = float(vals[1]) 

627 except Exception: 

628 x = float("NaN") 

629 y = float("NaN") 

630 

631 return Ds9Event(k, x, y) 

632 

633 

634try: 

635 haveGzip 

636except NameError: 

637 # does gzip work? 

638 haveGzip = not os.system("gzip < /dev/null > /dev/null 2>&1") 

639 

640 

641def _i_mtv(data, wcs, title, isMask): 

642 """Internal routine to display an image or a mask on a DS9 display. 

643 

644 Parameters 

645 ---------- 

646 data : Subclass of `lsst.afw.image.Image` or `lsst.afw.image.Mask` 

647 Data to display. 

648 wcs : `lsst.afw.geom.SkyWcs` 

649 WCS of data. 

650 title : `str` 

651 Title of display. 

652 isMask : `bool` 

653 Is ``data`` a mask? 

654 """ 

655 title = str(title) if title else "" 

656 

657 if True: 

658 if isMask: 

659 xpa_cmd = "xpaset %s fits mask" % getXpaAccessPoint() 

660 # ds9 mis-handles BZERO/BSCALE in uint16 data. 

661 # The following hack works around this. 

662 # This is a copy we're modifying 

663 if data.getArray().dtype == np.uint16: 

664 data |= 0x8000 

665 else: 

666 xpa_cmd = "xpaset %s fits" % getXpaAccessPoint() 

667 

668 if haveGzip: 

669 xpa_cmd = "gzip | " + xpa_cmd 

670 

671 pfd = os.popen(xpa_cmd, "w") 

672 else: 

673 pfd = open("foo.fits", "w") 

674 

675 ds9Cmd(flush=True, silent=True) 

676 

677 try: 

678 afwDisplay.writeFitsImage(pfd.fileno(), data, wcs, title) 

679 except Exception as e: 

680 try: 

681 pfd.close() 

682 except Exception: 

683 pass 

684 

685 raise e 

686 

687 try: 

688 pfd.close() 

689 except Exception: 

690 pass 

691 

692 

693if False: 

694 try: 

695 definedCallbacks 

696 except NameError: 

697 definedCallbacks = True 

698 

699 for k in ('XPA$ERROR',): 

700 interface.setCallback(k)