콘텐츠로 건너뛰기
Home » 점자 텍스트로 이미지 표현하기

점자 텍스트로 이미지 표현하기

지난 글에서 이미지를 아스키문자로 렌더링하는 방법에 대해서 알아보았는데, 이번에는 조금 다른 이미지 렌더링 방식에 대해서 알아보자. 인터넷에서 돌아다니는 다음과 같은 아스키아트를 본 적이 있을지 모르겠다.

기존의 아스키아트는 글자 1개가 픽셀 1개를 표현하는데, 이런 종류의 아스키 아트는 1글자가 픽셀 8개를 표현한다. 2×4의 점으로 한 글자가 이루어지는 이 문자들은 유니코드 점자 문자 세트의 글자들이다. 각각의 점이 on/off 상태로 총 128개의 문자를 표현할 수 있기 때문에 실질적으로 모든 픽셀을 표현하는 것이 가능하다.

점자 코드의 첫글자는 0x2800로 빈 칸을 표현하며, 이후 아래방향으로 그리고 다시 오른쪽으로 값이 1씩 올라갈 때마다 이진수의 표현대로 표시된다. 이것을 사용하여 가로 2 픽셀, 세로 4픽셀의 8개 픽셀 영역을 표시할 수 있다.

이미지를 점자로 변환하기

하나의 문자로 처리하는 단위가 가로 세로 2×4 영역이며, 왼쪽 위 픽셀을 기준으로 아래쪽으로 4칸을 체크하고 다시 오른쪽 4칸을 위에서부터 아래로 체크한다. 이 때 점을 찍을 위치인 경우에는 0x2800 값에 해당 비트 위치의 값을 OR 연산으로 더해준다. 여덟 픽셀을 처리한 후에는 해당 값을 점문자로 변환하여 추가한다.

참고로, 리사이징한 결과의 가로/세로가 각각 2, 4의 배수가 안될 경우를 대비해서 영역을 넘어가는 픽셀은 빈 칸으로 처리해주는 센스도 잊지 말자.

def convert(im: Image.Image, width: int=40) -> str:
    w = (width // 2) * 2
    h = int(w / im.width * im.height // 4) * 4
    im = imresize((w, h)).convert('1')
    res: list[str] = []
    for y in range(0, h, 4):
        line: list[str] = []
        for x in range(0, w, 2):
            p, q = 0, 1
            for i in range(8):
                a, b = divmod(i, 4)
                if x + a < w and y + b < h:
                    px = im.getpixel((x + a, y + b))
                    p |= q if px == 0 else 0
                q <<= 1
            line.append(chr(p+0x2800))
        res.append(''.join(line))
    return '\n'.join(res)

아래는 이전 글의 자동차 이미지를 점자로 렌더링한 결과이다. 뭔가 지저분하게 보인다. 좀 더 개선이 필요하겠다.

렌더링 품질 개선하기

위에서 사용한 원본 이미지를 리사이징하고 다시 흑백 이미지로 변환한 결과를 이미지 자체로 보면 품질이 좋지 못한 이유를 알 수 있는데, convert('1')을 사용해서 변환한 흑백이미지는 기본적으로 디더링이 되어 회색 부분이 작은 점으로 채워지기 때문이다. 이 경우에는 문자로 렌더링한 결과가 깔끔하지 않게 된다. 깨끗한 흑백 이미지로 변환하고 싶다면 Image.point(f) 메소드를 사용하여, 픽셀 값을 완전한 0아니면 1로 바꾸어주면 된다. 이 메소드는 전달된 f 함수에 각 픽셀 값을 넘겨주고, 반환된 값으로 픽셀을 변경한다. (일종의 픽셀에 대한 map 연산이라고 보면 된다.)

127보다 큰 값은 255로 그렇지 않은 값은 0으로 변경하는 부분은 다음과 같이 처리할 수 있다.

im = imresize((w, h)).convert('1')

# -->

im = resize((w, h)).convert('L').point(lambda x: 0 if x < 128 else 255)
im.show() # 이미지를 미리 한 번 본다.

결과가 조금 더 깔끔해지긴한다. 기왕 이렇게 하는 김에 흑백을 구분할 기준값이 중간이 127이 아닌 다른 값을 쓸 수 있도록 따로 지정할 수 있으면 좋겠다. 이 값을 threadh 라는 인자로 추가하자. 그리고 하는 김에 반전 효과를 처리할 수 있도록 inv라는 인자도 추가했다.

def convert(im: Image.Image, width: int=40, threadh=128, inv=False) -> str:
    def lut(x):
        if inv and x < threadh:
            return 255
        elif not inv and x >= threadh:
            return 255
        return 0
            
    w = (width // 2) * 2
    h = int(w / im.width * im.height // 4) * 4
    im = imresize((w, h)).convert('L').point(lut)
    res: list[str] = []
    for y in range(0, h, 4):
        line: list[str] = []
        for x in range(0, w, 2):
            p, q = 0, 1
            for i in range(8):
                a, b = divmod(i, 4)
                if x + a < w and y + b < h:
                    px = im.getpixel((x + a, y + b))
                    p |= q if px == 0 else 0
                q <<= 1
            line.append(chr(p+0x2800))
        res.append(''.join(line))
    return '\n'.join(res)

위 코드를 적용해서 회색조 이미지를 흑백으로 변환할 때 좀 더 면 위주로 구분되게 하였다. 이 때 임계값을 조정해보면 보다 디테일을 또렷하게 살린 결과를 볼 수 있다.

개선된 이진화 방법을 적용한 결과(좌)와 임계값을 낮추어 적용한 결과