云计算百科
云计算领域专业知识百科平台

视频画面裁剪工具的设计与实现:PyQt5、OpenCV 与 ffmpeg 的协同

在视频处理的实际工作中,经常需要对画面进行局部裁剪,例如去除黑边、聚焦特定区域或适配不同显示比例。一个直观易用的裁剪工具应当允许用户先预览视频内容,再通过鼠标拖拽选择保留区域,并在导出时保持原始比例与音频轨道。本文完整记录了一款基于 Python、PyQt5、OpenCV 与 ffmpeg 的视频裁剪工具的开发历程,从界面交互到后台编码,并深入探讨了导入错误、音频丢失以及进度条卡死这三个典型问题的成因与解决方案。
在这里插入图片描述

界面交互与坐标映射

工具的主画布由自定义控件 CropCanvas 实现。它负责显示视频帧,并允许用户在其上拖拽出一个矩形选区。视频帧的原始尺寸记为 WframeW_{frame}Wframe​ 与 HframeH_{frame}Hframe​,而控件自身的尺寸为 WwidgetW_{widget}Wwidget​ 与 HwidgetH_{widget}Hwidget​。为了在保持视频原始比例的前提下完整显示画面,需要计算一个缩放因子 sss,使得画面能够恰好放入控件:

s=min⁡(WwidgetWframe,HwidgetHframe)s = \\min\\left(\\frac{W_{widget}}{W_{frame}}, \\frac{H_{widget}}{H_{frame}}\\right)s=min(Wframe​Wwidget​​,Hframe​Hwidget​​)

随后,画面在控件中的绘制区域 RviewR_{view}Rview​ 可以通过居中计算得到。视频帧中的任意一点 (xframe,yframe)(x_{frame}, y_{frame})(xframe​,yframe​) 映射到控件坐标 (xview,yview)(x_{view}, y_{view})(xview​,yview​) 的公式为:

xview=Rview.x+xframe⋅sx_{view} = R_{view}.x + x_{frame} \\cdot sxview​=Rview​.x+xframe​⋅s
yview=Rview.y+yframe⋅sy_{view} = R_{view}.y + y_{frame} \\cdot syview​=Rview​.y+yframe​⋅s

反向映射则用于将鼠标位置转换为视频帧内的像素坐标,从而确定选区的位置。选区本身用一个矩形 rect=(x,y,w,h)rect = (x, y, w, h)rect=(x,y,w,h) 表示,其中 xxx 与 yyy 是左上角坐标,www 与 hhh 分别是宽度和高度。当用户勾选“锁定原始比例”时,选区必须满足 w/h=Wframe/Hframew / h = W_{frame} / H_{frame}w/h=Wframe​/Hframe​。在拖拽调整大小时,程序会以固定对角点为锚点,根据鼠标位置重新计算 www 与 hhh,并按比例约束进行修正,最后将选区限制在画面边界内。

鼠标交互分为三种模式:新建选区、移动选区和缩放选区。在 mousePressEvent 中,通过判断鼠标是否落在四个角点的手柄附近来决定进入缩放模式;若落在选区内部则进入移动模式;否则开始新建选区。移动时,选区整体平移,但需保证 x∈[0,Wframe−w]x \\in [0, W_{frame} – w]x∈[0,Wframe​−w] 且 y∈[0,Hframe−h]y \\in [0, H_{frame} – h]y∈[0,Hframe​−h]。缩放时,根据拖动方向实时计算新的 www 与 hhh,并应用比例锁定。这些逻辑确保了操作的流畅性与边界安全性。

视频预览与时间控制

视频的读取与预览由 OpenCV 的 VideoCapture 完成。程序获取视频的帧率 fpsfpsfps 与总帧数 NNN,并利用 QTimer 定时读取下一帧。定时器的间隔为 1000/fps1000 / fps1000/fps 毫秒,从而模拟出接近原始速度的播放效果。当前播放时间 tcurt_{cur}tcur​ 与总时长 TtotalT_{total}Ttotal​ 分别由当前帧索引 iii 和总帧数计算:

tcur=ifps,Ttotal=N−1fpst_{cur} = \\frac{i}{fps}, \\quad T_{total} = \\frac{N – 1}{fps}tcur​=fpsi​,Ttotal​=fpsN−1​

进度条与时间标签同步更新,用户可以拖动滑块跳转到任意帧。为了避免在拖动过程中触发重复读取,程序使用 _seeking 标志进行状态保护。播放与暂停通过工具栏按钮或空格键切换,停止播放时会重置按钮文本并停止定时器。

导出方案:从 OpenCV 到 ffmpeg

