💻 炼丹炉:用 Python/OpenCV 打造视频关键帧提取与图片去重利器 ✨

194 阅读9分钟

💻 炼丹炉:用 Python/OpenCV 打造视频关键帧提取与图片去重利器 ✨

🚀 引言:为什么我们需要“驯服”视频数据?

在处理海量的视频数据时,我们常常会遇到两个棘手的问题:

  1. 关键帧提取的“盲区”:如何从一段时长动辄数分钟甚至数小时的视频中,精准、高效地捕捉到内容发生本质变化的关键帧 (Key Frames)?仅仅平均采样会导致大量重复或无意义的图片。
  2. 图片冗余的“浪费”:关键帧提取后,由于视频内容变化缓慢,仍可能存在大量高度相似的图片,它们不仅浪费存储空间,还会拖慢后续的分析(如图像识别、模型训练)。

作为一名资深技术架构师,我将带你深入了解如何使用 OpenCV、NumPy 和 Scikit-image 的底层原理,构建一套完整的 “视频关键帧提取 -> 冗余图片去重” 解决方案。读完本文,你将不仅能跑通代码,还能清晰掌握其背后的差分计算、信号平滑与结构相似性 (SSIM) 等核心原理!

目标读者: 有经验的 Python 开发者、对视频处理底层逻辑感兴趣的工程师。

SEO 核心关键词: OpenCV、关键帧提取SSIM、冗余图片去重、视频处理、图像差分。


Part 1:🎬 深入理解 OpenCV 关键帧提取原理

高效的关键帧提取,本质上是将视频帧序列转化为一个**“帧间变化率”的时间序列,然后从这个序列中找到局部最大值 (Local Maxima)**。变化率越大,越可能是场景切换或内容变化的发生点。

1.1 核心概念:基于帧差的场景变化检测

我们使用 相邻帧的 LUV 颜色空间差值之和 作为衡量帧间变化的指标。

  • 为什么要用 LUV 颜色空间? 相比于传统的 BGR 空间,LUV 空间将亮度 (Luminance) 与色度分离。在计算帧差时,LUV 更能聚焦于视觉内容的变化,减少由于光照、曝光等变化引起的干扰。
  • 如何计算帧差? 关键在于 cv2.absdiff()np.sum(),计算的是两个帧对应像素值的绝对差值的总和。这个总和,即 count,就是我们衡量的帧间变化量。

1.2 原理深化:信号平滑与局部最大值 (Local Maxima)

直接使用原始的帧差数组(frame_diffs)作为判断依据是不可靠的,因为视频中存在大量细微的、不规律的“噪声”变化。这就是引入信号处理的原因:

A. 信号平滑 (Smoothing)

我们使用 Hanning 窗进行卷积平滑(即代码中的 smooth 函数),这在信号处理中是一种低通滤波操作,目的是滤除帧差序列中的高频噪声,让场景切换(趋势变化)的信号变得更清晰、更易于识别。

B. 局部最大值检测

平滑后的帧差曲线(sm_diff_array)的峰值点正是我们寻找的关键。利用 scipy.signal.argrelextrema 可以高效地找出这些局部最大值的索引。这些峰值点,意味着视频内容在极短时间内发生了最大的变化,因此对应于场景切换瞬间

关键帧提取代码(节选自用户提供的代码块 1)

# 核心帧差计算与存储逻辑
while (ret):
    luv = cv2.cvtColor(frame, cv2.COLOR_BGR2LUV)
    curr_frame = luv
    if curr_frame is not None and prev_frame is not None:
        # 计算绝对差值
        diff = cv2.absdiff(curr_frame, prev_frame)
        # 累加所有像素差值作为变化量
        count = np.sum(diff) 
        frame_diffs.append(count)
        frame = Frame(i, frame, count)
        frames.append(frame)
    # ... 省略部分代码
    prev_frame = curr_frame
    i = i + 1
    ret, frame = cap.read()

*总结:这段逻辑完成了从视频帧到“帧间变化率时间序列”*的转换,为后续的信号分析奠定了数据基础。


Part 2:🖼️ 深度去重:基于 SSIM 的冗余图片清除

即使通过关键帧提取减少了图片数量,仍可能存在内容高度相似的帧。为了进一步移除冗余图片,我们需要使用比像素差更先进的图像相似度指标——结构相似性 (SSIM)

