PySceneDetect 中场景检测器
Published in:2025-05-15 |
Words: 7.8k | Reading time: 33min | reading:

PySceneDetect 中场景检测器

主要包括:ContentDetector, ThresholdDetector, AdaptiveDetector的算法及参数含义、算法思想以及如何确定这些参数。

通用概念

  • 场景 (Scene): 一系列连续的、在视觉上或叙事上构成一个单元的镜头。
  • 切点 (Cut/Scene Break): 两个不同场景之间的边界。
  • 帧间差异 (Frame-to-Frame Difference): 检测器通常通过比较连续(或间隔的)视频帧之间的差异来识别潜在的切点。差异的计算方式因检测器而异。
  • 阈值 (Threshold): 一个预设的数值。当计算出的帧间差异超过这个阈值时,检测器就认为可能发生了一个场景切换。
  • 最小场景长度 (Min Scene Length / min_scene_len): 检测到的场景必须达到的最短持续时间(通常以帧数或时间码表示)。如果两个检测到的原始切点之间的距离小于这个值,它们可能会被合并,以避免产生过多非常短的、无意义的场景片段。为了分析和找到最佳检测参数,我们通常将这个值设为1帧,以观察检测器最原始的输出。

算法概述

1. ThresholdDetector (阈值检测器)

主要检测淡入淡出到黑屏或白屏场景

  • 命令: detect-threshold

  • 核心思想: 主要通过监测视频帧的平均亮度/强度是否跨越一个固定的阈值来判断淡入/淡出事件。它假设场景切换(特别是淡入淡出)会伴随着画面整体变亮或变暗到某个程度。

  • 主要参数及其含义:

    • threshold (或 -t VAL):

      • 含义: 一个 0-255 之间的整数(或浮点数)。这是判断画面是否进入“淡出”或“淡入”状态的亮度基准。
      • 算法交互:method 参数配合使用。
      • 如何确定:
        1. 使用亮度分析工具 (如我们之前编写的 analyze_brightness.py):查看你的视频在发生淡入淡出到“黑屏”或“白屏”时,那些“黑/白”帧的实际平均亮度值。
        2. 如果使用 method = FLOOR (检测淡出到暗/从暗淡入):threshold 应略高于视频中“黑屏”状态的平均亮度。例如,如果黑屏亮度在 2-5 之间,你可以尝试 threshold = 5810。如果阈值设得太低(比如1),那么只有几乎纯黑的帧才会被识别。
        3. 如果使用 method = CEILING (检测淡出到亮/从亮淡入):threshold 应略低于视频中“白屏”状态的平均亮度。例如,如果白屏亮度在 250-253 之间,你可以尝试 threshold = 250245
        4. 实验法: 从一个相对宽松的值开始(例如,对于 FLOOR 方法,从 15-20 开始;对于 CEILING,从 230-240 开始),然后逐渐向更严格的值调整(FLOOR 向下调,CEILING 向上调),同时观察 save-images 的结果。
    • fade_bias (或 -f PERCENT):

      • 含义: 一个百分比值(命令行是 -100 到 100,Python 类内部是 -1.0 到 +1.0)。它调整检测到的淡入淡出切点的精确位置
      • 算法交互: 当检测器识别到一次亮度从阈值一侧跨越到另一侧(例如,从高于阈值变为低于阈值,再恢复到高于阈值,这构成一次淡出再淡入),fade_bias 决定切点放在这个过程的哪个阶段。
        • 命令行 -100 (Python -1.0): 切点尽可能靠近完全淡出到黑/白的时刻,或者刚开始从黑/白恢复的时刻(取决于具体实现和淡入/淡出方向)。
        • 命令行 0 (Python 0.0): 切点放在淡出和淡入过程的中间。
        • 命令行 +100 (Python +1.0): 切点尽可能靠近刚开始淡出或完全淡入完成的时刻。
      • 如何确定:
        1. 通常对于视频间的切换,我们希望在画面完全变黑/白之后,或者刚开始有新画面时进行切割。所以一个中等到较大的负偏置(例如命令行的 3070,对应 Python 的 (PERCENT/50.0) - 1.0)可能是合适的,这会使切点更倾向于“黑场”的边缘。
        2. 实验法: 设置一个基础的 threshold,然后尝试不同的 fade_bias 值(例如 -80, -50, 0, 50, 80),观察 save-images 中切点图片的位置,看哪个最符合你的期望。
    • method (Python 类参数,ThresholdDetector.Method.FLOORThresholdDetector.Method.CEILING):

      • 含义: 定义了如何将帧的平均亮度与 threshold 进行比较来触发事件。
        • FLOOR: 当帧亮度低于 (falls below) threshold 时,认为是淡出(到暗);当从低于状态恢复到高于等于 threshold 时,认为是淡入(从暗)。
        • CEILING: 当帧亮度高于 (rises above) threshold 时,认为是淡出(到亮);当从高于状态恢复到低于 threshold 时,认为是淡入(从亮)。
      • 如何确定:
        1. 如果你的视频主要是淡入淡出到黑色,或者从黑色淡入,选择 FLOOR
        2. 如果你的视频主要是淡入淡出到白色,或者从白色淡入,选择 CEILING (并配合非常高的 threshold)。
        3. 你的 config.inimethod = FLOOR (因为 FRAME_AVERAGE 会回退到 FLOOR),所以当前是针对暗场景的。
    • min_scene_len (或 -m TIMECODE,命令行是 -m 1 表示1帧):

      • 含义: 最小场景长度。
      • 如何确定: 为了分析检测器的原始性能和找到最佳的 threshold / fade_bias始终将其设置为 1 (或 1f,根据具体命令格式)。在确定了最佳检测参数后,如果需要避免过多短片段,再在最终处理时考虑增大这个值。