最初的实现使用 OpenCV 的 VideoWriter 逐帧写入裁剪后的画面。这种方法简单直接,但存在一个致命缺陷:VideoWriter 仅处理视频帧,完全忽略音频流。因此导出的视频没有声音,无法满足“保留原视频音轨”的需求。

要同时处理视频与音频,必须借助 ffmpeg。ffmpeg 是一个功能强大的多媒体框架,能够对视频进行滤镜处理并重新编码音频。程序中通过 subprocess 调用 ffmpeg,核心命令如下:

ffmpeg -y -hide_banner -i input.mp4 -vf crop=w:h:x:y -c:v libx264 -crf 18 -preset medium -pix_fmt yuv420p -c:a aac -b:a 192k -movflags +faststart -progress pipe:1 output.mp4

其中 -vf crop=w:h:x:y 指定裁剪区域,参数顺序为宽度、高度、左上角 xxx 坐标、左上角 yyy 坐标。视频编码采用 libx264,质量由 -crf 18 控制,数值越低画质越好。音频编码采用 aac,比特率为 192192192 kbps,兼容性极佳。-movflags +faststart 使得 MP4 文件的元数据前置,便于网络播放。-progress pipe:1 将进度信息输出到标准输出,供程序解析。

为了在界面上实时显示导出进度,程序解析 ffmpeg 输出的 out_time_us= 或 out_time_ms= 行,得到已编码的时间 toutt_{out}tout​(单位秒),再结合视频总时长 TtotalT_{total}Ttotal​ 计算百分比 ppp:

p=min⁡(99,max⁡(0,⌊toutTtotal×100⌋))p = \\min\\left(99, \\max\\left(0, \\left\\lfloor \\frac{t_{out}}{T_{total}} \\times 100 \\right\\rfloor\\right)\\right)p=min(99,max(0,⌊Ttotal​tout​​×100⌋))

当编码结束时,直接将进度置为 100%100\\%100%,并弹出完成提示。若用户中途点击取消,程序会终止 ffmpeg 进程并删除未完成的输出文件。

进度条卡死与管道死锁

在初步实现 ffmpeg 导出后,进度条经常卡在某个百分比(例如 81%81\\%81%)不再前进,程序看似无响应。经过分析,根本原因在于 subprocess.Popen 的 stderr 管道被写满,导致 ffmpeg 进程阻塞。

ffmpeg 在运行过程中会向标准错误输出大量日志信息,包括编码参数、警告和统计信息。若使用 subprocess.PIPE 创建 stderr 管道,但不在编码过程中及时读取,这些日志会逐渐填满管道的内核缓冲区。在 Linux 与 Windows 上,管道缓冲区的典型大小约为 646464 KB。一旦缓冲区满,ffmpeg 在尝试写入下一条日志时就会阻塞,无法继续编码,自然也无法输出进度信息。而主线程此时正等待读取 stdout,双方互相等待,形成死锁。

解决方案是将 stderr 合并到 stdout,即设置 stderr=subprocess.STDOUT。这样所有输出都通过同一个管道返回,程序只需在循环中逐行读取 stdout,既能解析进度行,又能收集错误行用于诊断。由于输出流被持续消费,管道永远不会被写满,ffmpeg 得以顺畅运行。同时,程序将非进度行暂存于列表 error_lines 中,若进程返回码非零,则从列表中提取最后若干行作为错误提示,方便定位问题。

此外,取消操作通过调用 terminate() 终止 ffmpeg 进程,并在事后清理输出文件,确保不会留下损坏的视频。最终,进度条能够平稳地从 0%0\\%0% 推进到 99%99\\%99%,并在结束时跳至 100%100\\%100%,整个导出过程稳定可靠。

完整代码

以下是经过所有修正后的完整实现。代码包含界面绘制、鼠标交互、视频预览、ffmpeg 导出以及错误处理,可直接运行。

# -*- coding: utf-8 -*-
"""
视频画面裁剪工具 (PyQt5 + OpenCV + ffmpeg)

功能:
1. 打开视频,逐帧预览播放
2. 在预览画面上鼠标拖拽框选裁剪区域(可移动 / 四角缩放)
3. 可锁定与原始视频一致的比例
4. 导出裁剪后的视频(保留原始音频)

依赖:
pip install opencv-python pyqt5 numpy
pip install imageio-ffmpeg # 可选,用于自动获取 ffmpeg
或自行安装 ffmpeg 并加入 PATH
"""

import os
import sys
import shutil
import subprocess

import cv2
import numpy as np

from PyQt5.QtCore import (
Qt, QTimer, QRectF, QPointF, QThread, pyqtSignal
)
from PyQt5.QtGui import (
QImage, QPixmap, QPainter, QPen, QColor, QPainterPath, QKeySequence
)
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QLabel, QSlider, QCheckBox,
QVBoxLayout, QHBoxLayout, QFileDialog, QMessageBox, QProgressDialog,
QSizePolicy, QToolBar, QAction, QShortcut
)

