Skip to content

Commit 2ac7404

Browse files
authored
Merge pull request #8 from martinRenou/add_put_image_data
Add put_image_data method
2 parents 40ee544 + 467f21b commit 2ac7404

File tree

5 files changed

+216
-32
lines changed

5 files changed

+216
-32
lines changed

examples/binary_image.ipynb

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"metadata": {},
6+
"source": [
7+
"# Draw a NumPy array directly on the Canvas with `put_image_data`"
8+
]
9+
},
10+
{
11+
"cell_type": "code",
12+
"execution_count": null,
13+
"metadata": {},
14+
"outputs": [],
15+
"source": [
16+
"import numpy as np\n",
17+
"from ipycanvas import Canvas, hold_canvas"
18+
]
19+
},
20+
{
21+
"cell_type": "code",
22+
"execution_count": null,
23+
"metadata": {},
24+
"outputs": [],
25+
"source": [
26+
"dx, dy = 0.01, 0.01\n",
27+
"\n",
28+
"y, x = np.mgrid[slice(1, 5 + dy, dy),\n",
29+
" slice(1, 5 + dx, dx)]\n",
30+
"\n",
31+
"z = np.sin(x)**5 + np.cos(5 + y*x) * np.cos(x)"
32+
]
33+
},
34+
{
35+
"cell_type": "code",
36+
"execution_count": null,
37+
"metadata": {},
38+
"outputs": [],
39+
"source": [
40+
"min = np.min(z)\n",
41+
"max = np.max(z)\n",
42+
"\n",
43+
"def scale(value):\n",
44+
" scaled_value = (value - min) / (max - min) \n",
45+
" return 255 if value > max else scaled_value * 255\n",
46+
"\n",
47+
"vecscale = np.vectorize(scale)"
48+
]
49+
},
50+
{
51+
"cell_type": "code",
52+
"execution_count": null,
53+
"metadata": {},
54+
"outputs": [],
55+
"source": [
56+
"zz = np.stack((np.zeros_like(z), vecscale(z), vecscale(z)), axis=2)"
57+
]
58+
},
59+
{
60+
"cell_type": "code",
61+
"execution_count": null,
62+
"metadata": {},
63+
"outputs": [],
64+
"source": [
65+
"canvas = Canvas(size=(zz.shape[0], zz.shape[1]))\n",
66+
"canvas"
67+
]
68+
},
69+
{
70+
"cell_type": "code",
71+
"execution_count": null,
72+
"metadata": {},
73+
"outputs": [],
74+
"source": [
75+
"canvas.put_image_data(zz, 0, 0)"
76+
]
77+
}
78+
],
79+
"metadata": {
80+
"kernelspec": {
81+
"display_name": "Python 3",
82+
"language": "python",
83+
"name": "python3"
84+
},
85+
"language_info": {
86+
"codemirror_mode": {
87+
"name": "ipython",
88+
"version": 3
89+
},
90+
"file_extension": ".py",
91+
"mimetype": "text/x-python",
92+
"name": "python",
93+
"nbconvert_exporter": "python",
94+
"pygments_lexer": "ipython3",
95+
"version": "3.7.3"
96+
}
97+
},
98+
"nbformat": 4,
99+
"nbformat_minor": 4
100+
}

