Source code for anatomist.notebook.api


"""
Embed an Anatomist 3D view in a jupyter notebook widget

To work this notebook widget needs:

* to install ipycanvas, ipyevents, ipywidgets, numpy, anatomist, jupyter notebook
* to register jupyter notebook extensions (may require sudo permissions if
  jupyter is installed system-wide)::

    jupyter nbextension enable --py widgetsnbextension
    jupyter nbextension enable --py ipyevents
    jupyter nbextension enable --py ipycanvas

There are two ways to use it, in a notebook cell. The first is an "integrated"
variant of the Anatomist application which redirects all its views to notebook
canvases, the second is a "by window" method.

Integrated Anatomist::

    import anatomist.notebook as ana

    a = ana.Anatomist()
    w = a.createWindow('3D')
    mesh = a.loadObject('/home/dr144257/data/ra_head.mesh')
    w.addObjects(mesh)

By window::

    import anatomist.headless as ana
    # need to be instantiated before Qt implementations are loaded
    a = ana.HeadlessAnatomist()

    from anatomist.notebook.api import AnatomistInteractiveWidget

    w = a.createWindow('3D')
    mesh = a.loadObject('/home/riviere/data/ra_head.mesh')
    w.addObjects(mesh)

    canvas = AnatomistInteractiveWidget(w)
    display(canvas)

Note that the integrated anatomist.notebook implementation is a headless implementation, and wraps Anatomist windows widget as a single canvas. It is able to render Qt interfaces in a web browser, but cannot open pop-ups, menus, tooltips, or parameters dialogs. Qt widgets renderings are not synchronized because we lack a callback slot when this is done.

"""

from ipycanvas import Canvas
import anatomist.headless as ana
from soma.qt_gui import qt_backend
#import PIL

import time
import logging
import weakref
#from io import BytesIO
#import PIL.Image

from ipyevents import Event
import numpy as np
from ipywidgets import Image
from functools import partial
import anatomist.direct.api as anatomist
from soma.qt_gui.qt_backend import Qt
import types

from . import Anatomist


INTERACTION_THROTTLE = 100

log = logging.getLogger(__name__)
log.setLevel("CRITICAL")
#log.setLevel("DEBUG")
log.addHandler(logging.StreamHandler())
debug = True

if debug:
    from ipywidgets import HTML
    h = HTML('Event info')