2. ContentDetector (内容检测器)

  • 命令: detect-content

  • 核心思想: 通过比较连续帧之间视觉内容的整体差异来检测场景切换。它不仅仅看亮度,还会考虑颜色分布(色调 Hue, 饱和度 Saturation)和可能的边缘信息。对于平均亮度变化不大但内容发生显著改变的切换(如交叉淡化、某些快速剪辑)通常更有效。

  • 主要参数及其含义:

    • threshold (或 -t VAL):

      • 含义: 一个浮点数,表示帧间内容差异的阈值。这个差异值是综合了色调、饱和度、亮度(以及可能有的边缘)通道的变化计算出来的。
      • 算法交互: 当计算出的帧间内容综合差异值超过此阈值时,触发切点。
      • 如何确定:
        1. ContentDetector 的阈值范围和敏感度与 ThresholdDetector 不同。默认值通常在 20-35 之间。
        2. 较低的阈值意味着更敏感。 对于平滑的交叉淡化或细微的内容变化,你可能需要将阈值降低到 15-25,甚至更低(5-15)。
        3. 实验法是关键:
          • 使用 threshold_explorer.py 工具(代码见后):这是最系统的方法。设定一个阈值范围(例如,从 5.0 到 30.0,步长 1.0 或 2.0),对抽样视频运行。
          • 观察图表: 查看“平均场景数 vs. 阈值”图。寻找曲线的“拐点”——即阈值再降低,场景数开始急剧增加(可能引入噪声);或者阈值再升高,场景数急剧减少(可能遗漏切换)的那个临界区域。
          • 结合 save-images 对于你在图表上找到的几个有希望的阈值点,使用 --save_images_for 选项(在 threshold_explorer.py 中)或直接用 scenedetect 命令行工具(配合 save-images)来实际查看这些阈值下的切点图片。人工判断这些切点是否符合你对交叉淡化或其他场景切换的定义。
        4. 从一个相对保守的值开始(例如 25-30),如果检测不到足够的场景,逐渐降低阈值,并观察结果。
    • min_scene_len (或 -m TIMECODE,命令行通常是 -m 1 表示1帧):

      • 含义: 最小场景长度。
      • 如何确定:ThresholdDetector,在参数探索阶段,始终将其设置为 1
    • weights (Hue, Saturation, Luma, Edges - Python 类参数):

      • 含义: 定义在计算帧间内容差异时,色调、饱和度、亮度、边缘这几个分量各自所占的权重。
      • 算法交互: 影响最终的综合差异值。
      • 如何确定:
        1. 通常从默认权重开始。 PySceneDetect 的默认权重是经过一定调优的。
        2. 如果默认权重效果不佳,并且你对视频内容的特性有了解:
          • 如果场景切换主要体现在颜色变化上(例如,从冷色调场景交叉淡化到暖色调场景),可以尝试增加 weights_hueweights_saturation 的权重,同时减少 weights_luma 的权重。
          • 如果场景切换主要体现在结构或物体轮廓变化上,确保边缘检测被启用并且 weights_edges 有合适的权重。
          • 这部分调整通常需要更多的实验和对视频内容的细致分析。你的 config.iniscene_detector.py 允许你设置这些权重。
    • luma_only (布尔值):

      • 含义: 如果为 true,则在计算内容差异时只考虑亮度通道,忽略色调和饱和度。
      • 算法交互: 相当于将 weights_hueweights_saturation 设为 0。
      • 如何确定:
        1. 对于颜色信息丰富且对场景区分很重要的视频(尤其是交叉淡化),通常应将此设为 false(默认)。
        2. 如果视频是黑白的,或者颜色信息干扰性强且不重要,可以设为 true

