Skip to content

Commit eed9acf

Browse files
authored
Refactor how snapshot works (#152)
* WIP refactor how snapshot works * clean up * fix tests
1 parent 7f90af8 commit eed9acf

4 files changed

Lines changed: 59 additions & 157 deletions

File tree

jupyter_rfb/_utils.py

Lines changed: 2 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import io
22
import builtins
33
import traceback
4-
from base64 import encodebytes
54

6-
from IPython.display import DisplayObject
75
import ipywidgets
86

97
from ._png import array2png
@@ -75,73 +73,6 @@ def __exit__(self, etype, value, tb):
7573
return True # declare that we handled the exception
7674

7775

78-
class Snapshot(DisplayObject):
79-
"""An IPython DisplayObject representing an image snapshot.
80-
81-
The ``data`` attribute is the image array object. One could use
82-
this to process the data further, e.g. storing it to disk.
83-
"""
84-
85-
# Not an IPython.display.Image, because we want to use some HTML to
86-
# give it a custom css class and a title.
87-
88-
def __init__(self, data, width, height, title="snapshot", class_name=None):
89-
super().__init__(data)
90-
self.width = width
91-
self.height = height
92-
self.title = title
93-
self.class_name = class_name
94-
95-
def _check_data(self):
96-
assert hasattr(self.data, "shape") and hasattr(self.data, "dtype")
97-
98-
def _repr_mimebundle_(self, **kwargs):
99-
return {"text/html": self._repr_html_()}
100-
101-
def _repr_html_(self):
102-
# Convert to PNG
103-
mimetype, data = array2compressed(self.data, 70)
104-
src = f"data:image/{mimetype};base64," + encodebytes(data).decode()
105-
# Create html repr
106-
class_str = f"class='{self.class_name}'" if self.class_name else ""
107-
img_style = f"width:{self.width}px;height:{self.height}px;"
108-
tt_style = "position: absolute; top:0; left:0; padding:3px 4px; border-radius:0 0 4px 0;"
109-
tt_style += (
110-
"background: #777; color:#fff; font-size: 90%; font-family:sans-serif; "
111-
)
112-
html = f"""
113-
<div {class_str} style='position:relative;'>
114-
<img src='{src}' style='{img_style}' />
115-
<div style='{tt_style}'>{self.title}</div>
116-
</div>
117-
"""
118-
return html.replace("\n", "").replace(" ", "").strip()
119-
120-
12176
def remove_rfb_models_from_nb(d):
122-
"""Remove the widget model output from a notebook dict.
123-
124-
Given a notebook as a dict (loaded using json), remove the widget
125-
model output if there is also a text/html snapshot output.
126-
127-
This is to work around the fact that nbsphinx favors the model over
128-
the text/html output. Which is sad, because that's where we put the
129-
initial screenshot for offline viewing.
130-
"""
131-
132-
to_remove = set()
133-
for key, val in d.items():
134-
if key == "cells" and isinstance(val, list):
135-
for v in val:
136-
remove_rfb_models_from_nb(v)
137-
elif key == "outputs" and isinstance(val, list):
138-
for v in val:
139-
data = v.get("data", None)
140-
if data:
141-
remove_rfb_models_from_nb(data)
142-
elif key == "application/vnd.jupyter.widget-view+json":
143-
html_sibling = d.get("text/html", [])
144-
if html_sibling and "<div class='snapshot-" in html_sibling[0]:
145-
to_remove.add(key)
146-
for key in to_remove:
147-
d.pop(key)
77+
"""Deprecated, is a no-op for backwards compatibility."""
78+
pass

jupyter_rfb/widget.py

Lines changed: 54 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@
1616
from importlib.resources import files as resource_files
1717

1818
import anywidget
19-
import numpy as np
20-
from IPython.display import display
19+
from IPython.display import display, HTML
2120
from traitlets import Bool, Dict, Int, Unicode
2221

23-
from ._utils import array2compressed, RFBOutputContext, Snapshot
22+
from ._utils import array2compressed, RFBOutputContext
2423

2524

2625
def _load_js_and_css():
@@ -105,7 +104,7 @@ def __init__(self, *args, **kwargs):
105104
display(self._output_context)
106105
# Init attributes for drawing
107106
self._rfb_last_frame = None
108-
self._rfb_pending_display = None
107+
self._rfb_pending_snapshot_display = None
109108
self._rfb_draw_requested = False
110109
self._rfb_frame_index = 0
111110
self._rfb_last_confirmed_index = 0
@@ -122,21 +121,6 @@ def __init__(self, *args, **kwargs):
122121
names=["_frame_feedback", "_has_visible_views"],
123122
)
124123

