架构设计与实现

从 GIF 到 ASCII:动态色块的渲染记录

记录如何将带有噪点的透明背景 GIF 转换为由纯文本构成的动态色块矩阵。

在构思个人主页时,我想在页面中放一只动态的像素狐狸。它最好不是一张普通的图片,而是由真正的纯文本字符构成,可以用鼠标划选。这带来了一些有趣的实现细节,权当一次小实验记录于此。


原始素材与基础问题

使用的素材是一张 300x300 分辨率的透明背景 GIF。它的边缘带有一些零星的白色环境噪点。

原始 GIF 动画

最基础的思路是提取每一帧的像素,映射为 ██ 这样的全角字符。但在初步尝试中遇到了两个小问题:

1. 浏览器的最小字体限制 如果尝试用极小的字号(如 10.5px)来塞进定宽容器,浏览器通常会将其拉伸到 12px 的最小限制,导致文本行变宽而产生截断。为此,改为使用正常大小的 12px 字符渲染,随后通过 CSS transform: scale() 按容器比例微缩。

2. 亚像素抗锯齿留下的缝隙 最初想过用半像素字符 来提升垂直分辨率,但不同浏览器的抗锯齿渲染会在字符交界处产生细微的裂线。最终还是采用了规整的全块字符 ██,依靠微缩技术来保证像素的细密感。


时空与色彩滤波

主要的问题在于如何干净地去除原图四周的散乱噪点,以便进行紧凑的包围盒裁切,提升有效分辨率。这就需要一套针对性的帧解析脚本。

1. 色彩判定

观察发现,残留的噪点大多是低饱和度的暗灰色,而狐狸主体是纯白,尾部色彩鲜艳。我们可以通过亮度和饱和度来初步分离它们。

S=max(R,G,B)min(R,G,B)S = \max(R,G,B) - \min(R,G,B) L=max(R,G,B)L = \max(R,G,B)

如果一个像素极暗,或是呈现出低饱和度的灰阶状态,便将其判定为噪点。

def is_noise(r, g, b):
# 过滤极暗像素 (阈值25)
if max(r, g, b) < 25:
return True
# 过滤低饱和度灰阶噪点,同时保护纯白主体 (亮度>=220)
if max(r, g, b) - min(r, g, b) < 25 and max(r, g, b) < 220:
return True
return False

2. 时域过滤

为了消除单帧中偶然闪烁的像素,建立一个全局的时域命中图(Hit Map)。只有在时间序列中稳定出现的像素才会被保留。

# 建立时域命中图
hit_map = [0] * (width * height)
for frame in raw_frames:
for i, (r, g, b) in enumerate(frame.getdata()):
if not is_noise(r, g, b):
hit_map[i] += 1
# 命中次数大于等于 2 的像素才纳入有效遮罩
temporal_mask = Image.new("L", (width, height))
temporal_mask.putdata([255 if hits >= 2 else 0 for hits in hit_map])

3. 空间过滤

在时域遮罩的基础上,施加 3×33 \times 3 的形态学开运算(先腐蚀,再膨胀),平滑掉残留的孤立像素。最后,将纯净的遮罩反向乘回原图。

Fclean(x,y,t)=Fraw(x,y,t)×M_global(x,y)F*{clean}(x,y,t) = F*{raw}(x,y,t) \times M\_{global}(x,y)

这保证了原图色彩不被破坏,同时排除了所有背景杂质。

# 施加空域形态学开运算 (3x3 最小滤镜与最大滤镜)
clean_mask = temporal_mask.filter(ImageFilter.MinFilter(size=3)).filter(ImageFilter.MaxFilter(size=3))

4.回代裁切

在上述处理的基础上,重新分析图像,裁切无用的像素,以期能对剩余的部分进行更高分辨率的裁切。

# 获得紧凑的包围盒
bbox = clean_mask.getbbox()
final_frames = []
black_bg = Image.new("RGB", (width, height), (0, 0, 0))
for frame in raw_frames:
# 使用纯净 Mask 零损覆盖源图像的噪点,保护原色彩
masked_frame = Image.composite(frame, black_bg, clean_mask)
# 按最小包围盒裁切并进行近邻重采样缩放
cropped = masked_frame.crop(bbox)
frame_resized = cropped.resize((new_w, new_h), Image.Resampling.NEAREST)

5.像素转矩阵

这实在没什么好说的。

pixels = list(frame_resized.getdata())
hex_pixels = []
for r, g, b in pixels:
if is_noise(r, g, b):
hex_pixels.append(None)
else:
hex_pixels.append('#%02x%02x%02x' % (r, g, b))

结果演示

完成自适应裁切后,画面多余的留白被去除,使得相同区域内能够容纳更多有效的字符像素。这就是网页开始这组文本动画的来源(可以尝试用鼠标划选文本)。

以上便是这一小段代码实验的全部记录。