图片转ASCII工具

Star Dust Lv1

前阵子用 Python 写了一个图片转彩色 ASCII 艺术画的工具,支持命令行和图形界面,还能导出 ANSI 或 HTML。这里把用法和实现思路整理出来,方便以后复用。

功能介绍

  • 彩色输出:保留原图的颜色信息,字符画不再是单调的黑白。
  • 可调精细度:提供低、中、高三档预设,自动控制宽度和字符集密度。
  • 自定义宽度:通过滑块或命令行参数自由调节输出宽度(控制字符数)。
  • 图像后处理:支持伽马修正(调节亮度)和锐化,改善字符画观感。
  • 两种输出格式
    • ANSI 文本:在终端里直接打印彩色字符画(某些终端需支持真彩色)。
    • HTML 文件:在浏览器中预览效果,字体极小,能展示更细腻的细节。
  • 双模式运行:不带任何参数启动 GUI;带参数则自动进入命令行模式,方便脚本调用。

用法示例

1. 图形界面模式

直接双击或在终端运行:

1
python ascii_art.py

主界面会打开,选择图片后调整宽度、伽马、锐化等参数,再点「在浏览器预览」或「保存文件」。

2. 命令行模式

1
python ascii_art.py input.jpg -d high -s 150 --gamma 1.2 --html -o result.html

参数说明:

参数作用
-d / --detail精细度预设:low, medium, high
-s / --scale输出宽度(字符数)
--gamma伽马值,调节整体亮度,默认 1.0
--sharpen锐化强度(0~2),默认 0
--charset自定义字符集,留空使用预设
--html输出 HTML 格式
-o / --output输出文件路径

如果不传图片路径,命令行模式也会弹出文件对话框。

实现思路

字符集设计

按灰度密度排序的字符集是 ASCII 艺术的核心。工具内置了三套预设,例如 HIGH_CHARSET 包含 70 个字符,每个字符的「视觉重量」经过精心排列,保证对原图的灰度还原度更高。

图像缩放

为保证字符比例不失调,缩放时考虑了终端字符宽高比(约 0.55),公式:

1
new_height = int(original_height / original_width * target_width * aspect_ratio)

其中 aspect_ratio=0.55 是为了抵消等宽字体下字符被拉高的视觉效果。