# ————————————————————————— #
# 工具函数
# ————————————————————————— #
def cv2_to_qimage(frame_bgr: np.ndarray) –> QImage:
"""OpenCV BGR 图像 -> QImage(RGB888)"""
rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
h, w, ch = rgb.shape
return QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy()

def find_ffmpeg():
"""依次从 PATH、imageio-ffmpeg、常见安装位置查找 ffmpeg 可执行文件"""
p = shutil.which("ffmpeg")
if p:
return p
try:
import imageio_ffmpeg # type: ignore
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
pass
candidates = [
r"C:\\ffmpeg\\bin\\ffmpeg.exe",
r"C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe",
r"D:\\ffmpeg\\bin\\ffmpeg.exe",
os.path.expanduser(r"~\\ffmpeg\\bin\\ffmpeg.exe"),
]
for c in candidates:
if os.path.isfile(c):
return c
return None

# ————————————————————————— #
# 裁剪画布:显示帧 + 拖拽框选
# ————————————————————————— #
class CropCanvas(QWidget):
"""显示视频帧,并允许在其上拖拽框选裁剪区域(坐标以视频原始像素为单位)"""

cropChanged = pyqtSignal(QRectF)

MIN_SIZE = 16.0 # 最小选区尺寸(像素)
HANDLE_SIZE = 9 # 角点手柄绘制尺寸
HANDLE_HIT = 11 # 角点手柄命中半径

def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumSize(480, 300)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setMouseTracking(True)
self.setCursor(Qt.CrossCursor)
self.setFocusPolicy(Qt.StrongFocus)

self._pixmap = None
self._frame_size = (0, 0) # (w, h) 视频原始尺寸
self._crop = None # 选区(视频像素坐标)
self._aspect = 16.0 / 9.0
self._lock_aspect = True

# 拖拽状态
self._mode = None # 'new' / 'move' / 'resize'
self._drag_origin = None # QPointF(帧坐标)
self._crop_at_press = None
self._crop_before_drag = None
self._anchor = None # 缩放时固定的对角点(帧坐标)

# —————- 对外接口 —————-
def reset_for_new_video(self, w: int, h: int):
self._frame_size = (w, h)
self._aspect = (w / h) if h else 16.0 / 9.0
self._crop = None
self._pixmap = None
self.update()

def set_frame(self, frame_bgr: np.ndarray):
if frame_bgr is None:
return
h, w = frame_bgr.shape[:2]
if (w, h) != self._frame_size:
self.reset_for_new_video(w, h)
self._pixmap = QPixmap.fromImage(cv2_to_qimage(frame_bgr))
if self._crop is None:
self._init_crop()
self.cropChanged.emit(QRectF(self._crop))
self.update()

def reset_crop(self):
if self._frame_size[0] <= 0:
return
self._init_crop()
self.cropChanged.emit(QRectF(self._crop))
self.update()

def set_lock_aspect(self, flag: bool):
self._lock_aspect = bool(flag)
if self._lock_aspect and self._crop is not None:
self._crop = self._clamp_rect(self._crop)
self.cropChanged.emit(QRectF(self._crop))
self.update()

def crop_rect(self):
return QRectF(self._crop) if self._crop is not None else None

# —————- 坐标换算 —————-
def _view_rect(self) –> QRectF:
"""视频画面在控件中的绘制区域(保持比例,居中)"""
fw, fh = self._frame_size
if fw <= 0 or fh <= 0 or self.width() <= 0 or self.height() <= 0:
return QRectF(0, 0, 1, 1)
s = min(self.width() / fw, self.height() / fh)
dw, dh = fw * s, fh * s
return QRectF((self.width() – dw) / 2.0,
(self.height() – dh) / 2.0, dw, dh)

def _scale(self) –> float:
r = self._view_rect()
fw = self._frame_size[0]
return (r.width() / fw) if fw > 0 else 1.0

def _to_view(self, pt: QPointF) –> QPointF:
r = self._view_rect()
s = self._scale()
return QPointF(r.x() + pt.x() * s, r.y() + pt.y() * s)

def _to_frame(self, pt) –> QPointF:
r = self._view_rect()
s = self._scale()
if s <= 0:
return QPointF(0, 0)
return QPointF((pt.x() – r.x()) / s, (pt.y() – r.y()) / s)

def _crop_view_rect(self) –> QRectF:
if self._crop is None:
return QRectF()
tl = self._to_view(self._crop.topLeft())
br = self._to_view(self._crop.bottomRight())
return QRectF(tl, br).normalized()