2.1 SSIM:结构相似性指标

  • 是什么? SSIM (Structural Similarity Index Measure) 是一种感知度量指标,它从亮度 (Luminance)、对比度 (Contrast) 和结构 (Structure) 三个维度来衡量两张图片感知上的相似度,其结果更符合人眼对相似度的判断(范围 [0,1][0, 1],越接近 1 越相似)。
  • 应用: 代码中设置了阈值 max_ssim = 0.5。当两张图片的 SSIM 值大于此阈值时,即被认为是冗余的。

2.2 清晰度判断与择优保留机制

在 SSIM 大于阈值的情况下,我们不能随机删除一张,而应该保留更清晰的那张。

  • 清晰度指标:使用 拉普拉斯算子 (Laplacian Operator) 计算图像的二阶导数。拉普拉斯方差反映了图像的边缘信息和清晰度。方差越大,图像越清晰,我们就要保留它。

冗余图片去重代码(节选自用户提供的代码块 2)

# 拉普拉斯算子计算清晰度
def getImageVar(image):
    img2gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    # cv2.CV_64F 确保计算精度,返回的是方差
    imageVar = cv2.Laplacian(img2gray, cv2.CV_64F).var() 
    return imageVar

# ...
ssim = compare_ssim(img, img1, multichannel=True)
if ssim > max_ssim:
    # 当达到了门限,比较两个图像的清晰度
    print(var_img, var_img1, img_files[currIndex], img_files[currIndex1 + currIndex + 1], ssim)
    if (var_img > var_img1):
        del_list.append(img_files.pop(currIndex1 + currIndex + 1)) # 保留 var_img 清晰度高的
    else:
        del_list.append(img_files.pop(currIndex)) # 保留 var_img1 清晰度高的
# ...

总结:去重逻辑是: 先用 SSIM 识别相似,再用 拉普拉斯方差 择优保留,确保最终输出的图片集合具备高质量和非冗余性。


Part 3:🛠️ 实用工具箱:视频片段截取与 FFmpeg 技巧

下面提供两个非常实用的视频处理脚本,一个使用 OpenCV 实现时间点截取,另一个使用 FFmpeg 进行高级流操作。

3.1 OpenCV 基于时间戳的精确截取

利用 OpenCV 可以将时间点转化为帧号,实现视频的精确截取和写入。

  • 核心公式:帧号=时间(秒)×FPS帧号 = \text{时间(秒)} \times \text{FPS}

视频片段截取代码(用户提供的代码块 3)

import numpy as np
import cv2
import os
import time

START_HOUR = 0
START_MIN = 1
START_SECOND = 45
START_TIME = START_HOUR * 3600 + START_MIN * 60 + START_SECOND  # 设置开始时间(单位秒)
END_HOUR = 0
END_MIN = 1
END_SECOND = 55
END_TIME = END_HOUR * 3600 + END_MIN * 60 + END_SECOND  # 设置结束时间(单位秒)

video = r"E:\videos\1.mp4"
cap = cv2.VideoCapture(video)
FPS = cap.get(cv2.CAP_PROP_FPS)
print(FPS)
FPS = 25
# size = (cap.get(cv2.CAP_PROP_FRAME_WIDTH), cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
size = (1920,1080)
print(size)
TOTAL_FRAME = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))  # 获取视频总帧数
frameToStart = START_TIME * FPS  # 开始帧 = 开始时间*帧率
print(frameToStart)
frametoStop = END_TIME * FPS  # 结束帧 = 结束时间*帧率
print(frametoStop)
videoWriter =cv2.VideoWriter(r"E:\videos\3.mp4",cv2.VideoWriter_fourcc('X','V','I','D'),FPS,size)

# cap.set(cv2.CAP_PROP_POS_FRAMES, frameToStart)  # 设置读取的位置,从第几帧开始读取视频
COUNT = 0
while True:
    success, frame = cap.read()
    if success:
        COUNT += 1
        if COUNT <= frametoStop and COUNT > frameToStart:  # 选取起始帧
            print('correct= ', COUNT)
            videoWriter.write(frame)
    # print('mistake= ', COUNT)
    if COUNT > frametoStop:
        break
print('end')

3.2 FFmpeg 的高效命令行操作

FFmpeg 是处理视频流的工业级工具,效率极高。

✅ 使用 ffmpeg 针对文件进行加水印

使用 overlay 滤镜,将 logo.png 添加到视频的右下角,距离边缘 10 像素。