3. AdaptiveDetector (自适应检测器)

  • 命令: detect-adaptive

  • 核心思想:ContentDetector 类似,它也计算帧间内容的差异。但不同之处在于,它不是使用一个固定的全局阈值,而是根据最近一段视频窗口内的帧间差异动态地计算一个自适应阈值。当某一帧的差异显著高于这个动态计算出的局部平均差异时,触发切点。

  • 适用场景: 对于视频整体亮度或内容变化幅度不一致的情况可能有用。例如,一个视频可能有些部分变化剧烈,有些部分变化平缓。固定阈值可能难以同时适应这两种情况。

  • 主要参数及其含义:

    • adaptive_threshold (或 -t VAL):

      • 含义: 一个乘数因子(通常是较小的浮点数,例如 2.0-5.0,默认可能是 3.0)。
      • 算法交互: 检测器会计算一个基于滑动窗口的帧间差异的统计量(例如平均值或移动平均值)。如果当前帧的差异超过了这个统计量乘以 adaptive_threshold,则认为是一个切点。值越大,检测越不敏感(需要更大的相对变化)。
      • 如何确定:
        1. 实验法: 从默认值(例如 3.0)开始。
        2. 如果检测到的场景太少,尝试降低此值(例如 2.5, 2.0)。
        3. 如果检测到的场景太多(噪声),尝试增加此值(例如 3.5, 4.0)。
        4. 同样,结合 save-images 进行人工检查。
    • min_scene_len (或 -m TIMECODE):

      • 含义: 最小场景长度。
      • 如何确定: 同上,参数探索阶段设为 1
    • window_width (或 --window_width VAL, 帧数):

      • 含义: 用于计算自适应阈值的滑动窗口的宽度(以帧为单位)。如果为 0,检测器通常会根据视频帧率自动设置一个合理的窗口大小。
      • 算法交互: 窗口越大,自适应阈值对局部快速变化的敏感度越低,对较长时间的平均变化更敏感。窗口越小,对局部突变越敏感。
      • 如何确定:
        1. 通常可以从默认值 (0,即自动) 开始。
        2. 如果自动设置效果不佳,可以根据视频的平均镜头长度或变化节奏来手动设置。例如,如果快速剪辑多,可以尝试较小的窗口;如果长镜头多,可以尝试较大的窗口。
    • min_delta_hsv (浮点数 0-255):

      • 含义: 有些版本的 AdaptiveDetector 可能使用这个参数。它设定了一个帧间 HSV(色调、饱和度、亮度)差异的最小绝对值。即使相对差异(通过 adaptive_threshold 判断)很大,但如果绝对差异本身非常小(例如,在几乎全黑的场景中微小的噪声变化),也可能不被视为切点。这有助于过滤掉在非常低对比度区域的噪声。
      • 如何确定:
        1. 如果你的视频中有非常暗或对比度非常低的区域,并且 AdaptiveDetector 在这些区域产生了误报,可以尝试增加此值(例如从默认的 10-15 增加到 20-25)。

通用参数确定流程:

  1. 选择检测器类型:

    • 淡入淡出到黑/白: 优先尝试 ThresholdDetector
    • 交叉淡化、内容变化明显但亮度变化不大: 优先尝试 ContentDetector
    • 视频内容变化特征不一致,难以用固定阈值把握: 可以尝试 AdaptiveDetector
    • 不确定或想组合使用: 可以使用 MultiDetector,并分别配置上述检测器(在你的 config.ini 中将 [SceneDetectorSetup]detector_type 设为 MultiDetector,然后在各个检测器的配置节中设置 enabled = true 和相应的参数)。
  2. 设置 min_scene_len = 1 在寻找最佳检测参数的阶段,始终这样做。

  3. 选择一个(或几个)代表性的抽样视频: 这些视频应该包含你期望检测到的各种场景切换效果。

  4. 对于选定的检测器,系统地扫描其主要阈值参数:

    • ThresholdDetector: 扫描 thresholdfade_bias
    • ContentDetector: 主要扫描 threshold。可以后续再调整 weights
    • AdaptiveDetector: 主要扫描 adaptive_threshold。可以后续再调整 window_width
    • 使用你的 threshold_explorer.py 工具 (如果它是针对 ContentDetector 的,你需要为其他检测器做类似的参数扫描逻辑,或者修改它使其更通用)。
  5. 可视化和人工检查:

    • 查看场景数量随阈值变化的图表。
    • 对于几个有希望的阈值设置,使用 save-images 生成的图片进行人工检查,看切点是否准确、是否有漏检或误报。
  6. 迭代和微调: 根据可视化和人工检查的结果,调整参数范围,进行更细致的扫描,或者尝试调整次要参数(如权重、窗口宽度等)。

  7. 选择“最佳”参数: “最佳”通常是在“召回率”(找到所有想找的切换)和“精确率”(找到的切换都是正确的)之间的一个平衡。这取决于你的具体需求。可能没有一组参数对所有视频都完美,你可能需要根据视频类型选择不同的参数集,或者接受一定的折衷。

