ASCI art w konsoli linuksa
10 komentarzy | Kategorie: Programowanie, Ruby, Techblog, Tips & tricks | trackbackTagi: 256 art ascii colors rmagick ruby
Jako programista spędzam spędzam sporo czasu pracując z konsolą. Konsola ma to do siebie, że wyświetla głównie... znaki. Wydawać by się mogło, że brak możliwości wyświetlania grafiki nie pozwala na urozmaicenie tego co jest w niej wyświetlane. Na szczęście tak nie jest. Mamy przecież ASCII art! ASCII art to sztuka tworzenia obrazków złożonych ze znaków ASCII.
Chociaż ASCII art głównie polega na (jak mi się wydaje) mozolnym układaniu obrazka z różnych znaków, to mnie zainteresowała kwestia konwersji obrazka do postaci tekstowej.
Okazuje się, że do uzyskania całkiem zadowalających efektów nie trzeba bardzo skomplikonwago algorytmu. Najprostsze podejście to wejściowy obrazek pomniejszyć do docelowego rozmiaru w znakach. Następnie każdy pojedynczy pixel będzie odpowiadać jednemu wyświetlonymu znakowi. Pozostaje jeszcze kwestia koloru.
Większość terminali linuksowych pozwala na wyświetlanie 16 kolorów, za pomocą tzw. sekwencji unikowych (escape sequences). Okazuje się jednak, że nowsze wersje, np. używany przeze mnie gnome-terminal, potrafi wyświetlać nawet 256 kolorów. Z taką ilością kolorów można uzyskać dosyć ciekawe efekty.
Jeśli chciałbyś sprawdzić czy Twój terminal potrafi wyświetlać 256 kolorów to możesz skorzystać ze strony: http://www.frexx.de/xterm-256-notes/. Pobierz perlowy skrypt 256colors2.pl, który próbuje wyświetlić wszystkie kolory.
Na tej stronie znalazłem także program w języku C, który dla zadanego koloru w postaci (r,g,b) szuka najbardziej pasującego koloru z palety 256 "terminalowych" kolorów. Jest to bardzo pomocne, jeśli chcemy wyświetlać nasze ASCII arty (a taki był mój cel) w konsoli.
Piszemy w Rubym
Pierwszym krokiem było przepisanie kodu odpowiedzialnego za konwertowanie kolorów.
module Xterm256Color
# the 6 value iterations in the xterm color cube
VALUERANGE = [
0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF
]
# 16 basic colors
BASIC16 = [
[ 0x00, 0x00, 0x00 ], # 0
[ 0xCD, 0x00, 0x00 ], # 1
[ 0x00, 0xCD, 0x00 ], # 2
[ 0xCD, 0xCD, 0x00 ], # 3
[ 0x00, 0x00, 0xEE ], # 4
[ 0xCD, 0x00, 0xCD ], # 5
[ 0x00, 0xCD, 0xCD ], # 6
[ 0xE5, 0xE5, 0xE5 ], # 7
[ 0x7F, 0x7F, 0x7F ], # 8
[ 0xFF, 0x00, 0x00 ], # 9
[ 0x00, 0xFF, 0x00 ], # 10
[ 0xFF, 0xFF, 0x00 ], # 11
[ 0x5C, 0x5C, 0xFF ], # 12
[ 0xFF, 0x00, 0xFF ], # 13
[ 0x00, 0xFF, 0xFF ], # 14
[ 0xFF, 0xFF, 0xFF ] # 15
]
# convert an xterm color value (0-253) to three elements array [r, g, b]
def self.xterm2rgb(color)
case color
when 0..15
return BASIC16[color].dup
when 16..232
color -= 16
return [VALUERANGE[(color / 36) % 6], VALUERANGE[(color / 6) % 6], VALUERANGE[color % 6]]
when 233..253
return [8 + (color - 232) * 0x0a] * 3
else
raise ArgumentError, "expected color value in range 0..253 but was #{color}"
end
end
# fill the colortable for use with rgb2xterm
def self.make_table
@colortable ||=
begin
colortable = []
0.upto(253) do |color|
rgb = xterm2rgb(color)
colortable << rgb
end
colortable
end
end
# selects the nearest xterm color for a given [r, g, b] color table
def self.rgb2xterm(rgb)
@rgb2xterm_cache ||= {}
return @rgb2xterm_cache[rgb] ||=
begin
self.make_table
smallest_distance = 1_000_000_000_000
best_match = 0
0.upto(253) do |color|
d = self.euclidean_distance(@colortable[color], rgb)
if d < smallest_distance
smallest_distance = d
best_match = color
end
end
best_match
end
end
# Return euclidean distance between two given vectors v1 and v2.
# Example:
# Xterm256Color.euclidean_distance([0, 0], [1, 1]) # => 1.4142...
def self.euclidean_distance(v1, v2)
raise ArgumentError, "Expected two arrays" unless v1.is_a?(Array) && v2.is_a?(Array)
raise ArgumentError, "Expected two array with the same size (#{v1.size} <=> #{v2.size})" if v1.size != v2.size
#sum_of_squares = v1.zip(v2).inject(0) {|acc, (a, b)| acc + (a - b) ** 2 }
sum_of_squares = 0
v1.size.times do |i|
sum_of_squares += (v1[i] - v2[i]) * (v1[i] - v2[i])
end
return Math.sqrt(sum_of_squares)
end
end
Nie wdając się w szczegóły nas interesuje metoda Xterm256Color.rgb2xterm(rgb), która dla 3 elementowej tablicy [r,g,b] zwraca liczbę z przedziału 0..253 (szczerze mówiąc nie wiem czemu nie 255).
Mała poprawka w algorytmie
W zasadzie z dotychczasowych elementów moglibyśmy już napisać działający program. Chciałem jednak zwrócić uwagę na jeszcze jedną kwestię. Mianowicie jaki znak powinien być wyświetlany jako piksel? W najprostszym przypadku można wyświetlać zawsze ten sam znak (np. "0", "#") lub z zadanego zbioru naprzemiennie (np. dla "01" będzie 010101...). Takie podejście możemy zaobserwować korzystając z konwertera online na stronie http://www.text-image.com/convert/.
Okazuje się, że wystarczy mała poprawka by uzyskać lepszy efekt. Należy zdefiniować ciąg znaków (który nazwiemy gradientem). Następnie w zależności od intensywności danego koloru zostanie wybrany odpowiadający mu (proporcjonalnie) znak. Chodzi o to, żeby kolory, które odbieramy jako mało intensywne reprezentować poprzez mało rzucające sie w oczy znaki. Np. intensywny czerwony kolor (rgb=[255, 0, 0]) wyświetlimy poprzez znak "@", zaś szary kolor poprzez ":". Dzieki temu obrazek powinien uzyskać większej glębi niż przy wyświetlaniu go za pomocą jednego znaku.
Pora na kodowanie
Poniżej umieszczam implementację klasy AsciiArt służącej do konwersji obrazka na ASCII art.
require "xterm256color"
require "RMagick"
require "open-uri"
class AsciiArt
class Error < StandardError; end
DEFAULT_GRADIENT = ".:coCO8@"
attr_accessor :characters_array, :colored
def initialize(colored)
@colored = colored
@characters_array = []
end
def colored?
return @colored
end
# Create AsciiArt instance from given image file.
# Parameters:
# image_file_path can be regular path to file or http URL
# width - width in characters of created ascii art
# gradient - nil or string with gradient characters
# Example:
# ascii_art = AsciiArt.from_image_file("images/image.jpg")
# ascii_art.print
def self.from_image_file(image_file_path, width, colored = true, gradient = nil)
gradient = DEFAULT_GRADIENT if gradient.nil?
image = Magick::Image.read(image_file_path).first
image.change_geometry("#{width}x") do |w, h, img|
img.resize!(w, h / 2.2, Magick::BoxFilter)
end
image = image.sharpen(1.0)
image = image.contrast(true)
characters_array = []
0.upto(image.rows - 1) do |row|
characters_array << []
0.upto(image.columns - 1) do |column|
pixel = image.pixel_color(column, row)
rgb = [pixel.red, pixel.green, pixel.blue]
rgb.map! {|value| value >> (Magick::QuantumDepth - 8) }
char = gradient[pixel.intensity * gradient.size / (Magick::QuantumRange + 1), 1]
characters_array.last << [char, rgb]
end
end
ascii_art = AsciiArt.new(colored)
ascii_art.characters_array = characters_array
return ascii_art
rescue Magick::ImageMagickError => e
raise Error.new("Could not read file #{image_file_path} (#{e.message})")
end
def to_html
html = ""
html << "<pre style=\"background-color: black; font-size: 8px;\">"
self.characters_array.each do |row|
row.each do |char, rgb|
if self.colored?
html << sprintf("<span style=\"color: rgb(%d, %d, %d)\;\">%s</span>", rgb[0], rgb[1], rgb[2], char)
else
html << char
end
end
html << "\n"
end
html << "</pre>"
return html
end
def to_text
text = ""
self.characters_array.each do |row|
row.each do |char, rgb|
if self.colored?
color = Xterm256Color.rgb2xterm(rgb)
text << sprintf("\033[38;5;%dm%s\e[0m", color, char)
else
text << char
end
end
text << "\n"
end
return text
end
end
Teraz wystarczy napisać prosty skrypt korzystający z tej klasy i sprawdzić rezultaty.
require "rubygems"
require "ascii_art"
if !ARGV.size.between?(1, 2)
puts "Usage: ruby image2ascii IMAGE_FILE [COLUMNS=80]"
puts "Display given IMAGE_FILE (regular file or URL) as colored ascii art.\nCOLUMNS defines number of chars per one line."
exit 1
end
file = ARGV.first
width = (ARGV[1] || 80).to_i
ascii_art = AsciiArt.from_image_file(file, width, true)
print ascii_art.to_text
Uzyskane rezultaty
Poniżej prezentuję uzyskane przeze mnie rezyltaty.
Jeśli dobrze się przyjrzysz to zauważysz, że program może dostać jako parametr scieżkę do pliku bądź adres URL. Zatem jeśli tylko chcesz to ściągaj kod i baw się dobrze :).
Źródła na pastie.org:
xterm256color.rb
ascii_art.rb
image2ascii.rb
Obrazki skonwertowane skryptami nie można nazwać „Ascii-Artem” w gwoli jasności.
Artem faktycznie tego nazwać nie można, ale sam program jest świetny! :] Swego czasu korzystałem z wersji online, ale ta będzie lepsza.
będąc czepialskim osobnikiem dodam, że w zasadzie wystarczy #!/usr/bin/env ruby
na początku skryptu image2ascii, wtedy możemy go używać bez poprzedzania skryptu poleceniem „ruby”.
zadziała w zasadzie pod każdym POSIX’em
@pecet: zapewne masz rację, na swoje usprawiedliwienie powiem, że na wiki (tak jak podlinkowałem we wpisie) jest przykład z tego typu obrazkiem.
@dmilith: „shebang line” jest mi znane. Ponieważ ostatnio testuje swoje programy pod różnymi wersjami rubiego to nie zaprzątam sobie głowy takimi szczegółami.
A moze push na githuba? :)
@oki, chodziła mi taka myśl po głowie i pomyślałem, że to zbyt mały kawałek kodu. W sumie może ktoś chciałby się tym pobawić. Przekonałeś mnie ;-).
http://github.com/Radarek/ascii_art/tree/master
Będę musiał zmienił strukturę (lib/ascii_art.rb itp) i zamknąć wszystkie klasy w module AsciiArt.
To, że konsola to nie znaczy że nie można wyświetlać grafiki – od czego jest framebffer. Z powodzeniem oglądałem np filmy w mplayerze.
Wrzuć to jako gema :D
Genialnie to wygląda w sumie :D
Wiem, że podniecam się jak dziecko, ale pierwszy raz widzę takie efekty w konsoli ;-)
@Drogomir: tak też zrobię. Widzę, że dodałeś do obserwowanych projektów na githubie więc będziesz pierwszy wiedzieć jak zrobię pusha;-).
Doskonale!