Skip to content

Commit 8aacbd8

Browse files
authored
Merge pull request #47 from martinRenou/image_sync
Image synchronization
2 parents d3b5471 + f9edb78 commit 8aacbd8

File tree

5 files changed

+193
-6
lines changed

5 files changed

+193
-6
lines changed

docs/source/basic_usage.rst

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ that does not need to update much while other objects moves a lot on the screen.
3333
- observe some of its attributes and call functions when they change
3434
- link some of its attributes to other widget attributes
3535

36-
Clear canvas
36+
Clear Canvas
3737
------------
3838

39-
The ``Canvas`` class has a ``clear`` method which allows to clear the entire canvas.
39+
The ``Canvas`` and ``MultiCanvas`` classes have a ``clear`` method which allows to clear the entire canvas.
4040

4141
.. code:: Python
4242
@@ -48,6 +48,37 @@ The ``Canvas`` class has a ``clear`` method which allows to clear the entire can
4848
4949
canvas.clear()
5050
51+
Save Canvas to a file
52+
---------------------
53+
54+
You can dump the current ``Canvas`` or ``MultiCanvas`` image using the ``to_file`` method. You first need to specify that you want the image data to be synchronized between the front-end and the back-end setting the ``sync_image_data`` attribute to ``True``.
55+
56+
.. code:: Python
57+
58+
from ipycanvas import Canvas
59+
60+
canvas = Canvas(size=(200, 200), sync_image_data=True)
61+
62+
# Perform some drawings...
63+
64+
canvas.to_file('my_file.png')
65+
66+
Note that this won't work if executed in the same Notebook cell. Because the Canvas won't have drawn anything yet. If you want to put all your code in the same Notebook cell, you need to define a callback function that will be called when the Canvas is ready to be dumped to an image file.
67+
68+
.. code:: Python
69+
70+
from ipycanvas import Canvas
71+
72+
canvas = Canvas(size=(200, 200), sync_image_data=True)
73+
74+
# Perform some drawings...
75+
76+
def save_to_file(*args, **kwargs):
77+
canvas.to_file('my_file.png')
78+
79+
# Listen to changes on the ``image_data`` trait and call ``save_to_file`` when it changes.
80+
canvas.observe(save_to_file, 'image_data')
81+
5182
Optimizing drawings
5283
-------------------
5384