可视化确定内容检测阈值方法

内容检测器阈值参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
import csv
import cv2
import numpy as np
import argparse
import sys
import os
import random
import logging
from pathlib import Path
from typing import List, Tuple, Dict

from scenedetect import open_video, SceneManager, ContentDetector, FrameTimecode, VideoStreamCv2

try:
import matplotlib.pyplot as plt
import pandas as pd

MATPLOTLIB_AVAILABLE = True
except ImportError:
MATPLOTLIB_AVAILABLE = False
print("警告: Matplotlib 或 Pandas 未安装,将无法生成可视化图表。")
print("请运行: pip install matplotlib pandas")

# --- 日志设置 ---
logger = logging.getLogger("ThresholdExplorer")
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')


def get_video_files(input_path: Path, extensions: List[str], recursive: bool) -> List[Path]:
"""从指定路径收集视频文件列表。"""
video_files = []
logger.info(f"正在扫描目录: {input_path} (递归: {recursive}),寻找扩展名: {extensions}")
for ext in extensions:
pattern = f"**/*{ext}" if recursive else f"*{ext}"
for file_path in input_path.glob(pattern):
if file_path.is_file() and not file_path.name.startswith('._'):
video_files.append(file_path)
logger.info(f"在文件系统中找到 {len(video_files)} 个视频文件。")
return video_files


def get_sampled_videos(video_list: List[Path], sample_ratio: float = -1.0, num_samples: int = -1) -> List[Path]:
"""根据比例或数量对视频列表进行抽样。"""
if not video_list:
return []

if num_samples > 0:
if num_samples >= len(video_list):
return video_list
return random.sample(video_list, num_samples)
elif 0.0 < sample_ratio <= 1.0:
k = int(len(video_list) * sample_ratio)
if k == 0 and len(video_list) > 0: k = 1 # 至少抽一个样本
return random.sample(video_list, k)
else: # 默认不抽样,处理所有
return video_list


def run_detection_for_threshold(
video_path: Path,
threshold_value: float,
min_len: int = 1, # 保持为1以获得原始切点
save_images_output_dir: Path = None,
save_images_for_this_run: bool = False # 是否为本次特定的阈值运行保存图像
) -> int:
"""对单个视频和单个阈值运行 ContentDetector。"""
scene_manager = SceneManager()
# 注意:ContentDetector 的其他参数(如 weights, luma_only)这里使用默认值。
# 如果需要,也可以将它们作为参数传入。
detector = ContentDetector(threshold=threshold_value, min_scene_len=min_len)
scene_manager.add_detector(detector)

video = None
num_scenes = 0
try:
video = open_video(str(video_path))
# video.set_downscale_factor(1) # 可以考虑降采样以加速,但可能影响检测精度

# 获取有限的帧数进行分析,而不是整个视频,以加快速度
# 这里我们仍然处理整个(抽样的)视频,因为交叉淡化可能在任何地方
# 如果视频非常长,可以考虑只分析视频的一部分,但这会使抽样更复杂
scene_manager.detect_scenes(video=video, show_progress=False)
scene_list = scene_manager.get_scene_list()
num_scenes = len(scene_list)

if save_images_for_this_run and save_images_output_dir and scene_list:
from scenedetect.video_splitter import save_images as save_images_func
video_images_dir = save_images_output_dir / f"{video_path.stem}_thresh_{threshold_value:.1f}"
video_images_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"为视频 {video_path.name} (阈值 {threshold_value:.1f}) 保存场景图片到 {video_images_dir}")
save_images_func(
scene_list=scene_list,
video_manager=video, # open_video 返回的是 VideoManager 或其后端实例
num_images=2, # 每个切点前后各一张
output_dir=str(video_images_dir),
image_name_template='$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER'
)

