Source code for luma.emulator.device

# -*- coding: utf-8 -*-
# Copyright (c) 2017-2020 Richard Hull and contributors
# See LICENSE.rst for details.

import os
import sys
import struct

try:
    import fcntl
    import termios
    import curses
    ASCII_AVAILABLE = True
except ImportError:
    # If running on windows, these package are not available!
    ASCII_AVAILABLE = False
import atexit
import logging
import string
import collections
from io import StringIO
from PIL import Image, ImageFont, ImageDraw

from luma.core.device import device
from luma.core.interface.serial import noop
from luma.emulator.render import transformer
from luma.emulator.clut import rgb2short
from luma.emulator.segment_mapper import regular


logger = logging.getLogger(__name__)

__all__ = ["capture", "gifanim", "pygame"]


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)
        self._contrast = 1.0
        self._last_image = None
        self.segment_mapper = regular

    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
        if self._last_image is not None:
            self.display(self._last_image)

    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)


[docs]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
[docs] def display(self, image): """ Takes a :py:mod:`PIL.Image` and dumps it to a numbered PNG file. """ assert(image.size == self.size) self._last_image = image self._count += 1 filename = self._file_template.format(self._count) image = self.preprocess(image) surface = self.to_surface(image, alpha=self._contrast) logger.debug(f"Writing: {filename}") self._pygame.image.save(surface, filename)
[docs]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)
[docs] 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) self._last_image = image image = self.preprocess(image) surface = self.to_surface(image, alpha=self._contrast) rawbytes = self._pygame.image.tostring(surface, "RGB", False) im = Image.frombytes("RGB", surface.get_size(), 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)
[docs] 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))
[docs]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.display.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 def _abort(self): keystate = self._pygame.key.get_pressed() return keystate[self._pygame.K_ESCAPE] or self._pygame.event.peek(self._pygame.QUIT)
[docs] 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()
if ASCII_AVAILABLE: __all__.extend(["asciiart", "asciiblock"])
[docs] 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.ascii_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 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))
[docs] 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", surface.get_size(), 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.addstr(ch, curses.color_pair(color)) except curses.error: # End of screen reached pass self._stdscr.refresh()
[docs] 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()
[docs] class asciiblock(emulator): """ Pseudo-device that acts like a physical display, except that it converts the image pixels to display into colored ASCII half-blocks (ASCII code 220, '▄'), where the upper part background is used for one pixel, and the lower part foreground is used for the pixel on the next row. As most terminal display characters are in ratio 2:1, the half-block appears square. Inspired by `Command Line Curiosities - Making the Terminal Sing by Hamza Haiken <https://www.youtube.com/watch?v=j5zA5Xi_ph8>`__ .. versionadded:: 1.1.0 """ def __init__(self, width=128, height=64, rotate=0, mode="RGB", transform="scale2x", scale=2, **kwargs): super(asciiblock, self).__init__(width, height, rotate, mode, transform, scale) self._CSI("2J") def _terminal_size(self): s = struct.pack('HHHH', 0, 0, 0, 0) t = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, s) return struct.unpack('HHHH', t) def _generate_art(self, image, width, height): """ Return an iterator that produces the ascii art. """ image = image.resize((width, height), Image.ANTIALIAS).convert("RGB") pixels = list(image.getdata()) for y in range(0, height - 1, 2): for x in range(width): i = y * width + x bg = rgb2short(*(pixels[i])) fg = rgb2short(*(pixels[i + width])) yield (fg, bg) def _CSI(self, cmd): """ Control sequence introducer """ sys.stdout.write('\x1b[') sys.stdout.write(cmd)
[docs] def display(self, image): """ Takes a :py:mod:`PIL.Image` and renders it to the current terminal as ASCII-blocks. """ 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", surface.get_size(), rawbytes) scr_width = self._terminal_size()[1] scale = float(scr_width) / image.width self._CSI('1;1H') # Move to top/left for (fg, bg) in self._generate_art(image, int(image.width * scale), int(image.height * scale)): self._CSI('38;5;{0};48;5;{1}m'.format(fg, bg)) sys.stdout.write('▄') self._CSI('0m') sys.stdout.flush()
[docs] def cleanup(self): super(asciiblock, self).cleanup() self._CSI('0m') self._CSI('2J')