[docs]class AnatomistInteractiveWidget(Canvas): """Taken and modified from: https://github.com/Kitware/ipyvtklink/blob/master/ipyvtklink/viewer.py Remote controller for Anatomist render windows. In Anatomist 5.1, Anatomist 3D views are sync'ed to the canvas automatically at each 3D rendering (via a Qt signal). In earlier Anatomist (5.0) this sync is not automatic and is only forced when input events are caught, which means that renderings done on Anatomist side for other reasons (such as animations) will not be rendered. Other Qt widgets (browsers...) are not sync'ed either because we have no obvious means to capture paint events from arbitrary Qt widgets. Parameters ---------- allow_wheel : bool, optional Capture wheel events and allow zooming using the mouse wheel. quality : float, optional Full rendering image quality. 100 for best quality, 0 for min quality. Default 85. quick_quality : float, optional Quick rendering image quality during mouse dragging. 100 for best quality, 0 for min quality. Default 50. Keep this number low to allow rapid rendering on limited bandwidth. on_close : callable A callable function with no arguments to be triggered when the widget is destroyed. This is useful to have a callback to close/clean up the render window. """ def __init__(self, awindow, log_events=False, transparent_background=False, allow_wheel=True, quality=85, quick_quality=50, on_close=None, only_3d=False, **kwargs): super(AnatomistInteractiveWidget, self).__init__(**kwargs) if quality < 0 or quality > 100: raise ValueError('`quality` parameter must be between 0 and 100') self._quality = quality self._render_window = weakref.ref(awindow) self.transparent_background = transparent_background self._full_quality = quality self._quick_quality = quick_quality # Frame rate (1/renderDelay) self.last_render_time = 0 self.quick_render_delay_sec = 0.01 self.quick_render_delay_sec_range = [0.02, 2.0] self.adaptive_render_delay = True self.last_mouse_move_event = None self._only_3d = only_3d self.qtimer = Qt.QTimer() # refresh if mouse is just moving (not dragging) self.track_mouse_move = False #self.track_mouse_move = True #if self.is_window3d(): #self.track_mouse_move = True self.message_timestamp_offset = None # Set Canvas size from window size self.width, self.height \ = self.render_window.width(), self.render_window.height() #self.layout.width = '%dpx' % awindow.getInternalRep().width() #self.layout.height = '%dpx' % awindow.getInternalRep().height() self.layout.width = 'auto' self.layout.height = 'auto' self.render_connected = False if hasattr(awindow.getInternalRep(), 'view') \ and hasattr(awindow.getInternalRep().view(), 'viewRendered'): awindow.getInternalRep().view().viewRendered.connect( self.render_callback) self.render_connected = True # record first render time tstart = time.time() self.update_canvas() self._first_render_time = time.time() - tstart log.debug('First image in %.5f seconds', self._first_render_time) # this is the minimum time to render anyway self.set_quick_render_delay(self._first_render_time) self.dragging = False self.interaction_events = Event() # Set the throttle or debounce time in millseconds (must be an non-negative integer) # See https://github.com/mwcraig/ipyevents/pull/55 self.interaction_events.throttle_or_debounce = "throttle" self.interaction_events.wait = INTERACTION_THROTTLE self.interaction_events.source = self allowed_events = [ "dragstart", "mouseenter", "mouseleave", "mousedown", "mouseup", "mousemove", "keyup", "keydown", "dblclick", "contextmenu", # prevent context menu from appearing on right-click ] # May be disabled out so that user can scroll through the # notebook using mousewheel if allow_wheel: allowed_events.append("wheel") self.interaction_events.watched_events = allowed_events self.interaction_events.msg_throttle = 1 # does not seem to have effect self.interaction_events.prevent_default_action = True self.interaction_events.on_dom_event(self.handle_interaction_event) # Errors are not displayed when a widget is displayed, # this variable can be used to retrieve error messages self.error = None # Enable logging of UI events self.log_events = log_events self.logged_events = [] self.elapsed_times = [] self.age_of_processed_messages = [] if hasattr(on_close, '__call__'): self._on_close = on_close else: self._on_close = lambda: None @property def render_window(self): """reference the weak reference""" ren_win = self._render_window() if ren_win is None: raise RuntimeError('render window has closed') return ren_win @property def ana_view(self): if self.is_window3d(): return self.render_window.view() w = self.render_window if hasattr(w, 'getInternalRep'): return w.getInternalRep() def is_closed(self): if getattr(self, '_closed', False): return True try: win = self.render_window return False except: return True def set_quick_render_delay(self, delay_sec): if delay_sec < self.quick_render_delay_sec_range[0]: delay_sec = self.quick_render_delay_sec_range[0] elif delay_sec > self.quick_render_delay_sec_range[1]: delay_sec = self.quick_render_delay_sec_range[1] self.quick_render_delay_sec = delay_sec
[docs] def update_canvas(self, force_render=True, quality=75): """Updates the canvas with the current render""" try: raw_img = self.get_image(force_render=force_render) if raw_img is None: return # something like recursive call happened # save using Qt to avoid a copy buffer = Qt.QByteArray() fbuf = Qt.QBuffer(buffer) fbuf.open(fbuf.WriteOnly) raw_img.save(fbuf, 'JPEG', quality) image = Image( value=bytes(fbuf.buffer()), width=raw_img.width(), height=raw_img.height()) if self.width != raw_img.width(): self.width = raw_img.width() self.layout.width = 'auto' if self.height != raw_img.width(): self.height = raw_img.height() self.layout.height = 'auto' # this one was using a np array and PIL #f = BytesIO() #PIL.Image.fromarray(raw_img).save(f, 'JPEG', quality=quality) #image = Image( #value=f.getvalue(), width=raw_img.shape[1], #height=raw_img.shape[0]) #if self.width != raw_img.shape[1]: #self.width = raw_img.shape[1] #self.layout.width = 'auto' #if self.height != raw_img.shape[0]: #self.height = raw_img.shape[0] #self.layout.height = 'auto' self.draw_image(image) except RuntimeError: # the render window may have been closed on serer side self.close()
def get_image(self, force_render=True): if force_render and self.is_window3d(): self.render_window.view().blockSignals(True) self.render_window.camera(force_redraw=1) self.render_window.view().blockSignals(False) return self._fast_image @property def _fast_image(self): if getattr(self, '_recursive_getimage', False): return self._recursive_getimage = True if self.is_window3d(): self.render_window.view().blockSignals(True) qdata3 = self.render_window.snapshotImage() self.render_window.view().blockSignals(False) if self._only_3d: qdata = qdata3 else: qdata = self.render_window.grab() if qdata.isNull(): qdata = qdata3 else: rpos = self.render_window.view().mapTo( self.render_window.getInternalRep(), Qt.QPoint(0, 0)) painter = Qt.QPainter(qdata) painter.drawImage(rpos.x(), rpos.y(), qdata3) del painter else: qdata = self.render_window.grab() self._recursive_getimage = False return qdata # return a QImage #data = qt_backend.qimage_to_np(qdata) #if self.transparent_background: #return data #else: # ignore alpha channel #return data[:, :, :-1] def render_callback(self): # purge events, avoid to have too much recursive events Qt.QApplication.instance().sendPostedEvents() Qt.QApplication.instance().processEvents() Qt.QApplication.instance().processEvents() if self.is_closed(): return if getattr(self, '_recursive_getimage', False): return try: if self._render_window() is None: # the anatromist window has been closed self.close() return self.update_canvas(force_render=False, quality=self._quick_quality) # trigger a better quality image if not self.is_closed(): self.qtimer.singleShot( int(float(INTERACTION_THROTTLE) / 1000), partial(self.update_canvas, force_render=False, quality=self._full_quality)) except RuntimeError: # the render window may have been closed on server side self.close() #@throttle(0.1) def full_render(self): try: import time tstart = time.time() self.update_canvas(True, self._full_quality) self.last_render_time = time.time() log.debug('full render in %.5f seconds', time.time() - tstart) except Exception as e: self.error = str(e) #@throttle(0.01) def quick_render(self): if self.render_connected: return # leave this job to the callback try: self.update_canvas(quality=self._quick_quality) if self.log_events: self.elapsed_times.append(time.time() - self.last_render_time) self.last_render_time = time.time() except Exception as e: self.error = str(e) def handle_interaction_event(self, event): def get_key_modifiers(event): qevent_mod = Qt.Qt.NoModifier if event['ctrlKey']: qevent_mod |= Qt.Qt.ControlModifier if event['shiftKey']: qevent_mod |= Qt.Qt.ShiftModifier if event['metaKey']: qevent_mod |= Qt.Qt.MetaModifier if event['altKey']: qevent_mod |= Qt.Qt.AltModifier return qevent_mod def get_mouse_event_buttons(event): qevent_btn = Qt.Qt.NoButton if event["button"] == 0: qevent_btn = Qt.Qt.LeftButton if event["button"] == 2: qevent_btn = Qt.Qt.RightButton elif event["button"] == 1: qevent_btn = Qt.Qt.MiddleButton qevent_btns = Qt.Qt.NoButton if event["buttons"] & 1: qevent_btns |= Qt.Qt.LeftButton if event["buttons"] & 2: qevent_btns |= Qt.Qt.RightButton elif event["buttons"] & 4: qevent_btns |= Qt.Qt.MiddleButton qevent_mod = get_key_modifiers(event) return qevent_btn, qevent_btns, qevent_mod def get_mouse_qevent(event): event_name = event["event"] if event_name == 'mousemove': qevent_type = Qt.QEvent.MouseMove elif event_name == 'mousedown': qevent_type = Qt.QEvent.MouseButtonPress elif event_name == 'mouseup': qevent_type = Qt.QEvent.MouseButtonRelease elif event_name == 'dblclick': qevent_type = Qt.QEvent.MouseButtonDblClick qevent_btn, qevent_btns, qevent_mod = get_mouse_event_buttons( event) qevent = Qt.QMouseEvent( qevent_type, Qt.QPointF(event["relativeX"], event['relativeY']), qevent_btn, qevent_btn, qevent_mod) return qevent def get_key_qevent(event): qevent_type = Qt.QEvent.KeyPress if event['event'] == 'keyup': qevent_type = Qt.QEvent.KeyRelease qevent_mod = get_key_modifiers(event) code = event['code'] if not code.startswith('Key'): code = 'Key_%s' % code else: code = 'Key_%s' % code[3:] qevent_key = getattr(Qt.Qt, code) qevent_mod = get_key_modifiers(event) qevent = Qt.QKeyEvent(qevent_type, qevent_key, qevent_mod, event['key'], event['repeat']) return qevent event_name = event["event"] if debug: lines = ['{}: {}'.format(k, v) for k, v in event.items()] h.value = 'new event: %s' % event_name display(h) #if 'offsetX' in event: #event['offsetX'] = round(event["clientX"]-event["boundingRectLeft"]) #re-calculate coordinates #scale_x = self.width/event['boundingRectWidth'] #event['offsetX'] = round(event['offsetX']*scale_x) #event['offsetY'] = round(event["clientY"]-event["boundingRectTop"]) #re-calculate coordinates #scale_y = self.height/event['boundingRectHeight'] #event['offsetY'] = round(event['offsetY']*scale_y) try: if self.log_events: self.logged_events.append(event) if event_name == "mousemove": if self.message_timestamp_offset is None: self.message_timestamp_offset = ( time.time() - event["timeStamp"] * 0.001 ) self.last_mouse_move_event = event if not self.dragging and not self.track_mouse_move: if debug: content = '<br>'.join(lines) h.value += '<br>' + content return if self.adaptive_render_delay: ageOfProcessedMessage = time.time() - ( event["timeStamp"] * 0.001 + self.message_timestamp_offset ) if ageOfProcessedMessage > 1.5 * self.quick_render_delay_sec: # we are falling behind, try to render less frequently self.set_quick_render_delay(self.quick_render_delay_sec * 1.05) elif ageOfProcessedMessage < 0.5 * self.quick_render_delay_sec: # we can keep up with events, try to render more frequently self.set_quick_render_delay(self.quick_render_delay_sec / 1.05) if self.log_events: self.age_of_processed_messages.append( [ageOfProcessedMessage, self.quick_render_delay_sec] ) qevent = get_mouse_qevent(event) self.post_qevent(qevent) elif event_name == "mouseenter": self.last_mouse_move_event = None self.dragging = False qevent = Qt.QFocusEvent(Qt.QEvent.FocusIn, Qt.Qt.MouseFocusReason) self.post_qevent(qevent) elif event_name == "mouseleave": self.last_mouse_move_event = None if self.dragging: # have to trigger a leave event and release event self.dragging = False qevent_btn, qevent_btns, qevent_mod \ = get_mouse_event_buttons(event) qevent = Qt.QMouseEvent( Qt.QEvent.MouseButtonRelease, Qt.QPointF(event["relativeX"], event['relativeY']), qevent_btn, qevent_btn, qevent_mod) self.post_qevent(qevent) qevent = Qt.QFocusEvent(Qt.QEvent.FocusOut, Qt.Qt.MouseFocusReason) self.post_qevent(qevent) elif event_name == "mousedown": self.dragging = True qevent = get_mouse_qevent(event) self.post_qevent(qevent) elif event_name == "mouseup": self.dragging = False qevent = get_mouse_qevent(event) self.post_qevent(qevent) elif event_name == "dblclick": qevent = get_mouse_qevent(event) self.post_qevent(qevent) elif event_name == "keydown": if ( event["key"] != "Shift" and event["key"] != "Control" and event["key"] != "Alt" ): qevent = get_key_qevent(event) self.post_qevent(qevent) elif event_name == "keyup": if ( event["key"] != "Shift" and event["key"] != "Control" and event["key"] != "Alt" ): qevent = get_key_qevent(event) self.post_qevent(qevent) elif event_name == 'wheel': if 'wheel' in self.interaction_events.watched_events: qevent_btn, qevent_btns, qevent_mod \ = get_mouse_event_buttons(event) qevent = Qt.QWheelEvent( Qt.QPointF(event['relativeX'], event['relativeY']), Qt.QPointF(event['screenX'], event['screenY']), Qt.QPoint(event['deltaX'], event['deltaY']), Qt.QPoint(0, 0), -event['deltaY'] * 2, Qt.Qt.Vertical, qevent_btns, qevent_mod) self.post_qevent(qevent) #elif event_name == 'contextmenu': #qevent = Qt.QContextMenuEvent( #Qt.QContextMenuEvent.Mouse, #Qt.QPoint(event['relativeX'], event['relativeY'])) except Exception as e: self.error = str(e) if debug: lines.append(str(e)) if debug: content = ', '.join(lines) h.value += '<br>' + content def is_window3d(self): return hasattr(self.render_window, 'getInternalRep') \ and hasattr(self.render_window.getInternalRep(), 'view') \ and isinstance(self.render_window.view(), anatomist.cpp.GLWidgetManager) def post_qevent(self, qevent): if self._only_3d: widget = self.ana_view else: widget = self.render_window.getInternalRep() if hasattr(qevent, 'pos'): w2 = widget.childAt(qevent.pos()) if w2: if hasattr(self, '_last_widget_event') \ and qevent.type() in ( Qt.QEvent.MouseButtonRelease, Qt.QEvent.MouseMove, Qt.QEvent.FocusOut): # these must happen in the same widget as they were # started w2 = self._last_widget_event pos = w2.mapFromGlobal(widget.mapToGlobal(qevent.pos())) widget = w2 if isinstance(qevent, Qt.QMouseEvent): qevent = Qt.QMouseEvent( qevent.type(), pos, qevent.button(), qevent.buttons(), qevent.modifiers()) self._last_widget_event = widget Qt.qApp.postEvent(widget, qevent) # immediately send/process events because we expect a real-time # callback #Qt.QApplication.instance().sendPostedEvents() Qt.QApplication.instance().processEvents() if not self.is_window3d(): self.qtimer.singleShot( float(INTERACTION_THROTTLE) / 1000, partial(self.update_canvas, force_render=False, quality=self._full_quality)) def release_awindow(self): try: self.render_window.getInternalRep().view().viewRendered.disconnect( self.render_callback) self.render_window.getInternalRep().view().blockSignals(True) Qt.QApplication.instance().processEvents() Qt.QApplication.instance().processEvents() Qt.QApplication.instance().processEvents() self.render_window.getInternalRep().view().blockSignals(False) except: pass try: del self.render_window.canvas # release weak ref class A(object): pass a = A() self._render_window = weakref.ref(a) del a # the weak ref is null now. except: pass
[docs] def close(self): if getattr(self, '_closed', False): return # already closed self._closed = True self.release_awindow() super(AnatomistInteractiveWidget, self).close() self._on_close()
def __del__(self): self.close() super(AnatomistInteractiveWidget, self).__del__()
[docs]class NotebookAnatomist(anatomist.Anatomist): ''' A derived Anatomist class which automatically redirects its views to Jupyter notebook canvases. It only overloads the createWindow() method which creates an AnatomistInteractiveWidget canvas together with each window. It is normally used with the "headless" variant of Anatomist. Usage, in a notebook:: import anatomist.headless as ana a = ana.HeadlessAnatomist( implementation='anatomist.notebook.api.NotebookAnatomist') w = a.createWindow('3D') mesh = a.loadObject('/home/dr144257/data/ra_head.mesh') w.addObjects(mesh) This is also implemented as a variant of Anatomist implementation:: import anatomist anatomist.setDefaultImplementation('notebook') import anatomist.api as ana a = ana.Anatomist() Or, simply:: import anatomist.notebook as ana a = ana.Anatomist() .. note:: In this example we load ``anatomist.notebook`` without the ``api`` submodule, because the latter loads Qt and thus prevents the optimized headless implementation to load and use VirtualGL. The :meth:`createWindow` method overload adds an additonal keyword argument, ``only_3D`` which enables to display only the 3D rendering view, or the full window with buttons and sliders. .. warning:: *Limitations* The notebook execution in a script (like for sphinx docs) may crash anatomist: the notebook execution while a GUI event loop is running, is done through GUI events. Such events thus contain code to execute. The code may contain (or imply) objects deletion, but they may be triggered from within Anatomist winwows methods. For instance, the ``AWindow3D.snapshotImage()`` method, used to draw the canvas, needs to call ``QApplication.processEvents()``, because it needs to force the window paint before grabing its content, and this is done in Qt through events. Here with notebooks, the events may contain code which can delete the window being used, and lead to a crash. We have not found a solution for now. However running the notebook interactively, or running notebooks which do not delete windows objects, seem to be safe. ''' def __singleton_init__(self, *args, **kwargs): super(NotebookAnatomist, self).__singleton_init__(*args, **kwargs)
[docs] class AWindow(anatomist.Anatomist.AWindow): def __del__(self): canvas = getattr(self, 'canvas', None) if canvas and hasattr(canvas, 'close'): canvas.close() super(NotebookAnatomist.AWindow, self).__del__()
[docs] def createWindow(self, wintype, geometry=[], block=None, no_decoration=None, options=None, only_3d=False): ''' Overload for :meth:`anatomist.direct.api.Anatomist.createWindow` which embeds the window in a Jupyter notebook canvas. It has an additional optional keyword argument: Parameters ---------- only_3d: bool if False, the full window is rendered in the notebook canvas (except that menubars are hidden since popups are not working). If True, only the 3D view part is rendered in the canvas (which should also be more efficient because it avoids a buffer copy). ''' if only_3d: no_decoration = True win = super(NotebookAnatomist, self).createWindow( wintype, geometry=geometry, block=None, no_decoration=no_decoration, options=None) if not no_decoration: # hide menubars win.menuBar().hide() canvas = AnatomistInteractiveWidget(win, only_3d=only_3d) display(canvas) win.canvas = canvas return win