except Exception as e:
logger.error(f"处理视频 {video_path.name} (阈值 {threshold_value}) 时出错: {e}")
num_scenes = -1 # 表示错误
finally:
if video:
# 尝试通用的 release 方法 (适用于 VideoManager)
if hasattr(video, 'release') and callable(getattr(video, 'release')):
try:
video.release()
logger.debug(f"Released video object for {video_path.name} via .release()")
except Exception as e_rel:
logger.warning(f"Error calling .release() on video object for {video_path.name}: {e_rel}")
# 针对 VideoStreamCv2 (以及继承它的 VideoStreamCv2Cuda) 的特殊处理
elif isinstance(video, VideoStreamCv2): # VideoStreamCv2 是从 scenedetect.backends.opencv 导入的
if hasattr(video, '_cap') and video._cap is not None:
try:
if video._cap.isOpened():
video._cap.release()
logger.debug(f"Released VideoStreamCv2._cap for {video_path.name}")
except Exception as e_cap_rel:
logger.warning(f"Error releasing VideoStreamCv2._cap for {video_path.name}: {e_cap_rel}")
else:
logger.warning(
f"Video object for {video_path.name} (type: {type(video).__name__}) "
"does not have a public .release() method and is not a known direct backend "
"with a specific release pattern. Relying on __del__ for cleanup."
)
return num_scenes


def explore_thresholds(
video_paths: List[Path],
threshold_range: Tuple[float, float, float], # (start, end, step)
sample_ratio: float = -1.0,
num_sample_videos: int = -1,
min_scene_len: int = 1,
output_dir_base: Path = Path("threshold_exploration_output"),
save_images_for_thresholds: List[float] = None # 指定哪些阈值需要保存图像
):
"""
对一批视频,在一系列阈值下运行 ContentDetector,并记录结果。
"""
if not video_paths:
logger.warning("没有提供视频文件进行分析。")
return None

sampled_videos = get_sampled_videos(video_paths, sample_ratio, num_sample_videos)
if not sampled_videos:
logger.warning("抽样后没有视频可供分析。")
return None

logger.info(f"将对以下 {len(sampled_videos)} 个抽样视频进行分析:")
for vid_path in sampled_videos:
logger.info(f" - {vid_path.name}")

start_thresh, end_thresh, step_thresh = threshold_range
thresholds_to_test = np.arange(start_thresh, end_thresh + step_thresh / 2, step_thresh) # +step/2 确保包含 end_thresh

logger.info(f"将测试以下阈值: {thresholds_to_test}")

# 创建主输出目录
output_dir_base.mkdir(parents=True, exist_ok=True)
images_base_dir = output_dir_base / "saved_scene_images"
if save_images_for_thresholds:
images_base_dir.mkdir(parents=True, exist_ok=True)

results = [] # 存储 (video_name, threshold, num_scenes)

for video_path in sampled_videos:
logger.info(f"--- 开始处理视频: {video_path.name} ---")
for threshold in thresholds_to_test:
logger.info(f" 测试阈值: {threshold:.2f} for {video_path.name}")

save_images_this_run = False
if save_images_for_thresholds and any(
abs(threshold - t_save) < 1e-5 for t_save in save_images_for_thresholds):
save_images_this_run = True

num_scenes = run_detection_for_threshold(
video_path,
threshold,
min_len=min_scene_len,
save_images_output_dir=images_base_dir if save_images_this_run else None,
save_images_for_this_run=save_images_this_run
)
if num_scenes != -1: # 如果没有发生错误
results.append({
"video_name": video_path.name,
"threshold": threshold,
"num_scenes": num_scenes
})
else:
logger.warning(f"跳过记录视频 {video_path.name} 在阈值 {threshold:.2f} 的结果,因为检测出错。")

if not results:
logger.warning("没有收集到任何有效的检测结果。")
return None

results_df = pd.DataFrame(results) if MATPLOTLIB_AVAILABLE and pd else None