125-
def _repr_mimebundle_(self, **kwargs):
126-
# Use default
127-
result = anywidget.AnyWidget._repr_mimebundle_(self, **kwargs)
128-
# Get dict to add more data
129-
data = None
130-
if isinstance(result, tuple):
131-
data = result[0]
132-
elif isinstance(result, dict):
133-
data = result
134-
# Add initial snapshot if we have it
135-
if data and self._rfb_pending_display and self._rfb_last_frame is not None:
136-
data["text/html"] = self.snapshot()._repr_html_()
137-
self._rfb_pending_display = None # no need to reload
138-
return result
139-
140124
def print(self, *args, **kwargs):
141125
"""Print to the widget's output area (for debugging purposes).
142126
@@ -155,7 +139,7 @@ def close(self, *args, **kwargs):
155139
# When the widget is closed, we notify by creating a close event. The
156140
# same event is emitted from JS when the model is closed in the client.
157141
anywidget.AnyWidget.close(self, *args, **kwargs)
158-
self._rfb_handle_msg(self, {"type": "close", "event_type": "close"}, [])
142+
self._rfb_handle_msg(self, {"type": "close"}, [])
159143

160144
def _rfb_handle_msg(self, widget, content, buffers):
161145
"""Receive custom messages and filter our events."""
@@ -193,43 +177,60 @@ def _rfb_handle_msg(self, widget, content, buffers):
193177

194178
# ---- drawing
195179

196-
def display(self):
197-
"""Display the widget.
198-
199-
The benefit of using this (instead of using the widget as the last expression of a cell)
200-
is that an html snapshot is added to the output. This happens either directly,
201-
or on the next drawn frame (if no frames have been drawn yet).
202-
"""
203-
self._rfb_pending_display = True
204-
id = display(self, display_id=True)
205-
if self._rfb_pending_display: # i.e. is not set to None by __repr__
206-
self._rfb_pending_display = id
207-
208180
def snapshot(self, pixel_ratio=None):
209-
"""Create a snapshot of the current state of the widget.
181+
"""Render a frame and include the resulting image in the output.
210182
211-
Returns an ``IPython DisplayObject`` that can simply be used as
212-
a cell output. The display object has a ``data`` attribute that holds
213-
the image array data (typically a numpy array).
183+
An initial placeholder output is produced, which is replaced by an html
184+
``<img>`` as soon as the next frame is rendered.
214185
215-
The ``pixel_ratio`` argument is deprecated and ignored.
186+
If the widget is not displayed yet, a resize event is emitted to mimic a widget
187+
size. This happens at most once in the widget's lifetime. It will use the
188+
``css_width`` and ``css_height`` if they are expressed in ``px``, and otherwise
189+
default to 500 or 300 pixels respectively. The ``pixel_ratio`` argument is then
190+
used to calculate the physical size.
216191
"""
217-
# Get the current size
218-
ref_resize_event = self._rfb_last_resize_event
219-
if ref_resize_event:
220-
w = ref_resize_event["width"]
221-
h = ref_resize_event["height"]
222-
else:
192+
if self._rfb_last_resize_event is None:
223193
css_width, css_height = self.css_width, self.css_height
224194
w = float(css_width[:-2]) if css_width.endswith("px") else 500
225195
h = float(css_height[:-2]) if css_height.endswith("px") else 300
226-
# Get last frame or single-pixel image
227-
array = self._rfb_last_frame
228-
if array is None:
229-
array = np.ones((1, 1, 3), np.uint8) * 127
230-
# Super-weird, but it looks like nbsphinx only selects the text/html field when we use a css class
231-
# that starts with 'snapshot-'. Is this some upstream hack to make jupyter-rfb work, that we don't know of?
232-
return Snapshot(array, w, h, "snapshot", f"snapshot-rfb model{self._model_id}")
196+
r = float(pixel_ratio) if pixel_ratio is not None else 1.0
197+
pw, ph = int(w * r), (h * r)
198+
event = {
199+
"type": "resize",
200+
"width": pw / r,
201+
"height": ph / r,
202+
"pwidth": pw,
203+
"pheight": ph,
204+
"ratio": r,
205+
"timestamp": 0,
206+
}
207+
self._rfb_handle_msg(self, event, [])
208+
209+
self._rfb_pending_snapshot_display = display(
210+
HTML(
211+
"<div style='display: inline-block; padding: 5px; border-radius: 5px; background:#ddd; color:#000'>pending screenshot ...</span>"
212+
),
213+
display_id=True,
214+
)
215+
self.request_draw()
216+
217+
# Note: It could be that _replace_snapshot() is called directly,
218+
# (and _rfb_pending_snapshot_display set to None). But it could
219+
# also be that it is called later. In any case, we just return None
220+
221+
def _replace_snapshot(self, array):
222+
pending_display = self._rfb_pending_snapshot_display
223+
self._rfb_pending_snapshot_display = None
224+
225+
event = self._rfb_last_resize_event or {}
226+
w = event.get("width", array.shape[1])
227+
h = event.get("height", array.shape[0])
228+
229+
mimetype, data = array2compressed(array, 70)
230+
src = f"data:image/{mimetype};base64," + encodebytes(data).decode()
231+
html = f"<img src='{src}' style='width:{w}px;height:{h}px;' />"
232+
233+
pending_display.update(HTML(html))
233234