# —————- 选区几何 —————-
def _init_crop(self):
fw, fh = self._frame_size
if fw <= 0 or fh <= 0:
return
w = fw * 0.8
h = fh * 0.8
if self._lock_aspect and self._aspect > 0:
h = w / self._aspect
if h > fh:
h = fh * 0.8
w = h * self._aspect
self._crop = QRectF((fw – w) / 2.0, (fh – h) / 2.0, w, h)

def _clamp_point(self, p: QPointF) –> QPointF:
fw, fh = self._frame_size
return QPointF(min(max(p.x(), 0.0), fw),
min(max(p.y(), 0.0), fh))

def _clamp_rect(self, r: QRectF) –> QRectF:
"""把矩形限制在画面内,必要时按比例收缩"""
fw, fh = self._frame_size
if fw <= 0 or fh <= 0:
return r
w = min(max(r.width(), 1.0), float(fw))
h = min(max(r.height(), 1.0), float(fh))
if self._lock_aspect and self._aspect > 0:
if w / h > self._aspect:
w = h * self._aspect
else:
h = w / self._aspect
w = min(w, float(fw))
h = min(h, float(fh))
x = min(max(r.x(), 0.0), fw – w)
y = min(max(r.y(), 0.0), fh – h)
return QRectF(x, y, w, h)

def _rect_from_anchor(self, anchor: QPointF, pt: QPointF) –> QRectF:
"""以 anchor 为固定角,pt 为拖动角构造矩形(可锁定比例)"""
dx = pt.x() – anchor.x()
dy = pt.y() – anchor.y()
w, h = abs(dx), abs(dy)

if self._lock_aspect and self._aspect > 0:
if w < 1e-6 and h < 1e-6:
w = h = self.MIN_SIZE
elif w / max(h, 1e-6) > self._aspect:
h = w / self._aspect
else:
w = h * self._aspect
w = max(w, 1.0)
h = max(h, 1.0)

x = anchor.x() if dx >= 0 else anchor.x() – w
y = anchor.y() if dy >= 0 else anchor.y() – h
return self._clamp_rect(QRectF(x, y, w, h))

def _hit_handle(self, pos: QPointF, cr: QRectF):
if cr.isNull():
return None
corners = {
'tl': cr.topLeft(), 'tr': cr.topRight(),
'bl': cr.bottomLeft(), 'br': cr.bottomRight(),
}
for key, p in corners.items():
if abs(p.x() – pos.x()) <= self.HANDLE_HIT and \\
abs(p.y() – pos.y()) <= self.HANDLE_HIT:
return key
return None

def _opposite_corner(self, key: str) –> QPointF:
c = self._crop
if key == 'tl':
return QPointF(c.right(), c.bottom())
if key == 'tr':
return QPointF(c.left(), c.bottom())
if key == 'bl':
return QPointF(c.right(), c.top())
return QPointF(c.left(), c.top())

# —————- 鼠标事件 —————-
def mousePressEvent(self, event):
if self._pixmap is None or event.button() != Qt.LeftButton:
return
pos = QPointF(event.pos())
fpt = self._clamp_point(self._to_frame(pos))
cr = self._crop_view_rect()

self._crop_before_drag = QRectF(self._crop) if self._crop else None

handle = self._hit_handle(pos, cr)
if handle is not None and self._crop is not None:
self._mode = 'resize'
self._anchor = self._opposite_corner(handle)
elif self._crop is not None and cr.contains(pos):
self._mode = 'move'
self._drag_origin = fpt
self._crop_at_press = QRectF(self._crop)
else:
self._mode = 'new'
self._anchor = fpt
self._crop = QRectF(self._anchor, self._anchor)
self.update()

def mouseMoveEvent(self, event):
if self._pixmap is None:
return
pos = QPointF(event.pos())
fpt = self._clamp_point(self._to_frame(pos))

if self._mode == 'new' or self._mode == 'resize':
self._crop = self._rect_from_anchor(self._anchor, fpt)
self.cropChanged.emit(QRectF(self._crop))
self.update()
elif self._mode == 'move' and self._crop_at_press is not None:
delta = fpt – self._drag_origin
r = QRectF(self._crop_at_press)
r.translate(delta)
fw, fh = self._frame_size
x = min(max(r.x(), 0.0), fw – r.width())
y = min(max(r.y(), 0.0), fh – r.height())
r.moveTo(x, y)
self._crop = r
self.cropChanged.emit(QRectF(self._crop))
self.update()
else:
cr = self._crop_view_rect()
if self._crop is not None and self._hit_handle(pos, cr):
self.setCursor(Qt.SizeFDiagCursor)
elif self._crop is not None and cr.contains(pos):
self.setCursor(Qt.SizeAllCursor)
else:
self.setCursor(Qt.CrossCursor)

