Skip to content

Commit 94bf3e5

Browse files
manztalmarklein
andauthored
feat: avoid base64-encoding image data (#76)
* feat: avoid base64-encoding image data * fix: use correct mimetype * fix: remove remaining 'b64_encoding_sum' stats * chore: remove unused imports * ci: bump node version * Tiny refactor to put objectURL calls together. * Can do both * Turn websocket on by default * Fix test and add test for websocket use * flake --------- Co-authored-by: Almar Klein <almar@almarklein.org>
1 parent 269fd87 commit 94bf3e5

File tree

4 files changed

+81
-41
lines changed

4 files changed

+81
-41
lines changed

js/lib/widget.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,13 @@ export class RemoteFrameBufferModel extends DOMWidgetModel {
8484
this._request_animation_frame();
8585
}
8686

87+
/**
88+
* @param {Object} msg
89+
* @param {DataView[]} buffers
90+
*/
8791
on_msg(msg, buffers) {
8892
if (msg.type === 'framebufferdata') {
89-
this.frames.push(msg);
93+
this.frames.push({ ...msg, buffers: buffers });
9094
}
9195
}
9296

@@ -137,9 +141,18 @@ export class RemoteFrameBufferModel extends DOMWidgetModel {
137141
}
138142
// Pick the oldest frame from the stack
139143
let frame = this.frames.shift();
144+
let new_src;
145+
if (frame.buffers.length > 0) {
146+
let blob = new Blob([frame.buffers[0].buffer], { type: frame.mimetype });
147+
new_src = URL.createObjectURL(blob);
148+
} else {
149+
new_src = frame.data_b64;
150+
}
151+
let old_src = this.img_elements?.[0]?.src;
152+
if (old_src.startsWith('blob:')) { URL.revokeObjectURL(old_src); }
140153
// Update the image sources
141154
for (let img of this.img_elements) {
142-
img.src = frame.src;
155+
img.src = new_src;
143156
}
144157
// Let the server know we processed the image (even if it's not shown yet)
145158
this.last_frame = frame;

jupyter_rfb/_utils.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,24 @@ def array2compressed(array, quality=90):
1717
"""Convert the given image (a numpy array) as a compressed array.
1818
1919
If the quality is 100, a PNG is returned. Otherwise, JPEG is
20-
preferred and PNG is used as a fallback. Returns (preamble, bytes).
20+
preferred and PNG is used as a fallback. Returns (mimetype, bytes).
2121
"""
2222

2323
# Drop alpha channel if there is one
2424
if len(array.shape) == 3 and array.shape[2] == 4:
2525
array = array[:, :, :3]
2626

2727
if quality >= 100:
28-
preamble = "data:image/png;base64,"
28+
mimetype = "image/png"
2929
result = array2png(array)
3030
else:
31-
preamble = "data:image/jpeg;base64,"
31+
mimetype = "image/jpeg"
3232
result = array2jpg(array, quality)
3333
if result is None:
34-
preamble = "data:image/png;base64,"
34+
mimetype = "image/png"
3535
result = array2png(array)
3636

37-
return preamble, result
37+
return mimetype, result
3838

3939

4040
class RFBOutputContext(ipywidgets.Output):

jupyter_rfb/widget.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ def __init__(self, *args, **kwargs):
9191
self._rfb_last_resize_event = None
9292
self._rfb_warned_png = False
9393
self._rfb_lossless_draw_info = None
94+
self._use_websocket = True # Could be a prop, private for now
9495
# Init stats
9596
self.reset_stats()
9697
# Setup events
@@ -285,12 +286,16 @@ def _rfb_send_frame(self, array, is_lossless_redraw=False):
285286

286287
# Turn array into a based64-encoded JPEG or PNG
287288
t1 = time.perf_counter()
288-
preamble, data = array2compressed(array, quality)
289+
mimetype, data = array2compressed(array, quality)
290+
if self._use_websocket:
291+
datas = [data]
292+
data_b64 = None
293+
else:
294+
datas = []
295+
data_b64 = f"data:{mimetype};base64," + encodebytes(data).decode()
289296
t2 = time.perf_counter()
290-
src = preamble + encodebytes(data).decode()
291-
t3 = time.perf_counter()
292297

293-
if "jpeg" in preamble:
298+
if "jpeg" in mimetype:
294299
self._rfb_schedule_lossless_draw(array)
295300
else:
296301
self._rfb_cancel_lossless_draw()
@@ -308,7 +313,6 @@ def _rfb_send_frame(self, array, is_lossless_redraw=False):
308313
else:
309314
# Stats
310315
self._rfb_stats["img_encoding_sum"] += t2 - t1
311-
self._rfb_stats["b64_encoding_sum"] += t3 - t2
312316
self._rfb_stats["sent_frames"] += 1
313317
if self._rfb_stats["start_time"] <= 0: # Start measuring
314318
self._rfb_stats["start_time"] = timestamp
@@ -317,11 +321,12 @@ def _rfb_send_frame(self, array, is_lossless_redraw=False):
317321
# Compose message and send
318322
msg = dict(
319323
type="framebufferdata",
320-
src=src,
324+
mimetype=mimetype,
325+
data_b64=data_b64,
321326
index=self._rfb_frame_index,
322327
timestamp=timestamp,
323328
)
324-
self.send(msg)
329+
self.send(msg, datas)
325330

326331
# ----- related to stats
327332

@@ -336,7 +341,6 @@ def reset_stats(self):
336341
"roundtrip_sum": 0,
337342
"delivery_sum": 0,
338343
"img_encoding_sum": 0,
339-
"b64_encoding_sum": 0,
340344
}
341345

