一句话总结:我按「一次画六格再切」的思路写了 40 行代码,把一张 3×2 的图切成 6 帧合成 GIF。同时用「每帧独立加噪」模拟逐帧生成,对比两组帧的平均色差值:一次画完的差值全同号、标准差 1.17;逐帧生成的差值翻向 2 次、标准差 7.12,其中还有一帧比上一帧更亮,看起来像动画倒回去了一下。
上一篇讲 GPT-Image-2.6 那个 GIF 方案的时候,我说「六帧在同一张画布上,风格天然一致」。
写完自己有点不确定——这话听起来对,但没验证过。
于是动手做了一遍。
一、先写代码:40 行画一张六格图
核心就三件事:画一张 3×2 的大图、按格子裁开、合成 GIF。
from PIL import Image, ImageDraw
W, H = 180, 120 # 每格尺寸
COLS, ROWS = 3, 2
N = COLS * ROWS # 6 帧
DUR = 120 # 每帧 120 毫秒
def paint(draw, idx, x0, y0, jitter=0):
"""在第 idx 格里画背景渐变 + 一个小球"""
for y in range(H):
k = (idx * H + y) / (N * H)
rgb = (int(238 - 58 * k) + jitter,
int(244 - 38 * k) + jitter,
250 - jitter)
draw.line([(x0, y0 + y), (x0 + W, y0 + y)], fill=rgb)
cx = x0 + 20 + (W - 40) * idx / (N - 1) # 球从左往右移
cy = y0 + H / 2
r = 16 + jitter // 4
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(37, 99, 235))
def build_sheet(jitter_each=False):
img = Image.new("RGB", (W * COLS, H * ROWS), "white")
d = ImageDraw.Draw(img)
for i in range(N):
c, r = i % COLS, i // COLS
# 逐帧生成时,每帧有自己的噪声
j = ([0, 0, 6, -6, 10, 4][i] if jitter_each else 0)
paint(d, i, c * W, r * H, jitter=j)
return img
def slice_sheet(img):
frames = []
for i in range(N):
c, r = i % COLS, i // COLS
frames.append(img.crop((c * W, r * H, (c + 1) * W, (r + 1) * H)))
return frames
paint 里的 jitter 是关键。正常做法(一次画完)jitter 恒为 0;对比组里我给它加了 [0, 0, 6, -6, 10, 4] 这组偏差,模拟「每帧独立生成时各自的采样噪声」。
合成就一句:
frames[0].save("out.gif", save_all=True, append_images=frames[1:],
duration=DUR, loop=0)
跑出来:六帧,单帧 180×120,GIF 7.0 KB,循环播放。
先把口径说清楚:这是我用 PIL 画的模拟图,不是真的调 GPT-Image-2.6。 我验证的是「同一张画布切出来的帧」和「各自生成的帧」在数值上的差别,不是那个产品的实际输出。
二、切完之后,六帧是什么样的
取每帧的平均红通道,看它怎么变:
各帧平均红通道:225, 214, 204, 195, 187, 179
逐帧差值: -11, -10, -9, -8, -8
差值全同号? 是
差值标准差 1.17
背景是从浅到深的渐变,所以六帧的红通道单调下降,每帧降 8 到 11。
重点在「全同号」——五个差值全是负数,方向一致,而且绝对值在稳定收敛(11→10→9→8→8)。
这就是「平滑」在数据上的样子。
三、对比逐帧生成:差值会「翻向」
同一套逻辑,只把 jitter 打开:
各帧平均红通道:225, 214, 209, 191, 194, 182
逐帧差值: -11, -5, -18, +3, -12
差值全同号? 否,翻向 2 次
差值标准差 7.12

