콘텐츠로 건너뛰기
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에서 확인할 수 있다.

import argparse
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("-w", "–width", dest="width", type=int, default=80)
parser.add_argument("-l", "–level", dest="level", type=int, default=16)
parser.add_argument("-i", "–invert", dest="invert", action="store_true")
parser.add_argument("filename")
return parser.parse_args()
def create_char_map(*, level: int = 16, inv: bool = False) -> str:
cmap: list[tuple[int | float, str]] = []
filepath = Path(__file__).parent / "CascadiaMono.ttf"
ft = ImageFont.truetype(filepath.as_posix(), size=12)
canvas = Image.new("L", (20, 20), color=0)
ctx = ImageDraw.Draw(canvas)
for i in range(128):
c = chr(i)
if c.isprintable():
ctx.rectangle((0, 0, 20, 20), 0)
ctx.text((0, 0), c, font=ft, fill=255)
val = sum(canvas.getdata())
cmap.append((val, c))
m, n = max(cmap)[0], min(cmap)[0]
cmap = [((v – n) / (m – n) * 255, c) for v, c in cmap]
result: list[str] = []
for i in range(level):
v = i / level * 255
result.append(min(cmap, key=lambda x: abs(x[0] – v))[1])
return "".join(result if inv else result[::-1])
def convert(
img: Image.Image, width: int = 80, level: int = 16, invert: bool = False
) -> str:
cmaps = create_char_map(level=level, inv=invert)
W, H = width, int(width / img.width * img.height)
im_res = img.convert("L").resize((W, H), resample=Image.Resampling.BOX)
res: list[str] = []
for y in range(im_res.height):
line: list[str] = []
for x in range(im_res.width):
p = im_res.getpixel((x, y))
line.append(cmaps[int(p / 256 * level)])
res.append("".join(line))
return "\n".join(res)
if __name__ == "__main__":
args = parse_args()
im = Image.open(args.filename)
print(convert(im, args.width, args.level, args.invert))
view raw img2asc.py hosted with ❤ by GitHub