提示

返回博客列表

横屏视频转竖屏不只是裁一刀:智能 reframe 与主体跟踪裁切

客户有一批横屏(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 倍升采样,画质会下降);裁切窗口不能跟着主体每帧跳(会晕),要"大部分时间不动、必要时平滑移动"。

目录

一、为什么中间裁切不够用

中间裁切(或"三分线裁切")的假设是:主体一直在画面中央。这个假设对某些内容成立(新闻播报、固定机位访谈),但对下面这些完全不成立:

内容 问题
讲座(讲师走动) 人走到白板那边,画面里没人
体育/舞蹈 主体快速左右移动
多人对话 中间裁切可能把两人都切掉一半
演示操作(手在屏幕某处) 关键区域在边上
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% 人工看",而不是"全自动不用管"。

十、坑清单

  1. 直接用检测结果裁切 → 画面疯狂抖动。必须平滑。
  2. 平滑但没有死区 → 主体不动时画面还在慢慢飘。加死区。
  3. 没有限速 → 检测跳变时画面瞬移。
  4. 只用人脸检测 → 背对镜头/侧脸/遮挡时失效。要有兜底(运动/中心)。
  5. 人脸 minSize 太小 → 把观众席的小脸当主体。
  6. 多人场景摇摆 → 要么选最大的人脸,要么用"所有人的重心",要么固定不跟随。
  7. sendcmd 文件里 crop 没写默认参数 → 报错。滤镜链里要给 w/h/y 默认值。
  8. 命令条数太多 → ffmpeg 处理慢。只在变化超阈值时写。
  9. 裁切窗口越界(x < 0 或 x + w > 源宽)→ 报错或者画面异常。要 clamp。
  10. 忘了 -vf 里的 scale → 输出尺寸不对。
  11. 没考虑分辨率损失 → 客户抱怨"怎么变糊了"。事前说明。
  12. 没避开平台安全区 → 关键信息被 UI 挡住。
  13. 竖版字幕没重新排 → 横版的字幕位置在竖版里跑到画面外。字幕要重新定位。
  14. 音频没处理 → 一般不用处理,但要确认音轨还在。
  15. 不做人工抽查 → 检测失败的片段会很尴尬(画面里没人)。抽 20% 看一遍。

最后说说这个需求背后的行业变化。

"横转竖"这件事本身就说明了一个问题:内容的拍摄和分发形态已经分裂了。

以前是"拍什么比例就发什么比例"。现在是:

  • 拍摄:横屏(相机、摄像机天然是横的);
  • 分发:竖屏(手机、短视频平台);
  • 还有:方屏(信息流)、16:9(网页/电视)。

同一个内容要适配三四种画幅,所以"智能 reframe"这类需求会越来越多。

但我的第一个建议仍然是:从源头解决。 具体说:

  1. 用 4K 拍(给后期裁切留空间);
  2. 重要内容拍两个机位(一个横一个竖,成本增加不多);
  3. 构图时给边缘留余量(主体别太靠边);
  4. 如果只做一个平台,就按那个平台拍。

后期智能 reframe 是补救手段,不是最优解。跟客户沟通时我会把这个优先级讲清楚——因为"4K 拍摄"的边际成本,远低于"后期给每个视频做智能裁切"。

还有一个心得:这类"看起来是算法问题"的需求,实际上大部分工作量在"平滑和边界处理"上。检测主体位置,用现成的人脸检测几行代码就够;但让裁切窗口"看起来专业",要调死区、限速、置信度权重、边界 clamp、命令稀疏化——这些"不性感"的细节才是决定成败的。

我在这个项目里花在检测上的时间是 1 小时,花在平滑参数上的时间是 2 天。这个比例,可能就是"能跑"和"能用"之间的差距。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部