Skip to content

Commit

Permalink
Merge 7a03915 into 5ed4718
Browse files Browse the repository at this point in the history
  • Loading branch information
rm-hull committed Apr 14, 2017
2 parents 5ed4718 + 7a03915 commit 21b70b0
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 2 deletions.
7 changes: 7 additions & 0 deletions README.rst
Expand Up @@ -31,6 +31,7 @@ without running a physical device. These include:
* LED matrix and 7-segment renderers
* PNG screen capture
* Animated GIF animator
* Real-time ASCII-art emulator

.. image:: https://raw.githubusercontent.com/rm-hull/luma.oled/master/doc/images/clock_anim.gif?raw=true
:alt: clock
Expand All @@ -41,6 +42,12 @@ without running a physical device. These include:
.. image:: https://raw.githubusercontent.com/rm-hull/luma.oled/master/doc/images/crawl_anim.gif?raw=true
:alt: crawl

.. image:: https://raw.githubusercontent.com/rm-hull/luma.emulator/master/doc/images/ascii-art.png?raw=true
:alt: asciiart

.. image:: https://raw.githubusercontent.com/rm-hull/luma.led_matrix/master/doc/images/emulator.gif
:alt: max7219 emulator

License
-------
The MIT License (MIT)
Expand Down
Binary file added doc/images/ascii-art.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions luma/emulator/clut.py
@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Richard Hull and contributors
# See LICENSE.rst for details.

# From a comment by @TerrorBite on https://gist.github.com/MicahElliott/719710

# Default color levels for the color cube
cubelevels = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]

# Generate a list of midpoints of the above list
snaps = [(x + y) / 2 for x, y in zip(cubelevels, [0] + cubelevels)[1:]]


def rgb2short(r, g, b):
"""
Converts RGB values to the nearest equivalent xterm-256 color.
"""
# Using list of snap points, convert RGB value to cube indexes
r, g, b = map(lambda x: len(tuple(s for s in snaps if s < x)), (r, g, b))

# Simple colorcube transform
return (r * 36) + (g * 6) + b + 16
123 changes: 121 additions & 2 deletions luma/emulator/device.py
Expand Up @@ -6,17 +6,22 @@
import sys
import atexit
import logging
import string
import curses
import collections
from cStringIO import StringIO

from PIL import Image
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"]
__all__ = ["capture", "gifanim", "pygame", "asciiart"]


class emulator(device):
Expand Down Expand Up @@ -197,3 +202,117 @@ 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
"""
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_height, scr_width = self._stdscr.getmaxyx()
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()

# Print out captured stdout/stderr
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()

0 comments on commit 21b70b0

Please sign in to comment.