342346
def get_stats(self):
@@ -364,7 +368,6 @@ def get_stats(self):
364368
"roundtrip": d["roundtrip_sum"] / roundtrip_count_div,
365369
"delivery": d["delivery_sum"] / roundtrip_count_div,
366370
"img_encoding": d["img_encoding_sum"] / sent_frames_div,
367-
"b64_encoding": d["b64_encoding_sum"] / sent_frames_div,
368371
"fps": d["confirmed_frames"] / fps_div,
369372
}
370373

tests/test_widget.py

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ def __init__(self):
2525
self.has_visible_views = True
2626
self.msgs = []
2727

28-
def send(self, msg):
28+
def send(self, msg, buffers):
2929
"""Overload the send method so we can check what was sent."""
30+
msg = msg.copy()
31+
msg["buffers"] = buffers
3032
self.msgs.append(msg)
3133

3234
def get_frame(self):
@@ -47,6 +49,14 @@ def trigger(self, request):
4749
self._rfb_draw_requested = True
4850
self._rfb_maybe_draw()
4951

52+
def flush(self):
53+
"""Prentend to flush a frame by setting the widget's frame feedback."""
54+
if not len(self.msgs):
55+
return
56+
self.frame_feedback["index"] = len(self.msgs)
57+
self.frame_feedback["timestamp"] = self.msgs[-1]["timestamp"]
58+
self.frame_feedback["localtime"] = time.time()
59+
5060

5161
def test_widget_frames_and_stats_1():
5262
"""Test sending frames with max 1 in-flight, and how it affects stats."""
@@ -69,10 +79,7 @@ def test_widget_frames_and_stats_1():
6979
assert fs.get_stats()["sent_frames"] == 1
7080
assert fs.get_stats()["confirmed_frames"] == 0
7181

72-
# Flush
73-
fs.frame_feedback["index"] = 1
74-
fs.frame_feedback["timestamp"] = fs.msgs[-1]["timestamp"]
75-
fs.frame_feedback["localtime"] = time.time()
82+
fs.flush()
7683

7784
# Trigger, the previous request is still open
7885
fs.trigger(False)
@@ -81,10 +88,7 @@ def test_widget_frames_and_stats_1():
8188
assert fs.get_stats()["sent_frames"] == 2
8289
assert fs.get_stats()["confirmed_frames"] == 1
8390

