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

272 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-01 03:37 -0700

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 shutil 

28import sys 

29import time 

30 

31import numpy as np 

32 

33import lsst.afw.display.interface as interface 

34import lsst.afw.display.virtualDevice as virtualDevice 

35import lsst.afw.display.ds9Regions as ds9Regions 

36 

37try: 

38 from . import xpa as xpa 

39except ImportError as e: 

40 print(f"Cannot import xpa: {e}", file=sys.stderr) 

41 

42import lsst.afw.display as afwDisplay 

43import lsst.afw.math as afwMath 

44 

45try: 

46 needShow 

47except NameError: 

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

49 

50 

51class Ds9Error(IOError): 

52 """Represents an error communicating with DS9. 

53 """ 

54 

55 

56try: 

57 _maskTransparency 

58except NameError: 

59 _maskTransparency = None 

60 

61 

62def getXpaAccessPoint(): 

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

64 

65 Returns 

66 ------- 

67 

68 xpaAccessPoint : `str` 

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

70 string ``"ds9"``. 

71 

72 Notes 

73 ----- 

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

75 when we return ``"ds9"``. 

76 """ 

77 xpa_port = os.environ.get("XPA_PORT") 

78 if xpa_port: 

79 mat = re.search(r"^DS9:ds9\s+(\d+)\s+(\d+)", xpa_port) 

80 if mat: 

81 port1, port2 = mat.groups() 

82 

83 return f"127.0.0.1:{port1}" 

84 else: 

85 print(f"Failed to parse XPA_PORT={xpa_port}", file=sys.stderr) 

86 

87 return "ds9" 

88 

89 

90def ds9Version(): 

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

92 

93 Returns 

94 ------- 

95 version : `str` 

96 Version of DS9 in use. 

97 """ 

98 try: 

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

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

101 except Exception as e: 

102 print(f"Error reading version: {e}", file=sys.stderr) 

103 return "0.0.0" 

104 

105 

106try: 

107 cmdBuffer 

108except NameError: 

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

110 XPA_SZ_LINE = 4096 - 100 

111 

112 class Buffer: 

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

114 

115 Notes 

116 ----- 

117 The usual usage pattern is: 

118 

119 >>> with ds9.Buffering(): 

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

121 ... ds9.flush() 

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

123 """ 

124 

125 def __init__(self, size=0): 

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

127 self._lenCommands = len(self._commands) 

128 self._bufsize = [] # stack of bufsizes 

129 

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

131 

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

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

134 

135 Parameters 

136 ---------- 

137 size : `int` 

138 Size of buffer. Requesting a negative size provides the 

139 largest possible buffer given bugs in xpa. 

140 silent : `bool`, optional 

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

142 """ 

143 if size < 0: 

144 size = XPA_SZ_LINE - 5 

145 

146 if size > XPA_SZ_LINE: 

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

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

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

150 return 

151 

152 if self._bufsize: 

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

154 else: 

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

156 

157 self.flush(silent=silent) 

158 

159 def _getSize(self): 

160 """Get the current DS9 buffer size. 

161 

162 Returns 

163 ------- 

164 size : `int` 

165 Size of buffer. 

166 """ 

167 return self._bufsize[-1] 

168 

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

170 """Replace current DS9 command buffer size. 

171 

172 Parameters 

173 ---------- 

174 size : `int`, optional 

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

176 buffer. 

177 

178 Notes 

179 ----- 

180 See also `popSize`. 

181 """ 

182 self.flush(silent=True) 

183 self._bufsize.append(0) 

184 self.set(size, silent=True) 

185 

186 def popSize(self): 

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

188 

189 Notes 

190 ----- 

191 See also `pushSize`. 

192 """ 

193 self.flush(silent=True) 

194 

195 if len(self._bufsize) > 1: 

196 self._bufsize.pop() 

197 

198 def flush(self, silent=True): 

199 """Flush the pending commands. 

200 

201 Parameters 

