前言:在抖音,快手等社交平台上,我们常常见到各种各样的GIF动画。在各大评论区里面,GIF图片以其短小精悍、生动有趣的特点,被广泛用于分享各种有趣的场景、搞笑的瞬间、精彩的动作等,能够快速吸引我们的注意力,增强内容的传播性和互动性。生活中,我们将各种有趣的人物表情、动作、台词等制作成GIF表情包,既可以更生动地表达我们此时的情感和态度,也让聊天的过程拥有了更多的趣味性和幽默感。当然,GIF动画不止在娱乐领域里应用广泛,在计算机的网页设计中很多时候也会使用GIF动画可以为页面增添动态效果,使页面更加生动活泼,吸引用户的注意力。例如,可以在网页的标题、导航栏、按钮等元素中添加GIF动画,提升页面的视觉效果和用户体验等。总而言之,GIF动画在我们的日常生活中扮演着重要的角色,我们有必要了解GIF动画的制作方法及相关制作工具。话不多说,我们今天就来学习一下如何利用Python来制作一款GIF动画工具。
编程思路:本次编程我们将会调用到Python中的众多库:包括诸如PyQt5,pillow,moviepy等的第三方库和sys,pathlib等的标准库。PyQt5被用于创建一个图形用户界面 (GUI) 应用程序(具体为代码中的GifMakerGUI类)。我们将创建窗口和布局(这里包括GUI窗口的大小,位置等),创建GUI中的相关组件(如按钮,标签,菜单等),处理事件和信号(主要负责将用户触发的事件与GUI控件联系起来),应用程序的启动和运行等。Pillow是Python中很重要的一个图片处理库,利用它我们可以对图片进行图像操作(包括图片的加载,操作,保存等),图像转换(包括图像颜色表示模式的转换(如RGB转HSV),以及图像尺寸大小的设置),图像序列处理(保存图像序列为GIF或其他格式),图像合成等操作。与pillow不同,moviepy是一个视频文件处理库(具体来说,它可以进行视频剪辑(打开,截取视频文件,也能进行音频处理(合成音频剪辑,视频音频合并,音频文件保存等))。imageio库比较简单,它主要被用于处理图像序列(简单来说就是将一系列图像保存为动画文件,如本次的GIF)。
第一步:导入库
标准库:sys,pathlib。
第三方库:PyQt5,pillow,imageio,moviepy。
#导入库
import sys
import math
import random
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from moviepy import VideoFileClip, CompositeAudioClip
import imageio
from PyQt5.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QLineEdit, QPushButton, QFileDialog,
QProgressBar, QMessageBox, QColorDialog,
QComboBox, QSpinBox, QListWidget, QMenu, QAction)
from PyQt5.QtCore import QThread, pyqtSignal, QSettings, Qt
from PyQt5.QtGui import QPixmap
第二步:创建功能类
两个:
GifCreator类:在后台线程中创建GIF或MP4文件(主要取决于用户是否选择音频文件(不选则生成gif动图,选则生成MP4文件))。
GifMakerGUI类:用于创建一个图形用户界面(GUI)应用程序,允许我们选择源文件(视频或图片)、添加音频文件、设置输出路径和参数,并启动GIF或MP4文件(同上)的生成过程。
与上次相同,本次也添加了很多扩展的实用性功能:
1,新增历史管理功能(包括删除,清空历史选项)等。
2,新增水印颜色预览功能(8X8像素颜色预览框)。
3,新增多种水印动画(包括3D旋转,缩放旋转,呼吸效果,弹跳淡入,淡入淡出等)。
4,新增多种图片滤镜(包括盒式模糊,高斯模糊,油画,浮雕,水墨画,轮廓,赛博朋克,漩涡等)。
5,新增支持中文文字水印功能(设置有默认字体,同时保留了用户可自行设置字体文件的功能),且可跨操作平台使用。
#Gif生成器类
class GifCreator(QThread):
progress_updated = pyqtSignal(int)
finished = pyqtSignal(str)
error_occurred = pyqtSignal(str)
#初始化函数
def __init__(self, config):
super().__init__()
self.config = config
self.font = None
if config['text']:
try:
# 支持中文的字体加载
self.font = ImageFont.truetype(
config['font_path'],
config['text_size'],
encoding="utf-8"
)
except Exception as e:
QMessageBox.warning(None, "字体错误", f"字体加载失败: {str(e)},使用默认字体")
self.font = ImageFont.load_default()
def run(self):
try:
if self.config['audio_path'] and not self.config['output'].endswith('.mp4'):
self.error_occurred.emit("音频仅支持MP4输出格式")
return
if self.config['source_type'] == 'video':
self._create_from_video()
else:
self._create_from_images()
if self.config['audio_path']:
self._add_audio()
self.finished.emit(self.config['output'])
except Exception as e:
self.error_occurred.emit(str(e))
def _process_frame(self, frame, index):
img = Image.fromarray(frame).resize(self.config['size'])
if self.config['text']:
draw = ImageDraw.Draw(img)
text_position = self._get_animated_text_position(index)
if self.config['text_animation'] in ['旋转', '3D翻转', '缩放旋转']:
# 处理需要旋转或缩放的动画
text_img = Image.new('RGBA', (200, 200))
text_draw = ImageDraw.Draw(text_img)
text_draw.text((0, 0), self.config['text'], font=self.font, fill=self.config['text_color'])
if self.config['text_animation'] == '旋转':
angle = index * 5 % 360
text_img = text_img.rotate(angle, expand=True)
elif self.config['text_animation'] == '3D翻转':
angle = index * 5 % 360
text_img = text_img.transform(
(200, 200), Image.Transform.AFFINE,
(1, 0, 0, math.sin(math.radians(angle)), 1, 0)
)
elif self.config['text_animation'] == '缩放旋转':
scale = 1 + 0.2 * math.sin(index * 0.1)
angle = index * 5 % 360
text_img = text_img.resize((int(200 * scale), int(200 * scale)))
text_img = text_img.rotate(angle, expand=True)
img.paste(text_img, text_position, text_img)
else:
# 普通文字绘制
draw.text(text_position, self.config['text'], font=self.font, fill=self.config['text_color'])
if self.config['filter']:
img = self._apply_filter(img)
self.progress_updated.emit(int((index + 1) / self.total_frames * 100))
return img
def _get_animated_text_position(self, index):
x, y = self.config['text_position']
base_x, base_y = self.config['text_position']
max_width, max_height = self.config['size']
if self.config['text_animation'] == '滚动':
x = (x + index * 5) % max_width
elif self.config['text_animation'] == '渐入':
alpha = min(255, index * 10)
self.config['text_color'] = self.config['text_color'][:3] + (alpha,)
elif self.config['text_animation'] == '跳动':
y = base_y + int(10 * math.sin(index * 0.5))
elif self.config['text_animation'] == '闪烁':
alpha = 128 + int(127 * math.sin(index))
self.config['text_color'] = self.config['text_color'][:3] + (alpha,)
elif self.config['text_animation'] == '随机移动':
if index % 10 == 0:
self.rand_x = random.randint(0, max_width - 100)
self.rand_y = random.randint(0, max_height - 30)
x, y = self.rand_x, self.rand_y
elif self.config['text_animation'] == '左右移动':
x = (x + index * 5) % (max_width + 100)
if x > max_width:
x = 2 * max_width - x
elif self.config['text_animation'] == '上下弹跳':
y = base_y + int(20 * abs(math.sin(index * 0.2)))
elif self.config['text_animation'] == '呼吸效果':
scale = 1 + 0.2 * math.sin(index * 0.1)
self.config['text_size'] = int(self.config['text_size'] * scale)
elif self.config['text_animation'] == '淡入淡出':
alpha = int(128 + 127 * math.sin(index * 0.1))
self.config['text_color'] = self.config['text_color'][:3] + (alpha,)
elif self.config['text_animation'] == '弹跳淡入':
y = base_y + int(20 * abs(math.sin(index * 0.2)))
alpha = int(128 + 127 * math.sin(index * 0.1))
self.config['text_color'] = self.config['text_color'][:3] + (alpha,)
return (x, y)
def _apply_filter(self, img):
filter_map = {
'黑白': lambda x: x.convert('L'),
'复古': lambda x: x.convert('RGB').filter(ImageFilter.SMOOTH),
'模糊': lambda x: x.filter(ImageFilter.BLUR),
'边缘检测': lambda x: x.filter(ImageFilter.FIND_EDGES),
'细节增强': lambda x: x.filter(ImageFilter.DETAIL),
'锐化': lambda x: x.filter(ImageFilter.SHARPEN),
'浮雕': lambda x: x.filter(ImageFilter.EMBOSS),
'轮廓': lambda x: x.filter(ImageFilter.CONTOUR),
'反色': lambda x: Image.eval(x, lambda v: 255 - v),
'水墨画': lambda x: x.convert('L').filter(ImageFilter.EDGE_ENHANCE_MORE).filter(ImageFilter.FIND_EDGES),
'水彩画': lambda x: x.filter(ImageFilter.SMOOTH_MORE).filter(ImageFilter.EDGE_ENHANCE),
'高斯模糊': lambda x: x.filter(ImageFilter.GaussianBlur(radius=10)),
'盒式模糊': lambda x: x.filter(ImageFilter.EDGE_ENHANCE_MORE).filter(ImageFilter.BoxBlur(radius=8)),
'像素化': lambda x: self.pixelate(x, 12),
'赛博朋克': lambda x: self.cyberpunk(x),
'漩涡': lambda x: self.swirl(x, 20)
}
return filter_map.get(self.config['filter'], lambda x: x)(img)
def pixelate(self, img, pixel_size=8):
small = img.resize((img.width // pixel_size, img.height // pixel_size))
return small.resize(img.size, Image.NEAREST)
def cyberpunk(self, img):
r, g, b = img.split()
r = r.point(lambda x: x * 1.2)
b = b.point(lambda x: x * 1.5)
return Image.merge('RGB', (r, g, b))
def swirl(self, img, strength=500):
width, height = img.size
result = Image.new('RGB', (width, height))
for y in range(height):
for x in range(width):
dx = x - width / 2
dy = y - height / 2
theta = math.atan2(dy, dx)
radius = math.sqrt(dx ** 2 + dy ** 2)
new_radius = radius ** 0.97
new_x = width / 2 + new_radius * math.cos(theta + strength * radius / 1000)
new_y = height / 2 + new_radius * math.sin(theta + strength * radius / 1000)
if 0 <= new_x < width and 0 <= new_y < height:
result.putpixel((x, y), img.getpixel((int(new_x), int(new_y))))
return result
def _create_from_video(self):
with VideoFileClip(str(self.config['sources'][0])) as clip:
if self.config['duration']:
clip = clip.subclip(0, self.config['duration'])
self.total_frames = int(clip.duration * self.config['fps'])
frames = []
for i, frame in enumerate(clip.iter_frames(fps=self.config['fps'])):
frames.append(self._process_frame(frame, i))
frames[0].save(
self