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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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,3 @@
1
+ SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams
2
+
3
+ SPDX-License-Identifier: MIT
@@ -0,0 +1 @@
1
+ opensign