Any fool can make things bigger, more complex, and more violent. It takes a touch of genius and a lot of courage to move in the opposite direction. Albert Einstein

o Ruby

ASCI art w konsoli linuksa

10 komentarzy | Kategorie: Programowanie, Ruby, Techblog, Tips & tricks | trackback
Tagi:

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.

ruby logo in ascii art format new ruby logo in ascii format java duke with ruby in ascii format linux tux logo in ascii format firefox logo in ascii format

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

Jeśli spodobał Ci się wpis to może umieścisz ten blog w swoim czytniku RSS?

Komentarze

1. avatar icon pecet napisał(a) 20 Lis 2008 o godz. 01:16:

Obrazki skonwertowane skryptami nie można nazwać „Ascii-Artem” w gwoli jasności.

2. avatar icon Zal napisał(a) 20 Lis 2008 o godz. 01:49:

Artem faktycznie tego nazwać nie można, ale sam program jest świetny! :] Swego czasu korzystałem z wersji online, ale ta będzie lepsza.

3. avatar icon dmilith napisał(a) 20 Lis 2008 o godz. 02:01:

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

4. avatar icon Radarek napisał(a) 20 Lis 2008 o godz. 09:13:

@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.

5. avatar icon oki napisał(a) 20 Lis 2008 o godz. 10:28:

A moze push na githuba? :)

6. avatar icon Radarek napisał(a) 20 Lis 2008 o godz. 11:00:

@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.

7. avatar icon deluge napisał(a) 20 Lis 2008 o godz. 11:34:

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.

8. avatar icon Drogomir napisał(a) 20 Lis 2008 o godz. 18:29:

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

9. avatar icon Radarek napisał(a) 21 Lis 2008 o godz. 14:29:

@Drogomir: tak też zrobię. Widzę, że dodałeś do obserwowanych projektów na githubie więc będziesz pierwszy wiedzieć jak zrobię pusha;-).

10. avatar icon JJ napisał(a) 21 Kwi 2011 o godz. 10:04:

Doskonale!

Dodaj coś od siebie

Możesz korzystać ze składni Textile.

Pola oznaczone * są wymagane.

Proszę o dodawanie komentarzy związanych z tematem postu, sprawy osobiste proszę załatwiać przez maila bądź gg.

Zastrzegam sobie prawo do moderacji komentarzy (edycja, usuwanie).