ffmpeg -i input.mp4 -i logo.png -filter_complex 'overlay=main_w-overlay_w-10:main_h-overlay_h-10' output.mp4
✅ 使用 ffmpeg 对文件降低 FPS 进行保存

利用 ffmpeg-python 库调用 FFmpeg,将视频帧率降采样到 25 FPS。

import ffmpeg
videoPath = r'input.mp4'

info = ffmpeg.probe(videoPath)

(
ffmpeg
    .input('input.mp4')
    .filter('fps', fps=25, round='up')
    .output('dummy2.mp4')
    .run()
)

📜 技术总结与展望

我们已经构建了一个从视频到核心静态图片的完整技术链。核心价值在于,它避免了盲目采样,而是基于图像处理与信号分析的底层原理实现智能识别与去重

核心技术点底层原理技术价值
关键帧提取LUV 颜色空间差分 + Hanning 窗平滑 + 局部最大值检测效率:避免冗余帧,精准定位场景切换点。
冗余图片去重SSIM 结构相似性 + 拉普拉斯方差(清晰度)质量:基于感知相似度去重,保留最清晰的优质图片。
视频截取时间 \rightarrow 帧号 ×\times FPS精确:基于时间戳的精确视频片段抽取。
FFmpeg 实用overlay 滤镜 / fps 滤镜高效:专业级视频流操作与处理。

展望: 这套框架可进一步应用于视频摘要生成、内容审核的预处理流程,甚至可以作为深度学习模型训练时进行数据降维的基础模块。掌握这些底层原理,将助你在处理大规模多媒体数据时游刃有余!


附录:完整的用户原始代码清单

1. 针对视频进行截取图片(关键帧提取)

# -*- coding: utf-8 -*-

import cv2
import operator
import numpy as np
import matplotlib.pyplot as plt
import os
from scipy.signal import argrelextrema

# Setting fixed threshold criteria
USE_THRESH = False
# fixed threshold value
THRESH = 0.6
# Setting fixed threshold criteria
USE_TOP_ORDER = False
# Setting local maxima criteria
USE_LOCAL_MAXIMA = True
# Number of top sorted frames
NUM_TOP_FRAMES = 20
# 视频输入目录
input_dir = r'/home/rexchen/Videos'
# 图像输出目录
output_dir = r'/home/rexchen/Images/'
# smoothing window size
len_window = int(25)


def smooth(x, window_len=13, window='hanning'):
    """smooth the data using a window with requested size.
    This method is based on the convolution of a scaled window with the signal.
    The signal is prepared by introducing reflected copies of the signal
    (with the window size) in both ends so that transient parts are minimized
    in the begining and end part of the output signal.
    input:
        x: the input signal
        window_len: the dimension of the smoothing window
        window: the type of window from 'flat', 'hanning', 'hamming', 'bartlett', 'blackman'
             flat window will produce a moving average smoothing.
    output:
        the smoothed signal
    example:
    import numpy as np
    t = np.linspace(-2,2,0.1)
    x = np.sin(t)+np.random.randn(len(t))*0.1
    y = smooth(x)
    see also:
    numpy.hanning, numpy.hamming, numpy.bartlett, numpy.blackman, numpy.convolve
    scipy.signal.lfilter
    TODO: the window parameter could be the window itself if an array instead of a string
    """
    print(len(x), window_len)
    # if x.ndim != 1:
    #     raise ValueError, "smooth only accepts 1 dimension arrays."
    #
    # if x.size < window_len:
    #     raise ValueError, "Input vector needs to be bigger than window size."
    #
    # if window_len < 3:
    #     return x
    #
    # if not window in ['flat', 'hanning', 'hamming', 'bartlett', 'blackman']:
    #     raise ValueError, "Window is on of 'flat', 'hanning', 'hamming', 'bartlett', 'blackman'"

    s = np.r_[2 * x[0] - x[window_len:1:-1], x, 2 * x[-1] - x[-1:-window_len:-1]]
    # print(len(s))

    if window == 'flat':  # moving average
        w = np.ones(window_len, 'd')
    else:
        w = getattr(np, window)(window_len)
    y = np.convolve(w / w.sum(), s, mode='same')
    return y[window_len - 1:-window_len + 1]


# Class to hold information about each frame


