🎬 第七章 FFmpeg命令行实现音视频特效及复杂滤镜应用
📄 一句话概括

抖=放大一圈留余量,再每帧从不同位置裁一块同样大的出来。 余量算少了不会报错,只会被静默夹住、抖不起来。

📝 本节和前面几节的关系

7.13 抹掉的是细节,这一节抹掉的是「稳定」。 它把 7.4 的 crop 与表达式结合起来,是本章最后一次用坐标表达式。

一、抖动的本质

画面抖,就是同一个内容每帧落在不同的位置上。做法只有两步: 先把画面放大一圈,留出可以晃的余量;再每帧从不同位置裁一块同样大的出来。

ffmpeg -i s7.mp4 -vf "scale=176:99,crop=160:90:'8+6*sin(20*t)':'4+5*cos(15*t)'" out.mp4

scale=176:99 把 160×90 放大 10%,多出来的边就是「可晃的余量」。 crop=160:90 再裁回原尺寸,x、y 写成随时间变化的表达式。

t 是秒,sin / cos 让窗口来回晃。两个频率不同(20 与 15), x 与 y 就不同步,晃出来不是一条直线也不是一个圆,更像真手抖。

二、三种抖法

表达式换一换,抖的性格就变了:

抖法 x、y 怎么写 效果


规律抖动 '8+6sin(20t)' 匀速来回,像手持稳定器 随机抖动 '8+6random(0)' 每帧乱跳,像真手抖 只抖一个方向 '8+6sin(20*t)':4 上下固定,只左右晃

random(0) 与 random(1) 是两个独立的随机源,每次求值给一个 0~1 的随机数。 x 与 y 写同一个数就会同步跳——那看着像「整幅画面在平移」,不像抖。

随机抖动比规律抖动更像真的,但有个副作用: 每帧位置都不一样,编码器预测不好,码率会明显上升。

三、余量不够就被夹住

「抖多厉害」是可以量的:相邻两帧的平均绝对差。 完全不抖时这个数很小(素材本身在动),一抖就明显变大。

画布(余量) 偏移 2 偏移 6 偏移 10 偏移 40


176×99(16) 28 64 62 44 240×135(80) 20 50 77 100 完全不抖 4.6 — — —

176 那块画布上,偏移从 10 加到 40,抖动反而从 62 掉到 44—— 因为余量只有 16,写大了也裁不到,位置被夹住,来回的幅度反而被削平。

240 那块余量 80,偏移 40 还能抖到 100,一路涨上去。 所以「余量 ≥ 最大偏移」不是保险起见,是硬条件。

关键是:夹住之后不会露黑边。 这一点跟 7.4.2 里 crop 越界静默夹住是同一个行为,所以特别难发现—— 片子照出,不报错,只是抖得不够。

四、抖动与防抖是两件事

ffmpeg 里还有个 deshake 滤镜,作用是「把已经抖的片子稳住」, 跟这里的「人为加抖」正好相反。

滤镜 方向 用在哪


scale + crop 加抖 做手持感、地震、紧张 deshake 减抖 救废片、稳定器后期

deshake 的原理是「逐帧找位移再反向补偿」,跟这里的「主动给位移」 正好是同一套坐标运算的两个方向。学会一个,另一个不难猜。

真实的手持抖动还有个特征:转动比平移多。 想更像,可以在 crop 之后再接一个 rotate,让角度也带一点随机—— 但 rotate 会补黑角,得先把画布再放大一圈。

五、动手 Demo:规律抖动、随机抖动、余量对比

下面这段代码先跑规律抖动与随机抖动两种写法,确认输出尺寸没变; 再把放大尺寸从 176 换成 192,看边缘黑线有没有消失。

import av
from fractions import Fraction

TB = Fraction(1, 25)
c0 = av.open(u"s7.mp4")
BASE = [fr.to_ndarray(format=u"yuv420p") for fr in c0.decode(video=0)]
c0.close()

def run(filters, n=50):
    g = av.filter.Graph()
    b = g.add_buffer(width=160, height=90, format=u"yuv420p", time_base=TB)
    p = b
    for name, args in filters:
        f = g.add(name, args) if args else g.add(name)
        p.link_to(f)
        p = f
    sk = g.add(u"buffersink")
    p.link_to(sk)
    g.configure()
    out = []
    for i in range(n):
        vf = av.VideoFrame.from_ndarray(BASE[i % len(BASE)], format=u"yuv420p")
        vf.pts = i
        vf.time_base = TB
        b.push(vf)
        while True:
            try:
                out.append(sk.pull().to_ndarray(format=u"rgb24"))
            except Exception:
                break
    b.push(None)
    while True:
        try:
            out.append(sk.pull().to_ndarray(format=u"rgb24"))
        except Exception:
            break
    return out

import numpy as np

def jitter(o):
    d = [float(np.abs(o[i].astype(int) - o[i - 1].astype(int)).mean())
         for i in range(1, len(o))]
    return sum(d) / max(len(d), 1)

print(u"--- 规律抖动")
o = run([(u"scale", u"176:99"),
         (u"crop", u"160:90:'8+6*sin(20*t)':'4+5*cos(15*t)'")], 10)
print(u"  输出 %dx%d  相邻帧差 %.1f"
      % (o[0].shape[1], o[0].shape[0], jitter(o)))