图 1:上面那组五个柱子全在零线下方,下面那组有一个跑到上面去了
三个问题一次暴露:
第一个,第 4 个间隔是 +3。 第四帧比第三帧还亮。放在动画里,就是「画面突然回弹了一下」——观众看到的是动画顿了一下,然后倒回去。
第二个,差值忽大忽小。 -11、-5、-18、+3、-12,没有任何规律。标准差 7.12,是一次画完那组的 6 倍。
第三个,翻向意味着方向错了。 一次画完的差值全同号,说明整体趋势单一;翻向说明中间有一帧「不听话」。
这三个现象,用肉眼看不出来(单帧看都挺正常),但连起来播就明显了。这就是逐帧生成做 GIF 的老毛病:单张都对,连起来就跳。
四、为什么同一张画布能保证帧间一致
原因不复杂:同一张画布上的六个格子,共享同一套「画图的决定」。
具体说三件事:
光照方向。 一次生成时,模型对整个画面定一个光源方向。六格都按这个方向画阴影。逐帧生成时,每帧独立定,可能出现第三帧光源在左、第四帧在右。
色彩空间。 一次生成用同一套调色,六格色调统一。逐帧生成每帧各自调色,累积起来就偏了。
风格一致性。 线条粗细、笔触、材质,一次生成时是「一口气画完」的,天然一致。
换句话说:它把「一致性」这个难题,转成了「一次画完」这个简单要求。
这里有个可以对照的例子:上下文压缩那篇算过分层压缩的代价——保住全部约束,压缩率只有 36.7%。两者都在做取舍:压缩那边舍的是压缩率,这里舍的是时长和连贯度。
五、这个方法什么时候不管用
三个边界。
一,帧数写死。 六个格子就是六帧。想要更长的动画得画更多格(4×3、5×4),但格子越多,每格分到的分辨率越低。180×120 六格是 540×240 的画布,如果画 4×4 十六格,同样画布下每格只有 135×60。
二,运动是「跳」的不是「滑」的。 六帧之间没有插值,球的位置是一格一格跳过去的。想要顺滑,得靠提高帧率(缩短每帧时长),但帧数不变,加快播放只会让跳得更快。
三,复杂动作画不出来。 我的例子里球只是平移。如果要画「球弹跳、旋转、变形」,六格根本不够表达中间过程。格子数量决定了动作的复杂度上限。
六、坑清单
1. 每格边界要留白,否则切开后内容被切断。
我一开始让球贴着格子边缘移动,切出来第一帧和末帧的球有一半在画面外。改法是给每格留 20px 内边距,球的活动范围限制在里面。
2. 用 getpixel 取平均色,别用 getdata。
Pillow 从某个版本起把 getdata() 标成 deprecated(我这版提示 2027 年移除)。改成 im.resize((1,1)).getpixel((0,0)),效果一样,不报警告。
3. 判断「平滑」要看差值的符号,不是看标准差。
我第一版只输出了标准差,A 和 B 差得不多(15.65 对 14.75),看不出问题。因为背景本身有大跨度渐变,把噪声淹没了。改成算逐帧差值之后,A 的 1.17 和 B 的 7.12 才显出 6 倍差距。
这是个通用的教训:指标选错,结论就看不出来。
4. GIF 的调色板只有 256 色,渐变会出色带。
我的背景是渐变,转成 GIF 之后出现了明显的台阶。要减少色带,可以在保存时传 optimize=False 并手动做抖动,或者干脆用纯色背景。
5. duration 是每帧毫秒数,不是总时长。
想总长一秒,六帧就是 duration=167。写成 duration=1000 会变成每帧一秒、总共六秒。
七、结语
做完这遍,我对那个方案的判断从「听起来聪明」变成「确实有道理」。
原因不是它省了什么,是它把一个难问题换成了简单问题。让六帧保持一致很难,让一张图里的六个格子看起来是一套的,本来就容易。
工程上很多好方案都是这个形状:不去硬解难题,而是换一个问法,让难题消失。
至于这个方法能不能用到别的地方——我觉得判断标准是:你的「序列」能不能塞进一张画布。 能塞,就值得试;塞不进去(比如要画长视频),那就老老实实做逐帧一致性。
参考资料
- GPT-Image-2.6 的 GIF 方案(站内上一篇) - 六格切 GIF 的机制来源,本文是它的实现验证
- Pillow 文档 - Image 模块 -
crop、resize、save的参数说明 - Pillow 文档 - GIF 保存 -
duration、loop、save_all的用法
- 参考资料
发表评论
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。