今日依舊無事,不想搞畢設。
無聊的人想法多,今日就想到把一只 Super Mario 在終端中輸出。
具體做法十分「老土」,就是玩字符畫那一套,但我這次想把這個字符串輸出成彩色的。
准備工作
第一步當然是把圖片轉換為 24 位的位圖,即 bmp
格式的圖片,使用 Windows 自帶的畫圖工具即可。
Aside
之所以叫 24 位圖,是因為在這種格式的圖片中,一個像素由三個整數 (R, G, B) 表示,每個整數均為 8 bit 的整型。R 是 Red,G 是 Green,B 是 Blue,光學三原色是也。
這樣使用合適的庫打開某個圖片,訪問 image[i][j]
就可以獲得一個三元組 (R, G, B) ,后面的事情就是對這些三元組進行操作輸出到終端。也就是說,一個彩色圖片可以等價於一個三維數組 image[m][n][3]
。
打開圖片
from PIL import Image
image = Image.open(os.sys.argv[1])
image = image.resize((int(80), int(80)), Image.ANTIALIAS)
# 可以通過 resize 調整高度和寬度
獲取一個像素點
image.getpixel(i,j)
預處理為可操作的 list
類型
rgb_data = parse_image(image)
def parse_image(image: Image):
rgb_tuple_list = list()
width, height = image.size
for j in range(height):
l = list()
for i in range(width):
l.append(image.getpixel((i, j)))
rgb_tuple_list.append(list(l))
return rgb_tuple_list
終端帶顏色輸出
參考這篇文章 。
終端字符顏色實際上是通過轉移字符序列來控制的。也就是說,我們在需要輸出的字符串前面加入特定的 ASCII 序列就能夠改變字體的顏色。
輸出有以下 3 種控制方式:
- 顯示方式:默認值 (0),高亮 (1),下划線 (4),閃爍 (5),反顯 (7)
- 前景顏色:即字體顏色。紅色 (31),綠色 (32),黃色 (33),藍色 (34),洋紅色 (35),青色 (36),白色 (37)
- 背景顏色:黑色 (40),紅色 (41),綠色 (42),黃色 (43),藍色 (44),洋紅色 (45),青色 (46),灰白色 (47)
控制 ASCII 序列為 format_str = '\033[{};{};{}m'
,例如下面的 python 代碼可以輸出🌈顏色:
format_str = '\033[{};{};{}m'
ctl = [0, 1, 4, 5, 7]
for i in ctl:
for j in range(31, 37 + 1):
for k in range(40, 47 + 1):
print(format_str.format(i, j, k) + 'sinkinben', end=' ')
print('')
灰度圖字符畫
原本一個有顏色的像素點用 (r,g,b)
三個 8 位整型數值表示,灰度圖就是把 (r,g,b)
轉換為一個代表黑白深淺的數值,這樣 image[m][n]
一個整型二維數組可以表示一個黑白的圖片。
轉換函數是一個固定的公式:
def gray_val(r, g, b):
return int(0.2126 * r + 0.7152 * g + 0.0722 * b)
但是,這仍然是一個圖片,只不過是黑白的,我們無法在普通的終端輸出。因此需要把灰度值映射為一個字符,這樣就能做出網上常見的字符畫。
table = list("@W#$%0OEXC[(/?=^~_.` ")
# table = list("MNHQ$OC67)oa+>!:+. ")
def get_char(r, g, b):
step = int(256 / len(table)) + 1
return table[int(gray_val(r, g, b) / step)]
灰度值的范圍是 \([0,255]\) ,相鄰的灰度值呈現的灰度在視覺上是相近的,因此我們就用一個字符 table[i]
來表示某個區間的灰度。為什么代碼是這么寫?舉個例子說明。假設灰度值的范圍是 \([0,16]\),使用四個字符 table = '#@*O'
來表示。也就是說:
[0, 3] => table[0]
[4, 7] => table[1]
[8, 11] => table[2]
[12, 15] => table[3]
在這里灰度值映射得到的字符為 table[gray_val / 4]
,4 是區間的長度,表示一個字符表示灰度值的個數。
table
可以根據輸出的字體手動修改,這是影響「字符畫」美觀的主要因素之一(另外一個因素是寬度和高度的比值,因為終端的字體都是長方形的,如果不調整,輸出的字符畫也是長不拉幾的)。
輸出純字符畫代碼:
def gray_ascii_picture(rgb_data: list):
ascii_pic = ''
for row in rgb_data:
for t in row:
ascii_pic += get_char(*t) * 3
ascii_pic += '\n'
return ascii_pic
ascii_pic += get_char(*t) * 3
表示用 3 個字符表示一個像素點,這是調整寬度的一個技巧。
上色字符畫
首先我們解決一個問題,獲取終端顏色的 RGB 表示,這里使用的是 webcolors
這個庫:
color_list = ['black', 'red', 'green', 'yellow', 'blue',
'purple', 'skyblue']
color_dict = dict()
for i in range(len(color_list)):
t = tuple(webcolors.name_to_rgb(color_list[i]))
color_dict[t] = int(i)
# color_dict is {(0, 0, 0): 0, (255, 0, 0): 1, (0, 128, 0): 2, (255, 255, 0): 3, (0, 0, 255): 4, (128, 0, 128): 5, (135, 206, 235): 6}
圖片中的顏色數目是遠多於終端中可輸出的顏色,因此我們需要用 8 種終端顏色來表示所有的 (R, G, B) 顏色,這里采取的策略是,從終端顏色中挑選一個幾何距離最近的顏色:
# rgb = get_closest_rgb(terminal_colors=color_dict.keys(), rgb=tuple(r,g,b))
def get_closest_rgb(terminal_colors, rgb: tuple):
min_val = 255 * 255 * 3
result = None
for t in terminal_colors:
val = int(rgb[0] - t[0]) ** 2 + int(rgb[1] - t[1]) ** 2 + int(rgb[2] - t[2])
if val < min_val:
min_val = val
result = t
return result
最后對每一個像素處理,通過格式化字符串輸出一個「色塊」。
def colorful_ascii_picture(rgb_data: list):
format_str = '\033[{};{};40m{}\033[0m'
color_list = ['black', 'red', 'green', 'yellow', 'blue',
'purple', 'skyblue']
color_dict = dict()
for i in range(len(color_list)):
t = tuple(webcolors.name_to_rgb(color_list[i]))
color_dict[t] = int(i)
print('')
for row in rgb_data:
line = ''
for t in row:
rgb = get_closest_rgb(terminal_colors=color_dict.keys(), rgb=t)
icolor = color_dict[rgb]
print(format_str.format(1, icolor+30, get_char(*t)), end='')
print('')
return
效果圖
- 黑白 Doraemon :使用一個字符和一個空格來表示一個像素,在記事本中縮放查看的效果
- 彩色 Doraemon :使用 2 個字符表示一個像素,很不幸藍色映射為綠色了😅,終端字體調整為 1 的效果
- 彩色 Sun Xiaochuan:圖片縮放 200 × 200,2 個字符表示 1 個像素
- 彩色皮卡丘,背景是 Windows Terminal 自帶的 Acrylic 效果,如果在純黑色背景的終端,效果應該更好,下次用 Ubuntu 試試
- 彩色 Mario
完整代碼
Usage: python xxx.bmp
from PIL import Image
import numpy
import os
import matplotlib.pyplot as pyplot
import webcolors
table = list("@W#$%0OEXC[(/?=^~_.` ")
# table = list("MNHQ$OC67)oa+>!:+. ")
kernel_size = 2
merge_kernel = [[1 for i in range(kernel_size)] for j in range(kernel_size)]
def gray_val(r, g, b):
return int(0.2126 * r + 0.7152 * g + 0.0722 * b)
def get_char(r, g, b):
step = int(256 / len(table)) + 1
return table[int(gray_val(r, g, b) / step)]
def parse_image(image: Image):
rgb_tuple_list = list()
width, height = image.size
for j in range(height):
l = list()
for i in range(width):
l.append(image.getpixel((i, j)))
rgb_tuple_list.append(list(l))
return rgb_tuple_list
def show_in_gui(rgb_data: list):
# 在 pyplot 中顯示圖片
pyplot.subplot()
pyplot.imshow(rgb_data)
pyplot.show()
def gray_ascii_picture(rgb_data: list):
ascii_pic = ''
for row in rgb_data:
for t in row:
ascii_pic += get_char(*t) + ' '
ascii_pic += '\n'
return ascii_pic
def get_closest_rgb(terminal_colors, rgb: tuple):
min_val = 255 * 255 * 3
result = None
for t in terminal_colors:
val = int(rgb[0] - t[0]) ** 2 + \
int(rgb[1] - t[1]) ** 2 + int(rgb[2] - t[2])
if val < min_val:
min_val = val
result = t
return result
def colorful_ascii_picture(rgb_data: list):
format_str = '\033[{};{};40m{}\033[0m'
color_list = ['black', 'red', 'green', 'yellow', 'blue',
'purple', 'skyblue']
color_dict = dict()
for i in range(len(color_list)):
t = tuple(webcolors.name_to_rgb(color_list[i]))
color_dict[t] = int(i)
print('')
for row in rgb_data:
line = ''
for t in row:
rgb = get_closest_rgb(terminal_colors=color_dict.keys(), rgb=t)
icolor = color_dict[rgb]
# print(icolor, end=' ')
print(format_str.format(1, icolor+30, get_char(*t))*2, end='')
print('')
return
if __name__ == '__main__':
image = Image.open(os.sys.argv[1])
image = image.resize((int(200), int(150)), Image.ANTIALIAS)
rgb_data = parse_image(image)
# preview
# show_in_gui(rgb_data)
# print gray picture in terminal
# print(gray_ascii_picture(rgb_data))
colorful_ascii_picture(rgb_data)