def mouseReleaseEvent(self, event):
if self._mode is None:
return
if self._mode in ('new', 'resize'):
if (self._crop is None or
self._crop.width() < self.MIN_SIZE or
self._crop.height() < self.MIN_SIZE):
if self._crop_before_drag is not None:
self._crop = self._crop_before_drag
else:
self._init_crop()
self.cropChanged.emit(QRectF(self._crop))
self._mode = None
self._crop_at_press = None
self._crop_before_drag = None
self.update()

# —————- 绘制 —————-
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing, True)
painter.fillRect(self.rect(), QColor(24, 24, 27))

if self._pixmap is None:
painter.setPen(QColor(170, 170, 170))
painter.drawText(self.rect(), Qt.AlignCenter,
"请先打开一个视频文件\\n(工具栏 → 打开视频)")
return

vr = self._view_rect()
painter.drawPixmap(vr, self._pixmap, QRectF(self._pixmap.rect()))

if self._crop is None:
return

cr = self._crop_view_rect()

# 选区外遮罩
path = QPainterPath()
path.setFillRule(Qt.OddEvenFill)
path.addRect(vr)
path.addRect(cr)
painter.fillPath(path, QColor(0, 0, 0, 150))

# 三分参考线
painter.setPen(QPen(QColor(255, 255, 255, 60), 1))
for i in (1, 2):
x = cr.left() + cr.width() * i / 3.0
painter.drawLine(QPointF(x, cr.top()), QPointF(x, cr.bottom()))
y = cr.top() + cr.height() * i / 3.0
painter.drawLine(QPointF(cr.left(), y), QPointF(cr.right(), y))

# 边框
painter.setPen(QPen(QColor(0, 200, 255), 2))
painter.setBrush(Qt.NoBrush)
painter.drawRect(cr)

# 角点手柄
hs = self.HANDLE_SIZE
painter.setBrush(QColor(0, 200, 255))
painter.setPen(QPen(QColor(255, 255, 255), 1))
for p in (cr.topLeft(), cr.topRight(), cr.bottomLeft(), cr.bottomRight()):
painter.drawRect(QRectF(p.x() – hs / 2.0, p.y() – hs / 2.0, hs, hs))

# 尺寸文字
txt = "%d × %d" % (int(round(self._crop.width())),
int(round(self._crop.height())))
painter.setPen(QColor(255, 235, 59))
painter.drawText(QPointF(cr.left(), max(cr.top() – 7, 12)), txt)

def resizeEvent(self, event):
super().resizeEvent(event)
self.update()

# ————————————————————————— #
# 后台导出线程 —— ffmpeg 版(保留音频)
# ————————————————————————— #
class FfmpegCropWorker(QThread):
"""用 ffmpeg 一步完成裁剪 + 保留音轨"""
progressChanged = pyqtSignal(int)
finishedOk = pyqtSignal(str)
failed = pyqtSignal(str)
cancelled = pyqtSignal()

def __init__(self, src, dst, rect, duration, ffmpeg, parent=None):
super().__init__(parent)
self.src = src
self.dst = dst
self.x, self.y, self.w, self.h = rect
self.duration = duration # 秒,用于进度计算
self.ffmpeg = ffmpeg
self._proc = None
self._cancel = False

def cancel(self):
self._cancel = True
if self._proc is not None:
try:
self._proc.terminate()
except Exception:
pass

def run(self):
cmd = [
self.ffmpeg, "-y", "-hide_banner",
"-i", self.src,
"-vf", f"crop={self.w}:{self.h}:{self.x}:{self.y}",
"-c:v", "libx264", "-crf", "18", "-preset", "medium",
"-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "192k",
"-movflags", "+faststart",
"-progress", "pipe:1",
self.dst,
]
try:
# 关键:stderr 合并到 stdout,避免管道写满导致死锁
self._proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
encoding="utf-8",
errors="ignore",
)

error_lines = []
assert self._proc.stdout is not None
for line in self._proc.stdout:
if self._cancel:
break
line = line.strip()
if not line:
continue

if line.startswith("out_time_us=") or line.startswith("out_time_ms="):
try:
us = int(line.split("=", 1)[1])
sec = us / 1_000_000.0
if self.duration > 0:
pct = int(sec / self.duration * 100)
self.progressChanged.emit(min(99, max(0, pct)))
except ValueError:
pass
else:
# 非进度行收集起来,失败时用于提示
error_lines.append(line)
if len(error_lines) > 50:
error_lines.pop(0)

rc = self._proc.wait()
self._proc = None

if self._cancel:
try:
if os.path.exists(self.dst):
os.remove(self.dst)
except Exception:
pass
self.cancelled.emit()
return

