지난 글에서 이미지를 아스키문자로 렌더링하는 방법에 대해서 알아보았는데, 이번에는 조금 다른 이미지 렌더링 방식에 대해서 알아보자. 인터넷에서 돌아다니는 다음과 같은 아스키아트를 본 적이 있을지 모르겠다.
기존의 아스키아트는 글자 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)
위 코드를 적용해서 회색조 이미지를 흑백으로 변환할 때 좀 더 면 위주로 구분되게 하였다. 이 때 임계값을 조정해보면 보다 디테일을 또렷하게 살린 결과를 볼 수 있다.