ipycanvas/binary.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Binary module."""
2+
3+
try:
4+
from io import BytesIO as StringIO # python3
5+
except:
6+
from StringIO import StringIO # python2
7+
8+
import numpy as np
9+
10+
11+
def array_to_binary(ar):
12+
"""Turn a NumPy array into a binary buffer."""
13+
if ar is None:
14+
return None
15+
if ar.dtype != np.uint8:
16+
ar = ar.astype(np.uint8)
17+
if ar.ndim == 1:
18+
ar = ar[np.newaxis, :]
19+
if ar.ndim == 2:
20+
# extend grayscale to RGBA
21+
add_alpha = np.full((ar.shape[0], ar.shape[1], 4), 255, dtype=np.uint8)
22+
add_alpha[:, :, :3] = np.repeat(ar[:, :, np.newaxis], repeats=3, axis=2)
23+
ar = add_alpha
24+
if ar.ndim != 3:
25+
raise ValueError("Please supply an RGBA array with shape (width, height, 4).")
26+
if ar.shape[2] != 4 and ar.shape[2] == 3:
27+
add_alpha = np.full((ar.shape[0], ar.shape[1], 4), 255, dtype=np.uint8)
28+
add_alpha[:, :, :3] = ar
29+
ar = add_alpha
30+
if not ar.flags["C_CONTIGUOUS"]: # make sure it's contiguous
31+
ar = np.ascontiguousarray(ar, dtype=np.uint8)
32+
return ar.shape, memoryview(ar)

ipycanvas/canvas.py

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
from ._frontend import module_name, module_version
1414

15+
from .binary import array_to_binary
16+
1517

1618
def to_camel_case(snake_str):
1719
"""Turn a snake_case string into a camelCase one."""
@@ -42,6 +44,7 @@ def __init__(self, *args, **kwargs):
4244
"""Create a Canvas widget."""
4345
self.caching = kwargs.get('caching', False)
4446
self._commands_cache = []
47+
self._buffers_cache = []
4548

4649
super(Canvas, self).__init__(*args, **kwargs)
4750
self.layout.width = str(self.size[0]) + 'px'
@@ -50,19 +53,19 @@ def __init__(self, *args, **kwargs):
5053
# Rectangles methods
5154
def fill_rect(self, x, y, width, height):
5255
"""Draw a filled rectangle."""
53-
self._send_canvas_command('fillRect', x, y, width, height)
56+
self._send_canvas_command('fillRect', (x, y, width, height))
5457

5558
def stroke_rect(self, x, y, width, height):
5659
"""Draw a rectangular outline."""
57-
self._send_canvas_command('strokeRect', x, y, width, height)
60+
self._send_canvas_command('strokeRect', (x, y, width, height))
5861

5962
def clear_rect(self, x, y, width, height):
6063
"""Clear the specified rectangular area, making it fully transparent."""
61-
self._send_canvas_command('clearRect', x, y, width, height)
64+
self._send_canvas_command('clearRect', (x, y, width, height))
6265

6366
def rect(self, x, y, width, height):
6467
"""Draw a rectangle whose top-left corner is specified by (x, y) with the specified width and height."""
65-
self._send_canvas_command('rect', x, y, width, height)
68+
self._send_canvas_command('rect', (x, y, width, height))
6669

6770
# Paths methods
6871
def begin_path(self):
@@ -87,27 +90,27 @@ def fill(self):
8790

8891
def move_to(self, x, y):
8992
"""Move the "pen" to the given (x, y) coordinates."""
90-
self._send_canvas_command('moveTo', x, y)
93+
self._send_canvas_command('moveTo', (x, y))
9194

9295
def line_to(self, x, y):
9396
"""Add a straight line to the current path by connecting the path's last point to the specified (x, y) coordinates.
9497
9598
Like other methods that modify the current path, this method does not directly render anything. To
9699
draw the path onto the canvas, you can use the fill() or stroke() methods.
97100
"""
98-
self._send_canvas_command('lineTo', x, y)
101+
self._send_canvas_command('lineTo', (x, y))
99102

100103
def arc(self, x, y, radius, start_angle, end_angle, anticlockwise=False):
101104
"""Create a circular arc centered at (x, y) with a radius of radius.
102105
103106
The path starts at startAngle and ends at endAngle, and travels in the direction given by
104107
anticlockwise (defaulting to clockwise).
105108
"""
106-
self._send_canvas_command('arc', x, y, radius, start_angle, end_angle, anticlockwise)
109+
self._send_canvas_command('arc', (x, y, radius, start_angle, end_angle, anticlockwise))
107110

108111
def arc_to(self, x1, y1, x2, y2, radius):
109112
"""Add a circular arc to the current path, using the given control points and radius."""
110-
self._send_canvas_command('arcTo', x1, y1, x2, y2, radius)
113+
self._send_canvas_command('arcTo', (x1, y1, x2, y2, radius))
111114

112115
def quadratic_curve_to(self, cp1x, cp1y, x, y):
113116
"""Add a quadratic Bezier curve to the current path.
@@ -116,7 +119,7 @@ def quadratic_curve_to(self, cp1x, cp1y, x, y):
116119
The starting point is the latest point in the current path, which can be changed using move_to()
117120
before creating the quadratic Bezier curve.
118121
"""
119-
self._send_canvas_command('quadraticCurveTo', cp1x, cp1y, x, y)
122+
self._send_canvas_command('quadraticCurveTo', (cp1x, cp1y, x, y))
120123

121124
def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y):
122125
"""Add a cubic Bezier curve to the current path.
@@ -125,16 +128,27 @@ def bezier_curve_to(self, cp1x, cp1y, cp2x, cp2y, x, y):
125128
The starting point is the latest point in the current path, which can be changed using move_to()
126129
before creating the Bezier curve.
127130
"""
128-
self._send_canvas_command('bezierCurveTo', cp1x, cp1y, cp2x, cp2y, x, y)
131+
self._send_canvas_command('bezierCurveTo', (cp1x, cp1y, cp2x, cp2y, x, y))
129132

130133
# Text methods
131134
def fill_text(self, text, x, y, max_width=None):
132135
"""Fill a given text at the given (x,y) position. Optionally with a maximum width to draw."""
133-
self._send_canvas_command('fillText', text, x, y, max_width)
136+
self._send_canvas_command('fillText', (text, x, y, max_width))
134137

135138
def stroke_text(self, text, x, y, max_width=None):
136139
"""Stroke a given text at the given (x,y) position. Optionally with a maximum width to draw."""
137-
self._send_canvas_command('strokeText', text, x, y, max_width)
140+
self._send_canvas_command('strokeText', (text, x, y, max_width))
141+
142+
# Image methods
143+
def put_image_data(self, image_data, dx, dy):
144+
"""Draw an image on the Canvas.
145+
146+
`image_data` being a NumPy array defining the image to draw
147+
and `x` and `y` the pixel position where to draw.
148+
This method is not affected by the canvas transformation matrix.
149+
"""
150+
shape, image_buffer = array_to_binary(image_data)
151+
self._send_canvas_command('putImageData', ({'shape': shape}, dx, dy), (image_buffer, ))
138152

139153
def clear(self):
140154
"""Clear the entire canvas."""
@@ -145,10 +159,11 @@ def flush(self):
145159
if not self.caching:
146160
return
147161

148-
self.send(self._commands_cache)
162+
self.send(self._commands_cache, self._buffers_cache)
149163

150164
self.caching = False
151165
self._commands_cache = []
166+
self._buffers_cache = []
152167

153168
@observe('fill_style', 'stroke_style', 'global_alpha', 'font', 'textAlign', 'textBaseline', 'direction')
154169
def _on_set_attr(self, change):
@@ -159,14 +174,20 @@ def _on_set_attr(self, change):
159174
}
160175
self._send_command(command)
161176

162-
def _send_canvas_command(self, name, *args):
163-
self._send_command({'name': name, 'args': [arg for arg in args if arg is not None]})
177+
def _send_canvas_command(self, name, args=[], buffers=[]):
178+
command = {
179+
'name': name,
180+
'n_buffers': len(buffers),
181+
'args': [arg for arg in args if arg is not None]
182+
}
183+
self._send_command(command, buffers)
164184

165-
def _send_command(self, command):
185+
def _send_command(self, command, buffers=[]):
166186
if self.caching:
167187
self._commands_cache.append(command)
188+
self._buffers_cache += buffers
168189
else:
169-
self.send(command)
190+
self.send(command, buffers)
170191

171192

172193
class MultiCanvas(DOMWidget):

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
include_package_data = True,
8888
install_requires = [
8989
'ipywidgets>=7.5.0',
90+
'numpy'
9091
],
9192
extras_require = {
9293
'examples': [

src/widget.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,37 +40,67 @@ class CanvasModel extends DOMWidgetModel {
4040
this.on('msg:custom', this.onCommand.bind(this));
4141
}
4242

43-
private onCommand(command: any) {
44-
this.processCommand(command);
43+
private onCommand(command: any, buffers: any) {
44+
this.processCommand(command, buffers);
4545

4646
this.forEachView((view: CanvasView) => {
4747
view.updateCanvas();
4848
});
4949
}
5050

51-
private processCommand(command: any) {
51+
private processCommand(command: any, buffers: any) {
5252
if (command instanceof Array) {
53+
let remainingBuffers = buffers;
54+
5355
for (const subcommand of command) {
54-
this.processCommand(subcommand);
56+
let subbuffers = [];
57+
if (subcommand.n_buffers) {
58+
subbuffers = remainingBuffers.slice(0, subcommand.n_buffers);
59+
remainingBuffers = remainingBuffers.slice(subcommand.n_buffers)
60+
}
61+
this.processCommand(subcommand, subbuffers);
5562
}
5663
return;
5764
}
5865

59-
if (command.name == 'set') {
60-
this.ctx[command.attr] = command.value;
61-
return;
66+
switch (command.name) {
67+
case 'putImageData':
68+
this.putImageData(command.args, buffers);
69+
break;
70+
case 'set':
71+
this.setAttr(command.attr, command.value);
72+
break;
73+
case 'clear':
74+
this.clearCanvas();
75+
break;
76+
default:
77+
this.executeCommand(command.name, command.args);
78+
break;
6279
}
80+
}
6381

64-
if (command.name == 'clear') {
65-
this.forEachView((view: CanvasView) => {
66-
view.clear();
67-
});
68-
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
82+
private putImageData(args: any[], buffers: any) {
83+
const [bufferMetadata, dx, dy] = args;
6984

70-
return;
71-
}
85+
const data = new Uint8ClampedArray(buffers[0].buffer);
86+
const imageData = new ImageData(data, bufferMetadata.shape[1], bufferMetadata.shape[0]);
87+
88+
this.ctx.putImageData(imageData, dx, dy);
89+
}
90+
91+
private setAttr(attr: string, value: any) {
92+
this.ctx[attr] = value;
93+
}
94+
95+
private clearCanvas() {
96+
this.forEachView((view: CanvasView) => {
97+
view.clear();
98+
});
99+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
100+
}
72101

73-
this.ctx[command.name](...command.args);
102+
private executeCommand(name: string, args: any[]) {
103+
this.ctx[name](...args);
74104
}
75105

76106
private forEachView(callback: (view: CanvasView) => void) {

0 commit comments

Comments
 (0)