tkinter 配合 Pillow 来实现粒子消散的效果。
实现粒子消散,最容易踩的坑是“把整张图提前拆成粒子”。这样做不仅性能差,而且会丢失图像原本的清晰度。更合理的做法是:保留原图底稿 + 动态掩码切割 + 边缘实时生成粒子。
每帧动画开始时,先 copy() 一份原始图像。接着用一个从左向右推进的垂直波前 wave_x 控制消散进度。
mask_end = max(0, wave_x - 15)if mask_end > 0:draw.rectangle([0, 0, mask_end, self.h], fill="black")
波前经过的区域直接覆盖为黑色。这里特意留出 15px 的过渡带,是为了让粒子生成位置卡在“图像即将消失的边界”,视觉上物质就像在切割线处被剥落,而不是生硬地整块消失。
粒子不是一次性全部生成的,而是随着波前推进,在 spawn_x 位置实时采样创建:
for y in range(0, self.h, 2):r, g, b = self.img_pixels[spawn_x, y]if r + g + b > 40: # 过滤纯黑/极暗背景# 创建粒子对象...
使用 img.load() 获取像素访问器替代 getpixel(),大幅提升读取速度。垂直方向步长设为 2,是在粒子密度和 CPU 渲染压力之间取的平衡值。每个采样点只生成一次粒子,之后便脱离图像坐标系,交给物理逻辑接管。
灰烬飘散不需要复杂的碰撞检测,基础的运动学公式足够:
vx *= 0.97。vy += 0.08,形成先上扬后下坠的自然弧线。life 逐帧递减。尺寸和 RGB 颜色值按 ratio = life / max_life 线性缩放。颜色变暗、尺寸缩小,模拟的是物质失去能量、逐渐透明化的过程。Tkinter 的 root.after(33, self.animate) 提供非阻塞的定时刷新,约 30fps。每帧将绘制好的 PIL 图像追加到列表 self.frames 中。 动画结束后,调用 Pillow 的 save_all=True 将帧序列一次性打包为 GIF。这种“先跑完再编码”的方式避免了实时压缩带来的卡顿,代码结构也最干净。
animate() 中的几个参数:参数 | 作用 |
|---|---|
| 波前推进速度,调大消散更快,调小更缓慢 |
| 垂直采样步长,改为 |
| 阻力与重力系数,调小阻力粒子飞得更远;调大重力下坠更明显 |
| GIF 帧间隔(ms),对应 ~30fps,调小动画更流畅但文件更大 |
import tkinter as tkfrom tkinter import filedialogfrom PIL import Image, ImageDraw, ImageTkimport randomimport osclass ThanosSnapImageApp:def __init__(self, root):self.root = rootself.root.title("灭霸响指 - 图像粒子消散")self.root.geometry("520x580")self.root.configure(bg="black")self.root.resizable(False, False)# 🖼️ 显示区域self.canvas_label = tk.Label(root, bg="black")self.canvas_label.pack(padx=10, pady=10)# 🎛️ 控制按钮(注意:创建与布局必须分开,否则对象会变成 None)btn_frame = tk.Frame(root, bg="black")btn_frame.pack(pady=5)tk.Button(btn_frame, text="📂 选择图像", command=self.load_image,bg="#4a90e2", fg="white", font=("Microsoft YaHei", 11, "bold")).pack(side=tk.LEFT, padx=10)self.snap_btn = tk.Button(btn_frame, text="🖐 开始消散", command=self.start_snap, state=tk.DISABLED,bg="#ff6b35", fg="white", font=("Microsoft YaHei", 11, "bold"))self.snap_btn.pack(side=tk.LEFT, padx=10)self.save_btn = tk.Button(btn_frame, text="💾 保存GIF", command=self.save_gif, state=tk.DISABLED,bg="#2ecc71", fg="white", font=("Microsoft YaHei", 11, "bold"))self.save_btn.pack(side=tk.LEFT, padx=10)# 核心参数self.w, self.h = 500, 500self.img_original = Noneself.img_pixels = None # 快速像素访问对象self.particles = []self.frames = []self.is_animating = Falseself.frame_count = 0def load_image(self):path = filedialog.askopenfilename(filetypes=[("图像文件", "*.png *.jpg *.jpeg *.bmp *.webp")])if not path: returnimg = Image.open(path).convert("RGB")img.thumbnail((self.w, self.h), Image.LANCZOS)self.w, self.h = img.sizeself.img_original = imgself.img_pixels = img.load() # 获取快速像素读取器# 预览self.photo = ImageTk.PhotoImage(img)self.canvas_label.config(image=self.photo)self.snap_btn.config(state=tk.NORMAL, bg="#ff6b35")self.save_btn.config(state=tk.DISABLED, bg="#888")def start_snap(self):if self.is_animating: returnself.is_animating = Trueself.frame_count = 0self.particles = []self.frames = []self.snap_btn.config(state=tk.DISABLED, bg="#888")self.save_btn.config(state=tk.DISABLED, bg="#888")self.animate()def animate(self):# 波前推进速度(越大消散越快)wave_speed = 2.5wave_x = int(self.frame_count * wave_speed)# 终止条件:波前完全移出图像 且 所有粒子已消散if wave_x > self.w + 60 and not self.particles:self.is_animating = Falseself.snap_btn.config(state=tk.NORMAL, bg="#ff6b35")self.save_btn.config(state=tk.NORMAL, bg="#2ecc71")return# 1️⃣ 创建当前帧:先放完整原图frame = self.img_original.copy()draw = ImageDraw.Draw(frame)# 2️⃣ 绘制已消散区域(黑色覆盖)# 保留 15px 的过渡带,让粒子看起来是从边缘“剥落”的mask_end = max(0, wave_x - 15)if mask_end > 0:draw.rectangle([0, 0, mask_end, self.h], fill="black")# 3️⃣ 在波前处生成新粒子(仅从有效像素采样)spawn_x = wave_x - 5if 0 < spawn_x < self.w:for y in range(0, self.h, 2): # 每2行采样一次,平衡性能与密度r, g, b = self.img_pixels[spawn_x, y]if r + g + b > 40: # 忽略暗部/黑色背景self.particles.append({'x': float(spawn_x), 'y': float(y),'vx': random.uniform(1.8, 4.2),'vy': random.uniform(-2.5, 1.2),'life': random.randint(35, 65),'max_life': 65,'color': (r, g, b),'size': random.uniform(2.0, 4.5)})# 4️⃣ 更新并绘制所有活跃粒子active_particles = []for p in self.particles:# 物理更新:右向风力 + 微重力 + 空气阻力p['x'] += p['vx']p['y'] += p['vy'] + 0.08p['vx'] *= 0.97p['vy'] *= 0.97p['life'] -= 1if p['life'] > 0:active_particles.append(p)# 颜色与尺寸随生命周期衰减ratio = p['life'] / p['max_life']c = tuple(int(ch * ratio) for ch in p['color'])sz = int(p['size'] * ratio)if sz < 1: sz = 1# 绘制粒子draw.ellipse([p['x'] - sz, p['y'] - sz, p['x'] + sz, p['y'] + sz], fill=c)self.particles = active_particlesself.frames.append(frame.copy())# 刷新界面self.photo = ImageTk.PhotoImage(frame)self.canvas_label.config(image=self.photo)self.frame_count += 1self.root.after(33, self.animate) # ~30 FPSdef save_gif(self):if not self.frames: returnself.save_btn.config(text="⏳ 编码中...", state=tk.DISABLED)self.root.update_idletasks()try:out_path = "thanos_snap_final.gif"self.frames[0].save(out_path,save_all=True,append_images=self.frames[1:],duration=33,loop=0)self.save_btn.config(text="✅ 已保存!")print(f"🎬 GIF 已导出: {os.path.abspath(out_path)}")except Exception as e:self.save_btn.config(text=f"❌ 失败")print(f"保存异常: {e}")finally:self.save_btn.config(text="💾 保存GIF", state=tk.NORMAL)if __name__ == "__main__":try:from PIL import Image, ImageDraw, ImageTkexcept ImportError:print("❌ 缺少 Pillow 库,请运行: pip install Pillow")exit(1)root = tk.Tk()app = ThanosSnapImageApp(root)root.mainloop()