Coverage for python/lsst/display/firefly/firefly.py: 11%
283 statements
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-03 02:55 -0700
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-03 02:55 -0700
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#
23from io import BytesIO
24from socket import gaierror
25import tempfile
27import lsst.afw.display.interface as interface
28import lsst.afw.display.virtualDevice as virtualDevice
29import lsst.afw.display.ds9Regions as ds9Regions
30import lsst.afw.display as afwDisplay
31import lsst.afw.math as afwMath
32import lsst.log
34from .footprints import createFootprintsTable
36try:
37 import firefly_client
38 _fireflyClient = None
39except ImportError as e:
40 raise RuntimeError("Cannot import firefly_client: %s" % (e))
41from ws4py.client import HandshakeError
44class FireflyError(Exception):
46 def __init__(self, str):
47 Exception.__init__(self, str)
50def firefly_version():
51 """Return the version of firefly_client in use, as a string"""
52 return(firefly_client.__version__)
55class DisplayImpl(virtualDevice.DisplayImpl):
56 """Device to talk to a firefly display"""
58 @staticmethod
59 def __handleCallbacks(event):
60 if 'type' in event['data']:
61 if event['data']['type'] == 'AREA_SELECT':
62 lsst.log.debug('*************area select')
63 pParams = {'URL': 'http://web.ipac.caltech.edu/staff/roby/demo/wise-m51-band2.fits',
64 'ColorTable': '9'}
65 plot_id = 3
66 global _fireflyClient
67 _fireflyClient.show_fits(fileOnServer=None, plot_id=plot_id, additionalParams=pParams)
69 lsst.log.debug("Callback event info: {}".format(event))
70 return
71 data = dict((_.split('=') for _ in event.get('data', {}).split('&')))
72 if data.get('type') == "POINT":
73 lsst.log.debug("Event Received: %s" % data.get('id'))
75 def __init__(self, display, verbose=False, url=None,
76 name=None, *args, **kwargs):
77 virtualDevice.DisplayImpl.__init__(self, display, verbose)
79 if self.verbose:
80 print("Opening firefly device %s" % (self.display.frame if self.display else "[None]"))
82 global _fireflyClient
83 if not _fireflyClient:
84 import os
85 start_tab = None
86 html_file = kwargs.get('html_file',
87 os.environ.get('FIREFLY_HTML', 'slate.html'))
88 if url is None:
89 if (('fireflyLabExtension' in os.environ) and
90 ('fireflyURLLab' in os.environ)):
91 url = os.environ['fireflyURLLab']
92 start_tab = kwargs.get('start_tab', True)
93 start_browser_tab = kwargs.get('start_browser_tab', False)
94 if (name is None) and ('fireflyChannelLab' in os.environ):
95 name = os.environ['fireflyChannelLab']
96 elif 'FIREFLY_URL' in os.environ:
97 url = os.environ['FIREFLY_URL']
98 else:
99 raise RuntimeError('Cannot determine url from environment; you must pass url')
101 token = kwargs.get('token',
102 os.environ.get('ACCESS_TOKEN', None))
104 try:
105 if start_tab:
106 if verbose:
107 print('Starting Jupyterlab client')
108 _fireflyClient = firefly_client.FireflyClient.make_lab_client(
109 start_tab=True, start_browser_tab=start_browser_tab,
110 html_file=kwargs.get('html_file'), verbose=verbose,
111 token=token)
113 else:
114 if verbose:
115 print('Starting vanilla client')
116 _fireflyClient = firefly_client.FireflyClient.make_client(
117 url=url, html_file=html_file, launch_browser=True,
118 channel_override=name, verbose=verbose,
119 token=token)
121 except (HandshakeError, gaierror) as e:
122 raise RuntimeError("Unable to connect to %s: %s" % (url or '', e))
124 try:
125 _fireflyClient.add_listener(self.__handleCallbacks)
126 except Exception as e:
127 raise RuntimeError("Cannot add listener. Browser must be connected" +
128 "to %s: %s" %
129 (_fireflyClient.get_firefly_url(), e))
131 self._isBuffered = False
132 self._regions = []
133 self._regionLayerId = self._getRegionLayerId()
134 self._fireflyFitsID = None
135 self._fireflyMaskOnServer = None
136 self._client = _fireflyClient
137 self._channel = _fireflyClient.channel
138 self._url = _fireflyClient.get_firefly_url()
139 self._maskIds = []
140 self._maskDict = {}
141 self._maskPlaneColors = {}
142 self._maskTransparencies = {}
143 self._lastZoom = None
144 self._lastPan = None
145 self._lastStretch = None
147 def _getRegionLayerId(self):
148 return "lsstRegions%s" % self.display.frame if self.display else "None"
150 def _clearImage(self):
151 """Delete the current image in the Firefly viewer
152 """
153 self._client.dispatch(action_type='ImagePlotCntlr.deletePlotView',
154 payload=dict(plotId=str(self.display.frame)))
156 def _mtv(self, image, mask=None, wcs=None, title=""):
157 """Display an Image and/or Mask on a Firefly display
158 """
159 if title == "":
160 title = str(self.display.frame)
161 if image:
162 if self.verbose:
163 print('displaying image')
164 self._erase()
166 with tempfile.NamedTemporaryFile() as fd:
167 afwDisplay.writeFitsImage(fd.name, image, wcs, title)
168 fd.flush()
169 fd.seek(0, 0)
170 self._fireflyFitsID = _fireflyClient.upload_data(fd, 'FITS')
172 try:
173 viewer_id = ('image-' + str(_fireflyClient.render_tree_id) + '-' +
174 str(self.frame))
175 except AttributeError:
176 viewer_id = 'image-' + str(self.frame)
177 extraParams = dict(Title=title,
178 MultiImageIdx=0,
179 PredefinedOverlayIds=' ',
180 viewer_id=viewer_id)
181 # Firefly's Javascript API requires a space for parameters;
182 # otherwise the parameter will be ignored
184 if self._lastZoom:
185 extraParams['InitZoomLevel'] = self._lastZoom
186 extraParams['ZoomType'] = 'LEVEL'
187 if self._lastPan:
188 extraParams['InitialCenterPosition'] = '{0:.3f};{1:.3f};PIXEL'.format(
189 self._lastPan[0], self._lastPan[1])
190 if self._lastStretch:
191 extraParams['RangeValues'] = self._lastStretch
193 ret = _fireflyClient.show_fits(self._fireflyFitsID, plot_id=str(self.display.frame),
194 **extraParams)
196 if not ret["success"]:
197 raise RuntimeError("Display of image failed")
199 if mask:
200 if self.verbose:
201 print('displaying mask')
202 with tempfile.NamedTemporaryFile() as fdm:
203 afwDisplay.writeFitsImage(fdm.name, mask, wcs, title)
204 fdm.flush()
205 fdm.seek(0, 0)
206 self._fireflyMaskOnServer = _fireflyClient.upload_data(fdm, 'FITS')
208 maskPlaneDict = mask.getMaskPlaneDict()
209 for k, v in maskPlaneDict.items():
210 self._maskDict[k] = v
211 self._maskPlaneColors[k] = self.display.getMaskPlaneColor(k)
212 usedPlanes = int(afwMath.makeStatistics(mask, afwMath.SUM).getValue())
213 for k in self._maskDict:
214 if (((1 << self._maskDict[k]) & usedPlanes) and
215 (k in self._maskPlaneColors) and
216 (self._maskPlaneColors[k] is not None) and
217 (self._maskPlaneColors[k].lower() != 'ignore')):
218 _fireflyClient.add_mask(bit_number=self._maskDict[k],
219 image_number=0,
220 plot_id=str(self.display.frame),
221 mask_id=k,
222 title=k + ' - bit %d'%self._maskDict[k],
223 color=self._maskPlaneColors[k],
224 file_on_server=self._fireflyMaskOnServer)
225 if k in self._maskTransparencies:
226 self._setMaskTransparency(self._maskTransparencies[k], k)
227 self._maskIds.append(k)
229 def _remove_masks(self):
230 """Remove mask layers"""
231 for k in self._maskIds:
232 _fireflyClient.remove_mask(plot_id=str(self.display.frame), mask_id=k)
233 self._maskIds = []
235 def _buffer(self, enable=True):
236 """!Enable or disable buffering of writes to the display
237 param enable True or False, as appropriate
238 """
239 self._isBuffered = enable
241 def _flush(self):
242 """!Flush any I/O buffers
243 """
244 if not self._regions:
245 return
247 if self.verbose:
248 print("Flushing %d regions" % len(self._regions))
249 print(self._regions)
251 self._regionLayerId = self._getRegionLayerId()
252 _fireflyClient.add_region_data(region_data=self._regions, plot_id=str(self.display.frame),
253 region_layer_id=self._regionLayerId)
254 self._regions = []
256 def _uploadTextData(self, regions):
257 self._regions += regions
259 if not self._isBuffered:
260 self._flush()
262 def _close(self):
263 """Called when the device is closed"""
264 if self.verbose:
265 print("Closing firefly device %s" % (self.display.frame if self.display else "[None]"))
266 if _fireflyClient is not None:
267 _fireflyClient.disconnect()
268 _fireflyClient.session.close()
270 def _dot(self, symb, c, r, size, ctype, fontFamily="helvetica", textAngle=None):
271 """Draw a symbol onto the specified DS9 frame at (col,row) = (c,r) [0-based coordinates]
272 Possible values are:
273 + Draw a +
274 x Draw an x
275 * Draw a *
276 o Draw a circle
277 @:Mxx,Mxy,Myy Draw an ellipse with moments (Mxx, Mxy, Myy) (argument size is ignored)
278 An object derived from afwGeom.ellipses.BaseCore Draw the ellipse (argument size is ignored)
279 Any other value is interpreted as a string to be drawn. Strings obey the fontFamily (which may be extended
280 with other characteristics, e.g. "times bold italic". Text will be drawn rotated by textAngle (textAngle
281 is ignored otherwise).
283 N.b. objects derived from BaseCore include Axes and Quadrupole.
284 """
285 self._uploadTextData(ds9Regions.dot(symb, c, r, size, ctype, fontFamily, textAngle))
287 def _drawLines(self, points, ctype):
288 """Connect the points, a list of (col,row)
289 Ctype is the name of a colour (e.g. 'red')"""
291 self._uploadTextData(ds9Regions.drawLines(points, ctype))
293 def _erase(self):
294 """Erase all overlays on the image"""
295 if self.verbose:
296 print('region layer id is {}'.format(self._regionLayerId))
297 if self._regionLayerId:
298 _fireflyClient.delete_region_layer(self._regionLayerId, plot_id=str(self.display.frame))
300 def _setCallback(self, what, func):
301 if func != interface.noop_callback:
302 try:
303 status = _fireflyClient.add_extension('POINT' if False else 'AREA_SELECT', title=what,
304 plot_id=str(self.display.frame),
305 extension_id=what)
306 if not status['success']:
307 pass
308 except Exception as e:
309 raise RuntimeError("Cannot set callback. Browser must be (re)opened " +
310 "to %s%s : %s" %
311 (_fireflyClient.url_bw,
312 _fireflyClient.channel, e))
314 def _getEvent(self):
315 """Return an event generated by a keypress or mouse click
316 """
317 ev = interface.Event("q")
319 if self.verbose:
320 print("virtual[%s]._getEvent() -> %s" % (self.display.frame, ev))
322 return ev
323 #
324 # Set gray scale
325 #
327 def _scale(self, algorithm, min, max, unit=None, *args, **kwargs):
328 """Scale the image stretch and limits
330 Parameters:
331 -----------
332 algorithm : `str`
333 stretch algorithm, e.g. 'linear', 'log', 'loglog', 'equal', 'squared',
334 'sqrt', 'asinh', powerlaw_gamma'
335 min : `float` or `str`
336 lower limit, or 'minmax' for full range, or 'zscale'
337 max : `float` or `str`
338 upper limit; overrriden if min is 'minmax' or 'zscale'
339 unit : `str`
340 unit for min and max. 'percent', 'absolute', 'sigma'.
341 if not specified, min and max are presumed to be in 'absolute' units.
343 *args, **kwargs : additional position and keyword arguments.
344 The options are shown below:
346 **Q** : `float`, optional
347 The asinh softening parameter for asinh stretch.
348 Use Q=0 for linear stretch, increase Q to make brighter features visible.
349 When not specified or None, Q is calculated by Firefly to use full color range.
350 **gamma**
351 The gamma value for power law gamma stretch (default 2.0)
352 **zscale_contrast** : `int`, optional
353 Contrast parameter in percent for zscale algorithm (default 25)
354 **zscale_samples** : `int`, optional
355 Number of samples for zscale algorithm (default 600)
356 **zscale_samples_perline** : `int`, optional
357 Number of samples per line for zscale algorithm (default 120)
358 """
359 stretch_algorithms = ('linear', 'log', 'loglog', 'equal', 'squared', 'sqrt',
360 'asinh', 'powerlaw_gamma')
361 interval_methods = ('percent', 'maxmin', 'absolute', 'zscale', 'sigma')
362 #
363 #
364 # Normalise algorithm's case
365 #
366 if algorithm:
367 algorithm = dict((a.lower(), a) for a in stretch_algorithms).get(algorithm.lower(), algorithm)
369 if algorithm not in stretch_algorithms:
370 raise FireflyError('Algorithm %s is invalid; please choose one of "%s"' %
371 (algorithm, '", "'.join(stretch_algorithms)))
372 self._stretchAlgorithm = algorithm
373 else:
374 algorithm = 'linear'
376 # Translate parameters for asinh and powerlaw_gamma stretches
377 if 'Q' in kwargs:
378 kwargs['asinh_q_value'] = kwargs['Q']
379 del kwargs['Q']
381 if 'gamma' in kwargs:
382 kwargs['gamma_value'] = kwargs['gamma']
383 del kwargs['gamma']
385 if min == 'minmax':
386 interval_type = 'percent'
387 unit = 'percent'
388 min, max = 0, 100
389 elif min == 'zscale':
390 interval_type = 'zscale'
391 else:
392 interval_type = None
394 if not unit:
395 unit = 'absolute'
397 units = ('percent', 'absolute', 'sigma')
398 if unit not in units:
399 raise FireflyError('Unit %s is invalid; please choose one of "%s"' % (unit, '", "'.join(units)))
401 if unit == 'sigma':
402 interval_type = 'sigma'
403 elif unit == 'absolute' and interval_type is None:
404 interval_type = 'absolute'
405 elif unit == 'percent':
406 interval_type = 'percent'
408 self._stretchMin = min
409 self._stretchMax = max
410 self._stretchUnit = unit
412 if interval_type not in interval_methods:
413 raise FireflyError('Interval method %s is invalid' % interval_type)
415 rval = {}
416 if interval_type != 'zscale':
417 rval = _fireflyClient.set_stretch(str(self.display.frame), stype=interval_type,
418 algorithm=algorithm, lower_value=min,
419 upper_value=max, **kwargs)
420 else:
421 if 'zscale_contrast' not in kwargs:
422 kwargs['zscale_contrast'] = 25
423 if 'zscale_samples' not in kwargs:
424 kwargs['zscale_samples'] = 600
425 if 'zscale_samples_perline' not in kwargs:
426 kwargs['zscale_samples_perline'] = 120
427 rval = _fireflyClient.set_stretch(str(self.display.frame), stype='zscale',
428 algorithm=algorithm, **kwargs)
430 if 'rv_string' in rval:
431 self._lastStretch = rval['rv_string']
433 def _setMaskTransparency(self, transparency, maskName):
434 """Specify mask transparency (percent); or None to not set it when loading masks"""
435 if maskName is not None:
436 masklist = [maskName]
437 else:
438 masklist = set(self._maskIds + list(self.display._defaultMaskPlaneColor.keys()))
439 for k in masklist:
440 self._maskTransparencies[k] = transparency
441 _fireflyClient.dispatch(action_type='ImagePlotCntlr.overlayPlotChangeAttributes',
442 payload={'plotId': str(self.display.frame),
443 'imageOverlayId': k,
444 'attributes': {'opacity': 1.0 - transparency/100.},
445 'doReplot': False})
447 def _getMaskTransparency(self, maskName):
448 """Return the current mask's transparency"""
449 transparency = None
450 if maskName in self._maskTransparencies:
451 transparency = self._maskTransparencies[maskName]
452 return transparency
454 def _setMaskPlaneColor(self, maskName, color):
455 """Specify mask color """
456 _fireflyClient.remove_mask(plot_id=str(self.display.frame),
457 mask_id=maskName)
458 self._maskPlaneColors[maskName] = color
459 if (color.lower() != 'ignore'):
460 _fireflyClient.add_mask(bit_number=self._maskDict[maskName],
461 image_number=1,
462 plot_id=str(self.display.frame),
463 mask_id=maskName,
464 color=self.display.getMaskPlaneColor(maskName),
465 file_on_server=self._fireflyFitsID)
467 def _show(self):
468 """Show the requested window"""
469 if self._client.render_tree_id is not None:
470 # we are using Jupyterlab
471 self._client.dispatch(self._client.ACTION_DICT['StartLabWindow'],
472 {})
473 else:
474 localbrowser, url = _fireflyClient.launch_browser(verbose=self.verbose)
475 if not localbrowser and not self.verbose:
476 _fireflyClient.display_url()
478 #
479 # Zoom and Pan
480 #
482 def _zoom(self, zoomfac):
483 """Zoom display by specified amount
485 Parameters:
486 -----------
487 zoomfac: `float`
488 zoom level in screen pixels per image pixel
489 """
490 self._lastZoom = zoomfac
491 _fireflyClient.set_zoom(plot_id=str(self.display.frame), factor=zoomfac)
493 def _pan(self, colc, rowc):
494 """Pan to specified pixel coordinates
496 Parameters:
497 -----------
498 colc, rowc : `float`
499 column and row in units of pixels (zero-based convention,
500 with the xy0 already subtracted off)
501 """
502 self._lastPan = [colc+0.5, rowc+0.5] # saved for future use in _mtv
503 # Firefly's internal convention is first pixel is (0.5, 0.5)
504 _fireflyClient.set_pan(plot_id=str(self.display.frame), x=colc, y=rowc)
506 # Extensions to the API that are specific to using the Firefly backend
508 def getClient(self):
509 """Get the instance of FireflyClient for this display
511 Returns:
512 --------
513 `firefly_client.FireflyClient`
514 Instance of FireflyClient used by this display
515 """
516 return self._client
518 def clearViewer(self):
519 """Reinitialize the viewer
520 """
521 self._client.reinit_viewer()
523 def resetLayout(self):
524 """Reset the layout of the Firefly Slate browser
526 Clears the display and adds Slate cells to display image in upper left,
527 plot area in upper right, and plots stretch across the bottom
528 """
529 self.clearViewer()
530 try:
531 tables_cell_id = 'tables-' + str(_fireflyClient.render_tree_id)
532 except AttributeError:
533 tables_cell_id = 'tables'
534 self._client.add_cell(row=2, col=0, width=4, height=2, element_type='tables',
535 cell_id=tables_cell_id)
536 try:
537 image_cell_id = ('image-' + str(_fireflyClient.render_tree_id) + '-' +
538 str(self.frame))
539 except AttributeError:
540 image_cell_id = 'image-' + str(self.frame)
541 self._client.add_cell(row=0, col=0, width=2, height=3, element_type='images',
542 cell_id=image_cell_id)
543 try:
544 plots_cell_id = 'plots-' + str(_fireflyClient.render_tree_id)
545 except AttributeError:
546 plots_cell_id = 'plots'
547 self._client.add_cell(row=0, col=2, width=2, height=3, element_type='xyPlots',
548 cell_id=plots_cell_id)
550 def overlayFootprints(self, catalog, color='rgba(74,144,226,0.60)',
551 highlightColor='cyan', selectColor='orange',
552 style='fill', layerString='detection footprints ',
553 titleString='catalog footprints '):
554 """Overlay outlines of footprints from a catalog
556 Overlay outlines of LSST footprints from the input catalog. The colors
557 and style can be specified as parameters, and the base color and style
558 can be changed in the Firefly browser user interface.
560 Parameters:
561 -----------
562 catalog : `lsst.afw.table.SourceCatalog`
563 Source catalog from which to display footprints.
564 color : `str`
565 Color for footprints overlay. Colors can be specified as a name
566 like 'cyan' or afwDisplay.RED; as an rgb value such as
567 'rgb(80,100,220)'; or as rgb plus alpha (transparency) such
568 as 'rgba('74,144,226,0.60)'.
569 highlightColor : `str`
570 Color for highlighted footprints
571 selectColor : `str`
572 Color for selected footprints
573 style : {'fill', 'outline'}
574 Style of footprints display, filled or outline
575 insertColumn : `int`
576 Column at which to insert the "family_id" and "category" columns
577 layerString: `str`
578 Name of footprints layer string, to concatenate with the frame
579 Re-using the layer_string will overwrite the previous table and
580 footprints
581 titleString: `str`
582 Title of catalog, to concatenate with the frame
583 """
584 footprintTable = createFootprintsTable(catalog)
585 with BytesIO() as fd:
586 footprintTable.to_xml(fd)
587 tableval = self._client.upload_data(fd, 'UNKNOWN')
588 self._client.overlay_footprints(footprint_file=tableval,
589 title=titleString + str(self.display.frame),
590 footprint_layer_id=layerString + str(self.display.frame),
591 plot_id=str(self.display.frame),
592 color=color,
593 highlightColor=highlightColor,
594 selectColor=selectColor,
595 style=style)