if rc != 0:
tail = error_lines[–10:] if error_lines else ["(无详细错误信息)"]
self.failed.emit("ffmpeg 导出失败(返回码 %d):\\n%s"
% (rc, "\\n".join(tail)))
return

self.progressChanged.emit(100)
self.finishedOk.emit(self.dst)

except Exception as exc: # noqa: BLE001
self.failed.emit(str(exc))

# ————————————————————————— #
# 后台导出线程 —— OpenCV 版(无音频,作为 ffmpeg 不可用时的备用)
# ————————————————————————— #
class CropSaveWorker(QThread):
progressChanged = pyqtSignal(int)
finishedOk = pyqtSignal(str)
failed = pyqtSignal(str)
cancelled = pyqtSignal()

def __init__(self, src, dst, rect, fps, parent=None):
super().__init__(parent)
self.src = src
self.dst = dst
self.x, self.y, self.w, self.h = rect
self.fps = fps if fps and fps > 0 else 25.0
self._cancel = False

def cancel(self):
self._cancel = True

def run(self):
cap = None
writer = None
try:
cap = cv2.VideoCapture(self.src)
if not cap.isOpened():
self.failed.emit("无法打开源视频文件")
return

total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
if total <= 0:
total = 1

ext = os.path.splitext(self.dst)[1].lower()
fourcc = (cv2.VideoWriter_fourcc(*'XVID') if ext == '.avi'
else cv2.VideoWriter_fourcc(*'mp4v'))

writer = cv2.VideoWriter(self.dst, fourcc, self.fps, (self.w, self.h))
if not writer.isOpened():
self.failed.emit("无法创建输出文件(编码器或保存路径不可用)")
return

idx = 0
while True:
if self._cancel:
break
ok, frame = cap.read()
if not ok:
break

crop = frame[self.y:self.y + self.h, self.x:self.x + self.w]
if crop.shape[0] != self.h or crop.shape[1] != self.w:
crop = cv2.resize(crop, (self.w, self.h))
writer.write(crop)

idx += 1
if idx % 3 == 0:
self.progressChanged.emit(min(99, int(idx * 100 / total)))

writer.release()
writer = None
cap.release()
cap = None

if self._cancel:
self.cancelled.emit()
return

self.progressChanged.emit(100)
self.finishedOk.emit(self.dst)

except Exception as exc: # noqa: BLE001
self.failed.emit(str(exc))
finally:
if writer is not None:
writer.release()
if cap is not None:
cap.release()

# ————————————————————————— #
# 主窗口
# ————————————————————————— #
class MainWindow(QMainWindow):

def __init__(self):
super().__init__()
self.setWindowTitle("视频画面裁剪工具 — 预览 / 框选 / 保存(保留音频)")
self.resize(1120, 760)

self.cap = None
self.video_path = None
self.fps = 25.0
self.frame_count = 0
self.fw = 0
self.fh = 0
self.current_index = 0
self.is_playing = False
self._seeking = False

self.timer = QTimer(self)
self.timer.timeout.connect(self._on_timer)

self.worker = None
self.progress_dlg = None

self._build_ui()

# —————- UI —————-
def _build_ui(self):
toolbar = QToolBar("主工具栏", self)
toolbar.setMovable(False)
self.addToolBar(toolbar)

act_open = QAction("打开视频", self)
act_open.triggered.connect(self.open_video)
toolbar.addAction(act_open)

toolbar.addSeparator()

self.act_play = QAction("播放", self)
self.act_play.setEnabled(False)
self.act_play.triggered.connect(self.toggle_play)
toolbar.addAction(self.act_play)

self.act_reset = QAction("重置选区", self)
self.act_reset.triggered.connect(lambda: self.canvas.reset_crop())
toolbar.addAction(self.act_reset)

toolbar.addSeparator()

self.act_save = QAction("保存裁剪视频", self)
self.act_save.setEnabled(False)
self.act_save.triggered.connect(self.save_video)
toolbar.addAction(self.act_save)

central = QWidget(self)
vbox = QVBoxLayout(central)
vbox.setContentsMargins(6, 6, 6, 6)
vbox.setSpacing(6)

self.canvas = CropCanvas(self)
self.canvas.cropChanged.connect(self._on_crop_changed)
vbox.addWidget(self.canvas, 1)

bottom = QHBoxLayout()
bottom.setSpacing(8)

self.slider = QSlider(Qt.Horizontal, self)
self.slider.setRange(0, 0)
self.slider.sliderPressed.connect(self._on_slider_pressed)
self.slider.sliderReleased.connect(self._on_slider_released)
self.slider.valueChanged.connect(self._on_slider_value)
bottom.addWidget(self.slider, 1)