# 保存原始数据到 CSV
csv_output_path = output_dir_base / "threshold_scan_results.csv"
if results_df is not None:
try:
results_df.to_csv(csv_output_path, index=False)
logger.info(f"详细扫描结果已保存到: {csv_output_path}")
except Exception as e:
logger.error(f"无法保存扫描结果到 CSV: {e}")
else: # 如果 pandas 不可用,尝试手动保存
try:
with open(csv_output_path, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(['video_name', 'threshold', 'num_scenes'])
for res_dict in results:
writer.writerow([res_dict['video_name'], res_dict['threshold'], res_dict['num_scenes']])
logger.info(f"详细扫描结果已保存到: {csv_output_path} (手动保存)")
except Exception as e:
logger.error(f"无法手动保存扫描结果到 CSV: {e}")

# 可视化
if MATPLOTLIB_AVAILABLE and results_df is not None:
try:
# 图1: 每个视频的场景数 vs 阈值
plt.figure(figsize=(12, 7))
for video_name, group in results_df.groupby("video_name"):
plt.plot(group["threshold"], group["num_scenes"], marker='o', linestyle='-', label=video_name)
plt.xlabel("ContentDetector Threshold")
plt.ylabel("Number of Detected Scenes")
plt.title("Number of Scenes vs. Threshold (Per Video)")
plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left', borderaxespad=0.)
plt.grid(True)
plt.tight_layout(rect=[0, 0, 0.85, 1]) # 给 legend 留空间
plot_path1 = output_dir_base / "scenes_vs_threshold_per_video.png"
plt.savefig(plot_path1)
logger.info(f"每个视频的场景数-阈值图已保存到: {plot_path1}")
plt.close()

# 图2: 平均场景数 vs 阈值
avg_scenes_df = results_df.groupby("threshold")["num_scenes"].mean().reset_index()
plt.figure(figsize=(10, 6))
plt.plot(avg_scenes_df["threshold"], avg_scenes_df["num_scenes"], marker='o', linestyle='-')
plt.xlabel("ContentDetector Threshold")
plt.ylabel("Average Number of Detected Scenes")
plt.title("Average Number of Scenes vs. Threshold (Across Sampled Videos)")
plt.grid(True)
plot_path2 = output_dir_base / "avg_scenes_vs_threshold.png"
plt.savefig(plot_path2)
logger.info(f"平均场景数-阈值图已保存到: {plot_path2}")
plt.close()

except Exception as e:
logger.error(f"生成图表时出错: {e}")

# 提出建议(基于启发式,用户仍需判断)
logger.info("--- 阈值选择建议 ---")
if results_df is not None:
# 启发式:寻找场景数开始急剧下降然后趋于平缓的“拐点”
# 或者场景数在一个小范围内波动但相对稳定的区域
avg_scenes_series = results_df.groupby("threshold")["num_scenes"].mean()
if not avg_scenes_series.empty:
logger.info("请查看 'avg_scenes_vs_threshold.png' 图表。")
logger.info("理想的阈值通常位于以下区域:")
logger.info(" - 场景数量开始显著减少之前的点(如果你想捕捉更多细节)。")
logger.info(" - 场景数量趋于稳定,不再随阈值增加而大幅减少的点(如果你想更鲁棒,减少误报)。")
logger.info(" - 避免阈值过低导致场景数过多(可能是噪声或微小变化)。")
logger.info(" - 避免阈值过高导致场景数过少(可能遗漏真实切换)。")

# 尝试找到一个“拐点”的简单启发式
# 计算二阶差分,寻找变化最大的地方,但这可能不稳定
try:
diff1 = avg_scenes_series.diff().fillna(0)
diff2 = diff1.diff().fillna(0)
# 寻找 diff2 绝对值最大的几个点,或者第一个显著的正值(表示下降趋势减缓)
# 这是一个非常简化的方法,实际效果可能有限
potential_elbow_threshold = diff2.abs().nlargest(3).index.tolist()
if potential_elbow_threshold:
logger.info(
f" 根据简单的变化率分析,可以关注以下阈值附近的表现:{sorted(potential_elbow_threshold)}")
except Exception:
pass # 启发式失败,不影响主要功能

logger.info("结合 'saved_scene_images' (如果生成了) 中的实际场景图片进行人工判断。")
else:
logger.warning("未能计算平均场景数,无法提供基于图表的建议。")

logger.info(f"请检查输出目录 '{output_dir_base}' 中的结果。")
return results_df


if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="为一批视频探索 ContentDetector 的最佳阈值。",
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument("input_source", type=str,
help="包含视频文件的目录路径,或单个视频文件路径。")
parser.add_argument("-e", "--extensions", type=str, default=".mp4,.mkv,.mov,.avi",
help="逗号分隔的视频文件扩展名 (例如: .mp4,.mov)。")
parser.add_argument("-r", "--recursive", action="store_true",
help="如果 input_source 是目录,则递归扫描子目录。")

# 抽样参数
sample_group = parser.add_mutually_exclusive_group()
sample_group.add_argument("--sample_ratio", type=float, default=-1.0,
help="要处理的视频占总数的比例 (0.0 到 1.0)。例如 0.1 表示 10%%。默认为-1.0 (处理所有)。")
sample_group.add_argument("--num_sample_videos", type=int, default=-1,
help="要随机抽取的视频数量。默认为-1 (处理所有或按比例)。如果指定,则优先于 sample_ratio。")

# ContentDetector 参数
parser.add_argument("--threshold_start", type=float, default=15.0, help="测试的 ContentDetector 阈值起始值。")
parser.add_argument("--threshold_end", type=float, default=35.0, help="测试的 ContentDetector 阈值结束值。")
parser.add_argument("--threshold_step", type=float, default=1.0, help="测试的 ContentDetector 阈值步长。")
parser.add_argument("--min_len", type=int, default=1,
help="传递给 ContentDetector 的 min_scene_len 参数 (帧数)。建议保持为1以分析原始切点。")

# 输出和可视化
parser.add_argument("-o", "--output_dir", type=str, default="threshold_exploration_output",
help="保存结果 (CSV, 图表, 图片) 的主输出目录。")
parser.add_argument("--save_images_for", type=str, default=None,
help="逗号分隔的阈值列表,将为这些阈值下的检测结果保存场景图片。例如 '15.0,20.0,25.0'。")

if len(sys.argv) == 1:
parser.print_help(sys.stderr)
sys.exit(1)
args = parser.parse_args()

input_path_obj = Path(args.input_source)
video_files_to_scan = []

if input_path_obj.is_dir():
ext_list = [e.strip().lower() for e in args.extensions.split(',') if e.strip()]
video_files_to_scan = get_video_files(input_path_obj, ext_list, args.recursive)
elif input_path_obj.is_file():
video_files_to_scan.append(input_path_obj)
else:
logger.error(f"输入路径 '{args.input_source}' 不是有效的文件或目录。")
sys.exit(1)

if not video_files_to_scan:
logger.info("在指定路径下没有找到符合条件的视频文件。")
sys.exit(0)

threshold_r = (args.threshold_start, args.threshold_end, args.threshold_step)
output_dir_base_path = Path(args.output_dir)

save_thresholds_for_images = None
if args.save_images_for:
try:
save_thresholds_for_images = [float(t.strip()) for t in args.save_images_for.split(',')]
except ValueError:
logger.error("`--save_images_for` 参数中的阈值列表格式错误。应为逗号分隔的数字。")
sys.exit(1)

explore_thresholds(
video_files_to_scan,
threshold_r,
sample_ratio=args.sample_ratio,
num_sample_videos=args.num_sample_videos,
min_scene_len=args.min_len,
output_dir_base=output_dir_base_path,
save_images_for_thresholds=save_thresholds_for_images
)

logger.info("阈值探索完成。")

亮度参数选定方法参考代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
import cv2
import numpy as np
import argparse
import sys
from pathlib import Path


def calculate_average_brightness(frame_bgr):
"""
计算 BGR 帧的平均亮度。
一种常见的方法是先转换为灰度图,然后计算平均像素值。
另一种是直接计算 BGR 各通道的平均值,然后再取平均,或者使用加权平均(如亮度公式 Y = 0.299R + 0.587G + 0.114B)。
这里我们使用转换为灰度图的方法,因为它直接反映了人眼感知的亮度。
"""
gray_frame = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)
average_brightness = np.mean(gray_frame)
return average_brightness


