/
device.py
322 lines (264 loc) · 11.2 KB
/
device.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Richard Hull and contributors
# See LICENSE.rst for details.
import os
import sys
import atexit
import logging
import string
import curses
import collections
try:
from StringIO import StringIO
except ImportError:
from io import StringIO
from PIL import Image, ImageFont, ImageDraw
from luma.core.device import device
from luma.core.serial import noop
from luma.emulator.render import transformer
from luma.emulator.clut import rgb2short
logger = logging.getLogger(__name__)
__all__ = ["capture", "gifanim", "pygame", "asciiart"]
class emulator(device):
"""
Base class for emulated display driver classes
"""
def __init__(self, width, height, rotate, mode, transform, scale):
super(emulator, self).__init__(serial_interface=noop())
try:
import pygame
except ImportError:
raise RuntimeError("Emulator requires pygame to be installed")
self._pygame = pygame
self.capabilities(width, height, rotate, mode)
self.scale = 1 if transform == "none" else scale
self._transform = getattr(transformer(pygame, width, height, scale),
"none" if scale == 1 else transform)
def cleanup(self):
pass
def to_surface(self, image, alpha=1.0):
"""
Converts a :py:mod:`PIL.Image` into a :class:`pygame.Surface`,
transforming it according to the ``transform`` and ``scale``
constructor arguments.
"""
assert(0.0 <= alpha <= 1.0)
if alpha < 1.0:
im = image.convert("RGBA")
black = Image.new(im.mode, im.size, "black")
im = Image.blend(black, im, alpha)
else:
im = image.convert("RGB")
mode = im.mode
size = im.size
data = im.tobytes()
del im
surface = self._pygame.image.fromstring(data, size, mode)
return self._transform(surface)
class capture(emulator):
"""
Pseudo-device that acts like a physical display, except that it writes the
image to a numbered PNG file when the :func:`display` method is called.
Supports 24-bit color depth.
"""
def __init__(self, width=128, height=64, rotate=0, mode="RGB",
transform="scale2x", scale=2, file_template="luma_{0:06}.png",
**kwargs):
super(capture, self).__init__(width, height, rotate, mode, transform, scale)
self._count = 0
self._file_template = file_template
def display(self, image):
"""
Takes a :py:mod:`PIL.Image` and dumps it to a numbered PNG file.
"""
assert(image.size == self.size)
self._count += 1
filename = self._file_template.format(self._count)
image = self.preprocess(image)
logger.debug("Writing: {0}".format(filename))
image.save(filename)
class gifanim(emulator):
"""
Pseudo-device that acts like a physical display, except that it collects
the images when the :func:`display` method is called, and on exit,
assembles them into an animated GIF image. Supports 24-bit color depth,
albeit with an indexed color palette.
"""
def __init__(self, width=128, height=64, rotate=0, mode="RGB",
transform="scale2x", scale=2, filename="luma_anim.gif",
duration=0.01, loop=0, max_frames=None, **kwargs):
super(gifanim, self).__init__(width, height, rotate, mode, transform, scale)
self._images = []
self._count = 0
self._max_frames = max_frames
self._filename = filename
self._loop = loop
self._duration = int(duration * 1000)
atexit.register(self.write_animation)
def display(self, image):
"""
Takes an image, scales it according to the nominated transform, and
stores it for later building into an animated GIF.
"""
assert(image.size == self.size)
image = self.preprocess(image)
surface = self.to_surface(image)
rawbytes = self._pygame.image.tostring(surface, "RGB", False)
im = Image.frombytes("RGB", (self._w * self.scale, self._h * self.scale), rawbytes)
self._images.append(im)
self._count += 1
logger.debug("Recording frame: {0}".format(self._count))
if self._max_frames and self._count >= self._max_frames:
sys.exit(0)
def write_animation(self):
if len(self._images) > 0:
logger.debug("Please wait... building animated GIF")
with open(self._filename, "w+b") as fp:
self._images[0].save(fp, save_all=True, loop=self._loop,
duration=self._duration,
append_images=self._images[1:],
optimize=True, format="GIF")
file_size = os.stat(self._filename).st_size
logger.debug("Wrote {0} frames to file: {1} ({2} bytes)".format(
self._count, self._filename, file_size))
class pygame(emulator):
"""
Pseudo-device that acts like a physical display, except that it renders
to a displayed window. The frame rate is limited to 60FPS (much faster
than a Raspberry Pi can achieve, but this can be overridden as necessary).
Supports 24-bit color depth.
:mod:`pygame` is used to render the emulated display window, and it's
event loop is checked to see if the ESC key was pressed or the window
was dismissed: if so :func:`sys.exit()` is called.
"""
def __init__(self, width=128, height=64, rotate=0, mode="RGB", transform="scale2x",
scale=2, frame_rate=60, **kwargs):
super(pygame, self).__init__(width, height, rotate, mode, transform, scale)
self._pygame.init()
self._pygame.font.init()
self._pygame.display.set_caption("Luma.core Display Emulator")
self._clock = self._pygame.time.Clock()
self._fps = frame_rate
self._screen = None
self._last_image = Image.new(mode, (width, height))
self._contrast = 1.0
def _abort(self):
keystate = self._pygame.key.get_pressed()
return keystate[self._pygame.K_ESCAPE] or self._pygame.event.peek(self._pygame.QUIT)
def display(self, image):
"""
Takes a :py:mod:`PIL.Image` and renders it to a pygame display surface.
"""
assert(image.size == self.size)
self._last_image = image
image = self.preprocess(image)
self._clock.tick(self._fps)
self._pygame.event.pump()
if self._abort():
self._pygame.quit()
sys.exit()
surface = self.to_surface(image, alpha=self._contrast)
if self._screen is None:
self._screen = self._pygame.display.set_mode(surface.get_size())
self._screen.blit(surface, (0, 0))
self._pygame.display.flip()
def show(self):
self.contrast(0xFF)
def hide(self):
self.contrast(0x00)
def contrast(self, value):
assert(0 <= value <= 255)
self._contrast = value / 255.0
self.display(self._last_image)
class asciiart(emulator):
"""
Pseudo-device that acts like a physical display, except that it converts the
image to display into an ASCII-art representation and downscales colors to
match the xterm-256 color scheme. Supports 24-bit color depth.
This device takes hold of the terminal window (using curses), and any output
for sysout and syserr is captured and stored, and is replayed when the
cleanup method is called.
Loosely based on https://github.com/ajalt/pyasciigen/blob/master/asciigen.py
.. versionadded:: 0.2.0
"""
def __init__(self, width=128, height=64, rotate=0, mode="RGB", transform="scale2x",
scale=2, **kwargs):
super(asciiart, self).__init__(width, height, rotate, mode, transform, scale)
self._stdscr = curses.initscr()
curses.start_color()
curses.use_default_colors()
for i in range(0, curses.COLORS):
curses.init_pair(i, i, -1)
curses.noecho()
curses.cbreak()
# Capture all stdout, stderr
self._old_stdX = (sys.stdout, sys.stderr)
self._captured = (StringIO(), StringIO())
sys.stdout, sys.stderr = self._captured
# Sort printable characters according to the number of black pixels present.
# Don't use string.printable, since we don't want any whitespace except spaces.
charset = (string.letters + string.digits + string.punctuation + " ")
self._chars = list(reversed(sorted(charset, key=self._char_density)))
self._char_width, self._char_height = ImageFont.load_default().getsize("X")
self._contrast = 1.0
self._last_image = Image.new(mode, (width, height))
def _char_density(self, c, font=ImageFont.load_default()):
"""
Count the number of black pixels in a rendered character.
"""
image = Image.new('1', font.getsize(c), color=255)
draw = ImageDraw.Draw(image)
draw.text((0, 0), c, fill="white", font=font)
return collections.Counter(image.getdata())[0] # 0 is black
def _generate_art(self, image, width, height):
"""
Return an iterator that produces the ascii art.
"""
# Characters aren't square, so scale the output by the aspect ratio of a charater
height = int(height * self._char_width / float(self._char_height))
image = image.resize((width, height), Image.ANTIALIAS).convert("RGB")
for (r, g, b) in image.getdata():
greyscale = int(0.299 * r + 0.587 * g + 0.114 * b)
ch = self._chars[int(greyscale / 255. * (len(self._chars) - 1) + 0.5)]
yield (ch, rgb2short(r, g, b))
def display(self, image):
"""
Takes a :py:mod:`PIL.Image` and renders it to the current terminal as
ASCII-art.
"""
assert(image.size == self.size)
self._last_image = image
surface = self.to_surface(self.preprocess(image), alpha=self._contrast)
rawbytes = self._pygame.image.tostring(surface, "RGB", False)
image = Image.frombytes("RGB", (self._w * self.scale, self._h * self.scale), rawbytes)
scr_width = self._stdscr.getmaxyx()[1]
scale = float(scr_width) / image.width
self._stdscr.erase()
self._stdscr.move(0, 0)
try:
for (ch, color) in self._generate_art(image, int(image.width * scale), int(image.height * scale)):
self._stdscr.addch(ch, curses.color_pair(color))
except curses.error:
# End of screen reached
pass
self._stdscr.refresh()
def show(self):
self.contrast(0xFF)
def hide(self):
self.contrast(0x00)
def contrast(self, value):
assert(0 <= value <= 255)
self._contrast = value / 255.0
self.display(self._last_image)
def cleanup(self):
super(asciiart, self).cleanup()
# Stty sane
curses.nocbreak()
curses.echo()
curses.endwin()
# Restore stdout & stderr, then print out captured
sys.stdout, sys.stderr = self._old_stdX
sys.stdout.write(self._captured[0].getvalue())
sys.stdout.flush()
sys.stderr.write(self._captured[1].getvalue())
sys.stderr.flush()