202 ---------- 

203 silent : `bool`, optional 

204 Do not print error messages. 

205 """ 

206 ds9Cmd(flush=True, silent=silent) 

207 

208 cmdBuffer = Buffer(0) 

209 

210 

211def selectFrame(frame): 

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

213 

214 Parameters 

215 ---------- 

216 frame : `int` 

217 Frame number 

218 

219 Returns 

220 ------- 

221 frameString : `str` 

222 """ 

223 return f"frame {frame}" 

224 

225 

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

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

228 

229 Parameters 

230 ---------- 

231 cmd : `str`, optional 

232 Command to execute. 

233 trap : `bool`, optional 

234 Trap errors. 

235 flush : `bool`, optional 

236 Flush the output. 

237 silent : `bool`, optional 

238 Do not print trapped error messages. 

239 frame : `int`, optional 

240 Frame number on which to execute command. 

241 get : `bool`, optional 

242 Return xpa response. 

243 """ 

244 

245 global cmdBuffer 

246 if cmd: 

247 if frame is not None: 

248 cmd = f"{selectFrame(frame)};{cmd}" 

249 

250 if get: 

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

252 

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

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

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

256 ds9Cmd(flush=True, silent=silent) 

257 

258 cmdBuffer._commands += ";" + cmd 

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

260 

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

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

263 cmdBuffer._commands = "" 

264 cmdBuffer._lenCommands = 0 

265 else: 

266 return 

267 

268 cmd = cmd.rstrip() 

269 if not cmd: 

270 return 

271 

272 try: 

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

274 if ret: 

275 raise OSError(ret) 

276 except OSError as e: 

277 if not trap: 

278 raise Ds9Error(f"XPA: {e}, ({cmd})") 

279 elif not silent: 

280 print(f"Caught ds9 exception processing command \"{cmd}\": {e}", file=sys.stderr) 

281 

282 

283def initDS9(execDs9=True): 

284 """Initialize DS9. 

285 

286 Parameters 

287 ---------- 

288 execDs9 : `bool`, optional 

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

290 """ 

291 try: 

292 xpa.reset() 

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

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

295 

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

297 global needShow 

298 needShow = False 

299 try: 

300 if int(v0) == 5: 

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

302 except Exception: 

303 pass 

304 except Ds9Error as e: 

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

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

307 

308 if not execDs9: 

309 raise Ds9Error 

310 

311 if not shutil.which("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(f"ds9 doesn't appear to be running ({e}), I'll try to exec it for you") 

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(f"ds9 is unable to set transparency for individual maskplanes ({maskplane})", 

369 file=sys.stderr) 

370 return 

371 ds9Cmd(f"mask transparency {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.getBBox()) # 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(f"mask color {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 += f'regions command {{{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 += f'regions command {{{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(f"scale {algorithm}", frame=self.display.frame) 

566 

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

568 ds9Cmd(f"scale mode {min}") 

569 else: 

570 if unit: 

571 print(f"ds9: ignoring scale unit {unit}") 

572 

573 ds9Cmd(f"scale limits {min:g} {max:g}", 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 += f"zoom to {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 += f"pan to {colc + 1:g} {rowc + 1:g} physical; " 

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 isMask: 

658 xpa_cmd = f"xpaset {getXpaAccessPoint()} fits mask" 

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

660 # The following hack works around this. 

661 # This is a copy we're modifying 

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

663 data |= 0x8000 

664 else: 

665 xpa_cmd = f"xpaset {getXpaAccessPoint()} fits" 

666 

667 if haveGzip: 

668 xpa_cmd = "gzip | " + xpa_cmd 

669 

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

671 

672 ds9Cmd(flush=True, silent=True) 

673 

674 try: 

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

676 except Exception as e: 

677 try: 

678 pfd.close() 

679 except Exception: 

680 pass 

681 

682 raise e 

683 

684 try: 

685 pfd.close() 

686 except Exception: 

687 pass