抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

捣鼓了几天python代码,我现在也越来越发现python的魅力所在,它的强大之处在于有非常多的第三方库可以随意调用。我不需要知道这些第三方库各种函数的实现方式,只要知道这些函数有什么作用,能得到什么结果。只要构思好自己的想法,找到对应的库就可以一步步按照我的思路编写程序,实现我想要的结果,整个构思到实现的过程让我非常愉悦~

1. 前言

写这篇博客纯粹是个人爱好,也是一个巧合~

前几天刷b站看到有人做了个剪影的字符动画,我就很好奇python是否可以实现。参考了一下github上大佬们的图片转字符画的代码,对这些代码做了点深入研究,总算搞明白了其实现方式,并且自己动手修改代码,在原有基础上改了几个bug,新增几个模块的调用,最后一步封装写成了下面这个脚本。这个脚本的功能是只要输入视频文件和你想要的视频帧率,就可以自动将视频转化为字符动画

可以先看一下视频效果~ 或者点击这里进入b站观看

2. 实现思路

  • 调用ffmpeg根据帧率将视频切割成图片
  • 调用pillow库作图,每张图片转换字符画
  • 调用ffmpeg合并字符画并输出动画

3. 脚本代码及详解

思路很清晰,接下来是写代码实现的过程,细节方面需要调用其他库

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
from PIL import Image, ImageDraw, ImageFont # pillow库作图
import subprocess # 执行命令行命令,为了调用ffmpeg
import sys
import os # 操作目录用
import shutil # 删除目录用
import numpy as np # 转化numpy数组
import gc # 优化运行内存用到

# 定义输入值,这个脚本需要两个输入值:file_input和FPS
file_input = sys.argv[1]
FPS = sys.argv[2]

def do_turn(file_input, FPS):
# 调用ffmpeg切割视频
os.makedirs("tempfile/cut/") # 当前目录新建存放切割图片的临时文件夹
shell_vedio = "ffmpeg -i " + file_input + " -r " + FPS + " -qscale:v 2 ./tempfile/cut/%05d.jpg" # 按照XXXXX序号切割
shell_voice = "ffmpeg -i " + file_input + " ./tempfile/out.mp3"
subprocess.call(shell_vedio, shell=True) # 切割视频
subprocess.call(shell_voice, shell=True) # 分离音频
count =0
for file in os.listdir("./tempfile/cut/"):
count += 1
print("成功分离音频,截图开始转换字符画......" + "共计" + str(count) + "张") # 统计切割图张数

# 图片转换字符画
list_p = os.listdir("./tempfile/cut/")
cwd = os.getcwd()
os.mkdir("./tempfile/new/") # 新建存放字符画的临时文件夹
process = 1 # 统计完成转换的字符画数量
for id in list_p: # 遍历cut文件夹所有切割后的图片做字符画转换
address = str("".join(cwd + '/tempfile/cut/' + id)) # 拼接文件的绝对路径
im = Image.open(address) # 调用image打开图片
font = ImageFont.truetype("DejaVuSans-Bold", size=20) # 字体模式,可更改
rate = 0.1 # 缩放比(不调整的话像素点过多,这里统一调整)
aspect_ratio = font.getsize("x")[0] / font.getsize("x")[1] # 获得字符长宽比
new_im_size = np.array([im.size[0] * rate, im.size[1] * rate * aspect_ratio]).astype(int) # 转换numpy数组,调整大小
im = im.resize(new_im_size)
im = np.array(im.convert("L")) # 转换灰阶图,生成numpy数组

symbols = np.array(list(" .-vM@")) # 建立字符索引,注意要按照亮度手动排序
if im.max() == im.min(): # 全黑和全是一种颜色进行区分
if im.max() > 0: # 全是一种颜色,亮度值大于0,则全部用最亮的字符数值
im = (im / im) * (symbols.size - 1)
else:
im[np.isnan(im)] = 0 # 全黑时亮度值为NaN(非数值),则全部用最暗字符的字符数值,也就是全黑
else:
im = (im - im.min()) / (im.max() - im.min()) * (symbols.size - 1) # 根据索引赋予相应像素点相应的数值
ascii = symbols[im.astype(int)]
letter_size = font.getsize("x") # 获取字符大小,与前面一定要对应
im_out_size = new_im_size * letter_size # 这里乘以字符长宽,否则字符只有一个像素点大小
im_out = Image.new("RGB", tuple(im_out_size), "black") # 设置输出图片,背景
draw = ImageDraw.Draw(im_out)

y = 0 # 两个循环穷举赋值,做字符画图片
for i, m in enumerate(ascii):
for j, n in enumerate(m):
draw.text((letter_size[0] * j, y), n, font=font)
y += letter_size[1] # 注意+=,这里赋值字符宽度给y值
im_out.save("./tempfile/new/" + id + ".png") # 定义输出位置和图片格式
print(address + "转换成功!当前进度:" + str(process) + "/" + str(count)) # 显示进度
process += 1
gc.collect() # 重要!每次循环结束释放一次内存,否则容易内存溢出
print("转换成功!开始生成视频,请稍候......")

