客户有一批横屏(16:9)的讲座和访谈素材,要剪成竖版(9:16)发短视频平台。
第一版做法是"从中间裁一块":
bash ffmpeg -i in.mp4 -vf "crop=ih*9/16:ih" out.mp4简单,但问题很明显:主讲人经常不在中间。他站起来走到白板那边,画面里就只剩空椅子。统计下来,主体在画面内的时间只占 61%。
后来做了智能 reframe:检测每帧的主体位置,让裁切窗口跟着主体走,同时做平滑(不能抖)。改完之后主体在画面内的比例是 94%,而且看起来不像"机器在乱晃"。
这篇写完整实现。
TL;DR:三步:检测(每帧找主体在哪:人脸 / 人体 / 运动 / 显著性组合判断)→ 平滑(低通滤波 + 死区 + 限速,这是"看起来专业"的关键)→ 裁切(ffmpeg
crop,用sendcmd让裁切位置随时间变化)。两个必须知道的现实:横转竖必然损失分辨率(1920×1080 裁成 607×1080 再放大到 1080×1920,是 2 倍升采样,画质会下降);裁切窗口不能跟着主体每帧跳(会晕),要"大部分时间不动、必要时平滑移动"。
目录
- 一、为什么中间裁切不够用
- 二、三种裁切策略
- 三、主体检测:用什么信号
- 四、平滑:看起来专不专业全看这个
- 五、ffmpeg 落地:crop 怎么随时间变
- 六、绕不开的分辨率损失
- 七、构图与安全区
- 八、完整代码
- 九、实测数据
- 十、坑清单
一、为什么中间裁切不够用
中间裁切(或"三分线裁切")的假设是:主体一直在画面中央。这个假设对某些内容成立(新闻播报、固定机位访谈),但对下面这些完全不成立:
| 内容 | 问题 |
|---|---|
| 讲座(讲师走动) | 人走到白板那边,画面里没人 |
| 体育/舞蹈 | 主体快速左右移动 |
| 多人对话 | 中间裁切可能把两人都切掉一半 |
| 演示操作(手在屏幕某处) | 关键区域在边上 |
| PPT 讲解 | 内容铺满全屏,裁一块看不全 |
所以对"主体不固定"的内容,裁切窗口必须跟着主体走。
二、三种裁切策略
| 策略 | 做法 | 适合 | 成本 |
|---|---|---|---|
| 固定裁切 | 从中间或某固定位置裁 | 主体固定的内容 | 零 |
| 人工指定 | 人看一遍,标记几个时间段的裁切位置 | 高价值内容 | 高(人工) |
| 智能 reframe | 检测主体 + 平滑跟踪 | 主体移动的内容 | 中(自动) |
我的选择逻辑:
- 素材少、价值高 → 人工指定(质量最好);
- 素材多 → 智能 reframe(自动),然后人工抽查修正;
- 主体固定 → 固定裁切(别过度设计)。
三、主体检测:用什么信号
单一信号都不够可靠,我用的是加权组合:
| 信号 | 检测方式 | 权重思路 |
|---|---|---|
| 人脸 | Haar 级联 / DNN 人脸检测 | 权重最高(有人脸时以人脸为准) |
| 人体 | HOG 行人检测 / YOLO | 次高 |
| 运动 | 帧差法(变化的区域) | 补充(没人脸时看哪里在动) |
| 显著性 | 频谱残差显著性(cv2.saliency) | 补充 |
| 画面中心 | 默认位置 | 兜底(置信度低时用) |
实现(简化版,人脸 + 运动):
import cv2
import numpy as np
class SubjectLocator:
def __init__(self):
self.face = cv2.CascadeClassifier(
cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
self.prev_gray = None
def locate(self, frame) -> tuple:
"""返回 (主体中心 x 归一化坐标 0~1, 置信度 0~1)"""
h, w = frame.shape[:2]
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# 1. 人脸
faces = self.face.detectMultiScale(gray, 1.1, 5, minSize=(40, 40))
if len(faces):
# 取最大的人脸
x, y, fw, fh = max(faces, key=lambda f: f[2] * f[3])
return (x + fw / 2) / w, 0.9
# 2. 运动(帧差)
small = cv2.resize(gray, (96, 54))
if self.prev_gray is not None:
diff = cv2.absdiff(small, self.prev_gray)
_, thresh = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)
m = cv2.moments(thresh)
if m['m00'] > 500: # 有足够的运动量
cx = m['m10'] / m['m00']
self.prev_gray = small
return cx / 96, 0.5
self.prev_gray = small
# 3. 兜底:画面中心(低置信度)
return 0.5, 0.2
为什么人脸检测要设 minSize:避免把背景里的小人脸(观众席、海报上的人)当成主体。
如果内容里没有人(比如产品演示、风景),就把权重切换到运动/显著性。
四、平滑:看起来专不专业全看这个
这是最关键的一节——检测结果直接用会让画面疯狂抖动,观众会晕。
三个平滑手段:
1. 低通滤波(指数平滑)
class Smoother:
def __init__(self, alpha=0.05):
self.value = 0.5
self.alpha = alpha # 越小越平滑(越迟钝)
def update(self, target, confidence):
# 置信度越高,跟得越紧
a = self.alpha * confidence
self.value = self.value * (1 - a) + target * a
return self.value
2. 死区(deadzone):目标位置离当前位置不远时完全不动
DEADZONE = 0.04 # 归一化坐标,约画面宽度的 4%
def update_with_deadzone(current, target, conf):
if abs(target - current) < DEADZONE:
return current # 不动
return current + (target - current) * 0.05 * conf
死区的作用:主体小幅晃动时画面完全静止——这是"看起来像专业摄影师在构图"的核心。
3. 限速:每帧最多移动多少(避免突然跳)
MAX_SPEED = 0.02 # 每帧最多移动画面宽度的 2%
def limit_speed(current, target):
delta = target - current
if abs(delta) > MAX_SPEED:
delta = MAX_SPEED if delta > 0 else -MAX_SPEED
return current + delta
参数经验值(对 30fps、主体移动不剧烈的内容):
| 参数 | 值 | 说明 |
|---|---|---|
alpha |
0.03~0.08 | 越小越稳,但主体快速移动时会跟不上 |
deadzone |
0.03~0.06 | 太小会抖,太大会"该动的时候不动" |
max_speed |
0.015~0.03 | 快速摇镜头时放宽 |
我的调试方法:先设一组参数,输出一段 30 秒的样片,看三个场景——主体静止时(画面应该完全不动)、主体缓慢移动(跟随平滑)、主体快速移动(不能掉队)。
五、ffmpeg 落地:crop 怎么随时间变
问题:crop 滤镜的参数通常是固定的,但我们需要 x 随时间变化。
三种做法:
做法 1:固定裁切(主体位置变化不大时)
先算出整段的平均主体位置,用一个固定的 crop 值:
ffmpeg -i in.mp4 -vf "crop=607:1080:650:0,scale=1080:1920" out.mp4
简单可靠,适合访谈、固定机位。
做法 2:sendcmd(推荐,真正的动态)
ffmpeg 的 sendcmd 滤镜可以在指定时间发送命令修改其他滤镜的参数:
# crop.cmd
0 crop x 650;
5.2 crop x 700;
12.8 crop x 480;
30.4 crop x 650;
ffmpeg -i in.mp4 \
-vf "sendcmd=f=crop.cmd,crop=w=607:h=1080:y=0,scale=1080:1920" \
-c:v libx264 -crf 20 out.mp4
注意:sendcmd 里的 crop 必须有默认参数(在滤镜链里写 crop=w=607:h=1080:y=0),sendcmd 只改变化的那个(x)。
关键帧的问题:crop 是逐帧滤镜,改变 x 不需要关键帧,所以任意时间点都能改——但要注意 crop 位置变化会让画面"跳"(如果变化太大),所以需要前面说的平滑。
做法 3:分段处理 + concat(变化很复杂时)
把视频按裁切位置的变化切成若干段,每段用固定 crop,最后拼接。麻烦,一般不需要。
生成 crop.cmd 的代码:
def write_crop_commands(track, out_path, epsilon=0.02):
"""把轨迹转成 sendcmd 文件(只在变化超过阈值时写一条,减少命令数量)。"""
lines = []
last_x = None
for t, x_norm in track:
x_px = int(x_norm * SRC_W - CROP_W / 2)
x_px = max(0, min(x_px, SRC_W - CROP_W))
if last_x is None or abs(x_px - last_x) > epsilon * SRC_W:
lines.append(f'{t:.3f}\tcrop x {x_px};')
last_x = x_px
Path(out_path).write_text('\n'.join(lines), encoding='utf-8')
减少命令数量:只在位置变化超过阈值时写一条(否则每秒一条会有几千条命令,ffmpeg 处理慢)。
六、绕不开的分辨率损失
必须跟客户说清楚的一件事:
源文件:1920×1080(横屏)
竖版目标:1080×1920
裁切:608×1080(宽度只用了 1/3)
放大到 1080×1920 = 约 1.78 倍升采样
结果:有效分辨率只有 608×1080,被拉伸到 1080×1920
画质必然下降。这是物理限制,不是技术问题。
缓解办法:
| 办法 | 说明 |
|---|---|
| 源用 4K 拍 | 3840×2160 裁成 1215×2160,放大到 1080×1920 只损失一点点 |
| 接受 720×1280 输出 | 不放大那么多,画质好,但分辨率低 |
| 竖版单独拍摄 | 最根本的解决办法 |
| 轻度锐化 | 缓解"糊"的观感,但别过度 |
我给客户的标准建议:如果要做竖版分发,拍摄时就用 4K 或者同时架一个竖机位。后期横转竖永远是妥协。
(顺带说:现在有些手机/相机支持"同时记录横竖两种构图",或者用高分辨率传感器做"数字竖拍",就是为了解决这个痛点。)
七、构图与安全区
裁切窗口的垂直位置也有讲究:
1. 头部留白(headroom):人物头顶上方留 10~15% 的空间,不要顶到画面上边缘。
2. 平台安全区:短视频平台会在画面上叠加 UI(右侧的点赞栏、底部的文案和进度条、顶部的关注按钮)。所以主体和关键信息要避开:
| 区域 | 避开内容 |
|---|---|
| 底部 15~20% | 字幕、关键信息 |
| 右侧 15% | 主体、文字 |
| 顶部 10% | 重要内容(可能被标题栏遮挡) |
3. 三分法:主体放在竖线的三分之一处,而不是正中(尤其是单人时)。
我在裁切窗口的 y 上一般取"偏上一点"(因为人脸通常在上半部,且底部要留给字幕):
# y 位置:让主体的脸落在画面上部 1/3 处
crop_y = max(0, int(face_y - CROP_H / 3))
八、完整代码
import subprocess
from pathlib import Path
import cv2
import numpy as np
class Reframer:
def __init__(self, src_w, src_h, dst_w=1080, dst_h=1920, fps=30):
self.src_w, self.src_h = src_w, src_h
self.dst_w, self.dst_h = dst_w, dst_h
self.fps = fps
# 裁切窗口:保持源的高度,宽度按目标比例
self.crop_h = src_h
self.crop_w = int(src_h * dst_w / dst_h)
self.crop_w -= self.crop_w % 2
self.locator = SubjectLocator()
self.smoother = Smoother(alpha=0.05)
self.value = 0.5
def analyze(self, video_path, sample_fps=5):
"""按采样率分析每帧的主体位置,返回 [(时间, 归一化x)]。"""
cap = cv2.VideoCapture(video_path)
src_fps = cap.get(cv2.CAP_PROP_FPS) or self.fps
step = max(1, int(round(src_fps / sample_fps)))
track = []
idx = 0
while True:
ok, frame = cap.read()
if not ok:
break
if idx % step == 0:
x, conf = self.locator.locate(frame)
self.value = self.smoother.update(x, conf)
self.value = limit_speed(self.value, x)
# 裁切窗口的合法范围
lo = self.crop_w / 2 / self.src_w
hi = 1 - self.crop_w / 2 / self.src_w
track.append((idx / src_fps, float(np.clip(self.value, lo, hi))))
idx += 1
cap.release()
return track
def render(self, video_path, track, out_path, crf=20):
cmd_file = str(Path(out_path).with_suffix('.cropcmd'))
write_crop_commands(track, cmd_file, src_w=self.src_w, crop_w=self.crop_w)
vf = (f"sendcmd=f={cmd_file},crop=w={self.crop_w}:h={self.crop_h}:y=0,"
f"scale={self.dst_w}:{self.dst_h}:flags=lanczos")
cmd = ['ffmpeg', '-y', '-v', 'error', '-i', video_path,
'-vf', vf, '-c:v', 'libx264', '-crf', str(crf), '-preset', 'slow',
'-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', out_path]
subprocess.run(cmd, check=True)
Path(cmd_file).unlink(missing_ok=True)
return out_path
def write_crop_commands(track, out_path, src_w, crop_w, epsilon=0.02):
lines = []
last = None
for t, x_norm in track:
x_px = int(x_norm * src_w - crop_w / 2)
x_px = max(0, min(x_px, src_w - crop_w))
if last is None or abs(x_px - last) > epsilon * src_w:
lines.append(f'{t:.3f}\tcrop x {x_px};')
last = x_px
Path(out_path).write_text('\n'.join(lines), encoding='utf-8')
def limit_speed(current, target, max_speed=0.02):
delta = target - current
if abs(delta) > max_speed:
delta = max_speed if delta > 0 else -max_speed
return current + delta
if __name__ == '__main__':
import sys
src, out = sys.argv[1], sys.argv[2]
r = Reframer(1920, 1080)
t = r.analyze(src)
r.render(src, t, out)
print(f'完成:{out}(轨迹点 {len(t)} 个)')
九、实测数据
素材:12 段讲座录像(每段 20~40 分钟,1920×1080,讲师会走动)。
| 方案 | 主体在画面内的时间占比 | 主观评分 | 处理耗时 |
|---|---|---|---|
| 中间固定裁切 | 61% | 2.8 | 1 分钟 |
| 智能 reframe | 94% | 4.2 | 6 分钟 |
| 人工指定(抽样 3 段) | 97% | 4.5 | 30 分钟/段 |
智能 reframe 接近人工的水平,但成本只有 1/5。
剩下的 6% 是什么:
- 讲师走到画面最边缘(裁切窗口已经到边界了,物理上跟不了);
- 多人场景(算法在两个人之间摇摆);
- 检测失败(背对镜头、光线太暗)。
这些情况需要在抽查时人工修正——所以我的流程是"自动处理 + 抽 20% 人工看",而不是"全自动不用管"。
十、坑清单
- 直接用检测结果裁切 → 画面疯狂抖动。必须平滑。
- 平滑但没有死区 → 主体不动时画面还在慢慢飘。加死区。
- 没有限速 → 检测跳变时画面瞬移。
- 只用人脸检测 → 背对镜头/侧脸/遮挡时失效。要有兜底(运动/中心)。
- 人脸
minSize太小 → 把观众席的小脸当主体。 - 多人场景摇摆 → 要么选最大的人脸,要么用"所有人的重心",要么固定不跟随。
sendcmd文件里 crop 没写默认参数 → 报错。滤镜链里要给 w/h/y 默认值。- 命令条数太多 → ffmpeg 处理慢。只在变化超阈值时写。
- 裁切窗口越界(x < 0 或 x + w > 源宽)→ 报错或者画面异常。要 clamp。
- 忘了
-vf里的 scale → 输出尺寸不对。 - 没考虑分辨率损失 → 客户抱怨"怎么变糊了"。事前说明。
- 没避开平台安全区 → 关键信息被 UI 挡住。
- 竖版字幕没重新排 → 横版的字幕位置在竖版里跑到画面外。字幕要重新定位。
- 音频没处理 → 一般不用处理,但要确认音轨还在。
- 不做人工抽查 → 检测失败的片段会很尴尬(画面里没人)。抽 20% 看一遍。
最后说说这个需求背后的行业变化。
"横转竖"这件事本身就说明了一个问题:内容的拍摄和分发形态已经分裂了。
以前是"拍什么比例就发什么比例"。现在是:
- 拍摄:横屏(相机、摄像机天然是横的);
- 分发:竖屏(手机、短视频平台);
- 还有:方屏(信息流)、16:9(网页/电视)。
同一个内容要适配三四种画幅,所以"智能 reframe"这类需求会越来越多。
但我的第一个建议仍然是:从源头解决。 具体说:
- 用 4K 拍(给后期裁切留空间);
- 重要内容拍两个机位(一个横一个竖,成本增加不多);
- 构图时给边缘留余量(主体别太靠边);
- 如果只做一个平台,就按那个平台拍。
后期智能 reframe 是补救手段,不是最优解。跟客户沟通时我会把这个优先级讲清楚——因为"4K 拍摄"的边际成本,远低于"后期给每个视频做智能裁切"。
还有一个心得:这类"看起来是算法问题"的需求,实际上大部分工作量在"平滑和边界处理"上。检测主体位置,用现成的人脸检测几行代码就够;但让裁切窗口"看起来专业",要调死区、限速、置信度权重、边界 clamp、命令稀疏化——这些"不性感"的细节才是决定成败的。
我在这个项目里花在检测上的时间是 1 小时,花在平滑参数上的时间是 2 天。这个比例,可能就是"能跑"和"能用"之间的差距。