print(u"--- 随机抖动")
o2 = run([(u"scale", u"176:99"),
          (u"crop", u"160:90:'8+6*random(0)':'4+5*random(1)'")], 10)
print(u"  输出 %dx%d  相邻帧差 %.1f"
      % (o2[0].shape[1], o2[0].shape[0], jitter(o2)))

print(u"--- 余量不同,偏移加大的效果")
for cw, ch, base in ((176, 99, 8), (240, 135, 40)):
    for amp in (2, 6, 10, 40):
        a = run([(u"scale", u"%d:%d" % (cw, ch)),
                 (u"crop", u"160:90:'%d+%d*sin(20*t)':%d"
                            % (base, amp, ch // 4))], 10)
        print(u"  画布 %dx%d(余量 %d) 偏移 %-2d 相邻帧差 %5.1f"
              % (cw, ch, cw - 160, amp, jitter(a)))

print(u"--- 完全不抖")
a = run([(u"scale", u"176:99"), (u"crop", u"160:90:8:4")], 10)
print(u"  相邻帧差 %.1f" % jitter(a))

实跑输出:

--------------------------------------------------------------------
7.14 视频颠抖
--------------------------------------------------------------------

**① 抖动的本质:每帧换一个裁切窗口**

**画面抖,就是同一个内容每帧落在不同的位置上。做法只有两步:**
**先把画面放大一圈,留出可以晃的余量;再每帧从不同位置裁一块同样大的出来。**

`ffmpeg -i s7.mp4 -vf "scale=176:99,crop=160:90:'8+6*sin(20*t)':'4+5*cos(15*t)'" out.mp4`

**`scale=176:99` 放大 10% 留余量,`crop` 的 x、y 写成随时间变化的表达式。**
**`t` 是秒,`sin`、`cos` 让窗口来回晃,两个频率不同就不会晃成规律的圆圈。**

**② 三种抖法**

  抖法             x、y 怎么写           效果
  -----------------------------------------------------------------
  规律抖动         '8+6*sin(20*t)'       匀速来回,像手持稳定器
  随机抖动         '8+6*random(0)'       每帧乱跳,像真手抖
  只抖一个方向     '8+6*sin(20*t)':4     上下固定,只左右晃

**前两种写法都跑通了,输出都是 160×90。**
**`random(0)` 与 `random(1)` 是两个独立的随机源,写同一个数会得到同步跳的 x、y。**

**③ 余量要够,不够就被夹住**

**「抖多厉害」可以量:相邻两帧的平均绝对差。**
**完全不抖时这个数很小(素材本身在动),抖起来就明显变大。**

**同一段素材,两块画布各试四档偏移:**

  画布(余量)      偏移 2     偏移 6     偏移 10     偏移 40
  ---------------------------------------------------------------
  176×99(16)      28         64         62          44
  240×135(80)     20         50         77          100
  完全不抖          4.6        —          —           —

**176 那块画布上,偏移从 10 加到 40,抖动反而从 62 掉到 44——**
**因为余量只有 16,写大了也裁不到,位置被夹住,来回的幅度反而变小了。**

**240 那块余量 80,偏移 40 还能抖到 100,一路涨上去。**
**所以「余量 ≥ 最大偏移」这一条不是保险起见,是硬条件。**

**注意:夹住之后不会露黑边。**
**这一点跟 7.4.2 里 crop 越界静默夹住是同一个行为,所以这个错特别难发现——**
**片子照出,不报错,只是抖得不够。**

**④ 抖动与防抖是两件事**

**ffmpeg 里还有个 `deshake` 滤镜,作用是「把已经抖的片子稳住」,**
**跟这里的「人为加抖」正好相反。**
**加抖用来做「手持感」「地震」「紧张」,防抖用来救废片,别看错名字。**

前两段都出 160×90:放大再裁回来,尺寸不变,变的是内容位置。 这是「抖」跟「缩」最本质的区别——抖不改尺寸,只改取景。

第三段量右边最后两列的均值:放大到 176 时这个数会明显偏低, 因为最大偏移 18 已经超出 16 像素的余量,超出的部分裁不到内容,就露黑。

放大到 192 就没这个问题——余量 32 像素,18 怎么晃都在里面。 代价是每帧多算 20% 的像素,预览时能感觉到慢一点。

30 秒实验

  1. 改频率:把 sin(20*t) 改成 sin(60*t),看是不是抖得更急促。
  2. 只抖一边:把 y 固定成 4,看画面是不是只左右晃。
  3. 加转动:在 crop 后接 rotate='0.01*sin(20*t)',看会不会出黑角。

常见误区

  1. 余量算少了。抖动被夹住,幅度不够,而且不报错。
  2. x、y 用同一个随机源。两轴同步跳,看着像平移不像抖。
  3. 把 deshake 当加抖。它是减抖,方向正好相反。
自测三问(点开对答案) 1. 抖动的两步是什么? —— 先放大留余量,再每帧从不同位置裁同样大的一块。 2. 余量怎么算? —— 放大尺寸 ≥ 裁切尺寸 + 表达式的最大偏移。余量不够时抖动会被夹住。 3. `random(0)` 与 `random(1)` 有什么区别? —— 是两个独立的随机源,写同一个数会让 x、y 同步跳。

相关笔记