# 调用ffmpeg合并字符画为视频,并且合并分离的音频
outvedio = "ffmpeg -r " + FPS + " -i ./tempfile/new/%05d.jpg.png ./tempfile/out.mp4"
subprocess.call(outvedio, shell=True)
final_vedio = "ffmpeg -i ./tempfile/out.mp4 -i ./tempfile/out.mp3 final.mp4"
subprocess.call(final_vedio, shell=True)
shutil.rmtree("./tempfile") # 删除临时文件夹
print("字符动画final.mp4已生成!已移除临时文件夹")

# 输入格式错误则显示该条用法
def usage():
print("usage:", sys.argv[0], "<file_input> <FPS>")
exit(0)

if __name__ == "__main__": # 封装,只有在文件作为脚本直接执行时后面的语句才会被执行,而 import 到其他脚本中后面的语句是不会被执行的
if len(sys.argv) != 3: # 判断输入的值是否为两个,没错,是判断两个
usage()
else:
do_turn(file_input, FPS)

4. 注意要点

调用numpy模块生成数组,是因为python本身虽然可以建立多维度的数组,但是书写起来非常麻烦。numpy可以很好地解决这个问题,可以理解为能构建一个更好用的数组。在对数组进行遍历穷举,要注意两次穷举分别生成两个数组,第二次生成的数组只有一个数,所以下面draw.text第二个参数text可以用n,也可以用n[0]列举第一个数。

1
2
3
4
5
y = 0   
for i, m in enumerate(ascii): # 这里i是用不到的
for j, n in enumerate(m):
draw.text((letter_size[0] * j, y), n, font=font) # 第一个参数是确定坐标
y += letter_size[1]

字符大小,字符格式,以什么字符为参照,都是可以调整的。只要注意一点,我们是按照像素点的亮度来赋于这个像素点用什么字符的,所以索引列比较重要,要自己按照字符亮度排序,添加字符注意改值。

1
symbols = np.array(list(" .-vM@"))      # 可以改成自己想要用的字符,注意按照亮度升序

还有,在计算亮度和赋予索引值的时候,我们是按照相对亮度来计算的。因此,当图片所有像素点都是一种颜色的时候,im.max() 和 im.min()值是相等的,相对亮度会出现0/0的值,导致报错。所以我加了以下判断条件:纯色黑色和其他颜色属于两种不同情况,黑色时numpy数组亮度是非数值NaN,需要将数组全部值进行替换为亮度最小的字符的值;因为是RGB取值,其他颜色值固定在0-255之间,颜色均一,相对亮度就没有意义了,因此全部调整为最亮字符的值。

1
2
3
4
5
6
7
if im.max() == im.min():
if im.max() > 0:
im = (im / im) * (symbols.size - 1)
else:
im[np.isnan(im)] = 0
else:
im = (im - im.min()) / (im.max() - im.min()) * (symbols.size - 1)

顺便再说一个很有意思的模块subprocess,subprocess.call()函数可以执行命令行的命令,并且这个命令是在子进程实行的,只有子程序结束才会继续执行其他命令,使用起来真的特别方便!比如有些程序我的python库里没有但是我的linux里有,在python脚本的某一步我需要用到linux里的软件去处理,这个时候就可以调用subprocess.call()函数去执行linux命令行的命令了。

还有一个函数虽然不显眼,但是起着至关重要的作用 gc.collect()

没有这个函数部分运行内存不够的电脑会崩……我在这里踩了个大坑……

在对程序进行简化以后,我以为优化地差不多了,然后发现有的时候程序会被莫名其妙killed……

vi /var/log/messages 查看运行日志,好家伙,内存溢出了

经过一番度娘,我检查了一下自己也没有用到循环引用的变量啊,那么真相只有一个了:转换字符画部分程序有3个for循环嵌套,可能是for循环引用的对象没有及时回收导致内存不断增长,最后被系统kill掉(个人猜测)。虽然python本身有垃圾回收功能,而在程序运行的时候清理地并不是很及时,引入的gc模块是python的垃圾收集器模块,与默认的回收方式算法不同,gc.collect()函数可以强制进行垃圾回收。因此我在每个转化字符画的for循环执行一次结束后强制回收内存,如下:

效果是立竿见影的,内存占用再也没超过5%了,非常的稳定!

5. 食用方法

缺什么第三方库就装什么,主要是pillow库、numpy库和ffmpeg,用conda可以直接安装。上面那段脚本代码复制粘贴,保存为ascii.py,运行命令:

python ascii.py <视频文件> <你想要的视频帧率>

回车,OK,静静等屏幕上的提示就好了。视频文件不在当前文件夹的话自行加上绝对路径,完成以后只会在当前目录生成一个out.mp4的输出文件。

源代码将同步上传我的github

欢迎小伙伴们留言评论~