def analyze_video_brightness(video_path: str, output_csv_path: str = None, frame_skip: int = 0, max_frames: int = None):
"""
逐帧分析视频的平均亮度。

参数:
video_path (str): 视频文件路径。
output_csv_path (str, optional): CSV文件输出路径。如果提供,则将结果保存到此文件。
frame_skip (int, optional): 每隔多少帧分析一次(0 表示逐帧分析)。
max_frames (int, optional): 最多分析多少帧。如果为 None,则分析整个视频。
"""
if not Path(video_path).exists():
print(f"错误: 视频文件未找到: {video_path}")
return

cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
print(f"错误: 无法打开视频文件: {video_path}")
return

total_frames_video = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
fps = cap.get(cv2.CAP_PROP_FPS)
print(f"视频信息: {Path(video_path).name}")
print(f" 总帧数: {total_frames_video}")
print(f" FPS: {fps:.2f}")
print("-" * 30)
print("帧号\t平均亮度")
print("-" * 30)

csv_data = []
frame_count = 0
processed_frame_num = 0 # 实际处理的帧的计数器,用于 max_frames

while True:
ret, frame = cap.read()
if not ret:
break # 视频结束或读取错误

current_frame_num_video = int(
cap.get(cv2.CAP_PROP_POS_FRAMES)) # 获取当前帧在视频中的实际编号 (1-based for some backends, 0-based for others)
# 或者直接用 frame_count 作为0-based的帧号

if frame_count % (frame_skip + 1) == 0: # frame_skip=0 表示每帧都处理
avg_brightness = calculate_average_brightness(frame)
print(f"{frame_count}\t{avg_brightness:.2f}")
if output_csv_path:
csv_data.append([frame_count, avg_brightness])