class Frame:
    def __init__(self, id, frame, value):
        self.id = id
        self.frame = frame
        self.value = value

    def __lt__(self, other):
        if self.id == other.id:
            return self.id < other.id
        return self.id < other.id

    def __gt__(self, other):
        return other.__lt__(self)

    def __eq__(self, other):
        return self.id == other.id and self.id == other.id

    def __ne__(self, other):
        return not self.__eq__(other)


def rel_change(a, b):
    if (max(a, b) != 0):
        x = (b - a) / max(a, b)
        print(x)
    else:
        return 0
    return x


def write_frames(dir, filename):
    if USE_TOP_ORDER:
        # sort the list in descending order
        frames.sort(key=operator.attrgetter("value"), reverse=True)
        for keyframe in frames[:NUM_TOP_FRAMES]:
            name = "frame_" + str(keyframe.id) + ".jpg"
            cv2.imwrite(dir + "/" + filename + '_' + name, keyframe.frame)

    if USE_THRESH:
        print("Using Threshold")
        for i in range(1, len(frames)):
            if (rel_change(np.float(frames[i - 1].value), np.float(frames[i].value)) >= THRESH):
                # print("prev_frame:"+str(frames[i-1].value)+"  curr_frame:"+str(frames[i].value))
                name = "frame_" + str(frames[i].id) + ".jpg"
                cv2.imwrite(dir + "/" + filename + '_' + name, frames[i].frame)

    if USE_LOCAL_MAXIMA:
        print("Using Local Maxima")
        diff_array = np.array(frame_diffs)
        sm_diff_array = smooth(diff_array, len_window)
        frame_indexes = np.asarray(argrelextrema(sm_diff_array, np.greater))[0]
        for i in frame_indexes:
            name = "frame_" + str(frames[i - 1].id) + ".jpg"
            print(dir + name)
            cv2.imwrite(dir + "/" + filename + '_' + name, frames[i - 1].frame)


frame_diffs = []
frames = []


def all_path(dirname):
    for maindir, subdir, file_name_list in os.walk(dirname):
        for fn in file_name_list:
            file_path = os.path.join(maindir, fn)  # 合并成一个完整路径

            (filepath, tempfilename) = os.path.split(file_path)
            (filename, extension) = os.path.splitext(tempfilename)
            if not os.path.exists(output_dir + filename + '/'):
                os.mkdir(output_dir + filename + '/')
            videopath = file_path
            # Directory to store the processed frames
            dir = output_dir + filename
            print("Video :" + videopath)
            print("Frame Directory: " + dir)

            cap = cv2.VideoCapture(str(videopath))

            curr_frame = None
            prev_frame = None

            ret, frame = cap.read()
            i = 1

            while (ret):
                luv = cv2.cvtColor(frame, cv2.COLOR_BGR2LUV)
                curr_frame = luv
                if curr_frame is not None and prev_frame is not None:
                    # logic here
                    diff = cv2.absdiff(curr_frame, prev_frame)
                    count = np.sum(diff)
                    frame_diffs.append(count)
                    frame = Frame(i, frame, count)
                    frames.append(frame)

                    print(filename, i)
                    # 防止载入大视频内存溢出,每1000帧清空一次
                    if (i % 1000 == 0):
                        write_frames(dir, filename)
                        frame_diffs.clear()
                        frames.clear()
                prev_frame = curr_frame
                i = i + 1
                ret, frame = cap.read()

                cv2.imshow('frame', luv)
                if cv2.waitKey(1) & 0xFF == ord('q'):
                    break
            write_frames(dir, filename)
            cap.release()


all_path(input_dir)
cv2.destroyAllWindows()

2. 对图片进行相似度分析,移除冗余图片(SSIM 去重)

# -*- coding: utf-8 -*-
import os
import cv2
# from skimage.measure import compare_ssim
# from skimage import measure

from skimage.metrics import structural_similarity as compare_ssim
from skimage.metrics import peak_signal_noise_ratio as compare_psnr


import shutil

def yidong(filename1, filename2):
    shutil.move(filename1, filename2)


def delete(filename1):
    os.remove(filename1)


# 相似度,如果大于max_ssim就进行删除
max_ssim = 0.5


# 利用拉普拉斯算子计算图片的二阶导数,反映图片的边缘信息,同样事物的图片,
# 清晰度高的,相对应的经过拉普拉斯算子滤波后的图片的方差也就越大。
def getImageVar(image):
    img2gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    imageVar = cv2.Laplacian(img2gray, cv2.CV_64F).var()
    return imageVar


