pyopensign 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opensign/__init__.py +737 -0
- opensign/canvas.py +293 -0
- pyopensign-1.0.0.dist-info/METADATA +75 -0
- pyopensign-1.0.0.dist-info/RECORD +8 -0
- pyopensign-1.0.0.dist-info/WHEEL +5 -0
- pyopensign-1.0.0.dist-info/licenses/LICENSE +21 -0
- pyopensign-1.0.0.dist-info/licenses/LICENSE.license +3 -0
- pyopensign-1.0.0.dist-info/top_level.txt +1 -0
opensign/__init__.py
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
`opensign`
|
|
7
|
+
================================================================================
|
|
8
|
+
|
|
9
|
+
A library to facilitate easy RGB Matrix Sign Animations.
|
|
10
|
+
|
|
11
|
+
* Author(s): Melissa LeBlanc-Williams
|
|
12
|
+
|
|
13
|
+
Implementation Notes
|
|
14
|
+
--------------------
|
|
15
|
+
|
|
16
|
+
**Software and Dependencies:**
|
|
17
|
+
|
|
18
|
+
* Henner Zeller RGB Matrix Library:
|
|
19
|
+
https://github.com/hzeller/rpi-rgb-led-matrix
|
|
20
|
+
|
|
21
|
+
* Python Imaging Library (Pillow)
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
from PIL import Image, ImageChops
|
|
29
|
+
from rgbmatrix import RGBMatrix, RGBMatrixOptions
|
|
30
|
+
|
|
31
|
+
__version__ = "0.0.0-auto.0"
|
|
32
|
+
__repo__ = "https://github.com/Maker-Melissa/PyOpenSign.git"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# pylint: disable=too-many-public-methods, too-many-lines
|
|
36
|
+
class OpenSign:
|
|
37
|
+
"""Main class that controls the sign and graphics effects."""
|
|
38
|
+
|
|
39
|
+
# pylint: disable=too-many-locals
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
rows=16,
|
|
44
|
+
columns=32,
|
|
45
|
+
chain=1,
|
|
46
|
+
brightness=100,
|
|
47
|
+
gpio_mapping="adafruit-hat",
|
|
48
|
+
parallel=1,
|
|
49
|
+
pwm_bits=11,
|
|
50
|
+
panel_type="",
|
|
51
|
+
rgb_sequence="rgb",
|
|
52
|
+
show_refresh=False,
|
|
53
|
+
slowdown_gpio=None,
|
|
54
|
+
no_hardware_pulse=False,
|
|
55
|
+
pwm_lsb_nanoseconds=130,
|
|
56
|
+
row_addr_type=0,
|
|
57
|
+
multiplexing=0,
|
|
58
|
+
pixel_mapper="",
|
|
59
|
+
):
|
|
60
|
+
options = RGBMatrixOptions()
|
|
61
|
+
|
|
62
|
+
options.hardware_mapping = gpio_mapping
|
|
63
|
+
options.rows = rows
|
|
64
|
+
options.cols = columns
|
|
65
|
+
options.chain_length = chain
|
|
66
|
+
options.parallel = parallel
|
|
67
|
+
options.pwm_bits = pwm_bits
|
|
68
|
+
options.brightness = brightness
|
|
69
|
+
options.panel_type = panel_type
|
|
70
|
+
options.led_rgb_sequence = rgb_sequence
|
|
71
|
+
options.pwm_lsb_nanoseconds = pwm_lsb_nanoseconds
|
|
72
|
+
options.row_address_type = row_addr_type
|
|
73
|
+
options.multiplexing = multiplexing
|
|
74
|
+
options.pixel_mapper_config = pixel_mapper
|
|
75
|
+
|
|
76
|
+
if show_refresh:
|
|
77
|
+
options.show_refresh_rate = 1
|
|
78
|
+
if slowdown_gpio is not None:
|
|
79
|
+
options.gpio_slowdown = slowdown_gpio
|
|
80
|
+
if no_hardware_pulse:
|
|
81
|
+
options.disable_hardware_pulsing = True
|
|
82
|
+
|
|
83
|
+
self._matrix = RGBMatrix(options=options)
|
|
84
|
+
self._buffer = self._matrix.CreateFrameCanvas()
|
|
85
|
+
self._background = (0, 0, 0)
|
|
86
|
+
self._position = (0, 0)
|
|
87
|
+
# pylint: enable=too-many-locals
|
|
88
|
+
|
|
89
|
+
def _update(self):
|
|
90
|
+
self._buffer = self._matrix.SwapOnVSync(self._buffer)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def width(self):
|
|
94
|
+
"""Returns the width in pixels"""
|
|
95
|
+
return self._matrix.width
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def height(self):
|
|
99
|
+
"""Returns the height in pixels"""
|
|
100
|
+
return self._matrix.height
|
|
101
|
+
|
|
102
|
+
# pylint: disable=too-many-arguments, too-many-locals
|
|
103
|
+
def _add_background(self, image, x, y, opacity=1.0, shadow_intensity=0, shadow_offset=1):
|
|
104
|
+
"""Combine the foreground and background images and apply any shadow and opacity effects."""
|
|
105
|
+
if isinstance(self._background, tuple):
|
|
106
|
+
combined_image = Image.new(
|
|
107
|
+
"RGBA", (self._matrix.width, self._matrix.height), self._background
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
combined_image = Image.new("RGBA", (self._matrix.width, self._matrix.height))
|
|
111
|
+
combined_image.alpha_composite(self._background)
|
|
112
|
+
|
|
113
|
+
source_x = source_y = 0
|
|
114
|
+
if x < 0:
|
|
115
|
+
source_x = 0 - x
|
|
116
|
+
x = 0
|
|
117
|
+
if y < 0:
|
|
118
|
+
source_y = 0 - y
|
|
119
|
+
y = 0
|
|
120
|
+
|
|
121
|
+
# Keep opacity in the range of 0-1.0
|
|
122
|
+
opacity = max(0, min(1.0, opacity))
|
|
123
|
+
|
|
124
|
+
foreground_image = Image.new(
|
|
125
|
+
"RGBA", (self._matrix.width, self._matrix.height), (0, 0, 0, 0)
|
|
126
|
+
)
|
|
127
|
+
if source_x < image.width and source_y < image.height:
|
|
128
|
+
foreground_image.alpha_composite(image, dest=(x, y), source=(source_x, source_y))
|
|
129
|
+
|
|
130
|
+
alpha = foreground_image.split()[-1]
|
|
131
|
+
|
|
132
|
+
if opacity == 1:
|
|
133
|
+
opacity_mask = ImageChops.invert(alpha)
|
|
134
|
+
elif opacity == 0:
|
|
135
|
+
opacity_mask = Image.new("L", (self._matrix.width, self._matrix.height), 255)
|
|
136
|
+
else:
|
|
137
|
+
opacity_filter = Image.new(
|
|
138
|
+
"L", (self._matrix.width, self._matrix.height), round(opacity * 255)
|
|
139
|
+
)
|
|
140
|
+
opacity_mask = ImageChops.darker(alpha, opacity_filter)
|
|
141
|
+
opacity_mask = ImageChops.invert(opacity_mask)
|
|
142
|
+
|
|
143
|
+
if shadow_intensity:
|
|
144
|
+
shadow_image = Image.new("RGB", (self._matrix.width, self._matrix.height))
|
|
145
|
+
shadow_filter = Image.new(
|
|
146
|
+
"L",
|
|
147
|
+
(self._matrix.width, self._matrix.height),
|
|
148
|
+
round(shadow_intensity * opacity * 255),
|
|
149
|
+
)
|
|
150
|
+
shadow_mask = ImageChops.darker(alpha, shadow_filter)
|
|
151
|
+
shadow_shifted = Image.new("L", (self._matrix.width, self._matrix.height), 0)
|
|
152
|
+
shadow_shifted.paste(shadow_mask, box=(shadow_offset, shadow_offset))
|
|
153
|
+
shadow_shifted = ImageChops.invert(shadow_shifted)
|
|
154
|
+
combined_image = Image.composite(combined_image, shadow_image, shadow_shifted)
|
|
155
|
+
|
|
156
|
+
return Image.composite(combined_image, foreground_image, opacity_mask).convert("RGB")
|
|
157
|
+
|
|
158
|
+
# pylint: enable=too-many-arguments, too-many-locals
|
|
159
|
+
|
|
160
|
+
def _draw(self, canvas, x, y, opacity=1.0):
|
|
161
|
+
"""Draws a canvas to the buffer taking its current settings into account.
|
|
162
|
+
It also sets the current position and performs a swap.
|
|
163
|
+
"""
|
|
164
|
+
self._position = (x, y)
|
|
165
|
+
self._buffer.SetImage(
|
|
166
|
+
self._add_background(
|
|
167
|
+
canvas.get_image(),
|
|
168
|
+
x,
|
|
169
|
+
y,
|
|
170
|
+
opacity=(opacity * canvas.opacity),
|
|
171
|
+
shadow_intensity=canvas.shadow_intensity,
|
|
172
|
+
shadow_offset=canvas.shadow_offset,
|
|
173
|
+
),
|
|
174
|
+
0,
|
|
175
|
+
0,
|
|
176
|
+
)
|
|
177
|
+
self._update()
|
|
178
|
+
|
|
179
|
+
# pylint: disable=too-many-arguments
|
|
180
|
+
def _draw_image(self, image, x, y, opacity, shadow_intensity, shadow_offset):
|
|
181
|
+
"""Draws an image to the buffer. Settings are passed as additional parameters.
|
|
182
|
+
It also sets the current position and performs a swap.
|
|
183
|
+
"""
|
|
184
|
+
self._position = (x, y)
|
|
185
|
+
self._buffer.SetImage(
|
|
186
|
+
self._add_background(
|
|
187
|
+
image,
|
|
188
|
+
x,
|
|
189
|
+
y,
|
|
190
|
+
opacity=opacity,
|
|
191
|
+
shadow_intensity=shadow_intensity,
|
|
192
|
+
shadow_offset=shadow_offset,
|
|
193
|
+
),
|
|
194
|
+
0,
|
|
195
|
+
0,
|
|
196
|
+
)
|
|
197
|
+
self._update()
|
|
198
|
+
|
|
199
|
+
# pylint: enable=too-many-arguments
|
|
200
|
+
|
|
201
|
+
# pylint: disable=no-self-use
|
|
202
|
+
def _create_loop_image(self, image, x_offset, y_offset):
|
|
203
|
+
"""Attach a copy of an image by a certain offset so it can be looped."""
|
|
204
|
+
loop_image = Image.new(
|
|
205
|
+
"RGBA", (image.width + x_offset, image.height + y_offset), (0, 0, 0, 0)
|
|
206
|
+
)
|
|
207
|
+
loop_image.alpha_composite(image, dest=(0, 0))
|
|
208
|
+
loop_image.alpha_composite(image, dest=(x_offset, y_offset))
|
|
209
|
+
return loop_image
|
|
210
|
+
|
|
211
|
+
# pylint: enable=no-self-use
|
|
212
|
+
|
|
213
|
+
def _get_centered_position(self, canvas):
|
|
214
|
+
return int(self._matrix.width / 2 - canvas.width / 2), int(
|
|
215
|
+
self._matrix.height / 2 - canvas.height / 2
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def set_background_color(self, color):
|
|
219
|
+
"""Sets the background to a solid color. The color should be a 3 or 4 value
|
|
220
|
+
tuple or list or an hexidecimal value in the format of 0xRRGGBB.
|
|
221
|
+
|
|
222
|
+
:param color: The time to sleep in seconds.
|
|
223
|
+
:type color: tuple or list or int
|
|
224
|
+
"""
|
|
225
|
+
if isinstance(color, (tuple, list)) and len(color) == 3:
|
|
226
|
+
self._background = tuple(color)
|
|
227
|
+
elif isinstance(color, int):
|
|
228
|
+
self._background = ((color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF)
|
|
229
|
+
else:
|
|
230
|
+
raise ValueError("Color should be an integer or 3 value tuple or list.")
|
|
231
|
+
|
|
232
|
+
def set_background_image(self, file):
|
|
233
|
+
"""Sets the background to an image
|
|
234
|
+
|
|
235
|
+
The image is loaded into memory immediately so it remains available
|
|
236
|
+
even after the matrix library drops root privileges.
|
|
237
|
+
|
|
238
|
+
:param string file: The file location of the image to display.
|
|
239
|
+
"""
|
|
240
|
+
if not os.path.exists(file):
|
|
241
|
+
raise ValueError(f"Specified background file {file} was not found")
|
|
242
|
+
self._background = Image.open(file).convert("RGBA")
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def _wait(start_time, duration):
|
|
246
|
+
"""Uses time.monotonic() to wait from the start time for a specified duration"""
|
|
247
|
+
while time.monotonic() < (start_time + duration):
|
|
248
|
+
pass
|
|
249
|
+
return time.monotonic()
|
|
250
|
+
|
|
251
|
+
# pylint: disable=too-many-arguments
|
|
252
|
+
def scroll_from_to(self, canvas, duration, start_x, start_y, end_x, end_y):
|
|
253
|
+
"""
|
|
254
|
+
Scroll the canvas from one position to another over a certain period of
|
|
255
|
+
time.
|
|
256
|
+
|
|
257
|
+
:param canvas: The canvas to animate.
|
|
258
|
+
:param float duration: The period of time to perform the animation over in seconds.
|
|
259
|
+
:param int start_x: The Starting X Position
|
|
260
|
+
:param int start_yx: The Starting Y Position
|
|
261
|
+
:param int end_x: The Ending X Position
|
|
262
|
+
:param int end_y: The Ending Y Position
|
|
263
|
+
:type canvas: OpenSignCanvas
|
|
264
|
+
"""
|
|
265
|
+
steps = max(abs(end_x - start_x), abs(end_y - start_y))
|
|
266
|
+
if not steps:
|
|
267
|
+
return
|
|
268
|
+
increment_x = (end_x - start_x) / steps
|
|
269
|
+
increment_y = (end_y - start_y) / steps
|
|
270
|
+
for i in range(steps + 1):
|
|
271
|
+
start_time = time.monotonic()
|
|
272
|
+
current_x = start_x + round(i * increment_x)
|
|
273
|
+
current_y = start_y + round(i * increment_y)
|
|
274
|
+
self._draw(canvas, current_x, current_y)
|
|
275
|
+
if i <= steps:
|
|
276
|
+
self._wait(start_time, duration / steps)
|
|
277
|
+
|
|
278
|
+
# pylint: enable=too-many-arguments
|
|
279
|
+
|
|
280
|
+
def scroll_in_from_left(self, canvas, duration=1, x=0):
|
|
281
|
+
"""Scroll a canvas in from the left side of the display over a certain period of
|
|
282
|
+
time. The final position is centered.
|
|
283
|
+
|
|
284
|
+
:param canvas: The canvas to animate.
|
|
285
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
286
|
+
over in seconds. (default=1)
|
|
287
|
+
:param int x: (optional) The amount of x-offset from the center position (default=0)
|
|
288
|
+
:type canvas: OpenSignCanvas
|
|
289
|
+
"""
|
|
290
|
+
center_x, center_y = self._get_centered_position(canvas)
|
|
291
|
+
self.scroll_from_to(canvas, duration, 0 - canvas.width, center_y, center_x + x, center_y)
|
|
292
|
+
|
|
293
|
+
def scroll_in_from_right(self, canvas, duration=1, x=0):
|
|
294
|
+
"""Scroll a canvas in from the right side of the display over a certain period of
|
|
295
|
+
time. The final position is centered.
|
|
296
|
+
|
|
297
|
+
:param canvas: The canvas to animate.
|
|
298
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
299
|
+
over in seconds. (default=1)
|
|
300
|
+
:param int x: (optional) The amount of x-offset from the center position (default=0)
|
|
301
|
+
:type canvas: OpenSignCanvas
|
|
302
|
+
"""
|
|
303
|
+
center_x, center_y = self._get_centered_position(canvas)
|
|
304
|
+
self.scroll_from_to(canvas, duration, self._matrix.width, center_y, center_x + x, center_y)
|
|
305
|
+
|
|
306
|
+
def scroll_in_from_top(self, canvas, duration=1, y=0):
|
|
307
|
+
"""Scroll a canvas in from the top side of the display over a certain period of
|
|
308
|
+
time. The final position is centered.
|
|
309
|
+
|
|
310
|
+
:param canvas: The canvas to animate.
|
|
311
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
312
|
+
over in seconds. (default=1)
|
|
313
|
+
:param int y: (optional) The amount of y-offset from the center position (default=0)
|
|
314
|
+
:type canvas: OpenSignCanvas
|
|
315
|
+
"""
|
|
316
|
+
center_x, center_y = self._get_centered_position(canvas)
|
|
317
|
+
self.scroll_from_to(canvas, duration, center_x, 0 - canvas.height, center_x, center_y + y)
|
|
318
|
+
|
|
319
|
+
def scroll_in_from_bottom(self, canvas, duration=1, y=0):
|
|
320
|
+
"""Scroll a canvas in from the bottom side of the display over a certain period of
|
|
321
|
+
time. The final position is centered.
|
|
322
|
+
|
|
323
|
+
:param canvas: The canvas to animate.
|
|
324
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
325
|
+
over in seconds. (default=1)
|
|
326
|
+
:param int y: (optional) The amount of y-offset from the center position (default=0)
|
|
327
|
+
:type canvas: OpenSignCanvas
|
|
328
|
+
"""
|
|
329
|
+
center_x, center_y = self._get_centered_position(canvas)
|
|
330
|
+
self.scroll_from_to(canvas, duration, center_x, self._matrix.height, center_x, center_y + y)
|
|
331
|
+
|
|
332
|
+
def scroll_out_to_left(self, canvas, duration=1):
|
|
333
|
+
"""Scroll a canvas off the display from its current position towards the left
|
|
334
|
+
over a certain period of time.
|
|
335
|
+
|
|
336
|
+
:param canvas: The canvas to animate.
|
|
337
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
338
|
+
over in seconds. (default=1)
|
|
339
|
+
:type canvas: OpenSignCanvas
|
|
340
|
+
"""
|
|
341
|
+
current_x, current_y = self._position
|
|
342
|
+
self.scroll_from_to(canvas, duration, current_x, current_y, 0 - canvas.width, current_y)
|
|
343
|
+
|
|
344
|
+
def scroll_out_to_right(self, canvas, duration=1):
|
|
345
|
+
"""Scroll a canvas off the display from its current position towards the right
|
|
346
|
+
over a certain period of time.
|
|
347
|
+
|
|
348
|
+
:param canvas: The canvas to animate.
|
|
349
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
350
|
+
over in seconds. (default=1)
|
|
351
|
+
:type canvas: OpenSignCanvas
|
|
352
|
+
"""
|
|
353
|
+
current_x, current_y = self._position
|
|
354
|
+
self.scroll_from_to(canvas, duration, current_x, current_y, self._matrix.width, current_y)
|
|
355
|
+
|
|
356
|
+
def scroll_out_to_top(self, canvas, duration=1):
|
|
357
|
+
"""Scroll a canvas off the display from its current position towards the top
|
|
358
|
+
over a certain period of time.
|
|
359
|
+
|
|
360
|
+
:param canvas: The canvas to animate.
|
|
361
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
362
|
+
over in seconds. (default=1)
|
|
363
|
+
:type canvas: OpenSignCanvas
|
|
364
|
+
"""
|
|
365
|
+
current_x, current_y = self._position
|
|
366
|
+
self.scroll_from_to(canvas, duration, current_x, current_y, current_x, 0 - canvas.height)
|
|
367
|
+
|
|
368
|
+
def scroll_out_to_bottom(self, canvas, duration=1):
|
|
369
|
+
"""Scroll a canvas off the display from its current position towards the bottom
|
|
370
|
+
over a certain period of time.
|
|
371
|
+
|
|
372
|
+
:param canvas: The canvas to animate.
|
|
373
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
374
|
+
over in seconds. (default=1)
|
|
375
|
+
:type canvas: OpenSignCanvas
|
|
376
|
+
"""
|
|
377
|
+
current_x, current_y = self._position
|
|
378
|
+
self.scroll_from_to(canvas, duration, current_x, current_y, current_x, self._matrix.height)
|
|
379
|
+
|
|
380
|
+
def set_position(self, canvas, x=0, y=0):
|
|
381
|
+
"""Instantly move the canvas to a specific location. (0, 0) is the top-left corner.
|
|
382
|
+
|
|
383
|
+
:param canvas: The canvas to move.
|
|
384
|
+
:param int x: (optional) The x-position to move the canvas to. (default=0)
|
|
385
|
+
:param int y: (optional) The y-position to move the canvas to. (default=0)
|
|
386
|
+
:type canvas: OpenSignCanvas
|
|
387
|
+
"""
|
|
388
|
+
self._draw(canvas, x, y)
|
|
389
|
+
|
|
390
|
+
def show(self, canvas):
|
|
391
|
+
"""Show the canvas at its current position.
|
|
392
|
+
|
|
393
|
+
:param canvas: The canvas to show.
|
|
394
|
+
:type canvas: OpenSignCanvas
|
|
395
|
+
"""
|
|
396
|
+
x, y = self._position
|
|
397
|
+
self._draw(canvas, x, y)
|
|
398
|
+
|
|
399
|
+
def hide(self, canvas):
|
|
400
|
+
"""Hide the canvas at its current position.
|
|
401
|
+
|
|
402
|
+
:param canvas: The canvas to hide.
|
|
403
|
+
:type canvas: OpenSignCanvas
|
|
404
|
+
"""
|
|
405
|
+
x, y = self._position
|
|
406
|
+
self._draw(canvas, x, y, opacity=0)
|
|
407
|
+
|
|
408
|
+
def blink(self, canvas, count=3, duration=1):
|
|
409
|
+
"""Blink the foreground on and off a centain number of
|
|
410
|
+
times over a certain period of time.
|
|
411
|
+
|
|
412
|
+
:param canvas: The canvas to animate.
|
|
413
|
+
:param float count: (optional) The number of times to blink. (default=3)
|
|
414
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
415
|
+
over. (default=1)
|
|
416
|
+
:type canvas: OpenSignCanvas
|
|
417
|
+
"""
|
|
418
|
+
delay = duration / count / 2
|
|
419
|
+
for _ in range(count):
|
|
420
|
+
start_time = time.monotonic()
|
|
421
|
+
self.hide(canvas)
|
|
422
|
+
start_time = self._wait(start_time, delay)
|
|
423
|
+
self.show(canvas)
|
|
424
|
+
self._wait(start_time, delay)
|
|
425
|
+
|
|
426
|
+
def flash(self, canvas, count=3, duration=1):
|
|
427
|
+
"""Fade the foreground in and out a centain number of
|
|
428
|
+
times over a certain period of time.
|
|
429
|
+
|
|
430
|
+
:param canvas: The canvas to animate.
|
|
431
|
+
:param float count: (optional) The number of times to flash. (default=3)
|
|
432
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
433
|
+
over. (default=1)
|
|
434
|
+
:type canvas: OpenSignCanvas
|
|
435
|
+
"""
|
|
436
|
+
delay = duration / count / 2
|
|
437
|
+
steps = 50 // count
|
|
438
|
+
for _ in range(count):
|
|
439
|
+
self.fade_out(canvas, duration=delay, steps=steps)
|
|
440
|
+
self.fade_in(canvas, duration=delay, steps=steps)
|
|
441
|
+
|
|
442
|
+
def fade_in(self, canvas, duration=1, steps=50):
|
|
443
|
+
"""Fade the foreground in over a certain period of time
|
|
444
|
+
by a certain number of steps. More steps is smoother, but too high
|
|
445
|
+
of a number may slow down the animation too much.
|
|
446
|
+
|
|
447
|
+
:param canvas: The canvas to animate.
|
|
448
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
449
|
+
over. (default=1)
|
|
450
|
+
:param float steps: (optional) The number of steps to perform the animation. (default=50)
|
|
451
|
+
:type canvas: OpenSignCanvas
|
|
452
|
+
"""
|
|
453
|
+
current_x = int(self._matrix.width / 2 - canvas.width / 2)
|
|
454
|
+
current_y = int(self._matrix.height / 2 - canvas.height / 2)
|
|
455
|
+
delay = duration / (steps + 1)
|
|
456
|
+
for opacity in range(steps + 1):
|
|
457
|
+
start_time = time.monotonic()
|
|
458
|
+
self._draw(canvas, current_x, current_y, opacity=opacity / steps)
|
|
459
|
+
self._wait(start_time, delay)
|
|
460
|
+
|
|
461
|
+
def fade_out(self, canvas, duration=1, steps=50):
|
|
462
|
+
"""Fade the foreground out over a certain period of time
|
|
463
|
+
by a certain number of steps. More steps is smoother, but too high
|
|
464
|
+
of a number may slow down the animation too much.
|
|
465
|
+
|
|
466
|
+
:param canvas: The canvas to animate.
|
|
467
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
468
|
+
over. (default=1)
|
|
469
|
+
:param float steps: (optional) The number of steps to perform the animation. (default=50)
|
|
470
|
+
:type canvas: OpenSignCanvas
|
|
471
|
+
"""
|
|
472
|
+
delay = duration / (steps + 1)
|
|
473
|
+
for opacity in range(steps + 1):
|
|
474
|
+
start_time = time.monotonic()
|
|
475
|
+
self._draw(
|
|
476
|
+
canvas,
|
|
477
|
+
self._position[0],
|
|
478
|
+
self._position[1],
|
|
479
|
+
opacity=(steps - opacity) / steps,
|
|
480
|
+
)
|
|
481
|
+
self._wait(start_time, delay)
|
|
482
|
+
|
|
483
|
+
def join_in_horizontally(self, canvas, duration=0.5):
|
|
484
|
+
"""Show the effect of a split canvas joining horizontally
|
|
485
|
+
over a certain period of time.
|
|
486
|
+
|
|
487
|
+
:param canvas: The canvas to animate.
|
|
488
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
489
|
+
over. (default=0.5)
|
|
490
|
+
:type canvas: OpenSignCanvas
|
|
491
|
+
"""
|
|
492
|
+
current_x = int(self._matrix.width / 2 - canvas.width / 2)
|
|
493
|
+
current_y = int(self._matrix.height / 2 - canvas.height / 2)
|
|
494
|
+
image = canvas.get_image()
|
|
495
|
+
left_image = image.crop(box=(0, 0, image.width // 2 + 1, image.height))
|
|
496
|
+
right_image = image.crop(box=(image.width // 2 + 1, 0, image.width, image.height))
|
|
497
|
+
distance = self._matrix.width // 2
|
|
498
|
+
for i in range(distance + 1):
|
|
499
|
+
start_time = time.monotonic()
|
|
500
|
+
effect_image = Image.new(
|
|
501
|
+
"RGBA", (self._matrix.width + image.width, image.height), (0, 0, 0, 0)
|
|
502
|
+
)
|
|
503
|
+
effect_image.alpha_composite(left_image, dest=(i, 0))
|
|
504
|
+
effect_image.alpha_composite(
|
|
505
|
+
right_image, dest=(self._matrix.width + image.width // 2 - i + 1, 0)
|
|
506
|
+
)
|
|
507
|
+
self._draw_image(
|
|
508
|
+
effect_image,
|
|
509
|
+
current_x - self._matrix.width // 2,
|
|
510
|
+
current_y,
|
|
511
|
+
canvas.opacity,
|
|
512
|
+
canvas.shadow_intensity,
|
|
513
|
+
canvas.shadow_offset,
|
|
514
|
+
)
|
|
515
|
+
self._wait(start_time, duration / distance)
|
|
516
|
+
self._position = (current_x, current_y)
|
|
517
|
+
|
|
518
|
+
def join_in_vertically(self, canvas, duration=0.5):
|
|
519
|
+
"""Show the effect of a split canvas joining vertically
|
|
520
|
+
over a certain period of time.
|
|
521
|
+
|
|
522
|
+
:param canvas: The canvas to animate.
|
|
523
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
524
|
+
over. (default=0.5)
|
|
525
|
+
:type canvas: OpenSignCanvas
|
|
526
|
+
"""
|
|
527
|
+
current_x = int(self._matrix.width / 2 - canvas.width / 2)
|
|
528
|
+
current_y = int(self._matrix.height / 2 - canvas.height / 2)
|
|
529
|
+
image = canvas.get_image()
|
|
530
|
+
top_image = image.crop(box=(0, 0, image.width, image.height // 2 + 1))
|
|
531
|
+
bottom_image = image.crop(box=(0, image.height // 2 + 1, image.width, image.height))
|
|
532
|
+
distance = self._matrix.height // 2
|
|
533
|
+
for i in range(distance + 1):
|
|
534
|
+
start_time = time.monotonic()
|
|
535
|
+
effect_image = Image.new(
|
|
536
|
+
"RGBA", (image.width, self._matrix.height + image.height), (0, 0, 0, 0)
|
|
537
|
+
)
|
|
538
|
+
effect_image.alpha_composite(top_image, dest=(0, i))
|
|
539
|
+
effect_image.alpha_composite(
|
|
540
|
+
bottom_image, dest=(0, self._matrix.height + image.height // 2 - i + 1)
|
|
541
|
+
)
|
|
542
|
+
self._draw_image(
|
|
543
|
+
effect_image,
|
|
544
|
+
current_x,
|
|
545
|
+
current_y - self._matrix.height // 2,
|
|
546
|
+
canvas.opacity,
|
|
547
|
+
canvas.shadow_intensity,
|
|
548
|
+
canvas.shadow_offset,
|
|
549
|
+
)
|
|
550
|
+
self._wait(start_time, duration / distance)
|
|
551
|
+
self._position = (current_x, current_y)
|
|
552
|
+
|
|
553
|
+
def split_out_horizontally(self, canvas, duration=0.5):
|
|
554
|
+
"""Show the effect of a canvas splitting horizontally
|
|
555
|
+
over a certain period of time.
|
|
556
|
+
|
|
557
|
+
:param canvas: The canvas to animate.
|
|
558
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
559
|
+
over. (default=0.5)
|
|
560
|
+
:type canvas: OpenSignCanvas
|
|
561
|
+
"""
|
|
562
|
+
current_x, current_y = self._position
|
|
563
|
+
image = canvas.get_image()
|
|
564
|
+
left_image = image.crop(box=(0, 0, image.width // 2 + 1, image.height))
|
|
565
|
+
right_image = image.crop(box=(image.width // 2 + 1, 0, image.width, image.height))
|
|
566
|
+
distance = self._matrix.width // 2
|
|
567
|
+
for i in range(distance + 1):
|
|
568
|
+
start_time = time.monotonic()
|
|
569
|
+
effect_image = Image.new(
|
|
570
|
+
"RGBA", (self._matrix.width + image.width, image.height), (0, 0, 0, 0)
|
|
571
|
+
)
|
|
572
|
+
effect_image.alpha_composite(left_image, dest=(distance - i, 0))
|
|
573
|
+
effect_image.alpha_composite(right_image, dest=(distance + image.width // 2 + i + 1, 0))
|
|
574
|
+
self._draw_image(
|
|
575
|
+
effect_image,
|
|
576
|
+
current_x - self._matrix.width // 2,
|
|
577
|
+
current_y,
|
|
578
|
+
canvas.opacity,
|
|
579
|
+
canvas.shadow_intensity,
|
|
580
|
+
canvas.shadow_offset,
|
|
581
|
+
)
|
|
582
|
+
self._wait(start_time, duration / distance)
|
|
583
|
+
self._position = (current_x - self._matrix.width // 2, current_y)
|
|
584
|
+
|
|
585
|
+
def split_out_vertically(self, canvas, duration=0.5):
|
|
586
|
+
"""Show the effect of a canvas splitting vertically
|
|
587
|
+
over a certain period of time.
|
|
588
|
+
|
|
589
|
+
:param canvas: The canvas to animate.
|
|
590
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
591
|
+
over. (default=0.5)
|
|
592
|
+
:type canvas: OpenSignCanvas
|
|
593
|
+
"""
|
|
594
|
+
current_x, current_y = self._position
|
|
595
|
+
image = canvas.get_image()
|
|
596
|
+
top_image = image.crop(box=(0, 0, image.width, image.height // 2))
|
|
597
|
+
bottom_image = image.crop(box=(0, image.height // 2, image.width, image.height))
|
|
598
|
+
distance = self._matrix.height // 2
|
|
599
|
+
for i in range(distance + 1):
|
|
600
|
+
start_time = time.monotonic()
|
|
601
|
+
effect_image = Image.new(
|
|
602
|
+
"RGBA", (image.width, self._matrix.height + image.height), (0, 0, 0, 0)
|
|
603
|
+
)
|
|
604
|
+
effect_image.alpha_composite(top_image, dest=(0, distance - i))
|
|
605
|
+
effect_image.alpha_composite(
|
|
606
|
+
bottom_image, dest=(0, distance + image.height // 2 + i + 1)
|
|
607
|
+
)
|
|
608
|
+
self._draw_image(
|
|
609
|
+
effect_image,
|
|
610
|
+
current_x,
|
|
611
|
+
current_y - self._matrix.height // 2,
|
|
612
|
+
canvas.opacity,
|
|
613
|
+
canvas.shadow_intensity,
|
|
614
|
+
canvas.shadow_offset,
|
|
615
|
+
)
|
|
616
|
+
self._wait(start_time, duration / distance)
|
|
617
|
+
self._position = (current_x, current_y - self._matrix.height // 2)
|
|
618
|
+
|
|
619
|
+
def loop_left(self, canvas, duration=1, count=1):
|
|
620
|
+
"""Loop a canvas towards the left side of the display over a certain period of time by a
|
|
621
|
+
certain number of times. The canvas will re-enter from the right and end up back a the
|
|
622
|
+
starting position.
|
|
623
|
+
|
|
624
|
+
:param canvas: The canvas to animate.
|
|
625
|
+
:param float count: (optional) The number of times to loop. (default=1)
|
|
626
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
627
|
+
over. (default=1)
|
|
628
|
+
:type canvas: OpenSignCanvas
|
|
629
|
+
"""
|
|
630
|
+
current_x, current_y = self._position
|
|
631
|
+
distance = max(canvas.width, self._matrix.width)
|
|
632
|
+
loop_image = self._create_loop_image(canvas.get_image(), distance, 0)
|
|
633
|
+
for _ in range(count):
|
|
634
|
+
for _ in range(distance):
|
|
635
|
+
start_time = time.monotonic()
|
|
636
|
+
current_x -= 1
|
|
637
|
+
if current_x < 0 - canvas.width:
|
|
638
|
+
current_x += distance
|
|
639
|
+
self._draw_image(
|
|
640
|
+
loop_image,
|
|
641
|
+
current_x,
|
|
642
|
+
current_y,
|
|
643
|
+
canvas.opacity,
|
|
644
|
+
canvas.shadow_intensity,
|
|
645
|
+
canvas.shadow_offset,
|
|
646
|
+
)
|
|
647
|
+
self._wait(start_time, duration / distance / count)
|
|
648
|
+
|
|
649
|
+
def loop_right(self, canvas, duration=1, count=1):
|
|
650
|
+
"""Loop a canvas towards the right side of the display over a certain period of time by a
|
|
651
|
+
certain number of times. The canvas will re-enter from the left and end up back a the
|
|
652
|
+
starting position.
|
|
653
|
+
|
|
654
|
+
:param canvas: The canvas to animate.
|
|
655
|
+
:param float count: (optional) The number of times to loop. (default=1)
|
|
656
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
657
|
+
over. (default=1)
|
|
658
|
+
:type canvas: OpenSignCanvas
|
|
659
|
+
"""
|
|
660
|
+
current_x, current_y = self._position
|
|
661
|
+
distance = max(canvas.width, self._matrix.width)
|
|
662
|
+
loop_image = self._create_loop_image(canvas.get_image(), distance, 0)
|
|
663
|
+
for _ in range(count):
|
|
664
|
+
for _ in range(distance):
|
|
665
|
+
start_time = time.monotonic()
|
|
666
|
+
current_x += 1
|
|
667
|
+
if current_x > 0:
|
|
668
|
+
current_x -= distance
|
|
669
|
+
self._draw_image(
|
|
670
|
+
loop_image,
|
|
671
|
+
current_x,
|
|
672
|
+
current_y,
|
|
673
|
+
canvas.opacity,
|
|
674
|
+
canvas.shadow_intensity,
|
|
675
|
+
canvas.shadow_offset,
|
|
676
|
+
)
|
|
677
|
+
self._wait(start_time, duration / distance / count)
|
|
678
|
+
|
|
679
|
+
def loop_up(self, canvas, duration=0.5, count=1):
|
|
680
|
+
"""Loop a canvas towards the top side of the display over a certain period of time by a
|
|
681
|
+
certain number of times. The canvas will re-enter from the bottom and end up back a the
|
|
682
|
+
starting position.
|
|
683
|
+
|
|
684
|
+
:param canvas: The canvas to animate.
|
|
685
|
+
:param float count: (optional) The number of times to loop. (default=1)
|
|
686
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
687
|
+
over. (default=1)
|
|
688
|
+
:type canvas: OpenSignCanvas
|
|
689
|
+
"""
|
|
690
|
+
current_x, current_y = self._position
|
|
691
|
+
distance = max(canvas.height, self._matrix.height)
|
|
692
|
+
loop_image = self._create_loop_image(canvas.get_image(), 0, distance)
|
|
693
|
+
for _ in range(count):
|
|
694
|
+
for _ in range(distance):
|
|
695
|
+
start_time = time.monotonic()
|
|
696
|
+
current_y -= 1
|
|
697
|
+
if current_y < 0 - canvas.height:
|
|
698
|
+
current_y += distance
|
|
699
|
+
self._draw_image(
|
|
700
|
+
loop_image,
|
|
701
|
+
current_x,
|
|
702
|
+
current_y,
|
|
703
|
+
canvas.opacity,
|
|
704
|
+
canvas.shadow_intensity,
|
|
705
|
+
canvas.shadow_offset,
|
|
706
|
+
)
|
|
707
|
+
self._wait(start_time, duration / distance / count)
|
|
708
|
+
|
|
709
|
+
def loop_down(self, canvas, duration=0.5, count=1):
|
|
710
|
+
"""Loop a canvas towards the bottom side of the display over a certain period of time by a
|
|
711
|
+
certain number of times. The canvas will re-enter from the top and end up back a the
|
|
712
|
+
starting position.
|
|
713
|
+
|
|
714
|
+
:param canvas: The canvas to animate.
|
|
715
|
+
:param float count: (optional) The number of times to loop. (default=1)
|
|
716
|
+
:param float duration: (optional) The period of time to perform the animation
|
|
717
|
+
over. (default=1)
|
|
718
|
+
:type canvas: OpenSignCanvas
|
|
719
|
+
"""
|
|
720
|
+
current_x, current_y = self._position
|
|
721
|
+
distance = max(canvas.height, self._matrix.height)
|
|
722
|
+
loop_image = self._create_loop_image(canvas.get_image(), 0, distance)
|
|
723
|
+
for _ in range(count):
|
|
724
|
+
for _ in range(distance):
|
|
725
|
+
start_time = time.monotonic()
|
|
726
|
+
current_y += 1
|
|
727
|
+
if current_y > 0:
|
|
728
|
+
current_y -= distance
|
|
729
|
+
self._draw_image(
|
|
730
|
+
loop_image,
|
|
731
|
+
current_x,
|
|
732
|
+
current_y,
|
|
733
|
+
canvas.opacity,
|
|
734
|
+
canvas.shadow_intensity,
|
|
735
|
+
canvas.shadow_offset,
|
|
736
|
+
)
|
|
737
|
+
self._wait(start_time, duration / distance / count)
|
opensign/canvas.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
`opensign.canvas`
|
|
7
|
+
================================================================================
|
|
8
|
+
|
|
9
|
+
A library to facilitate easy RGB Matrix Sign Animations.
|
|
10
|
+
|
|
11
|
+
* Author(s): Melissa LeBlanc-Williams
|
|
12
|
+
|
|
13
|
+
Implementation Notes
|
|
14
|
+
--------------------
|
|
15
|
+
|
|
16
|
+
**Software and Dependencies:**
|
|
17
|
+
|
|
18
|
+
* Henner Zeller RGB Matrix Library:
|
|
19
|
+
https://github.com/hzeller/rpi-rgb-led-matrix
|
|
20
|
+
|
|
21
|
+
* Python Imaging Library (Pillow)
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
26
|
+
|
|
27
|
+
__version__ = "0.0.0-auto.0"
|
|
28
|
+
__repo__ = "https://github.com/Maker-Melissa/PyOpenSign.git"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OpenSignCanvas:
|
|
32
|
+
"""The Canvas is an empty image that you add text and graphics to. It will automatically
|
|
33
|
+
expand as you add content. You can then display the canvas on the sign and use the animation
|
|
34
|
+
functions to convey it."""
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self._fonts = {}
|
|
38
|
+
self._current_font = None
|
|
39
|
+
self._current_color = (255, 0, 0, 255)
|
|
40
|
+
self._image = Image.new("RGBA", (0, 0), (0, 0, 0, 0))
|
|
41
|
+
self._draw = ImageDraw.Draw(self._image)
|
|
42
|
+
self._cursor = [0, 0]
|
|
43
|
+
self._stroke_width = 0
|
|
44
|
+
self._stroke_color = None
|
|
45
|
+
self._shadow_intensity = 0
|
|
46
|
+
self._shadow_offset = 0
|
|
47
|
+
self._opacity = 1.0
|
|
48
|
+
|
|
49
|
+
def add_font(self, name, file, size=None, use=False):
|
|
50
|
+
"""Add a font to the font pool. If there is no current font set,
|
|
51
|
+
then the new font will automatically become the current font
|
|
52
|
+
|
|
53
|
+
:param string name: The name of the font. This is used when setting the font.
|
|
54
|
+
:param string file: The filename of the font. This should be the full path.
|
|
55
|
+
:param float size: (optional) The font-size to use if it is a True Type font.
|
|
56
|
+
Set to None for bitmap fonts. (default=None)
|
|
57
|
+
:param bool use: (optional) Whether or not the font should immediately be used.
|
|
58
|
+
(default=False)
|
|
59
|
+
"""
|
|
60
|
+
if size is not None:
|
|
61
|
+
self._fonts[name] = ImageFont.truetype(file, size)
|
|
62
|
+
else:
|
|
63
|
+
self._fonts[name] = ImageFont.load(file)
|
|
64
|
+
if use or self._current_font is None:
|
|
65
|
+
self._current_font = self._fonts[name]
|
|
66
|
+
|
|
67
|
+
def set_font(self, fontname):
|
|
68
|
+
"""Set the current font
|
|
69
|
+
|
|
70
|
+
:param string fontname: The name of the font to use. This should match the name parameter
|
|
71
|
+
used when adding the font.
|
|
72
|
+
"""
|
|
73
|
+
if self._fonts.get(fontname) is None:
|
|
74
|
+
raise ValueError("Font name not found.")
|
|
75
|
+
self._current_font = self._fonts[fontname]
|
|
76
|
+
|
|
77
|
+
def set_stroke(self, width, color=None):
|
|
78
|
+
"""Set the text stroke width and color
|
|
79
|
+
|
|
80
|
+
:param int width: The stroke width to use. This is how wide the outline of
|
|
81
|
+
the text is in pixels.
|
|
82
|
+
:param color: (optional) The color of the stroke. (default=None)
|
|
83
|
+
:type color: tuple or list or int
|
|
84
|
+
"""
|
|
85
|
+
self._stroke_width = width
|
|
86
|
+
if color is not None:
|
|
87
|
+
self._stroke_color = self._convert_color(color)
|
|
88
|
+
else:
|
|
89
|
+
self._stroke_color = None
|
|
90
|
+
|
|
91
|
+
# pylint: disable=no-self-use
|
|
92
|
+
def _convert_color(self, color):
|
|
93
|
+
if isinstance(color, (tuple, list)):
|
|
94
|
+
if len(color) == 3:
|
|
95
|
+
return (color[0], color[1], color[2], 255)
|
|
96
|
+
if len(color) == 4:
|
|
97
|
+
return tuple(color)
|
|
98
|
+
if isinstance(color, int):
|
|
99
|
+
return ((color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF, 255)
|
|
100
|
+
raise ValueError("Color should be an integer or 3 or 4 value tuple or list.")
|
|
101
|
+
|
|
102
|
+
# pylint: enable=no-self-use
|
|
103
|
+
|
|
104
|
+
def _enlarge_canvas(self, width, height):
|
|
105
|
+
if self._cursor[0] + width >= self._image.width:
|
|
106
|
+
new_width = self._cursor[0] + width
|
|
107
|
+
else:
|
|
108
|
+
new_width = self._image.width
|
|
109
|
+
if self._cursor[1] + height >= self._image.height:
|
|
110
|
+
new_height = self._cursor[1] + height
|
|
111
|
+
else:
|
|
112
|
+
new_height = self._image.height
|
|
113
|
+
new_image = Image.new("RGBA", (new_width, new_height))
|
|
114
|
+
new_image.alpha_composite(self._image)
|
|
115
|
+
self._image = new_image
|
|
116
|
+
self._draw = ImageDraw.Draw(self._image)
|
|
117
|
+
|
|
118
|
+
def set_color(self, color):
|
|
119
|
+
"""Set the current text color.
|
|
120
|
+
|
|
121
|
+
:param color: The color of the text.
|
|
122
|
+
:type color: tuple or list or int
|
|
123
|
+
"""
|
|
124
|
+
self._current_color = self._convert_color(color)
|
|
125
|
+
|
|
126
|
+
def set_shadow(self, intensity=0.5, offset=1):
|
|
127
|
+
"""Set the canvas to display a shadow of the content. To turn shadow off, set
|
|
128
|
+
the intensity to 0. The shadow is global for the entire canvas.
|
|
129
|
+
|
|
130
|
+
:param float intensity: (optional) The opaquness of the shadow (default=0.5).
|
|
131
|
+
:param int offset: (optional) The offset in pixels towards the lower right (default=1).
|
|
132
|
+
"""
|
|
133
|
+
intensity = max(0, min(1.0, intensity))
|
|
134
|
+
self._shadow_intensity = intensity
|
|
135
|
+
self._shadow_offset = offset
|
|
136
|
+
|
|
137
|
+
# pylint: disable=too-many-arguments
|
|
138
|
+
def add_text(
|
|
139
|
+
self,
|
|
140
|
+
text,
|
|
141
|
+
color=None,
|
|
142
|
+
font=None,
|
|
143
|
+
stroke_width=None,
|
|
144
|
+
stroke_color=None,
|
|
145
|
+
x_offset=0,
|
|
146
|
+
y_offset=0,
|
|
147
|
+
):
|
|
148
|
+
"""Add text to the canvas.
|
|
149
|
+
|
|
150
|
+
:param string text: The text to add.
|
|
151
|
+
:param color: (optional) The color of the text to override the current setting.
|
|
152
|
+
(default=Current Setting)
|
|
153
|
+
:param string fontname: (optional) The name of the font to override the current setting.
|
|
154
|
+
(default=Current Setting)
|
|
155
|
+
:param int stroke_width: (optional) The stroke width to override the current setting.
|
|
156
|
+
(default=Current Setting)
|
|
157
|
+
:param stroke_color: (optional) The color of the stroke to override the current setting.
|
|
158
|
+
(default=Current Setting)
|
|
159
|
+
:param int x_offset: (optional) The amount of x-offset to nudge the text. (default=0)
|
|
160
|
+
:param int y_offset: (optional) The amount of y-offset to nudge the text. (default=0)
|
|
161
|
+
:type color: tuple or list or int
|
|
162
|
+
:type stroke_color: tuple or list or int
|
|
163
|
+
"""
|
|
164
|
+
if font is not None:
|
|
165
|
+
font = self._fonts[font]
|
|
166
|
+
else:
|
|
167
|
+
font = self._current_font
|
|
168
|
+
if font is None:
|
|
169
|
+
font = ImageFont.load_default()
|
|
170
|
+
x, y = self._cursor
|
|
171
|
+
|
|
172
|
+
if color is None:
|
|
173
|
+
color = self._current_color
|
|
174
|
+
else:
|
|
175
|
+
color = self._convert_color(color)
|
|
176
|
+
|
|
177
|
+
if stroke_color is None:
|
|
178
|
+
stroke_color = self._stroke_color
|
|
179
|
+
else:
|
|
180
|
+
stroke_color = self._convert_color(stroke_color)
|
|
181
|
+
|
|
182
|
+
if stroke_width is None:
|
|
183
|
+
stroke_width = self._stroke_width
|
|
184
|
+
|
|
185
|
+
lines = text.split("\n")
|
|
186
|
+
for index, line in enumerate(lines):
|
|
187
|
+
# Pillow 10+ removed getsize; derive size from the bounding box.
|
|
188
|
+
text_width, text_height = self._text_size(font, line, stroke_width=stroke_width)
|
|
189
|
+
self._enlarge_canvas(text_width, text_height)
|
|
190
|
+
# Draw the text
|
|
191
|
+
self._draw.text(
|
|
192
|
+
(x + x_offset, y + y_offset),
|
|
193
|
+
line,
|
|
194
|
+
font=font,
|
|
195
|
+
fill=color,
|
|
196
|
+
stroke_width=stroke_width,
|
|
197
|
+
stroke_fill=stroke_color,
|
|
198
|
+
)
|
|
199
|
+
# Get size and add to cursor
|
|
200
|
+
self._cursor[0] += text_width
|
|
201
|
+
if index < len(lines) - 1:
|
|
202
|
+
y += text_height
|
|
203
|
+
self._cursor[0] = 0
|
|
204
|
+
self._cursor[1] += text_height
|
|
205
|
+
|
|
206
|
+
# pylint: enable=too-many-arguments
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _text_size(font, text, stroke_width=0):
|
|
210
|
+
"""Return (width, height) of text, replacing Pillow's removed getsize."""
|
|
211
|
+
left, top, right, bottom = font.getbbox(text, stroke_width=stroke_width)
|
|
212
|
+
return (right - left, bottom - top)
|
|
213
|
+
|
|
214
|
+
def add_image(self, file):
|
|
215
|
+
"""Add an image to the canvas.
|
|
216
|
+
|
|
217
|
+
:param string file: The filename of the image. This should be the full path.
|
|
218
|
+
"""
|
|
219
|
+
x, y = self._cursor
|
|
220
|
+
new_image = Image.open(file).convert("RGBA")
|
|
221
|
+
self._enlarge_canvas(new_image.width, new_image.height)
|
|
222
|
+
self._image.alpha_composite(new_image, dest=(x, y))
|
|
223
|
+
self._cursor[0] += new_image.width
|
|
224
|
+
|
|
225
|
+
def clear(self):
|
|
226
|
+
"""Clear the canvas content, but retain all of the style settings"""
|
|
227
|
+
self._image = Image.new("RGBA", (0, 0), (0, 0, 0, 0))
|
|
228
|
+
self._cursor = [0, 0]
|
|
229
|
+
|
|
230
|
+
def get_image(self):
|
|
231
|
+
"""Get the canvas content as an image"""
|
|
232
|
+
return self._image
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def width(self):
|
|
236
|
+
"""Get the current canvas width in pixels"""
|
|
237
|
+
return self._image.width
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def height(self):
|
|
241
|
+
"""Get the current canvas height in pixels"""
|
|
242
|
+
return self._image.height
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def shadow_offset(self):
|
|
246
|
+
"""Get or set the current shadow offset in pixels"""
|
|
247
|
+
return self._shadow_offset
|
|
248
|
+
|
|
249
|
+
@shadow_offset.setter
|
|
250
|
+
def shadow_offset(self, value):
|
|
251
|
+
if not isinstance(value, int):
|
|
252
|
+
raise TypeError("Shadow offset must be an integer")
|
|
253
|
+
value = max(value, 0)
|
|
254
|
+
self._shadow_offset = value
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def shadow_intensity(self):
|
|
258
|
+
"""Get or set the current shadow intensity where 0 is
|
|
259
|
+
no shadow and 1 is a fully opaque shadow."""
|
|
260
|
+
return self._shadow_intensity
|
|
261
|
+
|
|
262
|
+
@shadow_intensity.setter
|
|
263
|
+
def shadow_intensity(self, value):
|
|
264
|
+
if not isinstance(value, (int, float)):
|
|
265
|
+
raise TypeError("Shadow intensity must be an integer or float")
|
|
266
|
+
value = max(0, min(1.0, value))
|
|
267
|
+
self._shadow_intensity = value
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def opacity(self):
|
|
271
|
+
"""Get or set the maximum opacity of the canvas where 0 is
|
|
272
|
+
transparent and 1 is opaque."""
|
|
273
|
+
return self._opacity
|
|
274
|
+
|
|
275
|
+
@opacity.setter
|
|
276
|
+
def opacity(self, value):
|
|
277
|
+
if not isinstance(value, (int, float)):
|
|
278
|
+
raise TypeError("Opacity must be an integer or float")
|
|
279
|
+
value = max(0, min(1.0, value))
|
|
280
|
+
self._opacity = value
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def cursor(self):
|
|
284
|
+
"""Get or set the current cursor position in pixels with the top left
|
|
285
|
+
being (0, 0)."""
|
|
286
|
+
return self._cursor
|
|
287
|
+
|
|
288
|
+
@cursor.setter
|
|
289
|
+
def cursor(self, value):
|
|
290
|
+
if isinstance(value, (tuple, list)) and len(value) >= 2:
|
|
291
|
+
self._cursor = [value[0], value[1]]
|
|
292
|
+
else:
|
|
293
|
+
raise TypeError("Value must be a tuple or list")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyopensign
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A library to facilitate easy Python RGB Matrix Sign Animations.
|
|
5
|
+
Author-email: Melissa LeBlanc-Williams <melissa@makermelissa.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Maker-Melissa/PyOpenSign
|
|
8
|
+
Keywords: opensign,open,sign,rgb,matrix,led,animation,hzeller,makermelissa
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
12
|
+
Classifier: Topic :: System :: Hardware
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Description-Content-Type: text/x-rst
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
License-File: LICENSE.license
|
|
23
|
+
Requires-Dist: pillow>=9.2.0
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
Introduction
|
|
27
|
+
============
|
|
28
|
+
|
|
29
|
+
.. image:: https://readthedocs.org/projects/pyopensign/badge/?version=latest
|
|
30
|
+
:target: https://pyopensign.readthedocs.io/en/latest/
|
|
31
|
+
:alt: Documentation Status
|
|
32
|
+
|
|
33
|
+
.. image:: https://github.com/Maker-Melissa/PyOpenSign/workflows/Build%20CI/badge.svg
|
|
34
|
+
:target: https://github.com/Maker-Melissa/PyOpenSign/actions
|
|
35
|
+
:alt: Build Status
|
|
36
|
+
|
|
37
|
+
.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
|
|
38
|
+
:target: https://github.com/astral-sh/ruff
|
|
39
|
+
:alt: Code Style: Ruff
|
|
40
|
+
|
|
41
|
+
A library to facilitate easy RGB Matrix Sign Animations.
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
Dependencies
|
|
45
|
+
=============
|
|
46
|
+
This library depends on:
|
|
47
|
+
|
|
48
|
+
* `Henner Zeller RPi RGB LED Matrix <https://github.com/hzeller/rpi-rgb-led-matrix/>`_
|
|
49
|
+
* `Python Bindings for RGB Matrix Library <https://github.com/hzeller/rpi-rgb-led-matrix/tree/master/bindings/python>`_
|
|
50
|
+
* `Python Imaging Library (Pillow) <https://pypi.org/project/Pillow/>`_
|
|
51
|
+
|
|
52
|
+
Installing from PyPI
|
|
53
|
+
=====================
|
|
54
|
+
|
|
55
|
+
On supported GNU/Linux systems like the Raspberry Pi, you can install the driver locally `from
|
|
56
|
+
PyPI <https://pypi.org/project/pyopensign/>`_. To install for current user:
|
|
57
|
+
|
|
58
|
+
.. code-block:: shell
|
|
59
|
+
|
|
60
|
+
pip3 install pyopensign
|
|
61
|
+
|
|
62
|
+
To install system-wide (this may be required in some cases):
|
|
63
|
+
|
|
64
|
+
.. code-block:: shell
|
|
65
|
+
|
|
66
|
+
sudo pip3 install pyopensign
|
|
67
|
+
|
|
68
|
+
To install in a virtual environment in your current project:
|
|
69
|
+
|
|
70
|
+
.. code-block:: shell
|
|
71
|
+
|
|
72
|
+
mkdir project-name && cd project-name
|
|
73
|
+
python3 -m venv .env
|
|
74
|
+
source .env/bin/activate
|
|
75
|
+
pip3 install pyopensign
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
opensign/__init__.py,sha256=ogFfsZWnxMYicv-84tG2T9UFbKTECcauCg9-hQ9igBc,30494
|
|
2
|
+
opensign/canvas.py,sha256=4NicSs4MafWl2GEVHlmtefUgQkAh5NT-5GOZWdW92WI,10517
|
|
3
|
+
pyopensign-1.0.0.dist-info/licenses/LICENSE,sha256=jKCrqglMTvsdaxiseb1u0GGmo9nIierjsfeEHTQWvws,1091
|
|
4
|
+
pyopensign-1.0.0.dist-info/licenses/LICENSE.license,sha256=eMstXTi2mjyXZ7IeYhHb9-78ySEYJrAWH27G0eEwRFU,84
|
|
5
|
+
pyopensign-1.0.0.dist-info/METADATA,sha256=RGnysUv07MyB9hixDout6iq-QdIdG68yFauge-6-Yoo,2561
|
|
6
|
+
pyopensign-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
pyopensign-1.0.0.dist-info/top_level.txt,sha256=XXRTTblLg4LoxC0xUbOjJ-5psJJXN89ESWyp67xolkI,9
|
|
8
|
+
pyopensign-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020 Melissa LeBlanc-Williams
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
opensign
|