examples/plotting.ipynb

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"source": [
3333
"def init_2d_plot(x, y, color=None, scheme=branca.colormap.linear.RdBu_11, canvas=None, canvas_size=(800, 600), padding=0.1):\n",
3434
" if canvas is None:\n",
35-
" canvas = MultiCanvas(3, size=canvas_size)\n",
35+
" canvas = MultiCanvas(3, size=canvas_size, sync_image_data=True)\n",
3636
" else:\n",
3737
" canvas.size = canvas_size\n",
3838
"\n",
@@ -282,6 +282,27 @@
282282
"plot"
283283
]
284284
},
285+
{
286+
"cell_type": "code",
287+
"execution_count": null,
288+
"metadata": {},
289+
"outputs": [],
290+
"source": [
291+
"# Save the plot to an image file!\n",
292+
"plot.to_file('my_scatter.png')"
293+
]
294+
},
295+
{
296+
"cell_type": "code",
297+
"execution_count": null,
298+
"metadata": {},
299+
"outputs": [],
300+
"source": [
301+
"from ipywidgets import Image\n",
302+
"\n",
303+
"Image.from_file('my_scatter.png')"
304+
]
305+
},
285306
{
286307
"cell_type": "markdown",
287308
"metadata": {},

ipycanvas/canvas.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88

99
import numpy as np
1010

11-
from traitlets import Enum, Float, Instance, List, Tuple, Unicode
11+
from traitlets import Bool, Bytes, Enum, Float, Instance, List, Tuple, Unicode
1212

1313
from ipywidgets import CallbackDispatcher, Color, DOMWidget, Image, widget_serialization
14+
from ipywidgets.widgets.trait_types import bytes_serialization
1415

1516
from ._frontend import module_name, module_version
1617

@@ -107,6 +108,13 @@ class Canvas(DOMWidget):
107108
#: (float) Specifies where to start a dash array on a line. Default is ``0.``.
108109
line_dash_offset = Float(0.)
109110

111+
#: (bool) Specifies if the image should be synchronized from front-end to Python back-end
112+
sync_image_data = Bool(False).tag(sync=True)
113+
114+
#: (bytes) Current image data as bytes (PNG encoded). It is ``None`` by default and will not be
115+
#: updated if ``sync_image_data`` is ``False``.
116+
image_data = Bytes(default_value=None, allow_none=True, read_only=True).tag(sync=True, **bytes_serialization)
117+
110118
_client_ready_callbacks = Instance(CallbackDispatcher, ())
111119
_mouse_move_callbacks = Instance(CallbackDispatcher, ())
112120
_click_callbacks = Instance(CallbackDispatcher, ())
@@ -430,6 +438,19 @@ def flush(self):
430438
self._commands_cache = []
431439
self._buffers_cache = []
432440

441+
def to_file(self, filename):
442+
"""Save the current Canvas image to a PNG file.
443+
444+
This will raise an exception if there is no image to save (_e.g._ if ``image_data`` is ``None``).
445+
"""
446+
if self.image_data is None:
447+
raise RuntimeError('No image data to save')
448+
if not filename.endswith('.png') and not filename.endswith('.PNG'):
449+
raise RuntimeError('Can only save to a PNG file')
450+
451+
with open(filename, 'wb') as fobj:
452+
fobj.write(self.image_data)
453+
433454
# Events
434455
def on_client_ready(self, callback, remove=False):
435456
"""Register a callback that will be called when a new client is ready to receive draw commands.
@@ -503,7 +524,14 @@ class MultiCanvas(DOMWidget):
503524
_view_module = Unicode(module_name).tag(sync=True)
504525
_view_module_version = Unicode(module_version).tag(sync=True)
505526

506-
size = Tuple((700, 500), help='Size of the Canvas, this is not equal to the size of the view')
527+
size = Tuple((700, 500), help='Size of the Canvas, this is not equal to the size of the view').tag(sync=True)
528+
529+
#: (bool) Specifies if the image should be synchronized from front-end to Python back-end
530+
sync_image_data = Bool(False).tag(sync=True)
531+
532+
#: (bytes) Current image data as bytes (PNG encoded). It is ``None`` by default and will not be
533+
#: updated if ``sync_image_data`` is ``False``.
534+
image_data = Bytes(default_value=None, allow_none=True, read_only=True).tag(sync=True, **bytes_serialization)
507535

508536
_canvases = List(Instance(Canvas)).tag(sync=True, **widget_serialization)
509537

@@ -550,6 +578,19 @@ def flush(self):
550578
for layer in self._canvases:
551579
layer.flush()
552580

581+
def to_file(self, filename):
582+
"""Save the current MultiCanvas image to a PNG file.
583+
584+
This will raise an exception if there is no image to save (_e.g._ if ``image_data`` is ``None``).
585+
"""
586+
if self.image_data is None:
587+
raise RuntimeError('No image data to save')
588+
if not filename.endswith('.png') and not filename.endswith('.PNG'):
589+
raise RuntimeError('Can only save to a PNG file')
590+
591+
with open(filename, 'wb') as fobj:
592+
fobj.write(self.image_data)
593+
553594

554595
@contextmanager
555596
def hold_canvas(canvas):

src/utils.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,35 @@ function getArg(metadata: any, buffers: any) : Arg {
7171

7272
throw 'Could not process argument ' + metadata;
7373
}
74+
75+
export
76+
async function toBlob(canvas: HTMLCanvasElement) : Promise<Blob> {
77+
return new Promise<Blob>((resolve, reject) => {
78+
canvas.toBlob((blob) => {
79+
if (blob == null) {
80+
return reject('Unable to create blob');
81+
}
82+
83+
resolve(blob);
84+
});
85+
});
86+
}
87+
88+
export
89+
async function toBytes(canvas: HTMLCanvasElement) : Promise<Uint8ClampedArray> {
90+
const blob = await toBlob(canvas);
91+
92+
return new Promise<Uint8ClampedArray>((resolve, reject) => {
93+
const reader = new FileReader();
94+
95+
reader.onloadend = () => {
96+
if (typeof reader.result == 'string' || reader.result == null) {
97+
return reject('Unable to read blob');
98+
}
99+
100+
const bytes = new Uint8ClampedArray(reader.result);
101+
resolve(bytes);
102+
};
103+
reader.readAsArrayBuffer(blob);
104+
});
105+
}

src/widget.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from './version';
1111

1212
import {
13-
getArg
13+
getArg, toBytes
1414
} from './utils';
1515

1616

@@ -34,11 +34,16 @@ class CanvasModel extends DOMWidgetModel {
3434
_view_module: CanvasModel.view_module,
3535
_view_module_version: CanvasModel.view_module_version,
3636
size: [700, 500],
37+
sync_image_data: false,
38+
image_data: null,
3739
};
3840
}
3941

4042
static serializers: ISerializers = {
4143
...DOMWidgetModel.serializers,
44+
image_data: { serialize: (bytes: Uint8ClampedArray) => {
45+
return new DataView(bytes.buffer.slice(0));
46+
}}
4247
}
4348

4449
initialize(attributes: any, options: any) {
@@ -61,6 +66,9 @@ class CanvasModel extends DOMWidgetModel {
6166
this.forEachView((view: CanvasView) => {
6267
view.updateCanvas();
6368
});
69+
70+
this.trigger('new-frame');
71+
this.syncImageData();
6472
}
6573

6674
private async processCommand(command: any, buffers: any) {
@@ -271,6 +279,17 @@ class CanvasModel extends DOMWidgetModel {
271279
this.canvas.setAttribute('height', size[1]);
272280
}
273281

282+
private async syncImageData() {
283+
if (!this.get('sync_image_data')) {
284+
return;
285+
}
286+
287+
const bytes = await toBytes(this.canvas);
288+
289+
this.set('image_data', bytes);
290+
this.save_changes();
291+
}
292+
274293
static model_name = 'CanvasModel';
275294
static model_module = MODULE_NAME;
276295
static model_module_version = MODULE_VERSION;
@@ -350,13 +369,56 @@ class MultiCanvasModel extends DOMWidgetModel {
350369
_view_name: MultiCanvasModel.view_name,
351370
_view_module: MultiCanvasModel.view_module,
352371
_view_module_version: MultiCanvasModel.view_module_version,
372+
size: [700, 500],
353373
_canvases: [],
374+
sync_image_data: false,
375+
image_data: null,
354376
};
355377
}
356378

357379
static serializers: ISerializers = {
358380
...DOMWidgetModel.serializers,
359381
_canvases: { deserialize: (unpack_models as any) },
382+
image_data: { serialize: (bytes: Uint8ClampedArray) => {
383+
return new DataView(bytes.buffer.slice(0));
384+
}}
385+
}
386+
387+
initialize(attributes: any, options: any) {
388+
super.initialize(attributes, options);
389+
390+
this.on('change:_canvases', this.updateListeners.bind(this));
391+
this.updateListeners();
392+
}
393+
394+
private updateListeners() {
395+
// TODO: Remove old listeners
396+
for (const canvasModel of this.get('_canvases')) {
397+
canvasModel.on('new-frame', this.syncImageData, this);
398+
}
399+
}
400+
401+
private async syncImageData() {
402+
if (!this.get('sync_image_data')) {
403+
return;
404+
}
405+
406+
const [ width, height ] = this.get('size');
407+
408+
// Draw on a temporary off-screen canvas.
409+
const offscreenCanvas = document.createElement('canvas');
410+
offscreenCanvas.width = width;
411+
offscreenCanvas.height = height;
412+
const ctx = getContext(offscreenCanvas);
413+
414+
for (const canvasModel of this.get('_canvases')) {
415+
ctx.drawImage(canvasModel.canvas, 0, 0)
416+
}
417+
418+
const bytes = await toBytes(offscreenCanvas);
419+
420+
this.set('image_data', bytes);
421+
this.save_changes();
360422
}
361423

362424
static model_name = 'MultiCanvasModel';

0 commit comments

Comments
 (0)