Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ASCII-art emulator #15

Merged
merged 6 commits into from Apr 15, 2017
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 list(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 = [len(tuple(s for s in snaps if s < x)) for x in (r, g, b)]

# Simple colorcube transform
return (r * 36) + (g * 6) + b + 16
125 changes: 123 additions & 2 deletions luma/emulator/device.py
Expand Up @@ -6,17 +6,25 @@
import sys
import atexit
import logging
import string
import curses
import collections
try:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be avoided, we use py27 and newer.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you be more specific?
Not sure what you're referring to with 'this'

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the import workaround. simply from io import StringIO is enough, isn't it?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not exactly sure, but in py2, I think:

  • StringIO.StringIO is byte oriented and works with sys.stdout/stderr
  • io.StringIO is unicode oriented and doesn't work with sys.stdout/stderr

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A test would show the difference.

from StringIO import StringIO
except ImportError:
from io 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 +205,116 @@ 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
"""
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add 'since' version nr.

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()