Home » 이미지를 아스키코드로 렌더링하기

이미지를 아스키코드로 렌더링하기

인터넷에 보면 꽤나 유명한 사진 이미지를 아스키문자로 표현해놓은 것들을 종종 볼 수 있다. 선이나 슬래시 문자를 사용해서 그림처럼 그리는 것들은 아마도 장인정신을 발휘하여 한 땀 한 땀 수놓은 것들이겠지만, 그렇지 않고 문자와 문장 부호들만으로 멀리서 보면 그림처럼 보이게 표현해놓은 예들이 무척 많다. 이런 아스키 아트들은 사실 약간의 아이디어만 있으면 간단히 만들 수 있는데, 오늘은 아스키 코드로 이미지를 렌더링하는 방법에 대해서 알아보자. 이미지 처리를 위해서는 간단히 PIL 정도의 라이브러리만 있으면 된다.

회색조 이미지를 아스키문자로 렌더링하기

단색 아스키아트의 기본적인 아이디어는 글자 한 개가 회색조 중에서 특정한 밝기 값을 대체한다는 것이다. 콘솔에서 출력되는 텍스트가 고정폭문자라 할 때, 문자 1개는 똑같은 넓이의 사각형 내에서 그려질텐데 이 때 문자를 표시하기 위해 칠해지는 픽셀의 수를 세면, 해당 문자 영역에서의 문자의 밝기 값을 구할 수 있다. (검은색 바탕에 흰색 글씨를 칠한다고 가정)

이런 문자 중에서 적절한 단계로 그레이스케일을 만들 수 있는 문자 몇 개를 고르면, 픽셀의 밝기 수준에 따라 적절한 문자로 대체해서 이미지를 렌더링할 수 있다. 구글링해보면 이런 문자 세트를 몇 종류 구할 수 있는데, 다음은 10글자로 된 문자열이다. @부터 순서대로 가장 진하게 표현되는 글씨라고 보면 된다. 이걸 사용해서 이미지의 회색 픽셀을 적절한 농도(?)의 문자로 바꿔주면 된다.

"@%#*+=-:. "

위와 같은 농도별 문자로 구성된 문자열을 준비해두었다면, 아래와 같은 방법으로 이미지를 렌더링한 문자열을 생성하는 함수를 만들 수 있다.

  1. 이미지를 읽어서 적절히 작은 크기로 리사이징한다.
  2. getpixel((x, y))를 사용하여 (x, y)의 픽셀 값을 읽을 수 있다. 그레이스케일 이미지의 경우 0~255의 정수값 중 하나이다. 이 값을 10개의 문자 인덱스(0~9)로 만드려면 int(value / 256 * 10) 하면 된다.
from PIL import Image

def convert(im: Image.Image, width=80, inv=False):
    ASCII_CHARS = "@%#*+=-:. "
    res = []
    w = width
    h = int(im.width / w * im.height)
    im_res = im.convert('L').resize((w, h), resample=Image.BOX)
    for y in range(h):
        line = []
        for x in range(w):
            p = im_res.getpixel((x, y))
            line.append(ASCII_CHARS[int(p/256*10)])
        res.append(''.join(line))
    return '\n'.join(res)
          

왼쪽은 원본 이미지이고 아래는 이 이미지를 렌더링한 결과를 메모장에서 표시한 결과이다. (깨지지 않고 올바른 결과를 표시하려면, 메모장의 글꼴을 고정폭 글꼴로 설정해서 봐야한다. )

농도별 문자표 구하기

이미지를 아스키코드로 변환하기 위한 농도표(?)를 찾아보면 제각각인 문자열을 볼 수 있다. 대체로 어떤 문자열을 사용하든 결과는 그럴 듯 한데, 이러한 문자들은 어떻게 구하게 되는 것일까? 만약 10단계가 아니라 20단계나 32단계, 64단계 짜리 문자열은 구할 수 없는 것일까?

일정한 크기의 사각형에 글씨를 그린 후 픽셀의 수를 세면 그 값을 해당 사각형의 평균적인 밝기값으로 볼 수 있을 것이다. 이 아이디어를 사용해서 각 문자의 밝기를 구하고, 가장 밝은 (가작 픽셀이 적은) 문자와 가장 어두운 문자, 그리고 각 문자의 밝기값을 사용해서 정해진 단계와 가장 가까운 밝기를 가진 문자들을 얻을 수 있다면, 농도별 문자를 구할 수 있을 것이다.

def create_char_map(lv: int=16, inv: bool=False) -> str:
    cs = []
    canvas = Image.new("L", (20, 20), color=0)
    d = ImageDraw.Draw(canvas)
    ft = ImageFont.truetype("c:/windows/fonts/arial.ttf", size=12)
    for i in range(128):
        c = chr(i)
        if not c.isprintable():
            continue
        canvas.retangle((0, 0, 20, 20), 0)
        canvas.text((0, 0), c, font=ft, fill=255)
        val = sum(canvas.getdata())
        cs.append((val, c))
    
    m, n = max(cs)[0], min(cs)[1]
    res = []
    for i in range(levels):
        v = i / levels * 255
        res.append(min(cs, key=lambda x: abs(x[0] - v))[1])
    return "".join(res[::-1] if inv else res)

참고로 리스트에서 주어진 값과 가장 근접한 값을 찾는 간편한 방법으로는 min()함수를 사용하는 것이다. key= 함수에 특정 값과의 차이를 구하는 함수를 넘기는 가장 근접한 값을 구할 수 있다. 기본적인 아스키코드는 0~127 사이의 코드값을 갖는데, 그 중 출력가능한 문자에 대해서만 처리해야 하므로 isprintable() 메소드로 체크한다.

아래는 동일한 원본을 사용해서 32단계의 농도맵을 사용해서 표현한 것이다. 10단계 맵을 사용했을 때보다 좀 더 음영의 대비가 줄어드는 것을 볼 수 있다.

명령줄 옵션을 추가한 전체 코드는 아래 Gist에서 확인할 수 있다.

댓글 남기기