Coverage for python / lsst / display / firefly / firefly.py: 12%
297 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-21 08:31 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-21 08:31 +0000
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#
23import logging
24from io import BytesIO
25from socket import gaierror
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
33from .footprints import createFootprintsTable
35try:
36 import firefly_client
37 _fireflyClient = None
38except ImportError as e:
39 raise RuntimeError(f"Cannot import firefly_client: {e}")
40from ws4py.client import HandshakeError
42_LOG = logging.getLogger(__name__)
45class FireflyError(Exception):
47 def __init__(self, str):
48 Exception.__init__(self, str)
51def firefly_version():
52 """Return the version of firefly_client in use, as a string"""
53 return firefly_client.__version__
56class DisplayImpl(virtualDevice.DisplayImpl):
57 """Device to talk to a firefly display"""
59 @staticmethod
60 def _scoped_mask_id(frame, plane_name):
61 """Build a server-side mask layer id that is unique per frame.
63 The Firefly server keys mask overlays by ``mask_id`` alone, so two
64 frames that both register a layer named e.g. ``"DETECTED"`` would
65 overwrite each other. Prefixing the plane name with the frame number
66 keeps each frame's layers independent while remaining human-readable
67 in Firefly's layer panel.
68 """
69 return f"f{frame}__{plane_name}"
71 @staticmethod
72 def __handleCallbacks(event):
73 if 'type' in event['data']:
74 if event['data']['type'] == 'AREA_SELECT':
75 _LOG.debug('*************area select')
76 pParams = {'URL': 'http://web.ipac.caltech.edu/staff/roby/demo/wise-m51-band2.fits',
77 'ColorTable': '9'}
78 plot_id = 3
79 _fireflyClient.show_fits_image(fileOnServer=None, plot_id=plot_id, additionalParams=pParams)
81 _LOG.debug("Callback event info: %s", event)
82 return
83 data = dict(_.split('=') for _ in event.get('data', {}).split('&'))
84 if data.get('type') == "POINT":
85 _LOG.debug("Event Received: %s", data.get('id'))
87 def __init__(self, display, verbose=False, url=None,
88 name=None, *args, **kwargs):
89 virtualDevice.DisplayImpl.__init__(self, display, verbose)
91 if self.verbose:
92 print("Opening firefly device %s" % (self.display.frame if self.display else "[None]"))
94 global _fireflyClient
95 if not _fireflyClient:
96 import os
97 start_tab = None
98 html_file = kwargs.get('html_file',
99 os.environ.get('FIREFLY_HTML', ''))
100 if url is None:
101 if (('fireflyLabExtension' in os.environ) and
102 ('fireflyURLLab' in os.environ)):
103 url = os.environ['fireflyURLLab']
104 start_tab = kwargs.get('start_tab', True)
105 start_browser_tab = kwargs.get('start_browser_tab', False)
106 if (name is None) and ('fireflyChannelLab' in os.environ):
107 name = os.environ['fireflyChannelLab']
108 elif 'FIREFLY_URL' in os.environ:
109 url = os.environ['FIREFLY_URL']
110 else:
111 raise RuntimeError('Cannot determine url from environment; you must pass url')
113 token = kwargs.get('token',
114 os.environ.get('ACCESS_TOKEN', None))
116 try:
117 if start_tab:
118 if verbose:
119 print('Starting Jupyterlab client')
120 _fireflyClient = firefly_client.FireflyClient.make_lab_client(
121 start_tab=True, start_browser_tab=start_browser_tab,
122 html_file=html_file, verbose=verbose,
123 token=token)
125 else:
126 if verbose:
127 print('Starting vanilla client')
128 _fireflyClient = firefly_client.FireflyClient.make_client(
129 url=url, html_file=html_file, launch_browser=True,
130 channel_override=name, verbose=verbose,
131 token=token)
133 except (HandshakeError, gaierror) as e:
134 raise RuntimeError(f"Unable to connect to {url or ''}: {e}")
136 try:
137 _fireflyClient.add_listener(self.__handleCallbacks)
138 except Exception as e:
139 raise RuntimeError("Cannot add listener. Browser must be connected"
140 f"to {_fireflyClient.get_firefly_url()}: {e}")
142 self._isBuffered = False
143 self._regions = []
144 self._regionLayerId = self._getRegionLayerId()
145 self._fireflyFitsID = None
146 self._fireflyMaskOnServer = None
147 self._client = _fireflyClient
148 self._channel = _fireflyClient.channel
149 self._url = _fireflyClient.get_firefly_url()
150 self._maskIds = []
151 self._maskDict = {}
152 self._maskPlaneColors = {}
153 self._maskTransparencies = {}
154 self._lastZoom = None
155 self._lastPan = None
156 self._lastStretch = None
158 def _getRegionLayerId(self):
159 return f"lsstRegions{self.display.frame}" if self.display else "None"
161 def _clearImage(self):
162 """Delete the current image in the Firefly viewer
163 """
164 self._client.dispatch(action_type='ImagePlotCntlr.deletePlotView',
165 payload=dict(plotId=str(self.display.frame)))
167 def _mtv(self, image, mask=None, wcs=None, title="", metadata=None):
168 """Display an Image and/or Mask on a Firefly display
169 """
170 if title == "":
171 title = str(self.display.frame)
172 if image:
173 if self.verbose:
174 print('displaying image')
175 self._erase()
177 with BytesIO() as fd:
178 afwDisplay.writeFitsImage(fd, image, wcs, title, metadata=metadata)
179 fd.seek(0, 0)
180 self._fireflyFitsID = _fireflyClient.upload_fits_data(fd)
182 try:
183 viewer_id = f'image-{_fireflyClient.render_tree_id}-{self.frame}'
184 except AttributeError:
185 viewer_id = f'image-{self.frame}'
186 extraParams = dict(Title=title,
187 MultiImageIdx=0,
188 PredefinedOverlayIds=' ',
189 viewer_id=viewer_id)
190 # Firefly's Javascript API requires a space for parameters;
191 # otherwise the parameter will be ignored
193 if self._lastZoom:
194 extraParams['InitZoomLevel'] = self._lastZoom
195 extraParams['ZoomType'] = 'LEVEL'
196 if self._lastPan:
197 extraParams['InitialCenterPosition'] = f'{self._lastPan[0]:.3f};{self._lastPan[1]:.3f};PIXEL'
198 if self._lastStretch:
199 extraParams['RangeValues'] = self._lastStretch
201 ret = _fireflyClient.show_fits_image(self._fireflyFitsID, plot_id=str(self.display.frame),
202 **extraParams)
204 if not ret["success"]:
205 raise RuntimeError("Display of image failed")
207 if mask:
208 if self.verbose:
209 print('displaying mask')
210 with BytesIO() as fdm:
211 afwDisplay.writeFitsImage(fdm, mask, wcs, title, metadata=metadata)
212 fdm.seek(0, 0)
213 self._fireflyMaskOnServer = _fireflyClient.upload_fits_data(fdm)
215 maskPlaneDict = mask.getMaskPlaneDict()
216 for k, v in maskPlaneDict.items():
217 self._maskDict[k] = v
218 self._maskPlaneColors[k] = self.display.getMaskPlaneColor(k)
219 usedPlanes = int(afwMath.makeStatistics(mask, afwMath.SUM).getValue())
220 for k in self._maskDict:
221 if (((1 << self._maskDict[k]) & usedPlanes) and
222 (k in self._maskPlaneColors) and
223 (self._maskPlaneColors[k] is not None) and
224 (self._maskPlaneColors[k].lower() != 'ignore')):
225 _fireflyClient.add_mask(bit_number=self._maskDict[k],
226 image_number=0,
227 plot_id=str(self.display.frame),
228 mask_id=self._scoped_mask_id(self.display.frame, k),
229 title=k + ' - bit %d'%self._maskDict[k],
230 color=self._maskPlaneColors[k],
231 file_on_server=self._fireflyMaskOnServer)
232 if k in self._maskTransparencies:
233 self._setMaskTransparency(self._maskTransparencies[k], k)
234 self._maskIds.append((self.display.frame, k))
236 def _remove_masks(self):
237 """Remove mask layers for the current frame.
239 Layers belonging to other frames are left in place; otherwise
240 loading a new image in one frame would clear the mask overlays
241 of every other displayed frame.
242 """
243 frame = self.display.frame
244 kept = []
245 for f, name in self._maskIds:
246 if f == frame:
247 _fireflyClient.remove_mask(plot_id=str(frame),
248 mask_id=self._scoped_mask_id(f, name))
249 else:
250 kept.append((f, name))
251 self._maskIds = kept
253 def _buffer(self, enable=True):
254 """!Enable or disable buffering of writes to the display
255 param enable True or False, as appropriate
256 """
257 self._isBuffered = enable
259 def _flush(self):
260 """!Flush any I/O buffers
261 """
262 if not self._regions:
263 return
265 if self.verbose:
266 print("Flushing %d regions" % len(self._regions))
267 print(self._regions)
269 self._regionLayerId = self._getRegionLayerId()
270 _fireflyClient.add_region_data(region_data=self._regions, plot_id=str(self.display.frame),
271 region_layer_id=self._regionLayerId)
272 self._regions = []
274 def _uploadTextData(self, regions):
275 self._regions += regions
277 if not self._isBuffered:
278 self._flush()
280 def _close(self):
281 """Called when the device is closed"""
282 if self.verbose:
283 print("Closing firefly device %s" % (self.display.frame if self.display else "[None]"))
284 if _fireflyClient is not None:
285 _fireflyClient.disconnect()
286 _fireflyClient.session.close()
288 def _dot(self, symb, c, r, size, ctype, fontFamily="helvetica", textAngle=None):
289 """Draw a symbol onto the specified DS9 frame at (col,row) = (c,r) [0-based coordinates]
290 Possible values are:
291 + Draw a +
292 x Draw an x
293 * Draw a *
294 o Draw a circle
295 @:Mxx,Mxy,Myy Draw an ellipse with moments (Mxx, Mxy, Myy) (argument size is ignored)
296 An object derived from afwGeom.ellipses.BaseCore Draw the ellipse (argument size is ignored)
297 Any other value is interpreted as a string to be drawn. Strings obey the fontFamily (which may be extended
298 with other characteristics, e.g. "times bold italic". Text will be drawn rotated by textAngle (textAngle
299 is ignored otherwise).
301 N.b. objects derived from BaseCore include Axes and Quadrupole.
302 """
303 self._uploadTextData(ds9Regions.dot(symb, c, r, size, ctype, fontFamily, textAngle))
305 def _drawLines(self, points, ctype):
306 """Connect the points, a list of (col,row)
307 Ctype is the name of a colour (e.g. 'red')"""
309 self._uploadTextData(ds9Regions.drawLines(points, ctype))
311 def _erase(self):
312 """Erase all overlays on the image"""
313 if self.verbose:
314 print(f'region layer id is {self._regionLayerId}')
315 if self._regionLayerId:
316 _fireflyClient.delete_region_layer(self._regionLayerId, plot_id=str(self.display.frame))
318 def _setCallback(self, what, func):
319 if func != interface.noop_callback:
320 try:
321 status = _fireflyClient.add_extension('POINT' if False else 'AREA_SELECT', title=what,
322 plot_id=str(self.display.frame),
323 extension_id=what)
324 if not status['success']:
325 pass
326 except Exception as e:
327 raise RuntimeError("Cannot set callback. Browser must be (re)opened "
328 f"to {_fireflyClient.url_bw}{_fireflyClient.channel} : {e}")
330 def _getEvent(self):
331 """Return an event generated by a keypress or mouse click
332 """
333 ev = interface.Event("q")
335 if self.verbose:
336 print(f"virtual[{self.display.frame}]._getEvent() -> {ev}")
338 return ev
339 #
340 # Set gray scale
341 #
343 def _scale(self, algorithm, min, max, unit=None, *args, **kwargs):
344 """Scale the image stretch and limits
346 Parameters:
347 -----------
348 algorithm : `str`
349 stretch algorithm, e.g. 'linear', 'log', 'loglog', 'equal', 'squared',
350 'sqrt', 'asinh', powerlaw_gamma'
351 min : `float` or `str`
352 lower limit, or 'minmax' for full range, or 'zscale'
353 max : `float` or `str`
354 upper limit; overrriden if min is 'minmax' or 'zscale'
355 unit : `str`
356 unit for min and max. 'percent', 'absolute', 'sigma'.
357 if not specified, min and max are presumed to be in 'absolute' units.
359 *args, **kwargs : additional position and keyword arguments.
360 The options are shown below:
362 **Q** : `float`, optional
363 The asinh softening parameter for asinh stretch.
364 Use Q=0 for linear stretch, increase Q to make brighter features visible.
365 When not specified or None, Q is calculated by Firefly to use full color range.
366 **gamma**
367 The gamma value for power law gamma stretch (default 2.0)
368 **zscale_contrast** : `int`, optional
369 Contrast parameter in percent for zscale algorithm (default 25)
370 **zscale_samples** : `int`, optional
371 Number of samples for zscale algorithm (default 600)
372 **zscale_samples_perline** : `int`, optional
373 Number of samples per line for zscale algorithm (default 120)
374 """
375 stretch_algorithms = ('linear', 'log', 'loglog', 'equal', 'squared', 'sqrt',
376 'asinh', 'powerlaw_gamma')
377 interval_methods = ('percent', 'maxmin', 'absolute', 'zscale', 'sigma')
378 #
379 #
380 # Normalise algorithm's case
381 #
382 if algorithm:
383 algorithm = dict((a.lower(), a) for a in stretch_algorithms).get(algorithm.lower(), algorithm)
385 if algorithm not in stretch_algorithms:
386 raise FireflyError(
387 'Algorithm {} is invalid; please choose one of "{}"'.format(
388 algorithm, '", "'.join(stretch_algorithms)
389 )
390 )
391 self._stretchAlgorithm = algorithm
392 else:
393 algorithm = 'linear'
395 # Translate parameters for asinh and powerlaw_gamma stretches
396 if 'Q' in kwargs:
397 kwargs['asinh_q_value'] = kwargs['Q']
398 del kwargs['Q']
400 if 'gamma' in kwargs:
401 kwargs['gamma_value'] = kwargs['gamma']
402 del kwargs['gamma']
404 if min == 'minmax':
405 interval_type = 'percent'
406 unit = 'percent'
407 min, max = 0, 100
408 elif min == 'zscale':
409 interval_type = 'zscale'
410 else:
411 interval_type = None
413 if not unit:
414 unit = 'absolute'
416 units = ('percent', 'absolute', 'sigma')
417 if unit not in units:
418 raise FireflyError(
419 'Unit {} is invalid; please choose one of "{}"'.format(unit, '", "'.join(units))
420 )
422 if unit == 'sigma':
423 interval_type = 'sigma'
424 elif unit == 'absolute' and interval_type is None:
425 interval_type = 'absolute'
426 elif unit == 'percent':
427 interval_type = 'percent'
429 self._stretchMin = min
430 self._stretchMax = max
431 self._stretchUnit = unit
433 if interval_type not in interval_methods:
434 raise FireflyError(f'Interval method {interval_type} is invalid')
436 rval = {}
437 if interval_type != 'zscale':
438 rval = _fireflyClient.set_stretch(str(self.display.frame), stype=interval_type,
439 algorithm=algorithm, lower_value=min,
440 upper_value=max, **kwargs)
441 else:
442 if 'zscale_contrast' not in kwargs:
443 kwargs['zscale_contrast'] = 25
444 if 'zscale_samples' not in kwargs:
445 kwargs['zscale_samples'] = 600
446 if 'zscale_samples_perline' not in kwargs:
447 kwargs['zscale_samples_perline'] = 120
448 rval = _fireflyClient.set_stretch(str(self.display.frame), stype='zscale',
449 algorithm=algorithm, **kwargs)
451 if 'rv_string' in rval:
452 self._lastStretch = rval['rv_string']
454 def _setMaskTransparency(self, transparency, maskName):
455 """Specify mask transparency (percent); or None to not set it when loading masks.
457 Operates on the current frame. ``_maskIds`` holds ``(frame, name)``
458 tuples (recorded by ``_mtv``); we filter those to the current frame.
459 Plane names from ``_defaultMaskPlaneColor`` are also included, scoped
460 to the current frame.
461 """
462 frame = self.display.frame
463 if maskName is not None:
464 names = [maskName]
465 else:
466 names = {name for f, name in self._maskIds if f == frame}
467 names.update(self.display._defaultMaskPlaneColor.keys())
468 for k in names:
469 self._maskTransparencies[k] = transparency
470 _fireflyClient.dispatch(action_type='ImagePlotCntlr.overlayPlotChangeAttributes',
471 payload={'plotId': str(frame),
472 'imageOverlayId': self._scoped_mask_id(frame, k),
473 'attributes': {'opacity': 1.0 - transparency/100.},
474 'doReplot': False})
476 def _getMaskTransparency(self, maskName):
477 """Return the current mask's transparency"""
478 transparency = None
479 if maskName in self._maskTransparencies:
480 transparency = self._maskTransparencies[maskName]
481 return transparency
483 def _setMaskPlaneColor(self, maskName, color):
484 """Specify mask color for the current frame.
485 """
486 frame = self.display.frame
487 scoped_id = self._scoped_mask_id(frame, maskName)
488 _fireflyClient.remove_mask(plot_id=str(frame), mask_id=scoped_id)
489 self._maskPlaneColors[maskName] = color
490 if (color.lower() != 'ignore'):
491 _fireflyClient.add_mask(bit_number=self._maskDict[maskName],
492 image_number=1,
493 plot_id=str(frame),
494 mask_id=scoped_id,
495 color=self.display.getMaskPlaneColor(maskName),
496 file_on_server=self._fireflyFitsID)
498 def _show(self):
499 """Show the requested window"""
500 if self._client.render_tree_id is not None:
501 # we are using Jupyterlab
502 self._client.dispatch(self._client.ACTION_DICT['StartLabWindow'],
503 {})
504 else:
505 localbrowser, url = _fireflyClient.launch_browser(verbose=self.verbose)
506 if not localbrowser and not self.verbose:
507 _fireflyClient.display_url()
509 #
510 # Zoom and Pan
511 #
513 def _zoom(self, zoomfac):
514 """Zoom display by specified amount
516 Parameters:
517 -----------
518 zoomfac: `float`
519 zoom level in screen pixels per image pixel
520 """
521 self._lastZoom = zoomfac
522 _fireflyClient.set_zoom(plot_id=str(self.display.frame), factor=zoomfac)
524 def _pan(self, colc, rowc):
525 """Pan to specified pixel coordinates
527 Parameters:
528 -----------
529 colc, rowc : `float`
530 column and row in units of pixels (zero-based convention,
531 with the xy0 already subtracted off)
532 """
533 self._lastPan = [colc+0.5, rowc+0.5] # saved for future use in _mtv
534 # Firefly's internal convention is first pixel is (0.5, 0.5)
535 _fireflyClient.set_pan(plot_id=str(self.display.frame), x=colc, y=rowc)
537 # Extensions to the API that are specific to using the Firefly backend
539 def getClient(self):
540 """Get the instance of FireflyClient for this display
542 Returns:
543 --------
544 `firefly_client.FireflyClient`
545 Instance of FireflyClient used by this display
546 """
547 return self._client
549 def clearViewer(self):
550 """Reinitialize the viewer
551 """
552 self._client.reinit_viewer()
554 def resetLayout(self):
555 """Reset the layout of the Firefly Slate browser
557 Clears the display and adds Slate cells to display image in upper left,
558 plot area in upper right, and plots stretch across the bottom
559 """
560 self.clearViewer()
561 try:
562 tables_cell_id = 'tables-' + str(_fireflyClient.render_tree_id)
563 except AttributeError:
564 tables_cell_id = 'tables'
565 self._client.add_cell(row=2, col=0, width=4, height=2, element_type='tables',
566 cell_id=tables_cell_id)
567 try:
568 image_cell_id = ('image-' + str(_fireflyClient.render_tree_id) + '-' +
569 str(self.frame))
570 except AttributeError:
571 image_cell_id = 'image-' + str(self.frame)
572 self._client.add_cell(row=0, col=0, width=2, height=3, element_type='images',
573 cell_id=image_cell_id)
574 try:
575 plots_cell_id = 'plots-' + str(_fireflyClient.render_tree_id)
576 except AttributeError:
577 plots_cell_id = 'plots'
578 self._client.add_cell(row=0, col=2, width=2, height=3, element_type='xyPlots',
579 cell_id=plots_cell_id)
581 def overlayFootprints(self, catalog, color='rgba(74,144,226,0.60)',
582 highlightColor='cyan', selectColor='orange',
583 style='fill', layerString='detection footprints ',
584 titleString='catalog footprints '):
585 """Overlay outlines of footprints from a catalog
587 Overlay outlines of LSST footprints from the input catalog. The colors
588 and style can be specified as parameters, and the base color and style
589 can be changed in the Firefly browser user interface.
591 Parameters:
592 -----------
593 catalog : `lsst.afw.table.SourceCatalog`
594 Source catalog from which to display footprints.
595 color : `str`
596 Color for footprints overlay. Colors can be specified as a name
597 like 'cyan' or afwDisplay.RED; as an rgb value such as
598 'rgb(80,100,220)'; or as rgb plus alpha (transparency) such
599 as 'rgba('74,144,226,0.60)'.
600 highlightColor : `str`
601 Color for highlighted footprints
602 selectColor : `str`
603 Color for selected footprints
604 style : {'fill', 'outline'}
605 Style of footprints display, filled or outline
606 insertColumn : `int`
607 Column at which to insert the "family_id" and "category" columns
608 layerString: `str`
609 Name of footprints layer string, to concatenate with the frame
610 Re-using the layer_string will overwrite the previous table and
611 footprints
612 titleString: `str`
613 Title of catalog, to concatenate with the frame
614 """
615 footprintTable = createFootprintsTable(catalog)
616 with BytesIO() as fd:
617 footprintTable.to_xml(fd)
618 tableval = self._client.upload_data(fd, 'UNKNOWN')
619 self._client.overlay_footprints(footprint_file=tableval,
620 title=titleString + str(self.display.frame),
621 footprint_layer_id=layerString + str(self.display.frame),
622 plot_id=str(self.display.frame),
623 color=color,
624 highlightColor=highlightColor,
625 selectColor=selectColor,
626 style=style)
628 def alignImages(self, match_type="Standard", lock_match=True):
629 """Align and optionally lock the orientation of the images being
630 displayed.
632 See the Firefly native docs for additional kwargs reference:
633 https://caltech-ipac.github.io/firefly_client/api/firefly_client.FireflyClient.html#firefly_client.FireflyClient.align_images
635 Parameters
636 ----------
637 match_type : `str`, optional
638 Match type to use to align the images: align by WCS (‘Standard’),
639 by target (‘Target’), by pixel prigins (‘Pixel’), and by pixel at
640 image centers (‘PixelCenter’).
641 lock_match : `bool`, optional
642 Whether to lock the alignment. Panning/zooming in one image will
643 preserve the alignment in other images.
645 Returns
646 -------
647 out : `dict`
648 Status of the request.
650 Raises
651 ------
652 ValueError
653 Raised if match_type is not one of the allowed values.
654 """
655 types = {"Standard", "Target", "Pixel", "PixelCenter"}
656 if match_type not in types:
657 raise ValueError(f"match_type={match_type} not allowed from expected types: {types}.")
659 return self._client.align_images(match_type=match_type, lock_match=lock_match)