彩色输出

  • ANSI 版本:每个字符前插入 \033[38;2;{r};{g};{b}m ,终端会渲染对应颜色的字符,最后用 \033[0m 重置。
  • HTML 版本:用 <span style="color:rgb(r,g,b);"> 包裹每个字符,并设置极小字号(如 font-size:6px),让细节更密集。

后处理

  • 伽马校正:通过查找表调整 RGB 值,非线性地修改亮度,避免深色或浅色区域丢失细节。
  • 锐化:直接使用 PIL 的 ImageEnhance.Sharpness 增强边缘对比度,提升字符画清晰度。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
import argparse
import sys
import os
import shutil
import tempfile
import webbrowser
from PIL import Image, ImageEnhance
import tkinter as tk
from tkinter import filedialog, ttk, messagebox

# ---------- 字符集 ----------
LOW_CHARSET = " .:-=+*#%@"
MEDIUM_CHARSET = " .'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$"
HIGH_CHARSET = " `.-':_,^=;><+!rc*/z?sLTv)J7(|Fi{C}fI31tlu[neoZ5Yxjya]2ESwqkP6h9d4VpOGbUAKXHm8RD#$Bg0MNWQ%&@"

DETAIL_CONFIG = {
"low": {"width": 80, "charset": LOW_CHARSET},
"medium": {"width": 120, "charset": MEDIUM_CHARSET},
"high": {"width": 200, "charset": HIGH_CHARSET},
}

def get_terminal_width():
try:
return shutil.get_terminal_size().columns
except:
return None

def resize_image(image, new_width, aspect=0.55):
w, h = image.size
new_h = max(1, int(h / w * new_width * aspect))
return image.resize((new_width, new_h), Image.LANCZOS)

def apply_gamma(img, gamma):
if gamma == 1.0:
return img
inv = 1.0 / gamma
lut = [int(255 * (i/255)**inv) for i in range(256)]
return img.point(lut * 3)

def load_image(path):
try:
img = Image.open(path)
except Exception as e:
raise RuntimeError(f"无法打开图片: {e}")
if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
bg = Image.new("RGB", img.size, (255,255,255))
if img.mode == "P":
img = img.convert("RGBA")
bg.paste(img, mask=img.split()[-1])
img = bg
return img

def generate_ansi(img, width, charset):
img_rgb = img.convert("RGB")
img_rgb = resize_image(img_rgb, width)
pixels = img_rgb.getdata()
max_idx = len(charset) - 1
chars = []
for p in pixels:
r, g, b = p[0], p[1], p[2]
gray = 0.2126*r + 0.7152*g + 0.0722*b
idx = min(int(gray * max_idx / 255 + 0.5), max_idx)
chars.append(f"\033[38;2;{r};{g};{b}m{charset[idx]}")
lines = []
w = img_rgb.width
for i in range(0, len(chars), w):
line = "".join(chars[i:i+w])
lines.append(line + "\033[0m")
return "\n".join(lines)

def generate_html(img, width, charset):
img_rgb = img.convert("RGB")
img_rgb = resize_image(img_rgb, width)
w, h = img_rgb.width, img_rgb.height
pixels = list(img_rgb.getdata())
max_idx = len(charset) - 1
lines = ['<pre style="line-height:1.0; background:#000; color:#fff; font-family:monospace; font-size:6px;">']
for y in range(h):
row = ""
for x in range(w):
idx = y * w + x
r, g, b = pixels[idx][0], pixels[idx][1], pixels[idx][2]
gray = 0.2126*r + 0.7152*g + 0.0722*b
ch_idx = min(int(gray * max_idx / 255 + 0.5), max_idx)
ch = charset[ch_idx]
if ch == '<': ch = '&lt;'
elif ch == '>': ch = '&gt;'
elif ch == '&': ch = '&amp;'
row += f'<span style="color:rgb({r},{g},{b});">{ch}</span>'
lines.append(row)
lines.append("</pre>")
return "\n".join(lines)

def cmd_main(args):
image_path = args.image
if not image_path:
try:
root = tk.Tk()
root.withdraw()
image_path = filedialog.askopenfilename(
title="选择图片",
filetypes=[("图片", "*.jpg *.jpeg *.png *.bmp *.gif *.webp")]
)
root.destroy()
if not image_path:
print("未选择文件。")
sys.exit(0)
except Exception as e:
print(f"无法打开文件对话框: {e}")
sys.exit(1)

detail = args.detail
if detail not in DETAIL_CONFIG:
print(f"未知精细度: {detail},使用 medium")
detail = "medium"

if args.charset:
charset = args.charset
else:
charset = DETAIL_CONFIG[detail]["charset"]

if args.scale:
width = args.scale
else:
if detail == "low":
width = 80
elif detail == "high":
width = 200
else:
tw = get_terminal_width()
width = tw if tw else 120

img = load_image(image_path)
if args.sharpen > 0:
enhancer = ImageEnhance.Sharpness(img)
img = enhancer.enhance(args.sharpen)
img = apply_gamma(img.convert("RGB"), args.gamma)

if args.html:
result = generate_html(img, width, charset)
else:
result = generate_ansi(img, width, charset)

if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(result + "\n")
print(f"已保存至: {args.output}")
else:
try:
print(result)
except BrokenPipeError:
pass

class AsciiArtGUI:
def __init__(self, root):
self.root = root
self.root.title("彩色 ASCII 艺术生成器")
self.root.resizable(False, False)
self.image_path = None
self.img = None

main = ttk.Frame(root, padding=15)
main.grid(row=0, column=0, sticky="nsew")

ttk.Label(main, text="图片文件:").grid(row=0, column=0, sticky="w")
self.file_var = tk.StringVar(value="未选择")
ttk.Entry(main, textvariable=self.file_var, width=45, state="readonly").grid(row=0, column=1, padx=5)
ttk.Button(main, text="浏览...", command=self.select_file).grid(row=0, column=2)

ttk.Label(main, text="精细度预设:").grid(row=1, column=0, sticky="w", pady=(10,0))
self.detail_var = tk.StringVar(value="medium")
detail_frame = ttk.Frame(main)
detail_frame.grid(row=1, column=1, columnspan=2, sticky="w", pady=(10,0))
for i, val in enumerate(["low", "medium", "high"]):
ttk.Radiobutton(detail_frame, text=val, value=val, variable=self.detail_var,
command=self.on_detail_change).grid(row=0, column=i, padx=5)

ttk.Label(main, text="宽度 (字符数):").grid(row=2, column=0, sticky="w")
self.width_var = tk.IntVar(value=120)
self.width_scale = ttk.Scale(main, from_=40, to=300, variable=self.width_var,
orient="horizontal", command=self.on_scale_move)
self.width_scale.grid(row=2, column=1, sticky="we", padx=5)
self.width_label = ttk.Label(main, text="120")
self.width_label.grid(row=2, column=2)
self.width_var.trace_add("write", lambda *a: self.width_label.config(text=str(self.width_var.get())))

ttk.Label(main, text="伽马 (亮度):").grid(row=3, column=0, sticky="w")
self.gamma_var = tk.DoubleVar(value=1.0)
ttk.Scale(main, from_=0.3, to=2.5, variable=self.gamma_var, orient="horizontal").grid(row=3, column=1, sticky="we", padx=5)
self.gamma_label = ttk.Label(main, text="1.0")
self.gamma_label.grid(row=3, column=2)
self.gamma_var.trace_add("write", lambda *a: self.gamma_label.config(text=f"{self.gamma_var.get():.1f}"))

ttk.Label(main, text="锐化强度:").grid(row=4, column=0, sticky="w")
self.sharpen_var = tk.DoubleVar(value=0.0)
ttk.Scale(main, from_=0.0, to=2.0, variable=self.sharpen_var, orient="horizontal").grid(row=4, column=1, sticky="we", padx=5)
self.sharpen_label = ttk.Label(main, text="0.0")
self.sharpen_label.grid(row=4, column=2)
self.sharpen_var.trace_add("write", lambda *a: self.sharpen_label.config(text=f"{self.sharpen_var.get():.1f}"))

ttk.Label(main, text="字符集 (可选):").grid(row=5, column=0, sticky="w", pady=(10,0))
self.charset_var = tk.StringVar(value="")
ttk.Entry(main, textvariable=self.charset_var, width=48).grid(row=5, column=1, columnspan=2, pady=(10,0), sticky="we")
ttk.Label(main, text="留空使用预设字符集", foreground="gray").grid(row=6, column=1, columnspan=2, sticky="w")

btn_frame = ttk.Frame(main)
btn_frame.grid(row=7, column=0, columnspan=3, pady=20)
ttk.Button(btn_frame, text="在浏览器预览", command=self.preview_in_browser).pack(side="left", padx=5)
ttk.Button(btn_frame, text="保存 ANSI 文件", command=self.save_ansi).pack(side="left", padx=5)
ttk.Button(btn_frame, text="保存 HTML 文件", command=self.save_html).pack(side="left", padx=5)

self.status_var = tk.StringVar(value="就绪")
ttk.Label(main, textvariable=self.status_var, foreground="blue").grid(row=8, column=0, columnspan=3)

def select_file(self):
path = filedialog.askopenfilename(
title="选择图片",
filetypes=[("图片文件", "*.jpg *.jpeg *.png *.bmp *.gif *.webp"), ("所有文件", "*.*")]
)
if path:
self.file_var.set(path)
self.image_path = path
self.load_image()

def load_image(self):
try:
self.img = load_image(self.image_path)
self.status_var.set("图片已加载")
except Exception as e:
messagebox.showerror("错误", str(e))
self.img = None

def get_generation_params(self):
if not self.img:
messagebox.showwarning("提示", "请先选择一张图片")
return None
detail = self.detail_var.get()
custom = self.charset_var.get().strip()
charset = custom if custom else DETAIL_CONFIG[detail]["charset"]
return {
"img": self.img,
"width": self.width_var.get(),
"charset": charset,
"gamma": self.gamma_var.get(),
"sharpen": self.sharpen_var.get(),
}

def apply_postprocess(self, params):
img = params["img"].copy()
if params["sharpen"] > 0:
img = ImageEnhance.Sharpness(img).enhance(params["sharpen"])
return apply_gamma(img.convert("RGB"), params["gamma"])

def generate_preview_html(self):
params = self.get_generation_params()
if not params:
return None
try:
processed = self.apply_postprocess(params)
return generate_html(processed, params["width"], params["charset"])
except Exception as e:
messagebox.showerror("生成失败", str(e))
return None

def preview_in_browser(self):
html = self.generate_preview_html()
if html:
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8")
tmp.write(html)
tmp.close()
webbrowser.open("file://" + tmp.name)
self.status_var.set("已在浏览器中打开预览")

def save_ansi(self):
params = self.get_generation_params()
if not params:
return
path = filedialog.asksaveasfilename(defaultextension=".txt", filetypes=[("ANSI 文本", "*.txt")])
if not path:
return
try:
processed = self.apply_postprocess(params)
result = generate_ansi(processed, params["width"], params["charset"])
with open(path, "w", encoding="utf-8") as f:
f.write(result)
self.status_var.set(f"已保存: {os.path.basename(path)}")
except Exception as e:
messagebox.showerror("保存失败", str(e))

def save_html(self):
path = filedialog.asksaveasfilename(defaultextension=".html", filetypes=[("HTML 文件", "*.html")])
if not path:
return
html = self.generate_preview_html()
if html:
with open(path, "w", encoding="utf-8") as f:
f.write(html)
self.status_var.set(f"已保存: {os.path.basename(path)}")

def on_detail_change(self):
detail = self.detail_var.get()
if detail in DETAIL_CONFIG:
self.width_var.set(DETAIL_CONFIG[detail]["width"])

def on_scale_move(self, val):
pass

if __name__ == "__main__":
if len(sys.argv) == 1:
root = tk.Tk()
app = AsciiArtGUI(root)
root.mainloop()
else:
parser = argparse.ArgumentParser(description="ASCIIER")
parser.add_argument("image", nargs="?", help="输入图片路径")
parser.add_argument("-d", "--detail", choices=["low","medium","high"], default="medium")
parser.add_argument("-s", "--scale", type=int, help="输出宽度")
parser.add_argument("-o", "--output", help="输出文件")
parser.add_argument("--charset", help="自定义字符集")
parser.add_argument("--gamma", type=float, default=1.0)
parser.add_argument("--sharpen", type=float, default=0.0)
parser.add_argument("--html", action="store_true", help="输出 HTML 格式")
args = parser.parse_args()
cmd_main(args)

注意事项

  • 终端需支持 24 位真彩色(大部分现代终端如 Windows Terminal、iTerm2、Konsole 均可)。
  • HTML 模式下字体极小,建议使用浏览器缩放查看。
  • 若图片带有透明通道,会自动填充白色背景。

结语

这个小工具最初是为了好玩,后来发现偶尔在终端里生成一张字符画还挺好玩的,并且在写脚本时也可以让脚本更好看些 (。・ω・。)

  • 标题: 图片转ASCII工具
  • 作者: Star Dust
  • 创建于 : 2026-05-01 11:43:08
  • 更新于 : 2026-05-17 17:17:50
  • 链接: https://starblog.qzz.io/posts/fcda8253.html
  • 版权声明: 版权所有 © Star Dust,禁止转载。
评论