processed_frame_num += 1
if max_frames is not None and processed_frame_num >= max_frames:
print(f"\n已达到最大分析帧数限制: {max_frames} 帧。")
break

frame_count += 1

cap.release()
print("-" * 30)
print("分析完成。")

if output_csv_path and csv_data:
try:
output_file = Path(output_csv_path)
output_file.parent.mkdir(parents=True, exist_ok=True) # 创建输出目录
with open(output_file, 'w', newline='') as f:
import csv
writer = csv.writer(f)
writer.writerow(['FrameNumber', 'AverageBrightness']) # 写入表头
writer.writerows(csv_data)
print(f"亮度数据已保存到: {output_file}")
except Exception as e:
print(f"错误: 无法保存 CSV 文件到 {output_csv_path}: {e}")


if __name__ == "__main__":
parser = argparse.ArgumentParser(description="分析视频每帧的平均亮度。")
parser.add_argument("video_path", type=str, help="要分析的视频文件路径。")
parser.add_argument("-o", "--output_csv", type=str, default=None,
help="可选:将结果保存到的 CSV 文件路径。例如:brightness_data.csv")
parser.add_argument("-s", "--frame_skip", type=int, default=0,
help="可选:每隔多少帧分析一次(0 表示逐帧分析,1 表示隔1帧分析1帧,即处理第0,2,4...帧)。默认为0。")
parser.add_argument("-m", "--max_frames", type=int, default=None,
help="可选:最多分析的帧数。用于快速预览长视频。默认为分析整个视频。")

if len(sys.argv) == 1: # 如果没有参数,打印帮助信息并退出
parser.print_help(sys.stderr)
sys.exit(1)

args = parser.parse_args()

analyze_video_brightness(args.video_path, args.output_csv, args.frame_skip, args.max_frames)

阈值选择方法

查看 avg_scenes_vs_threshold.png 图。通常,你会寻找一个点,在该点之后,进一步降低阈值会导致场景数量急剧增加(可能引入过多噪声或非期望的切点),或者在该点之前,提高阈值会导致场景数量急剧减少(可能遗漏真实切点)。一个“平稳”的区域或者一个明显的“拐点”可能是好的候选范围。
对于你在图表上选出的几个候选阈值

  • 可视化阈值选择工具使用方法
1
2
3
4
5
6
7
8
9
10
python threshold_explorer.py "/Volumes/shared/czq/video/scene-detect/淡入淡出-result/无转场-result/无转场/无转场/无转场/无转场/无转场/无转场/无转场/无转场/无转场" \
-e ".mp4,.mkv" \
-r \
--sample_ratio 0.2 \
--threshold_start 1.0 \
--threshold_end 30.0 \
--threshold_step 1.0 \
--min_len 1 \
--output_dir "./content_detector_exploration" \
--save_images_for "8.0,12.0,15.0"
  • 亮度检测工具使用方法
1
python analyze_brightness.py '/Volumes/shared/czq/video/scene-detect/淡入淡出-result/无转场-result/无转场/无转场/无转场/无转场/无转场/无转场/无转场/无转场/无转场/3 (15).mp4'

结果显示

如图

  • 平均阈值

img

  • 检测视频所有阈值

img

1
2
3
4
5
6
7
8
9
10
11
2025-05-14 19:17:47,588 - INFO - --- 阈值选择建议 ---
2025-05-14 19:17:47,589 - INFO - 请查看 'avg_scenes_vs_threshold.png' 图表。
2025-05-14 19:17:47,589 - INFO - 理想的阈值通常位于以下区域:
2025-05-14 19:17:47,589 - INFO - - 场景数量开始显著减少之前的点(如果你想捕捉更多细节)。
2025-05-14 19:17:47,589 - INFO - - 场景数量趋于稳定,不再随阈值增加而大幅减少的点(如果你想更鲁棒,减少误报)。
2025-05-14 19:17:47,589 - INFO - - 避免阈值过低导致场景数过多(可能是噪声或微小变化)。
2025-05-14 19:17:47,589 - INFO - - 避免阈值过高导致场景数过少(可能遗漏真实切换)。
2025-05-14 19:17:47,590 - INFO - 根据简单的变化率分析,可以关注以下阈值附近的表现:[2.0, 3.0, 9.0]
2025-05-14 19:17:47,590 - INFO - 结合 'saved_scene_images' (如果生成了) 中的实际场景图片进行人工判断。
2025-05-14 19:17:47,590 - INFO - 请检查输出目录 'content_detector_exploration' 中的结果。
2025-05-14 19:17:47,590 - INFO - 阈值探索完成。

参考

Prev:
场景检测性能优化方向与测试方法
Next:
基于yolov8根据CVAT标注结果训练模型