84-
# Flush
85-
fs.frame_feedback["index"] = 2
86-
fs.frame_feedback["timestamp"] = fs.msgs[-1]["timestamp"]
87-
fs.frame_feedback["localtime"] = time.time()
91+
fs.flush()
8892

8993
fs.trigger(False)
9094
assert len(fs.msgs) == 2
@@ -135,10 +139,7 @@ def test_widget_frames_and_stats_3():
135139
assert fs.get_stats()["sent_frames"] == 3
136140
assert fs.get_stats()["confirmed_frames"] == 0
137141

138-
# Flush
139-
fs.frame_feedback["index"] = 3
140-
fs.frame_feedback["timestamp"] = fs.msgs[-1]["timestamp"]
141-
fs.frame_feedback["localtime"] = time.time()
142+
fs.flush()
142143

143144
# Trigger with True. We request a new frame, but there was a request open
144145
fs.trigger(True)
@@ -147,10 +148,7 @@ def test_widget_frames_and_stats_3():
147148
assert fs.get_stats()["sent_frames"] == 4
148149
assert fs.get_stats()["confirmed_frames"] == 3
149150

150-
# Flush
151-
fs.frame_feedback["index"] = 4
152-
fs.frame_feedback["timestamp"] = fs.msgs[-1]["timestamp"]
153-
fs.frame_feedback["localtime"] = time.time()
151+
fs.flush()
154152

155153
# Trigger, but nothing to send (no frame pending)
156154
fs.trigger(False)
@@ -173,10 +171,7 @@ def test_widget_frames_and_stats_3():
173171
fs.trigger(True)
174172
assert len(fs.msgs) == 7
175173

176-
# Flush
177-
fs.frame_feedback["index"] = 7
178-
fs.frame_feedback["timestamp"] = fs.msgs[-1]["timestamp"]
179-
fs.frame_feedback["localtime"] = time.time()
174+
fs.flush()
180175

181176
# Trigger with False. no new request, but there was a request open
182177
fs.trigger(False)
@@ -259,10 +254,7 @@ def test_has_visible_views():
259254
fs.trigger(True)
260255
assert len(fs.msgs) == 1
261256

262-
# Flush
263-
fs.frame_feedback["index"] = 1
264-
fs.frame_feedback["timestamp"] = fs.msgs[-1]["timestamp"]
265-
fs.frame_feedback["localtime"] = time.time()
257+
fs.flush()
266258

267259
fs.has_visible_views = False
268260
for _ in range(3):
@@ -302,3 +294,35 @@ def test_snapshot():
302294
s = w.snapshot()
303295
assert isinstance(s, Snapshot)
304296
assert np.all(s.data == w.get_frame())
297+
298+
299+
def test_use_websocket():
300+
"""Test the use of websocket and base64."""
301+
302+
w = MyRFB()
303+
304+
# The default uses a websocket
305+
w.flush()
306+
w.trigger(True)
307+
msg = w.msgs[-1]
308+
assert len(msg["buffers"]) == 1
309+
assert isinstance(msg["buffers"][0], bytes)
310+
assert msg["data_b64"] is None
311+
312+
# Websocket use can be turned off, falling back to base64 encoded images instead
313+
w._use_websocket = False
314+
w.flush()
315+
w.trigger(True)
316+
msg = w.msgs[-1]
317+
assert len(msg["buffers"]) == 0
318+
assert isinstance(msg["data_b64"], str)
319+
assert msg["data_b64"].startswith("data:image/jpeg;base64,")
320+
321+
# Turn it back on
322+
w._use_websocket = True
323+
w.flush()
324+
w.trigger(True)
325+
msg = w.msgs[-1]
326+
assert len(msg["buffers"]) == 1
327+
assert isinstance(msg["buffers"][0], bytes)
328+
assert msg["data_b64"] is None

0 commit comments

Comments
 (0)