self.lbl_time = QLabel("00:00 / 00:00")
bottom.addWidget(self.lbl_time)

self.chk_lock = QCheckBox("锁定原始比例")
self.chk_lock.setChecked(True)
self.chk_lock.toggled.connect(self.canvas.set_lock_aspect)
bottom.addWidget(self.chk_lock)

vbox.addLayout(bottom)
self.setCentralWidget(central)

self.lbl_info = QLabel("就绪")
self.statusBar().addPermanentWidget(self.lbl_info)

QShortcut(QKeySequence(Qt.Key_Space), self, activated=self.toggle_play)
QShortcut(QKeySequence("Ctrl+O"), self, activated=self.open_video)
QShortcut(QKeySequence("Ctrl+S"), self, activated=self.save_video)

# —————- 打开视频 —————-
def open_video(self):
path, _ = QFileDialog.getOpenFileName(
self, "选择视频文件", "",
"视频文件 (*.mp4 *.avi *.mov *.mkv *.flv *.wmv *.webm);;所有文件 (*)")
if path:
self.load_video(path)

def load_video(self, path: str):
self.stop_play()
if self.cap is not None:
self.cap.release()
self.cap = None

cap = cv2.VideoCapture(path)
if not cap.isOpened():
QMessageBox.critical(self, "错误", "无法打开该视频文件。")
return

self.cap = cap
self.video_path = path

fps = cap.get(cv2.CAP_PROP_FPS)
self.fps = fps if fps and 0 < fps <= 240 else 25.0
self.frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 0
self.fw = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
self.fh = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
self.current_index = 0

self.slider.blockSignals(True)
self.slider.setRange(0, max(self.frame_count – 1, 0))
self.slider.setValue(0)
self.slider.blockSignals(False)

self.canvas.reset_for_new_video(self.fw, self.fh)
self._read_frame_at(0)

self.act_play.setEnabled(True)
self.act_play.setText("播放")
self.act_save.setEnabled(True)

self.statusBar().showMessage(
"已加载:%s %d×%d %.2f fps %d 帧"
% (os.path.basename(path), self.fw, self.fh, self.fps, self.frame_count),
8000)

# —————- 播放控制 —————-
def toggle_play(self):
if self.cap is None:
return
self.stop_play() if self.is_playing else self.start_play()

def start_play(self):
if self.cap is None or self.frame_count <= 0:
return
if self.current_index >= self.frame_count – 1:
self.current_index = 0
self.cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
else:
self.cap.set(cv2.CAP_PROP_POS_FRAMES, self.current_index + 1)
self.is_playing = True
self.act_play.setText("暂停")
self.timer.start(max(1, int(round(1000.0 / self.fps))))

def stop_play(self):
self.is_playing = False
self.timer.stop()
self.act_play.setText("播放")

def _on_timer(self):
if self.cap is None:
return
ok, frame = self.cap.read()
if not ok:
self.stop_play()
return
self.current_index = min(self.current_index + 1, max(self.frame_count – 1, 0))
self.canvas.set_frame(frame)

self.slider.blockSignals(True)
self.slider.setValue(self.current_index)
self.slider.blockSignals(False)
self._update_time_label()

# —————- 进度条 —————-
def _on_slider_pressed(self):
self._seeking = True
if self.is_playing:
self.stop_play()

def _on_slider_released(self):
self._seeking = False
self._read_frame_at(self.slider.value())

def _on_slider_value(self, value):
if self._seeking or self.is_playing:
return
self._read_frame_at(value)

def _read_frame_at(self, index: int):
if self.cap is None:
return
index = max(0, min(int(index), max(self.frame_count – 1, 0)))
self.cap.set(cv2.CAP_PROP_POS_FRAMES, index)
ok, frame = self.cap.read()
if not ok:
return
self.current_index = index
self.canvas.set_frame(frame)

self.slider.blockSignals(True)
self.slider.setValue(index)
self.slider.blockSignals(False)
self._update_time_label()

def _update_time_label(self):
cur = self.current_index / self.fps
total = max(self.frame_count – 1, 0) / self.fps
self.lbl_time.setText("%s / %s" % (self._fmt_time(cur), self._fmt_time(total)))