234235
def request_draw(self):
235236
"""Schedule a new draw. This method itself returns immediately.
@@ -279,7 +280,7 @@ def _rfb_maybe_draw(self):
279280
should_draw = (
280281
self._rfb_draw_requested
281282
and frames_in_flight < self.max_buffered_frames
282-
and self._has_visible_views
283+
and (self._has_visible_views or self._rfb_pending_snapshot_display)
283284
)
284285
# Do the draw if we should.
285286
if should_draw:
@@ -359,9 +360,8 @@ def _rfb_send_frame(self, array, is_lossless_redraw=False):
359360
self._rfb_last_confirmed_index = self._rfb_frame_index - 1
360361

361362
# Reload the output if we did not have a frame when the widget was first loaded
362-
if self._rfb_pending_display is not None:
363-
if self._rfb_last_resize_event is not None:
364-
self._rfb_pending_display.update(self) # -> calls _repr_mimebundle_
363+
if self._rfb_pending_snapshot_display is not None:
364+
self._replace_snapshot(array)
365365

366366
# Compose message and send
367367
msg = dict(

tests/test_utils.py

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44
import numpy as np
5-
from jupyter_rfb._utils import array2compressed, RFBOutputContext, Snapshot
5+
from jupyter_rfb._utils import array2compressed, RFBOutputContext
66
from jupyter_rfb import _jpg
77

88

@@ -113,23 +113,3 @@ def test_output_context():
113113
# The print is a proper print
114114
c.print("foo", "bar", sep="-", end=".")
115115
assert c.stdouts[-1] == "foo-bar."
116-
117-
118-
def test_snapshot():
119-
"""Test the Snapshot class."""
120-
121-
a = np.zeros((10, 10), np.uint8)
122-
123-
s = Snapshot(a, 5, 5, "footitle", "KLS")
124-
125-
# The get_array method returns the raw data
126-
assert s.data is a
127-
128-
# Most importantly, it has a Jupyter mime data!
129-
data = s._repr_mimebundle_()
130-
assert "text/html" in data
131-
html = data["text/html"]
132-
assert "data:image/" in html # looks like the png/jpg is in there
133-
assert "width:5px" in html and "height:5px" in html # logical size
134-
assert "class='KLS'" in html # css class name
135-
assert "footitle" in html # the title

tests/test_widget.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import numpy as np
1010
from pytest import raises
1111
from jupyter_rfb import RemoteFrameBuffer
12-
from jupyter_rfb._utils import Snapshot
1312
from traitlets import TraitError
1413

1514

@@ -300,18 +299,10 @@ def test_print():
300299

301300

302301
def test_snapshot():
303-
"""Test that the widget has a snapshot method that produces a Snapshot."""
302+
"""Test that the widget has a snapshot method."""
304303
w = MyRFB()
305304
s = w.snapshot()
306-
assert isinstance(s, Snapshot)
307-
assert s.data.shape == (1, 1, 3)
308-
309-
w.request_draw()
310-
w._rfb_maybe_draw() # similate first frame
311-
312-
s = w.snapshot()
313-
assert isinstance(s, Snapshot)
314-
assert np.all(s.data == w.get_frame())
305+
assert s is None # snapshot() uses display()
315306

316307

317308
def test_use_websocket():

0 commit comments

Comments
 (0)