
Video Screenshot
- 32 installs
- 543 repo stars
- Updated August 5, 2026
- cat-xierluo/legal-skills
Extracts and de-duplicates key frames from screen-recording videos and saves them as images usable as legal evidence.
About
A standalone Python CLI that extracts, de-duplicates, and saves key frames from screen recordings like chat and meeting captures as images. Developers use it to turn long recordings into printable, submittable image evidence using scene-change, keyframe, interval, and smart-dedup strategies.
- Four extraction strategies with dHash/SSIM dedup
- Optional model-based review of discarded frames
Video Screenshot by the numbers
- 32 all-time installs (skills.sh)
- Ranked #1,205 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cat-xierluo/legal-skills --skill video-screenshotAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 32 |
|---|---|
| repo stars | ★ 543 |
| Last updated | August 5, 2026 |
| Repository | cat-xierluo/legal-skills ↗ |
What it does
Extracts and de-duplicates key frames from screen-recording videos and saves them as images usable as legal evidence.
Files
video-screenshot — 视频截图提取工具
从录屏视频(微信聊天录屏、会议录屏等)中自动抽取关键帧、去重并保存为可用作法律证据的图片文件。独立 Python CLI,无 Django 依赖。
适用场景
- 微信聊天录屏需要提取为逐页截图
- 会议录屏需要提取关键画面作为证据
- 长时间录屏需要去除重复帧,只保留有信息量的画面
- 需要将视频内容转换为可打印、可提交的图片证据
默认工作流
1. 确认输入
确认用户提供的视频文件路径。支持常见视频格式:.mp4 .mov .avi .mkv .webm .flv .wmv .ts
2. 确认参数
默认配置(大多数场景无需调整):
| 参数 | 默认值 | 说明 |
|---|---|---|
| 抽帧策略 | scene | 场景变化检测 |
| 场景阈值 | 0.10 | 变化幅度阈值(越小越敏感) |
| 定期采样间隔 | 2.0s | 静态画面保底采样(0=禁用) |
| 内容区裁剪 | 上 12% / 下 12% / 左右 4% | 排除状态栏、导航栏和边缘黑边后再比较 |
| dHash 去重阈值 | 4 | 对内容区计算汉明距离(0=禁用) |
| SSIM 阈值 | 0.93 | 结构相似度补充去重(0=禁用) |
| 滚动帧合并 | 关闭 | 需显式 --scroll-merge 开启 |
| OCR 去重 | 关闭 | 需显式开启 |
| 最小时间间隔 | 0.5s | 抑制同一时间段内过密保留帧(0=禁用) |
| 复核候选帧 | 关闭 | 需显式 --keep-drop-candidates 开启 |
| 最长边像素 | 0 | 保持原始分辨率(可设如 1920 限制尺寸) |
| JPEG 质量 | 2 | 最高质量(范围 1-31,越小越清晰) |
详细参数说明见 references/strategy-and-params.md,安装指南见 references/setup.md。
3. 执行抽帧
# 默认:场景检测 + 图像去重
uv run scripts/extract.py -i <视频文件路径>
# 场景检测 + OCR 去重(推荐用于聊天录屏)
uv run scripts/extract.py -i <视频文件路径> --ocr-dedup
# 复合复核模式:保留被算法丢弃的候选帧,供多模态模型回查
uv run scripts/extract.py -i <视频文件路径> --ocr-dedup --keep-drop-candidates
# 固定间隔,每 0.5 秒一帧
uv run scripts/extract.py -i <视频文件路径> -s interval --interval 0.5
# 关键帧提取,不去重
uv run scripts/extract.py -i <视频文件路径> -s keyframe -d 0
# 自定义输出目录
uv run scripts/extract.py -i <视频文件路径> -o /evidence/case_001/
# 更严格的场景检测(更多帧)
uv run scripts/extract.py -i <视频文件路径> --scene-threshold 0.15
# 禁用滚动帧合并(需要逐步滚动全过程时)
uv run scripts/extract.py -i <视频文件路径> --no-scroll-merge4. 输出说明
输出目录包含:
| 文件 | 说明 |
|---|---|
frame_001_00m00s.jpg | 保留帧(序号 + 时间戳命名) |
frame_002_00m03s.jpg | 下一帧 |
_report.json | 元数据报告(输入信息、去重统计、每帧 SHA256) |
_review_candidates/ | 仅在 --keep-drop-candidates 开启时生成,保存被算法丢弃但可复核的候选帧 |
_report.json 可用于证据链追溯,记录了每帧的 SHA256 哈希、捕获时间戳和去重统计;复合模式下还会记录 review.drop_candidates,列明候选帧文件名、丢弃原因和时间戳。
归档目录中的 frames/ 只保留 _report.json 清单内的本次有效帧。每次运行前会清理输出目录中旧的 frame_*.jpg 和本工具报告文件,避免旧帧混入新结果;不会删除其他用户文件。
5. 复合复核模式
当用户担心算法漏掉关键截图,或反馈同一秒内截图过多时,优先使用复合复核模式:
1. 运行 uv run scripts/extract.py -i <视频文件路径> --ocr-dedup --keep-drop-candidates。 2. 如果当前模型或可用工具支持图像输入,检查 _report.json 中 review.drop_candidates 记录的候选帧,重点看 min_gap、quality_*、duplicate_ssim、duplicate_scroll、ocr_duplicate 等原因对应的图片。 3. 视觉复核只做保守补回:候选帧包含新的法律相关内容、金额、身份信息、承诺、关键对话或比已保留帧更清晰时,才建议补回;不要仅因画面略有差异删除已保留帧。 4. 如果当前模型是纯文字模型,跳过视觉复核,并明确说明 _review_candidates/ 已生成但未做图像判断。
抽帧策略
| 策略 | 说明 | 推荐场景 |
|---|---|---|
scene | 场景变化检测,画面有显著变化时提取 | 聊天录屏、操作录屏(默认推荐) |
keyframe | 仅提取视频关键帧(I 帧) | 压缩视频、快速浏览 |
interval | 固定时间间隔提取 | 需要均匀时间采样 |
smart | ffmpeg 智能去重 | 不确定时尝试 |
去重与过滤机制
八级级联去重 + 可选过滤,每一级通过后才进入下一级:
1. SHA256 精确去重 — 完全相同的帧直接跳过 2. 内容区 dHash 感知哈希 — 排除顶部状态栏、底部导航栏和边缘黑边后比较结构 3. 内容区像素差异 — 48×48 灰度缩略图的平均绝对差值 4. SSIM 结构相似度 — 对内容区缩略图计算结构相似度,补充 dHash 漏检 5. 滚动帧合并(需显式开启)— 检测连续帧纵向位移后的重叠区域,只保留代表性画面 6. 内容质量过滤(默认开启)— 自动过滤空白页、启动/控制画面、页面切换过渡帧 7. 模糊帧过滤 — Laplacian 方差低于阈值的帧视为模糊跳过,需 --filter-blur 开启 8. OCR 文本相似度 — 比较最近 4 帧的 OCR 文本(SequenceMatcher + Jaccard),需 --ocr-dedup 开启 9. 复核候选帧保存 — --keep-drop-candidates 保存被前述规则丢弃的候选帧和原因,供多模态复核
依赖
| 依赖 | 版本要求 | 安装方式 |
|---|---|---|
ffmpeg | ≥ 5.0 | brew install ffmpeg |
Python | ≥ 3.10 | 系统自带或 brew install python |
uv | 最新 | brew install uv |
Pillow | ≥ 10.0 | 自动安装(PEP 723 内联依赖) |
rapidocr-onnxruntime | ≥ 1.0 | pip install rapidocr-onnxruntime(仅 OCR 去重需要) |
与其他技能配合
pdf:输出帧可组装为 PDF 证据包paddle-ocr:需要更高质量的 OCR 内容识别时,用输出帧作为输入legal-text-format:帧内容 OCR 后格式化video-compressor:抽帧前先压缩视频,减小 I/O 时间
硬约束
- 不修改原视频文件
- 输出图片使用 JPEG 格式,最长边不超过
--max-size参数 _report.json始终生成,确保证据可追溯
Changelog
[0.3.2] - 2026-06-02
新增
- 新增复合复核候选帧模式:
--keep-drop-candidates会保存被去重或过滤规则丢弃的候选帧,供多模态模型回查漏帧风险 - 新增
--drop-candidate-limit参数,限制复核候选帧保存数量,默认最多保存 200 张
改进
_report.json新增review.drop_candidates、review.drop_candidate_count和视觉复核状态字段,记录候选帧文件名、丢弃原因、时间戳和 SHA256- 归档结果新增
_review_candidates/,与正式frames/分离,避免复核候选帧污染证据清单
文档完善
- 补充复合复核模式说明:当前模型支持图像输入时才执行视觉复核;文字模型跳过复核并明确说明未做图像判断
- 校正参数文档中的场景阈值、dHash、SSIM、滚动合并和最小时间间隔默认值
[0.3.1] - 2026-05-20
修复
- 修复 archive 污染问题:归档时只复制
_report.json中记录的有效帧,不再把输出目录内所有 JPG 残留一并复制 - 修复输出目录残留问题:每次运行前自动清理旧的
frame_*.jpg、_report.json和工具元数据文件,避免旧帧混入新结果
技术优化
- 新增 archive 一致性校验,确保
archive/frames/文件名与_report.json的帧清单完全一致 - 报告和归档元数据新增
cleanup.stale_deleted_count、cleanup.stale_deleted_files;归档元数据新增archive_validation信息
[0.3.0] - 2026-05-20
改进
- 新增内容区聚焦去重:dHash、像素差异、SSIM 和滚动合并默认排除顶部状态栏、底部导航栏及左右边缘
- 新增 SSIM 结构相似度去重:
--ssim-threshold默认 0.70,作为 dHash 的补充判断 - 新增滚动帧合并:
--scroll-merge默认开启,支持--no-scroll-merge和--scroll-diff-threshold调参 - 修正 scene/keyframe/smart 模式下基于
-frame_pts的捕获时间戳计算,优先使用视频帧率避免时间戳被输入 time_base 缩小
文档完善
- 更新
SKILL.md和references/strategy-and-params.md,补充内容区裁剪、SSIM、滚动合并的参数说明和调参建议
0.2.0 (2026-05-20)
- 新增模糊帧过滤:
--filter-blur可选参数,基于 Laplacian 方差检测模糊帧(默认阈值 50.0) - 新增内容质量过滤:
--filter-quality可选参数,自动过滤空白页、启动/控制画面、页面切换过渡帧 - 去重流程扩展为六级:SHA256 → dHash → 像素差异 → 质量过滤 → 模糊过滤 → OCR 文本
- 归档元数据新增
filter_blur、blur_threshold、filter_quality参数和blur_drops、quality_drops统计 - 参考:移植自 fachuan(法穿)项目,质量检测算法为新增实现
0.1.0 (2026-05-20)
- 初始版本,核心算法移植自 fachuan(法穿)项目
chat_records/services/模块 - 四种抽帧策略:scene(场景检测)、keyframe(关键帧)、interval(固定间隔)、smart(智能去重)
- 四级去重:SHA256 → dHash → 像素差异 → OCR 文本相似度
- 独立 Python CLI,无 Django 依赖
- 本地 RapidOCR 离线 OCR 去重
- 默认保持原始分辨率、最高 JPEG 质量,优先保证证据清晰度
- archive 归档机制:每次分析自动留存参数、报告和帧副本,便于溯源调参
# OCR 提供者(local = RapidOCR 离线,目前仅支持 local)
OCR_PROVIDER=local
# RapidOCR 为本地 OCR,无需 API 配置
# 安装: pip install rapidocr-onnxruntime
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
安装与依赖
系统依赖
ffmpeg(必需)
视频帧提取的核心工具,必须安装。
# macOS
brew install ffmpeg
# Ubuntu/Debian
sudo apt install ffmpeg
# 验证安装
ffmpeg -version
ffprobe -version要求版本 ≥ 5.0,推荐 ≥ 7.0。本 Skill 使用 ffmpeg 的 -progress pipe:1、select 场景检测滤镜和 mpdecimate 去重滤镜。
Python(必需)
要求 Python ≥ 3.10。macOS 系统自带或通过 brew 安装:
brew install pythonuv(必需)
用于运行 PEP 723 内联依赖的 Python 脚本。
brew install uvPython 依赖
Pillow(自动安装)
图像处理核心库(dHash 计算、缩略图生成、OCR 预处理)。通过 extract.py 的 PEP 723 内联依赖声明,uv run 时自动安装,无需手动操作。
rapidocr-onnxruntime(可选,OCR 去重需要)
本地离线 OCR 引擎,用于基于文本相似度的帧去重。
# pip 安装
pip install rapidocr-onnxruntime
# 或 uv 安装
uv pip install rapidocr-onnxruntime如果未安装,--ocr-dedup 参数会自动降级为跳过 OCR 去重,不影响其他功能。
首次使用检查清单
# 1. 检查 ffmpeg
ffmpeg -version
# 2. 检查 Python 版本
python3 --version
# 3. 检查 uv
uv --version
# 4. 运行(Pillow 自动安装)
uv run scripts/extract.py -i <视频文件路径>
# 5. 如需 OCR 去重,安装 RapidOCR
pip install rapidocr-onnxruntime
uv run scripts/extract.py -i <视频文件路径> --ocr-dedup策略与参数详解
抽帧策略
scene(场景检测,默认)
使用 ffmpeg 的 select='gt(scene,<threshold>)' 滤镜。当连续帧之间的画面差异超过阈值时,提取该帧。配合 mpdecimate 去除接近重复的帧。
适用:聊天录屏(页面滚动、消息变化时自动捕获)、操作录屏。
参数:
--scene-threshold 0.10(默认):较敏感,适合变化缓慢的录屏--scene-threshold 0.15:稍严格,减少轻微变化带来的候选帧--scene-threshold 0.40:更严格,只提取大幅变化
keyframe(关键帧)
使用 -skip_frame nokey 仅解码视频的关键帧(I 帧)。速度最快,提取帧数最少。
适用:快速浏览视频内容、压缩视频。
interval(固定间隔)
使用 fps=N 滤镜,按固定时间间隔提取帧。--interval 1.0 表示每秒一帧。
适用:需要均匀时间采样的场景。
smart(智能去重)
使用 ffmpeg 的 mpdecimate 滤镜自动去除连续重复帧。介于 scene 和 interval 之间。
去重参数
内容质量过滤 (--filter-quality)
检测并过滤无信息量的帧,包括:
- 空白页:内容区域标准差接近 0,或大面积纯白/纯黑
- 启动/控制画面:录屏开始/结束时的控制面板、系统界面(低信息密度)
- 过渡帧:页面切换时上下半屏内容不一致(部分区域空白,部分有内容)
基于 3×3 网格分析帧的内容分布:计算每个网格区域的标准差,检测内容分布是否均匀。
--filter-quality:启用内容质量过滤(默认开启)--no-filter-quality:禁用内容质量过滤
模糊帧过滤 (--filter-blur)
基于 Laplacian 方差的模糊检测,识别页面滚动、手指触碰等导致的半模糊帧。使用 Pillow 实现的 3×3 Laplacian 卷积核,无需 OpenCV。
--filter-blur:启用模糊帧过滤(默认关闭)--blur-threshold 50.0(默认):Laplacian 方差低于此值的帧视为模糊
阈值参考:
- 清晰文字截图:通常 > 200
- 轻微模糊(手指触碰瞬间):50-150
- 明显模糊(页面快速滚动中):< 30
- 默认 50.0 只过滤明确模糊的帧,避免误杀
内容区裁剪参数
默认所有图像相似度比较都会先裁剪内容区,排除顶部状态栏、底部导航栏和左右边缘黑边:
--content-crop-top 0.12:裁掉顶部 12%--content-crop-bottom 0.12:裁掉底部 12%--content-crop-left 0.04:裁掉左侧 4%--content-crop-right 0.04:裁掉右侧 4%
如果录屏本身没有状态栏或导航栏,可把对应比例调低到 0;如果是手机聊天录屏且底部输入区固定不变,可适当提高 --content-crop-bottom。
dHash 阈值 (-d / --dedup-threshold)
dHash(差异哈希)将内容区缩至 9×8 灰度,比较相邻像素生成 64 位哈希。两帧的汉明距离(不同位数)小于阈值则视为重复。
0:禁用 dHash 去重4(默认):严格,仅非常相似的帧才被去除8:平衡,允许轻微变化12:宽松,更多帧被去除
像素差异阈值
固定为 8.0(内部参数,暂不暴露 CLI 选项)。对内容区生成 48×48 灰度缩略图后计算平均绝对差值。
SSIM 结构相似度 (--ssim-threshold)
SSIM(结构相似性指数)用于补充 dHash。它在内容区生成 32×32 灰度缩略图后比较亮度、对比度和结构一致性,更适合识别视觉上接近但 dHash 距离偏大的帧。
--ssim-threshold 0.93(默认):严格去重,只跳过结构高度接近的帧--ssim-threshold 0:禁用 SSIM 去重--ssim-threshold 0.85:更激进,可能减少更多滚动冗余,但需要抽查输出
滚动帧合并 (--scroll-merge)
滚动帧合并用于处理聊天录屏、网页滚动、App 列表滚动等场景。它会比较当前帧与最近保留帧在纵向位移后的重叠区域:如果大部分内容只是上下移动,且重叠区域平均像素差低于阈值,则跳过当前帧。
--scroll-merge:启用滚动帧合并(默认关闭)--no-scroll-merge:禁用滚动帧合并,适合需要完整保留滚动过程的场景--scroll-diff-threshold 32.0(默认):阈值越大,合并越激进
调参建议:
- 证据需要尽量少图且便于审阅:可尝试
--scroll-diff-threshold 36 - 担心漏掉边缘新内容:使用默认值或
--scroll-diff-threshold 24 - 需要每个滚动位置都保留:使用
--no-scroll-merge
最小时间间隔 (--min-gap)
用于抑制同一时间段内保留过多截图。默认 --min-gap 0.5,表示两个保留帧之间至少间隔 0.5 秒。
--min-gap 0:禁用时间间隔过滤--min-gap 0.5(默认):减少同秒多图,同时尽量保留快速变化--min-gap 1.0:更严格,每秒最多保留约一张,适合先压缩冗余再人工复核
OCR 去重参数
需要 --ocr-dedup 标志开启,需要安装 rapidocr-onnxruntime。
--ocr-threshold 0.92(默认):OCR 文本相似度超过 92% 且新字符少于 8 个时视为重复--ocr-min-new 8(默认):最少新字符数,防止因少量文字变化被误判为重复
OCR 预处理流程:裁剪边缘(顶部 16%、底部 14%、左右 6%)→ 灰度 → 自动对比度 → 对比度增强 1.35x → 锐化 1.15x。动态范围 < 18 的帧跳过 OCR(如纯黑/纯白画面)。
复合复核参数
丢弃候选帧 (--keep-drop-candidates)
开启后,脚本会把被去重或过滤规则丢弃的候选帧复制到 _review_candidates/,并在 _report.json 的 review.drop_candidates 中记录:
- 候选帧文件名
- 原始抽帧序号
- 捕获时间戳
- 丢弃原因(如
duplicate_ssim、duplicate_scroll、min_gap、quality_transition、ocr_duplicate) - SHA256 哈希
该模式只保存复核材料,不自动调用大模型。当前模型或工具支持图像输入时,可由多模态模型检查候选帧是否需要补回;如果当前模型是文字模型,则跳过视觉复核。
候选帧数量限制 (--drop-candidate-limit)
默认 --drop-candidate-limit 200。长视频中被丢弃的候选帧可能很多,建议保留默认值;如需完整回查可设为 0。
uv run scripts/extract.py -i recording.mp4 --ocr-dedup --keep-drop-candidates
uv run scripts/extract.py -i recording.mp4 --keep-drop-candidates --drop-candidate-limit 0输出参数
--max-size(默认 0,保持原始分辨率)
输出图片最长边的像素限制。设为 0 时不缩放,保持视频原始分辨率(推荐,保证证据清晰度)。如需限制可设如 --max-size 1920。
-q / --quality(默认 2,最高质量)
JPEG 输出质量,对应 ffmpeg 的 -q:v 参数。范围 1-31,越小越清晰。法律证据场景建议保持默认 2:
2:最高质量(默认推荐)6:高质量(文件较小)10:中等质量(不推荐用于证据)
--timeout(默认 1800)
总超时时间(秒)。超时后 ffmpeg 进程被终止。
输出文件
帧命名规则
frame_NNN_MMmSSs.jpgNNN:保留帧序号(去重后的顺序)MMmSSs:视频中的捕获时间戳
_report.json 结构
{
"input": "/path/to/video.mp4",
"duration_seconds": 180.5,
"strategy": "scene",
"total_extracted": 156,
"kept_after_dedup": 42,
"review": {
"drop_candidates_enabled": true,
"drop_candidate_count": 12,
"vision_review_status": "not_run",
"drop_candidates": [
{
"filename": "_review_candidates/candidate_001_min_gap_00m01s.jpg",
"reason": "min_gap",
"capture_time_seconds": 1.2
}
]
},
"dedup_stats": {
"sha256_duplicates": 3,
"dhash_duplicates": 89,
"pixel_duplicates": 12,
"ssim_duplicates": 4,
"scroll_duplicates": 18,
"ocr_duplicates": 10
},
"frames": [
{
"index": 1,
"filename": "frame_001_00m00s.jpg",
"capture_time_seconds": 0.0,
"sha256": "abc123..."
}
]
}#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "Pillow>=10.0.0",
# "rapidocr-onnxruntime",
# ]
# ///
"""video-screenshot 视频截图提取工具。
从录屏视频中抽取关键帧、去重并保存为图片文件。
"""
from __future__ import annotations
import argparse
import json
import logging
import re
import shutil
import sys
import tempfile
import time
from datetime import datetime
from hashlib import sha256
from pathlib import Path
# 将 scripts/ 同级目录加入搜索路径以便导入 lib
sys.path.insert(0, str(Path(__file__).resolve().parent))
from lib import (
DedupState,
ExtractParams,
FFProbeInfo,
calc_blur_score,
calc_capture_time,
calc_content_quality,
calc_dhash_hex,
calc_scroll_image,
calc_thumb_bytes,
check_ocr_similarity,
collect_frame_files,
create_ocr_engine,
crop_for_ocr_bytes_with_range,
find_tool,
is_frame_duplicate,
ocr_extract_text,
probe_video,
run_ffmpeg_extract,
shingles,
)
logger = logging.getLogger("video-screenshot")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
p = argparse.ArgumentParser(
description="视频取证关键帧提取工具",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
示例:
# 场景检测 + 图像去重(默认)
uv run scripts/extract.py -i recording.mp4
# 固定间隔,每 0.5 秒一帧
uv run scripts/extract.py -i recording.mp4 -s interval --interval 0.5
# 场景检测 + OCR 去重(适合聊天录屏)
uv run scripts/extract.py -i recording.mp4 --ocr-dedup
# 关键帧提取,不去重
uv run scripts/extract.py -i recording.mp4 -s keyframe -d 0
""",
)
p.add_argument("-i", "--input", required=True, help="输入视频文件路径")
p.add_argument("-o", "--output", default=None, help="输出目录(默认: <视频名>_frames/)")
p.add_argument("-s", "--strategy", default="scene",
choices=["scene", "keyframe", "interval", "smart"],
help="抽帧策略(默认: scene)")
p.add_argument("--interval", type=float, default=1.0, help="间隔秒数(interval 模式,默认: 1.0)")
p.add_argument("--scene-threshold", type=float, default=0.10, help="场景变化阈值(scene 模式,默认: 0.10)")
p.add_argument("--sample-interval", type=float, default=2.0, help="定期采样间隔秒数(scene 模式保底,默认: 2.0,0=禁用)")
p.add_argument("-d", "--dedup-threshold", type=int, default=4, help="dHash 汉明距离阈值(0=禁用,默认: 4)")
p.add_argument("--content-crop-top", type=float, default=0.12, help="内容区顶部裁剪比例(默认: 0.12)")
p.add_argument("--content-crop-bottom", type=float, default=0.12, help="内容区底部裁剪比例(默认: 0.12)")
p.add_argument("--content-crop-left", type=float, default=0.04, help="内容区左侧裁剪比例(默认: 0.04)")
p.add_argument("--content-crop-right", type=float, default=0.04, help="内容区右侧裁剪比例(默认: 0.04)")
p.add_argument("--ssim-threshold", type=float, default=0.93, help="SSIM 结构相似度阈值(0=禁用,默认: 0.93)")
p.add_argument("--scroll-merge", action="store_true", default=False, help="滚动帧合并(默认关闭)")
p.add_argument("--no-scroll-merge", action="store_false", dest="scroll_merge", help="禁用滚动帧合并")
p.add_argument("--scroll-diff-threshold", type=float, default=32.0, help="滚动重叠平均像素差阈值(默认: 32.0)")
p.add_argument("--ocr-dedup", action="store_true", help="启用 OCR 文本去重")
p.add_argument("--ocr-threshold", type=float, default=0.92, help="OCR 相似度阈值(默认: 0.92)")
p.add_argument("--ocr-min-new", type=int, default=8, help="OCR 最少新字符数(默认: 8)")
p.add_argument("--max-size", type=int, default=0, help="输出最长边像素限制(0=保持原始分辨率,默认: 0)")
p.add_argument("-q", "--quality", type=int, default=2, help="JPEG 输出质量 1-31,越小越清晰(默认: 2)")
p.add_argument("--timeout", type=float, default=1800, help="超时秒数(默认: 1800)")
p.add_argument("--filter-blur", action="store_true", help="启用模糊帧过滤")
p.add_argument("--blur-threshold", type=float, default=50.0, help="模糊阈值,Laplacian 方差低于此值视为模糊(默认: 50.0)")
p.add_argument("--filter-quality", action="store_true", default=True, help="内容质量过滤(默认开启,过滤空白页、启动画面、过渡帧)")
p.add_argument("--no-filter-quality", action="store_false", dest="filter_quality", help="禁用内容质量过滤")
p.add_argument("--min-gap", type=float, default=0.5, help="保留帧之间的最小时间间隔秒数(默认: 0.5)")
p.add_argument(
"--keep-drop-candidates",
action="store_true",
help="保存被去重或过滤丢弃的候选帧,供多模态复核",
)
p.add_argument(
"--drop-candidate-limit",
type=int,
default=200,
help="最多保存多少张丢弃候选帧(默认: 200,0=不限)",
)
p.add_argument("--keep-temp", action="store_true", help="保留临时 ffmpeg 输出文件")
return p.parse_args(argv)
def format_timestamp(seconds: float | None) -> str:
if seconds is None:
return "00m00s"
total = int(max(0, seconds))
h, rem = divmod(total, 3600)
m, s = divmod(rem, 60)
if h > 0:
return f"{h:02d}h{m:02d}m{s:02d}s"
return f"{m:02d}m{s:02d}s"
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
args = parse_args()
# 验证输入
video_path = str(Path(args.input).resolve())
if not Path(video_path).exists():
print(f"错误: 视频文件不存在: {video_path}", file=sys.stderr)
sys.exit(1)
# 验证 ffmpeg
if not find_tool("ffmpeg"):
print("错误: 未检测到 ffmpeg,请先安装: brew install ffmpeg", file=sys.stderr)
sys.exit(1)
# 确定输出目录(默认在视频文件同级目录下)
video_stem = Path(video_path).stem
output_dir = args.output or str(Path(video_path).parent / f"{video_stem}_frames")
output_dir = str(Path(output_dir).resolve())
Path(output_dir).mkdir(parents=True, exist_ok=True)
cleanup_stats = _clean_output_dir(output_dir)
if cleanup_stats["stale_deleted_count"]:
print(f" 已清理旧输出文件: {cleanup_stats['stale_deleted_count']}")
# 探测视频
print(f"探测视频: {video_path}")
try:
info = probe_video(video_path)
except Exception as e:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
print(f" 时长: {info.duration_seconds:.1f}s")
# 参数
params = ExtractParams(
interval_seconds=args.interval,
strategy=args.strategy,
dedup_threshold=args.dedup_threshold,
ocr_similarity_threshold=args.ocr_threshold,
ocr_min_new_chars=args.ocr_min_new,
content_crop_top=args.content_crop_top,
content_crop_bottom=args.content_crop_bottom,
content_crop_left=args.content_crop_left,
content_crop_right=args.content_crop_right,
ssim_threshold=args.ssim_threshold,
scroll_merge=args.scroll_merge,
scroll_diff_threshold=args.scroll_diff_threshold,
)
# OCR 引擎
ocr_engine = None
if args.ocr_dedup:
ocr_engine = create_ocr_engine()
if ocr_engine is None:
print("警告: RapidOCR 未安装,OCR 去重已禁用。安装: pip install rapidocr-onnxruntime", file=sys.stderr)
args.ocr_dedup = False
else:
print(" OCR: RapidOCR (本地)(SSIM 去重已自动禁用)")
params.ssim_threshold = 0
# 创建临时目录
started_at = time.monotonic()
tmpdir = tempfile.mkdtemp(prefix="video-screenshot-")
try:
# FFmpeg 抽帧
interval_based = params.strategy == "interval"
output_pattern = str(
Path(tmpdir) / ("frame_%06d.jpg" if interval_based else "frame_%010d.jpg")
)
print(f"抽帧中 (策略: {params.strategy})...")
ffmpeg_timeout = max(30.0, args.timeout - 5.0)
for kv in run_ffmpeg_extract(
video_path=video_path,
output_pattern=output_pattern,
strategy=params.strategy,
interval_seconds=params.interval_seconds,
scene_threshold=args.scene_threshold,
max_size=args.max_size,
quality=args.quality,
timeout_seconds=ffmpeg_timeout,
frame_rate_fps=info.frame_rate_fps,
sample_interval=args.sample_interval,
):
if "out_time_ms" in kv:
try:
out_us = int(kv["out_time_ms"])
out_s = out_us / 1_000_000.0
pct = int(out_s * 100 / info.duration_seconds) if info.duration_seconds else 0
pct = min(max(pct, 0), 99)
print(f"\r 进度: {pct}% ({out_s:.1f}s/{info.duration_seconds:.1f}s)", end="", flush=True)
except Exception:
pass
print() # 换行
# 收集帧文件
frame_files = collect_frame_files(tmpdir)
total_extracted = len(frame_files)
print(f"提取帧数: {total_extracted}")
if not frame_files:
print("警告: 未提取到任何帧", file=sys.stderr)
_write_report(
output_dir, video_path, info, params, 0, DedupState(), [], cleanup_stats,
[], args.keep_drop_candidates, args.drop_candidate_limit,
)
return
# 去重
state = DedupState()
window = 20
pixel_diff_threshold = 8.0
frames_meta: list[dict] = []
drop_candidates_meta: list[dict] = []
last_kept_time: float | None = None
print("去重中...")
for idx, frame_path in enumerate(frame_files, 1):
state.total_count += 1
with open(frame_path, "rb") as fp:
content = fp.read()
digest = sha256(content).hexdigest()
dhash_hex = calc_dhash_hex(
content,
crop_top_ratio=params.content_crop_top,
crop_bottom_ratio=params.content_crop_bottom,
crop_left_ratio=params.content_crop_left,
crop_right_ratio=params.content_crop_right,
)
capture_time = calc_capture_time(frame_path, idx, params, info)
# 图像去重
is_dup, drop_reason, thumb, ssim_thumb, scroll_image = is_frame_duplicate(
content, digest, dhash_hex, state, params, window, pixel_diff_threshold,
)
if is_dup:
_record_drop_candidate(
output_dir,
frame_path,
idx,
drop_reason,
capture_time,
digest,
drop_candidates_meta,
enabled=args.keep_drop_candidates,
limit=args.drop_candidate_limit,
)
continue
# 最小时间间隔过滤
if args.min_gap > 0 and last_kept_time is not None and capture_time is not None:
if capture_time - last_kept_time < args.min_gap:
state.min_gap_drops += 1
_record_drop_candidate(
output_dir,
frame_path,
idx,
"min_gap",
capture_time,
digest,
drop_candidates_meta,
enabled=args.keep_drop_candidates,
limit=args.drop_candidate_limit,
)
continue
# 内容质量过滤(空白页、启动画面、过渡帧)
if args.filter_quality:
quality = calc_content_quality(content)
if quality["label"]:
state.quality_drops += 1
_record_drop_candidate(
output_dir,
frame_path,
idx,
f"quality_{quality['label']}",
capture_time,
digest,
drop_candidates_meta,
enabled=args.keep_drop_candidates,
limit=args.drop_candidate_limit,
extra={"quality": quality},
)
continue
# 模糊帧过滤
if args.filter_blur:
blur_score = calc_blur_score(content)
if blur_score < args.blur_threshold:
state.blur_drops += 1
_record_drop_candidate(
output_dir,
frame_path,
idx,
"blur",
capture_time,
digest,
drop_candidates_meta,
enabled=args.keep_drop_candidates,
limit=args.drop_candidate_limit,
extra={"blur_score": blur_score},
)
continue
# OCR 去重
if args.ocr_dedup and ocr_engine is not None:
crop_bytes, crop_range = crop_for_ocr_bytes_with_range(content)
ocr_text = ""
if crop_bytes and crop_range >= 18:
ocr_text = ocr_extract_text(ocr_engine, crop_bytes)
ocr_text = re.sub(r"\s+", "", ocr_text or "")
ocr_text = re.sub(r"[^\w一-鿿]+", "", ocr_text)
if ocr_text and check_ocr_similarity(
ocr_text,
state.kept_ocr_texts,
state.kept_ocr_shingles,
params.ocr_similarity_threshold,
params.ocr_min_new_chars,
):
state.ocr_dups += 1
_record_drop_candidate(
output_dir,
frame_path,
idx,
"ocr_duplicate",
capture_time,
digest,
drop_candidates_meta,
enabled=args.keep_drop_candidates,
limit=args.drop_candidate_limit,
)
continue
else:
ocr_text = ""
# 保留帧
state.kept_count += 1
state.seen_sha256.add(digest)
if dhash_hex:
state.kept_dhashes.append(dhash_hex)
if not thumb:
thumb = calc_thumb_bytes(
content,
crop_top_ratio=params.content_crop_top,
crop_bottom_ratio=params.content_crop_bottom,
crop_left_ratio=params.content_crop_left,
crop_right_ratio=params.content_crop_right,
)
if thumb:
state.kept_thumbs.append(thumb)
if not ssim_thumb and params.ssim_threshold and params.ssim_threshold > 0:
ssim_thumb = calc_thumb_bytes(
content,
size=32,
crop_top_ratio=params.content_crop_top,
crop_bottom_ratio=params.content_crop_bottom,
crop_left_ratio=params.content_crop_left,
crop_right_ratio=params.content_crop_right,
autocontrast=True,
)
if ssim_thumb:
state.kept_ssim_thumbs.append(ssim_thumb)
if not scroll_image and params.scroll_merge:
scroll_image = calc_scroll_image(
content,
crop_top_ratio=params.content_crop_top,
crop_bottom_ratio=params.content_crop_bottom,
crop_left_ratio=params.content_crop_left,
crop_right_ratio=params.content_crop_right,
)
if scroll_image:
state.kept_scroll_images.append(scroll_image)
if ocr_text:
state.kept_ocr_texts.append(ocr_text)
state.kept_ocr_shingles.append(shingles(ocr_text))
# 复制到输出目录
ts = format_timestamp(capture_time)
out_name = f"frame_{state.kept_count:03d}_{ts}.jpg"
out_path = str(Path(output_dir) / out_name)
shutil.copy2(frame_path, out_path)
last_kept_time = capture_time
frames_meta.append({
"index": state.kept_count,
"filename": out_name,
"capture_time_seconds": capture_time,
"sha256": digest,
})
if idx % 50 == 0 or idx == total_extracted:
print(
f"\r 已处理: {idx}/{total_extracted}, "
f"保留: {state.kept_count}, "
f"去重: {idx - state.kept_count}",
end="", flush=True,
)
print() # 换行
# 写入报告
_write_report(
output_dir, video_path, info, params, total_extracted, state, frames_meta, cleanup_stats,
drop_candidates_meta, args.keep_drop_candidates, args.drop_candidate_limit,
)
# 归档
archive_dir = _archive_result(
output_dir, video_path, info, params, args, state, frames_meta, cleanup_stats,
drop_candidates_meta,
elapsed_seconds=time.monotonic() - started_at,
)
# 汇总
print(f"\n完成!")
print(f" 输出目录: {output_dir}")
print(f" 提取帧: {total_extracted}")
print(f" 保留帧: {state.kept_count}")
print(f" 去重统计:")
print(f" SHA256 重复: {state.sha256_dups}")
print(f" dHash 重复: {state.dhash_dups}")
print(f" 像素重复: {state.pixel_dups}")
print(f" SSIM 重复: {state.ssim_dups}")
print(f" 滚动合并: {state.scroll_dups}")
print(f" OCR 重复: {state.ocr_dups}")
if state.blur_drops:
print(f" 模糊过滤: {state.blur_drops}")
if state.quality_drops:
print(f" 质量过滤: {state.quality_drops}")
if state.min_gap_drops:
print(f" 时间间隔过滤: {state.min_gap_drops}")
if args.keep_drop_candidates:
print(f" 复核候选帧: {len(drop_candidates_meta)}")
if archive_dir:
print(f" 归档: {archive_dir}")
finally:
if not args.keep_temp:
shutil.rmtree(tmpdir, ignore_errors=True)
else:
print(f" 临时文件: {tmpdir}")
def _clean_output_dir(output_dir: str) -> dict[str, object]:
"""清理本工具生成的旧输出文件,避免本次结果混入残留帧。"""
root = Path(output_dir)
stale_files: list[Path] = []
stale_files.extend(root.glob("frame_*.jpg"))
stale_files.extend(root.glob("frame_*.jpeg"))
for name in ("_report.json", "extraction_meta.json"):
p = root / name
if p.exists() and p.is_file():
stale_files.append(p)
stale_dirs: list[Path] = []
candidates_dir = root / "_review_candidates"
if candidates_dir.exists() and candidates_dir.is_dir():
stale_dirs.append(candidates_dir)
deleted: list[str] = []
seen: set[Path] = set()
for p in stale_files:
if p in seen or not p.is_file():
continue
seen.add(p)
p.unlink()
deleted.append(p.name)
for p in stale_dirs:
shutil.rmtree(p, ignore_errors=True)
deleted.append(p.name + "/")
return {
"stale_deleted_count": len(deleted),
"stale_deleted_files": deleted,
}
def _record_drop_candidate(
output_dir: str,
frame_path: str,
source_index: int,
reason: str,
capture_time: float | None,
digest: str,
candidates: list[dict],
*,
enabled: bool,
limit: int,
extra: dict[str, object] | None = None,
) -> None:
"""保存被算法丢弃的候选帧,供后续视觉复核。"""
if not enabled:
return
if limit > 0 and len(candidates) >= limit:
return
candidates_dir = Path(output_dir) / "_review_candidates"
candidates_dir.mkdir(parents=True, exist_ok=True)
safe_reason = re.sub(r"[^a-z0-9_]+", "_", (reason or "drop").lower()).strip("_") or "drop"
seq = len(candidates) + 1
filename = f"candidate_{seq:03d}_{safe_reason}_{format_timestamp(capture_time)}.jpg"
dst = candidates_dir / filename
shutil.copy2(frame_path, dst)
item: dict[str, object] = {
"index": seq,
"filename": str(Path("_review_candidates") / filename),
"source_frame_index": source_index,
"source_temp_filename": Path(frame_path).name,
"reason": reason,
"capture_time_seconds": capture_time,
"sha256": digest,
}
if extra:
item.update(extra)
candidates.append(item)
def _write_report(
output_dir: str,
video_path: str,
info: FFProbeInfo,
params: ExtractParams,
total_extracted: int,
state: DedupState,
frames: list[dict],
cleanup_stats: dict[str, object],
drop_candidates: list[dict],
keep_drop_candidates: bool,
drop_candidate_limit: int,
) -> None:
report = {
"input": video_path,
"duration_seconds": info.duration_seconds,
"strategy": params.strategy,
"options": {
"interval_seconds": params.interval_seconds,
"dedup_threshold": params.dedup_threshold,
"ocr_similarity_threshold": params.ocr_similarity_threshold,
"ocr_min_new_chars": params.ocr_min_new_chars,
"content_crop": {
"top": params.content_crop_top,
"bottom": params.content_crop_bottom,
"left": params.content_crop_left,
"right": params.content_crop_right,
},
"ssim_threshold": params.ssim_threshold,
"scroll_merge": params.scroll_merge,
"scroll_diff_threshold": params.scroll_diff_threshold,
},
"total_extracted": total_extracted,
"kept_after_dedup": state.kept_count,
"cleanup": cleanup_stats,
"review": {
"drop_candidates_enabled": keep_drop_candidates,
"drop_candidate_limit": drop_candidate_limit,
"drop_candidate_count": len(drop_candidates),
"drop_candidates": drop_candidates,
"vision_review_status": "not_run",
"vision_review_note": "脚本只导出复核候选帧;是否执行多模态复核由当前模型能力决定。",
},
"dedup_stats": {
"sha256_duplicates": state.sha256_dups,
"dhash_duplicates": state.dhash_dups,
"pixel_duplicates": state.pixel_dups,
"ssim_duplicates": state.ssim_dups,
"scroll_duplicates": state.scroll_dups,
"ocr_duplicates": state.ocr_dups,
"blur_drops": state.blur_drops,
"quality_drops": state.quality_drops,
"min_gap_drops": state.min_gap_drops,
},
"frames": frames,
}
report_path = str(Path(output_dir) / "_report.json")
with open(report_path, "w", encoding="utf-8") as fp:
json.dump(report, fp, ensure_ascii=False, indent=2)
def _build_archive_subdir(video_path: str) -> Path:
"""创建 archive 子目录,命名格式: YYYYMMDD_HHMMSS_{视频名}"""
skill_root = Path(__file__).resolve().parent.parent
archive_root = skill_root / "archive"
video_stem = Path(video_path).stem
# 截断过长的文件名
if len(video_stem) > 60:
video_stem = video_stem[:60]
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
archive_dir = archive_root / f"{ts}_{video_stem}"
archive_dir.mkdir(parents=True, exist_ok=True)
return archive_dir
def _archive_result(
output_dir: str,
video_path: str,
info: FFProbeInfo,
params: ExtractParams,
args: argparse.Namespace,
state: DedupState,
frames_meta: list[dict],
cleanup_stats: dict[str, object],
drop_candidates: list[dict],
elapsed_seconds: float,
) -> Path | None:
"""将分析结果归档到 archive/ 目录。"""
archive_dir = _build_archive_subdir(video_path)
frames_dir = archive_dir / "frames"
frames_dir.mkdir(exist_ok=True)
expected_names = [str(frame["filename"]) for frame in frames_meta]
for name in expected_names:
src = Path(output_dir) / name
if not src.exists():
raise FileNotFoundError(f"归档失败,报告帧不存在: {src}")
shutil.copy2(src, frames_dir / name)
report_src = Path(output_dir) / "_report.json"
if not report_src.exists():
raise FileNotFoundError(f"归档失败,报告文件不存在: {report_src}")
shutil.copy2(report_src, archive_dir / "_report.json")
actual_names = sorted(p.name for p in frames_dir.glob("*.jpg"))
expected_sorted = sorted(expected_names)
if actual_names != expected_sorted:
extra = sorted(set(actual_names) - set(expected_sorted))
missing = sorted(set(expected_sorted) - set(actual_names))
raise RuntimeError(
"归档一致性校验失败: "
f"expected={len(expected_sorted)}, actual={len(actual_names)}, "
f"extra={extra[:5]}, missing={missing[:5]}"
)
review_dir = archive_dir / "_review_candidates"
if drop_candidates:
review_dir.mkdir(exist_ok=True)
for item in drop_candidates:
rel_name = str(item.get("filename") or "")
src = Path(output_dir) / rel_name
if src.exists() and src.is_file():
shutil.copy2(src, review_dir / src.name)
meta = {
"source_file": video_path,
"archive_path": str(archive_dir),
"timestamp": datetime.now().isoformat(),
"elapsed_seconds": round(elapsed_seconds, 1),
"video_info": {
"duration_seconds": info.duration_seconds,
"time_base_seconds": info.time_base_seconds,
"frame_rate_fps": info.frame_rate_fps,
},
"options": {
"strategy": params.strategy,
"interval_seconds": params.interval_seconds,
"scene_threshold": args.scene_threshold,
"dedup_threshold": params.dedup_threshold,
"content_crop": {
"top": params.content_crop_top,
"bottom": params.content_crop_bottom,
"left": params.content_crop_left,
"right": params.content_crop_right,
},
"ssim_threshold": params.ssim_threshold,
"scroll_merge": params.scroll_merge,
"scroll_diff_threshold": params.scroll_diff_threshold,
"ocr_dedup": args.ocr_dedup,
"ocr_similarity_threshold": params.ocr_similarity_threshold,
"ocr_min_new_chars": params.ocr_min_new_chars,
"max_size": args.max_size,
"quality": args.quality,
"filter_blur": args.filter_blur,
"blur_threshold": args.blur_threshold,
"filter_quality": args.filter_quality,
"keep_drop_candidates": args.keep_drop_candidates,
"drop_candidate_limit": args.drop_candidate_limit,
},
"cleanup": cleanup_stats,
"archive_validation": {
"frames_match_report": True,
"expected_frame_count": len(expected_sorted),
"actual_frame_count": len(actual_names),
},
"review": {
"drop_candidate_count": len(drop_candidates),
"drop_candidates_archived": bool(drop_candidates),
"vision_review_status": "not_run",
},
"result": {
"total_extracted": state.total_count,
"kept_after_dedup": state.kept_count,
"dedup_stats": {
"sha256_duplicates": state.sha256_dups,
"dhash_duplicates": state.dhash_dups,
"pixel_duplicates": state.pixel_dups,
"ssim_duplicates": state.ssim_dups,
"scroll_duplicates": state.scroll_dups,
"ocr_duplicates": state.ocr_dups,
"blur_drops": state.blur_drops,
"quality_drops": state.quality_drops,
"min_gap_drops": state.min_gap_drops,
},
},
"frame_count": state.kept_count,
}
with open(archive_dir / "extraction_meta.json", "w", encoding="utf-8") as fp:
json.dump(meta, fp, ensure_ascii=False, indent=2)
return archive_dir
if __name__ == "__main__":
main()
"""video-screenshot 共享工具函数。
从 fachuan chat_records/services/ 移植,去除 Django 依赖。
"""
from __future__ import annotations
import contextlib
import io
import json
import logging
import re
import select
import shutil
import subprocess
import time
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any, cast
from PIL import Image, ImageChops, ImageEnhance, ImageFilter, ImageOps, ImageStat
logger = logging.getLogger("video-screenshot")
_LANCZOS: Any = getattr(Image, "Resampling", Image).LANCZOS
# ======================================================================
# A. FFmpeg 工具
# ======================================================================
@dataclass(frozen=True)
class FFProbeInfo:
duration_seconds: float
time_base_seconds: float | None = None
frame_rate_fps: float | None = None
def _parse_rate(value: Any) -> float | None:
text = str(value or "")
if not text or text == "0/0":
return None
try:
if "/" in text:
n, d = text.split("/", 1)
denom = float(d)
return float(n) / denom if denom else None
rate = float(text)
return rate if rate > 0 else None
except Exception:
return None
def find_tool(name: str) -> str | None:
p = shutil.which(name)
if p:
return p
for root in ("/usr/local/bin", "/opt/homebrew/bin", "/usr/bin"):
candidate = str(Path(root) / name)
if Path(candidate).exists() and Path(candidate).stat().st_mode & 0o111:
return candidate
return None
def probe_video(video_path: str) -> FFProbeInfo:
if not video_path or not Path(video_path).exists():
raise FileNotFoundError(f"视频文件不存在: {video_path}")
duration = 0.0
time_base_seconds: float | None = None
frame_rate_fps: float | None = None
ffprobe = find_tool("ffprobe")
if ffprobe:
cmd = [
ffprobe, "-v", "error",
"-select_streams", "v:0",
"-show_entries", "format=duration:stream=time_base,avg_frame_rate,r_frame_rate",
"-of", "json",
video_path,
]
try:
result = subprocess.run(cmd, timeout=10, check=True, capture_output=True, text=True)
data = json.loads(result.stdout or "{}")
duration = float((data.get("format") or {}).get("duration") or 0.0)
streams = data.get("streams") or []
if streams:
tb = str((streams[0] or {}).get("time_base") or "")
if "/" in tb:
n, d = tb.split("/", 1)
time_base_seconds = float(n) / float(d) if float(d) else None
frame_rate_fps = (
_parse_rate((streams[0] or {}).get("avg_frame_rate"))
or _parse_rate((streams[0] or {}).get("r_frame_rate"))
)
except Exception:
logger.exception("ffprobe 解析失败: %s", video_path)
duration = 0.0
frame_rate_fps = None
else:
duration = _probe_duration_by_ffmpeg(video_path)
frame_rate_fps = None
if duration <= 0:
raise RuntimeError(f"无法解析视频时长: {video_path}")
return FFProbeInfo(
duration_seconds=duration,
time_base_seconds=time_base_seconds,
frame_rate_fps=frame_rate_fps,
)
def _probe_duration_by_ffmpeg(video_path: str) -> float:
ffmpeg = find_tool("ffmpeg")
if not ffmpeg:
return 0.0
cmd = [ffmpeg, "-hide_banner", "-i", video_path]
try:
result = subprocess.run(cmd, timeout=10, check=False, capture_output=True, text=True)
except Exception:
return 0.0
text = (result.stderr or "") + "\n" + (result.stdout or "")
m = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.\d+)", text)
if not m:
return 0.0
return int(m.group(1)) * 3600 + int(m.group(2)) * 60 + float(m.group(3))
def build_ffmpeg_filter_args(
strategy: str,
interval_seconds: float,
scene_threshold: float,
max_size: int = 0,
frame_rate_fps: float | None = None,
sample_interval: float = 5.0,
) -> tuple[list[str], str, list[str]]:
"""构建 ffmpeg 滤镜参数,返回 (input_args, vf, extra_args)。"""
# max_size=0 时保持原始分辨率,不缩放
if max_size and max_size > 0:
scale = (
f"scale='if(gt(iw,ih),min({max_size},iw),-2)':"
f"'if(gt(iw,ih),-2,min({max_size},ih))'"
)
else:
scale = ""
vfr_args = ["-vsync", "vfr", "-frame_pts", "1"]
fmt = ",format=yuvj420p"
# 构建 scale 部分的滤镜链(可能为空)
scale_part = f",{scale}" if scale else ""
# scene 策略:场景检测 + 定期采样保底(确保静态画面也有覆盖)
scene_expr = f"gt(scene,{float(scene_threshold)})"
if frame_rate_fps and frame_rate_fps > 0 and sample_interval and sample_interval > 0:
n_frames = max(1, round(frame_rate_fps * sample_interval))
scene_expr = f"{scene_expr}+not(mod(n\\,{n_frames}))"
scene_vf = f"select='{scene_expr}'{scale_part}{fmt}"
strategy_map: dict[str, tuple[list[str], str, list[str]]] = {
"scene": ([], scene_vf, vfr_args),
"keyframe": (["-skip_frame", "nokey"], f"{scale_part[1:]},mpdecimate{fmt}" if scale_part else f"mpdecimate{fmt}", vfr_args),
"smart": ([], f"{scale_part[1:]},mpdecimate{fmt}" if scale_part else f"mpdecimate{fmt}", vfr_args),
}
if strategy in strategy_map:
return strategy_map[strategy]
fps = 1.0 / interval_seconds
return [], f"fps={fps}{scale_part},mpdecimate{fmt}", []
def _force_kill_proc(proc: subprocess.Popen[str]) -> None:
with contextlib.suppress(Exception):
proc.terminate()
try:
proc.wait(timeout=2)
except Exception:
with contextlib.suppress(Exception):
proc.kill()
def _read_progress_lines(
proc: subprocess.Popen[str],
timeout_seconds: float | None,
started: float,
) -> Any:
if proc.stdout is None:
return
while True:
if timeout_seconds is not None and time.monotonic() - started > timeout_seconds:
_force_kill_proc(proc)
raise RuntimeError("ffmpeg 抽帧超时")
if proc.poll() is not None:
break
rlist, _, _ = select.select([proc.stdout], [], [], 0.2)
if not rlist:
continue
line = proc.stdout.readline()
if not line:
break
line = (line or "").strip()
if not line or "=" not in line:
continue
k, v = line.split("=", 1)
yield {k: v}
def _check_exit(proc: subprocess.Popen[str]) -> None:
try:
rc = proc.wait(timeout=5)
except Exception:
with contextlib.suppress(Exception):
proc.kill()
rc = proc.wait()
if rc != 0:
err = ""
try:
if proc.stderr is not None:
err = proc.stderr.read() or ""
except Exception:
err = ""
err = (err or "").strip()
if err:
tail = "\n".join(err.splitlines()[-12:])
raise RuntimeError(f"ffmpeg 抽帧失败:\n{tail}")
raise RuntimeError("ffmpeg 抽帧失败,请检查视频文件或 ffmpeg 安装")
def run_ffmpeg_extract(
*,
video_path: str,
output_pattern: str,
strategy: str = "scene",
interval_seconds: float = 1.0,
scene_threshold: float = 0.25,
max_size: int = 1280,
quality: int = 6,
timeout_seconds: float | None = None,
frame_rate_fps: float | None = None,
sample_interval: float = 5.0,
) -> Any:
"""运行 ffmpeg 抽帧,yield 进度字典。"""
ffmpeg = find_tool("ffmpeg")
if not ffmpeg:
raise RuntimeError("未检测到 ffmpeg,请先安装 (brew install ffmpeg)")
input_args, vf, extra_args = build_ffmpeg_filter_args(
strategy, interval_seconds, scene_threshold, max_size,
frame_rate_fps=frame_rate_fps,
sample_interval=sample_interval,
)
cmd = [
ffmpeg, "-hide_banner", "-nostats",
"-loglevel", "error",
"-progress", "pipe:1",
*input_args,
"-i", video_path,
"-vf", vf,
*extra_args,
"-q:v", str(quality),
output_pattern,
]
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
started = time.monotonic()
yield from _read_progress_lines(proc, timeout_seconds, started)
_check_exit(proc)
# ======================================================================
# B. 图像处理
# ======================================================================
def _content_crop_box(
width: int,
height: int,
*,
top_ratio: float = 0.12,
bottom_ratio: float = 0.12,
left_ratio: float = 0.04,
right_ratio: float = 0.04,
) -> tuple[int, int, int, int]:
top = int(max(0, min(height - 1, round(height * top_ratio))))
bottom_cut = int(max(0, min(height - 1, round(height * bottom_ratio))))
left = int(max(0, min(width - 1, round(width * left_ratio))))
right_cut = int(max(0, min(width - 1, round(width * right_ratio))))
bottom = max(top + 1, height - bottom_cut)
right = max(left + 1, width - right_cut)
return left, top, right, bottom
def _crop_content(
img: Image.Image,
*,
top_ratio: float = 0.12,
bottom_ratio: float = 0.12,
left_ratio: float = 0.04,
right_ratio: float = 0.04,
) -> Image.Image:
w, h = img.size
if w <= 0 or h <= 0:
return img
return img.crop(
_content_crop_box(
w,
h,
top_ratio=top_ratio,
bottom_ratio=bottom_ratio,
left_ratio=left_ratio,
right_ratio=right_ratio,
)
)
def calc_dhash_hex(
image_bytes: bytes,
*,
hash_size: int = 8,
crop_top_ratio: float = 0.12,
crop_bottom_ratio: float = 0.12,
crop_left_ratio: float = 0.04,
crop_right_ratio: float = 0.04,
) -> str:
if not image_bytes or hash_size <= 0:
return ""
img = Image.open(io.BytesIO(image_bytes))
img = img.convert("L")
img = _crop_content(
img,
top_ratio=crop_top_ratio,
bottom_ratio=crop_bottom_ratio,
left_ratio=crop_left_ratio,
right_ratio=crop_right_ratio,
)
img = img.resize((hash_size + 1, hash_size), _LANCZOS)
pixels = list(img.getdata())
bits = 0
for row in range(hash_size):
row_start = row * (hash_size + 1)
for col in range(hash_size):
left = pixels[row_start + col]
right = pixels[row_start + col + 1]
if left > right:
bits |= 1 << (row * hash_size + col)
hex_len = (hash_size * hash_size) // 4
return f"{bits:0{hex_len}x}"
def hamming_distance_hex(a: str, b: str) -> int | None:
if not a or not b:
return None
try:
x = int(a, 16)
y = int(b, 16)
except Exception:
return None
return (x ^ y).bit_count()
def calc_thumb_bytes(
image_bytes: bytes,
*,
size: int = 48,
crop_top_ratio: float = 0.12,
crop_bottom_ratio: float = 0.12,
crop_left_ratio: float = 0.04,
crop_right_ratio: float = 0.04,
autocontrast: bool = False,
) -> bytes:
if not image_bytes or size <= 0:
return b""
img = Image.open(io.BytesIO(image_bytes))
img = img.convert("L")
w, h = img.size
if w <= 0 or h <= 0:
return b""
img = _crop_content(
img,
top_ratio=crop_top_ratio,
bottom_ratio=crop_bottom_ratio,
left_ratio=crop_left_ratio,
right_ratio=crop_right_ratio,
)
if autocontrast:
img = ImageOps.autocontrast(img)
img = img.resize((size, size), _LANCZOS)
return cast(bytes, img.tobytes())
def mean_abs_diff(a: bytes, b: bytes) -> float | None:
if not a or not b or len(a) != len(b):
return None
total = 0
for x, y in zip(a, b):
total += x - y if x >= y else y - x
return total / float(len(a))
def ssim_bytes(a: bytes, b: bytes) -> float | None:
"""计算两个等长灰度缩略图的全局 SSIM。"""
if not a or not b or len(a) != len(b):
return None
n = len(a)
mean_a = sum(a) / float(n)
mean_b = sum(b) / float(n)
denom = max(n - 1, 1)
var_a = sum((x - mean_a) ** 2 for x in a) / float(denom)
var_b = sum((y - mean_b) ** 2 for y in b) / float(denom)
cov = sum((x - mean_a) * (y - mean_b) for x, y in zip(a, b)) / float(denom)
c1 = (0.01 * 255) ** 2
c2 = (0.03 * 255) ** 2
divisor = (mean_a * mean_a + mean_b * mean_b + c1) * (var_a + var_b + c2)
if not divisor:
return None
return ((2 * mean_a * mean_b + c1) * (2 * cov + c2)) / divisor
def calc_scroll_image(
image_bytes: bytes,
*,
width: int = 96,
height: int = 160,
crop_top_ratio: float = 0.12,
crop_bottom_ratio: float = 0.12,
crop_left_ratio: float = 0.04,
crop_right_ratio: float = 0.04,
) -> Image.Image | None:
if not image_bytes or width <= 0 or height <= 0:
return None
img = Image.open(io.BytesIO(image_bytes))
img = img.convert("L")
img = _crop_content(
img,
top_ratio=crop_top_ratio,
bottom_ratio=crop_bottom_ratio,
left_ratio=crop_left_ratio,
right_ratio=crop_right_ratio,
)
img = ImageOps.autocontrast(img)
return img.resize((width, height), _LANCZOS)
def _shifted_mean_abs_diff(a: Image.Image, b: Image.Image, shift: int) -> tuple[float, float]:
width, height = a.size
if b.size != a.size or width <= 0 or height <= 0:
return 999.0, 0.0
if shift >= 0:
box_a = (0, shift, width, height)
box_b = (0, 0, width, height - shift)
else:
box_a = (0, 0, width, height + shift)
box_b = (0, -shift, width, height)
crop_a = a.crop(box_a)
crop_b = b.crop(box_b)
if crop_a.size[1] <= 0 or crop_b.size[1] <= 0:
return 999.0, 0.0
diff = ImageChops.difference(crop_a, crop_b)
return ImageStat.Stat(diff).mean[0], crop_a.size[1] / float(height)
def scroll_overlap_duplicate(
current: Image.Image,
previous_images: list[Image.Image],
*,
threshold: float,
min_shift: int = 4,
max_shift_ratio: float = 0.35,
min_overlap_ratio: float = 0.70,
step: int = 4,
) -> bool:
"""检测当前帧是否只是最近保留帧的轻微纵向滚动版本。"""
if current is None or not previous_images or threshold <= 0:
return False
width, height = current.size
if width <= 0 or height <= 0:
return False
max_shift = max(min_shift, int(round(height * max_shift_ratio)))
for prev in reversed(previous_images):
if prev.size != current.size:
continue
best_diff = 999.0
best_shift = 0
best_overlap = 0.0
for shift in range(-max_shift, max_shift + 1, step):
diff, overlap = _shifted_mean_abs_diff(prev, current, shift)
if overlap < min_overlap_ratio:
continue
if diff < best_diff:
best_diff = diff
best_shift = shift
best_overlap = overlap
if abs(best_shift) >= min_shift and best_overlap >= min_overlap_ratio and best_diff <= threshold:
return True
return False
# 3x3 Laplacian 卷积核(用于模糊检测)
_LAPLACIAN = ImageFilter.Kernel((3, 3), [0, 1, 0, 1, -4, 1, 0, 1, 0], scale=1, offset=0)
def calc_blur_score(image_bytes: bytes, *, size: int = 128) -> float:
"""计算帧的 Laplacian 方差,值越低越模糊。"""
if not image_bytes:
return 0.0
img = Image.open(io.BytesIO(image_bytes))
img = img.convert("L")
img = img.resize((size, size), _LANCZOS)
filtered = img.filter(_LAPLACIAN)
return ImageStat.Stat(filtered).var[0]
def calc_content_quality(image_bytes: bytes) -> dict[str, Any]:
"""分析帧的内容质量,返回指标字典。"""
if not image_bytes:
return {"label": "empty", "content_std": 0.0, "white_ratio": 0.0, "grid_flat": 9}
img = Image.open(io.BytesIO(image_bytes)).convert("L")
w, h = img.size
if w <= 0 or h <= 0:
return {"label": "empty", "content_std": 0.0, "white_ratio": 0.0, "grid_flat": 9}
# 直方图分析
hist = img.histogram()
total = w * h
white_ratio = sum(hist[240:]) / total
black_ratio = sum(hist[:15]) / total
# 内容区域(排除顶部 8% 和底部 8% 的状态栏)
content_crop = img.crop((0, int(h * 0.08), w, int(h * 0.92)))
content_std = ImageStat.Stat(content_crop).stddev[0]
# 3×3 网格分析
grid_stds: list[float] = []
for row in range(3):
for col in range(3):
y1, y2 = row * h // 3, (row + 1) * h // 3
x1, x2 = col * w // 3, (col + 1) * w // 3
grid_stds.append(ImageStat.Stat(img.crop((x1, y1, x2, y2))).stddev[0])
grid_flat = sum(1 for s in grid_stds if s < 10)
grid_high = sum(1 for s in grid_stds if s > 50)
grid_spread = max(grid_stds) - min(grid_stds)
# 分类
label = ""
if content_std < 10 or white_ratio > 0.95 or black_ratio > 0.95:
label = "blank"
elif content_std < 35 and grid_high <= 2:
label = "startup"
elif grid_spread > 45 and grid_flat >= 2:
label = "transition"
return {
"label": label,
"content_std": content_std,
"white_ratio": white_ratio,
"black_ratio": black_ratio,
"grid_flat": grid_flat,
"grid_high": grid_high,
"grid_spread": grid_spread,
}
def crop_for_ocr_bytes_with_range(
image_bytes: bytes,
*,
crop_top_ratio: float = 0.16,
crop_bottom_ratio: float = 0.14,
crop_left_ratio: float = 0.06,
crop_right_ratio: float = 0.06,
max_width: int = 720,
) -> tuple[bytes, int]:
if not image_bytes:
return (b"", 0)
img = Image.open(io.BytesIO(image_bytes))
w, h = img.size
if w <= 0 or h <= 0:
return (b"", 0)
top = int(max(0, min(h - 1, round(h * crop_top_ratio))))
bottom_cut = int(max(0, min(h - 1, round(h * crop_bottom_ratio))))
bottom = max(top + 1, h - bottom_cut)
left = int(max(0, min(w - 1, round(w * crop_left_ratio))))
right_cut = int(max(0, min(w - 1, round(w * crop_right_ratio))))
right = max(left + 1, w - right_cut)
img = img.crop((left, top, right, bottom))
if max_width and img.size[0] > max_width:
new_w = int(max_width)
new_h = round(img.size[1] * (new_w / float(img.size[0])))
img = img.resize((new_w, max(1, new_h)), _LANCZOS)
img = img.convert("L")
img = ImageOps.autocontrast(img)
img = ImageEnhance.Contrast(img).enhance(1.35)
img = ImageEnhance.Sharpness(img).enhance(1.15)
dynamic_range = 0
try:
extrema = img.getextrema()
if isinstance(extrema, tuple) and len(extrema) == 2:
lo_val, hi_val = extrema
if isinstance(lo_val, (int, float)) and isinstance(hi_val, (int, float)):
dynamic_range = int(hi_val) - int(lo_val)
except Exception:
pass
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
return (buf.getvalue(), max(0, dynamic_range))
# ======================================================================
# C. 去重逻辑
# ======================================================================
def shingles(s: str, n: int = 3) -> set[str]:
s = s or ""
if not s:
return set()
if len(s) <= n:
return {s}
return {s[i : i + n] for i in range(0, len(s) - n + 1)}
def jaccard_sets(sa: set[str], sb: set[str]) -> float:
if not sa or not sb:
return 0.0
inter = len(sa & sb)
union = len(sa | sb)
return float(inter) / float(union) if union else 0.0
@dataclass
class ExtractParams:
interval_seconds: float = 1.0
strategy: str = "scene"
dedup_threshold: int = 8
ocr_similarity_threshold: float = 0.92
ocr_min_new_chars: int = 8
content_crop_top: float = 0.12
content_crop_bottom: float = 0.12
content_crop_left: float = 0.04
content_crop_right: float = 0.04
ssim_threshold: float = 0.93
scroll_merge: bool = True
scroll_diff_threshold: float = 32.0
@dataclass
class DedupState:
seen_sha256: set[str] = field(default_factory=set)
kept_dhashes: list[str] = field(default_factory=list)
kept_thumbs: list[bytes] = field(default_factory=list)
kept_ssim_thumbs: list[bytes] = field(default_factory=list)
kept_scroll_images: list[Image.Image] = field(default_factory=list)
kept_ocr_texts: list[str] = field(default_factory=list)
kept_ocr_shingles: list[set[str]] = field(default_factory=list)
sha256_dups: int = 0
dhash_dups: int = 0
pixel_dups: int = 0
ssim_dups: int = 0
scroll_dups: int = 0
ocr_dups: int = 0
blur_drops: int = 0
quality_drops: int = 0
min_gap_drops: int = 0
kept_count: int = 0
total_count: int = 0
def is_dhash_duplicate(
dhash_hex: str,
kept_dhashes: list[str],
window: int,
threshold: int,
) -> bool:
for prev in kept_dhashes[-window:]:
dist = hamming_distance_hex(prev, dhash_hex)
if dist is not None and dist <= threshold:
return True
return False
def is_pixel_duplicate(
thumb: bytes,
kept_thumbs: list[bytes],
window: int,
threshold: float,
) -> bool:
for prev_thumb in kept_thumbs[-window:]:
diff = mean_abs_diff(prev_thumb, thumb)
if diff is not None and diff <= threshold:
return True
return False
def is_ssim_duplicate(
thumb: bytes,
kept_thumbs: list[bytes],
window: int,
threshold: float,
) -> bool:
for prev_thumb in kept_thumbs[-window:]:
sim = ssim_bytes(prev_thumb, thumb)
if sim is not None and sim >= threshold:
return True
return False
def check_ocr_similarity(
ocr_text: str,
kept_ocr_texts: list[str],
kept_ocr_shingles: list[set[str]],
ocr_similarity_threshold: float,
ocr_min_new_chars: int,
) -> bool:
"""检查 OCR 文本是否与最近帧重复,返回 True 表示重复应跳过。"""
if not ocr_text or not kept_ocr_texts:
return False
cur_set = shingles(ocr_text)
for prev_text, prev_set in zip(
kept_ocr_texts[-4:],
kept_ocr_shingles[-4:],
):
if not prev_text:
continue
seq_sim = float(SequenceMatcher(None, prev_text, ocr_text).ratio())
jac_sim = jaccard_sets(prev_set, cur_set)
sim = max(seq_sim, jac_sim)
new_tokens = len(cur_set - prev_set) if prev_set else len(cur_set)
if sim >= ocr_similarity_threshold and new_tokens < ocr_min_new_chars:
return True
return False
def is_frame_duplicate(
content: bytes,
digest: str,
dhash_hex: str,
state: DedupState,
params: ExtractParams,
window: int = 20,
pixel_diff_threshold: float = 8.0,
) -> tuple[bool, str, bytes, bytes, Image.Image | None]:
"""图像层级去重,返回 (is_dup, reason, pixel_thumb, ssim_thumb, scroll_image)。"""
if digest in state.seen_sha256:
state.sha256_dups += 1
return True, "duplicate_sha256", b"", b"", None
if (
params.dedup_threshold
and state.kept_dhashes
and is_dhash_duplicate(dhash_hex, state.kept_dhashes, window, params.dedup_threshold)
):
state.dhash_dups += 1
return True, "duplicate_dhash", b"", b"", None
thumb = b""
if pixel_diff_threshold and state.kept_thumbs:
thumb = calc_thumb_bytes(
content,
crop_top_ratio=params.content_crop_top,
crop_bottom_ratio=params.content_crop_bottom,
crop_left_ratio=params.content_crop_left,
crop_right_ratio=params.content_crop_right,
)
if thumb and is_pixel_duplicate(thumb, state.kept_thumbs, window, pixel_diff_threshold):
state.pixel_dups += 1
return True, "duplicate_pixel", thumb, b"", None
ssim_thumb = b""
if params.ssim_threshold and params.ssim_threshold > 0 and state.kept_ssim_thumbs:
ssim_thumb = calc_thumb_bytes(
content,
size=32,
crop_top_ratio=params.content_crop_top,
crop_bottom_ratio=params.content_crop_bottom,
crop_left_ratio=params.content_crop_left,
crop_right_ratio=params.content_crop_right,
autocontrast=True,
)
if ssim_thumb and is_ssim_duplicate(ssim_thumb, state.kept_ssim_thumbs, window, params.ssim_threshold):
state.ssim_dups += 1
return True, "duplicate_ssim", thumb, ssim_thumb, None
scroll_image = None
if params.scroll_merge and params.scroll_diff_threshold > 0 and state.kept_scroll_images:
scroll_image = calc_scroll_image(
content,
crop_top_ratio=params.content_crop_top,
crop_bottom_ratio=params.content_crop_bottom,
crop_left_ratio=params.content_crop_left,
crop_right_ratio=params.content_crop_right,
)
if scroll_image and scroll_overlap_duplicate(
scroll_image,
state.kept_scroll_images[-8:],
threshold=params.scroll_diff_threshold,
):
state.scroll_dups += 1
return True, "duplicate_scroll", thumb, ssim_thumb, scroll_image
return False, "", thumb, ssim_thumb, scroll_image
def calc_capture_time(
path: str,
index: int,
params: ExtractParams,
info: FFProbeInfo,
) -> float | None:
interval_based = params.strategy in ("interval",)
if not interval_based and (info.time_base_seconds or info.frame_rate_fps):
m = re.search(r"(\d+)", Path(path).name)
if not m:
return None
pts = int(m.group(1))
if info.frame_rate_fps and info.frame_rate_fps > 0:
fps_time = float(pts) / float(info.frame_rate_fps)
if 0 <= fps_time <= info.duration_seconds * 1.1:
return fps_time
return float(pts * float(info.time_base_seconds)) if info.time_base_seconds else None
return float(index - 1) * float(params.interval_seconds)
def collect_frame_files(tmpdir: str) -> list[str]:
frame_files = [
str(Path(tmpdir) / f.name)
for f in Path(tmpdir).iterdir()
if f.name.lower().endswith((".jpg", ".jpeg", ".png"))
]
frame_files.sort()
return frame_files
# ======================================================================
# D. OCR 集成
# ======================================================================
_ocr_engine = None
def create_ocr_engine():
"""创建本地 RapidOCR 引擎(懒加载单例)。"""
global _ocr_engine
if _ocr_engine is not None:
return _ocr_engine
try:
from rapidocr_onnxruntime import RapidOCR
_ocr_engine = RapidOCR()
return _ocr_engine
except ImportError:
logger.warning(
"rapidocr-onnxruntime 未安装,OCR 去重不可用。"
"安装方式: pip install rapidocr-onnxruntime"
)
return None
def ocr_extract_text(ocr_engine: Any, image_bytes: bytes) -> str:
"""调用 OCR 提取文本。"""
if ocr_engine is None:
return ""
try:
result, _ = ocr_engine(image_bytes)
if result:
return "|".join(line[-1] for line in result)
except Exception:
logger.debug("OCR 识别失败", exc_info=True)
return ""