@staticmethod
def _fmt_time(sec: float) –> str:
sec = int(max(sec, 0))
return "%02d:%02d" % (sec // 60, sec % 60)

# —————- 选区信息 —————-
def _on_crop_changed(self, rect: QRectF):
if rect is None or rect.width() <= 0:
self.lbl_info.setText("未选择区域")
return
self.lbl_info.setText(
"选区 %d × %d @ (%d, %d)"
% (int(round(rect.width())), int(round(rect.height())),
int(round(rect.x())), int(round(rect.y()))))

# —————- 保存 —————-
def save_video(self):
if self.cap is None or not self.video_path:
return

rect = self.canvas.crop_rect()
if rect is None or rect.width() < 2 or rect.height() < 2:
QMessageBox.warning(self, "提示", "请先在画面上框选一个有效的裁剪区域。")
return

x = int(round(rect.x()))
y = int(round(rect.y()))
w = int(round(rect.width()))
h = int(round(rect.height()))

x = max(0, min(x, self.fw – 1))
y = max(0, min(y, self.fh – 1))
w = max(2, min(w, self.fw – x))
h = max(2, min(h, self.fh – y))
# H.264 一般要求宽高为偶数
w -= w % 2
h -= h % 2
if w < 2 or h < 2:
QMessageBox.warning(self, "提示", "裁剪区域太小,无法导出。")
return

base, _ = os.path.splitext(self.video_path)
default_out = base + "_crop.mp4"
out_path, _ = QFileDialog.getSaveFileName(
self, "保存裁剪后的视频", default_out,
"MP4 视频 (*.mp4);;AVI 视频 (*.avi)")
if not out_path:
return

self.stop_play()
self.act_save.setEnabled(False)

self.progress_dlg = QProgressDialog("正在导出裁剪后的视频…", "取消",
0, 100, self)
self.progress_dlg.setWindowTitle("导出中")
self.progress_dlg.setWindowModality(Qt.WindowModal)
self.progress_dlg.setMinimumDuration(0)
self.progress_dlg.setAutoClose(False)
self.progress_dlg.setValue(0)

ffmpeg = find_ffmpeg()
if ffmpeg is not None:
duration = (self.frame_count / self.fps) if self.fps > 0 else 0.0
self.worker = FfmpegCropWorker(
self.video_path, out_path, (x, y, w, h), duration, ffmpeg)
else:
reply = QMessageBox.question(
self, "未检测到 ffmpeg",
"未检测到 ffmpeg,将使用 OpenCV 导出(不保留音轨)。\\n\\n"
"如需保留音频,请先执行: pip install imageio-ffmpeg\\n"
"(或自行安装 ffmpeg 并加入 PATH),然后重新打开本程序。\\n\\n"
"是否继续导出?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
if reply != QMessageBox.Yes:
self._close_progress()
self.act_save.setEnabled(True)
return
self.worker = CropSaveWorker(
self.video_path, out_path, (x, y, w, h), self.fps)

self.worker.progressChanged.connect(self.progress_dlg.setValue)
self.worker.finishedOk.connect(self._on_save_done)
self.worker.failed.connect(self._on_save_failed)
self.worker.cancelled.connect(self._on_save_cancelled)
self.progress_dlg.canceled.connect(self.worker.cancel)
self.worker.start()

def _close_progress(self):
if self.progress_dlg is not None:
self.progress_dlg.reset()
self.progress_dlg.close()
self.progress_dlg = None

def _on_save_done(self, path: str):
self._close_progress()
self.act_save.setEnabled(True)
QMessageBox.information(self, "完成",
"裁剪视频已保存到:\\n%s" % path)

def _on_save_failed(self, msg: str):
self._close_progress()
self.act_save.setEnabled(True)
QMessageBox.critical(self, "导出失败", msg)

def _on_save_cancelled(self):
self._close_progress()
self.act_save.setEnabled(True)
self.statusBar().showMessage("已取消导出", 4000)

# —————- 关闭 —————-
def closeEvent(self, event):
self.timer.stop()
if self.worker is not None and self.worker.isRunning():
self.worker.cancel()
self.worker.wait(3000)
if self.cap is not None:
self.cap.release()
self.cap = None
super().closeEvent(event)

# ————————————————————————— #
def main():
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)

app = QApplication(sys.argv)
app.setStyle("Fusion")

win = MainWindow()
win.show()
sys.exit(app.exec_())

if __name__ == "__main__":
main()

结语

本文详述了一款视频裁剪工具从雏形到稳定的完整过程。通过 PyQt5 构建交互界面,OpenCV 完成视频解码与预览,ffmpeg 实现带音轨的裁剪导出,三者各司其职。在开发中遇到的导入错误、音频丢失与进度条死锁,分别对应了模块划分、多媒体容器理解和进程间通信这三个层面的知识。理解这些问题的本质,不仅有助于修复当前工具,也为今后处理更复杂的视频处理任务奠定了基础。读者可在此基础上继续扩展,例如增加批量处理、自定义编码参数或实时预览裁剪效果等功能。

赞(0)
未经允许不得转载:网硕互联帮助中心 » 视频画面裁剪工具的设计与实现:PyQt5、OpenCV 与 ffmpeg 的协同
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!