if __name__ == '__main__':
    img_path = r'/home/rexchen/Image'
    del_list = []
    img_files = [os.path.join(rootdir, file) for rootdir, _, files in os.walk(img_path) for file in files if
                 (file.endswith('.jpg'))]
    for currIndex, filename in enumerate(img_files):
        if not os.path.exists(img_files[currIndex]):
            print('not exist', img_files[currIndex])
            break
        new_cur = 0
        for i in range(10000000):
            currIndex1 = new_cur
            if currIndex1 >= len(img_files) - currIndex - 1:
                break
            else:
                size = os.path.getsize(img_files[currIndex1 + currIndex + 1])
                if size < 64:
                    # delete(img_files[currIndex + 1])
                    del_list.append(img_files.pop(currIndex1 + currIndex + 1))
                else:
                    img = cv2.imread(img_files[currIndex])
                    # 计算图像清晰度
                    var_img = getImageVar(img)
                    img = cv2.resize(img, (46, 46), interpolation=cv2.INTER_CUBIC)
                    img1 = cv2.imread(img_files[currIndex1 + currIndex + 1])
                    # 计算图像清晰度
                    var_img1 = getImageVar(img1)
                    img1 = cv2.resize(img1, (46, 46), interpolation=cv2.INTER_CUBIC)
                    ssim = compare_ssim(img, img1, multichannel=True)

                    if ssim > max_ssim:
                        # 当达到了门限,比较两个图像的清晰度,把不清晰的放入删除列表中
                        print(var_img, var_img1, img_files[currIndex], img_files[currIndex1 + currIndex + 1], ssim)
                        if (var_img > var_img1):
                            del_list.append(img_files.pop(currIndex1 + currIndex + 1))
                        else:
                            del_list.append(img_files.pop(currIndex))
                        new_cur = currIndex1
                    else:
                        new_cur = currIndex1 + 1
                        if ssim > 0.4:
                            print('small_ssim', img_files[currIndex], img_files[currIndex1 + currIndex + 1], ssim)
    for image in del_list:
        delete(image)
        print('delete', image)

3. 根据起始时间对视频进行截取

import numpy as np
import cv2
import os
import time

START_HOUR = 0
START_MIN = 1
START_SECOND = 45
START_TIME = START_HOUR * 3600 + START_MIN * 60 + START_SECOND  # 设置开始时间(单位秒)
END_HOUR = 0
END_MIN = 1
END_SECOND = 55
END_TIME = END_HOUR * 3600 + END_MIN * 60 + END_SECOND  # 设置结束时间(单位秒)

video = r"E:\videos\1.mp4"
cap = cv2.VideoCapture(video)
FPS = cap.get(cv2.CAP_PROP_FPS)
print(FPS)
FPS = 25
# size = (cap.get(cv2.CAP_PROP_FRAME_WIDTH), cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
size = (1920,1080)
print(size)
TOTAL_FRAME = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))  # 获取视频总帧数
frameToStart = START_TIME * FPS  # 开始帧 = 开始时间*帧率
print(frameToStart)
frametoStop = END_TIME * FPS  # 结束帧 = 结束时间*帧率
print(frametoStop)
videoWriter =cv2.VideoWriter(r"E:\videos\3.mp4",cv2.VideoWriter_fourcc('X','V','I','D'),FPS,size)

# cap.set(cv2.CAP_PROP_POS_FRAMES, frameToStart)  # 设置读取的位置,从第几帧开始读取视频
COUNT = 0
while True:
    success, frame = cap.read()
    if success:
        COUNT += 1
        if COUNT <= frametoStop and COUNT > frameToStart:  # 选取起始帧
            print('correct= ', COUNT)
            videoWriter.write(frame)
    # print('mistake= ', COUNT)
    if COUNT > frametoStop:
        break
print('end')

4. 使用 FFmpeg 针对文件进行加水印 (命令行)

ffmpeg -i input.mp4 -i logo.png -filter_complex 'overlay=main_w-overlay_w-10:main_h-overlay_h-10' output.mp4

5. 使用 FFmpeg 对文件降低 FPS 进行保存 (Python)

import ffmpeg
videoPath = r'input.mp4'

info = ffmpeg.probe(videoPath)

(
ffmpeg
    .input('input.mp4')
    .filter('fps', fps=25, round='up')
    .output('dummy2.mp4')
    .run()
)