首次提交
This commit is contained in:
16
.cos.yaml
Normal file
16
.cos.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
cos:
|
||||
base:
|
||||
secretid:
|
||||
secretkey:
|
||||
sessiontoken:
|
||||
protocol: https
|
||||
mode: ""
|
||||
cvmrolename: ""
|
||||
closeautoswitchhost: ""
|
||||
disableencryption: ""
|
||||
buckets:
|
||||
- name:
|
||||
alias:
|
||||
region: ""
|
||||
endpoint:
|
||||
ofs: false
|
||||
215
README.md
Normal file
215
README.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# OCR Data - 视频字幕提取与识别系统
|
||||
|
||||
## 项目简介
|
||||
|
||||
本项目是一个自动化视频字幕提取系统,通过视频抽帧、OCR 文字识别、音频语音识别等技术,从视频中提取硬字幕并生成 SRT 字幕文件。支持多线程批量处理,并与腾讯云 COS 对象存储集成(上传/下载)。
|
||||
|
||||
OCR 主力来自内部 OCR 服务(Tencent Youtu,封装为 `tencentcloud_ocr_api`);Whisper 仅做辅助校验(识别首尾音频过滤片头片尾台标、与 OCR 文本做拼音匹配),不是字幕文字的主要来源,因此 Whisper 模型大小对最终字幕质量影响有限。
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **视频抽帧**:用 FFmpeg `fps` 滤镜单次顺序解码,按固定频率抽帧(默认每秒 3 帧)
|
||||
- **字幕区域检测**:通过 OCR 自动定位字幕区域,识别/过滤水印
|
||||
- **图片拼接优化**:将多帧字幕区域裁剪后拼接为大图,大幅减少 OCR 调用次数
|
||||
- **并发 OCR**:getsrt 阶段对拼接图/单独帧并发调用 OCR(`subtitle.ocr_workers` 控制并发数)
|
||||
- **音频辅助校验**:用 Whisper 提取首尾音频文字,与 OCR 结果做拼音匹配
|
||||
- **SRT 字幕生成**:合并、去重(处理多种 OCR 差异变体),生成标准 SRT
|
||||
- **批量处理**:`ThreadPoolExecutor` 多视频并发;日志/计时按线程隔离
|
||||
- **运行统计**:每个视频的起止时间、各阶段耗时、图片处理数量写入 `数据.jsonl`
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
ocr_data/
|
||||
├── main.py # 主入口,批量任务调度
|
||||
├── rerun_srt.py # 复用已有OCR数据重生成SRT(调试getsrt用,不重抽帧)
|
||||
├── requirements.txt # Python 依赖
|
||||
├── coscli.exe / coscli-linux-amd64 # 腾讯云 COS CLI 工具
|
||||
├── config/
|
||||
│ ├── config.json # 配置文件(OCR密钥、抽帧参数、模型、并发等)
|
||||
│ └── config_utils.py # 配置管理器(单例)
|
||||
├── tool/
|
||||
│ ├── process.py # 单视频处理流程
|
||||
│ ├── merge.py # 图片拼接、字幕区域检测、音频文字提取
|
||||
│ ├── getsrt.py # OCR解析、字幕定位、去重、SRT生成(含并发OCR)
|
||||
│ ├── whisper_manager.py # Whisper 管理器(线程安全单例)
|
||||
│ ├── Ocr/tencentcloud_ocr.py # OCR API 封装(内部Youtu服务)
|
||||
│ ├── VideoProcessing/multithread_algo.py # FFmpeg 抽帧
|
||||
│ └── log/log_manager.py # 统一日志管理器(每视频状态线程本地)
|
||||
└── video/ # 视频文件存放目录
|
||||
```
|
||||
|
||||
## 处理流程
|
||||
|
||||
```
|
||||
1. 从 COS 下载视频(可选)
|
||||
↓
|
||||
2. FFmpeg 抽帧(每秒 3 帧,fps=3.0 精确)
|
||||
↓
|
||||
3. OCR 检测字幕区域 & 水印过滤
|
||||
↓
|
||||
4. 字幕区域裁剪 & 多帧拼接为大图
|
||||
↓
|
||||
5. 对拼接图/单独帧并发调用 OCR
|
||||
↓
|
||||
6. Whisper 提取首尾音频文字(辅助校验)
|
||||
↓
|
||||
7. 字幕定位 & 文本质量评估 & 相似帧合并去重
|
||||
↓
|
||||
8. 生成 SRT 字幕文件
|
||||
↓
|
||||
9. 上传结果到 COS & 记录元数据/运行统计
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python 3.10+
|
||||
- FFmpeg(`ffmpeg` + `ffprobe`,**抽帧和音频都依赖**,需加入 PATH)
|
||||
- coscli(COS 命令行工具,配置 `coscli_path`)
|
||||
- CUDA(可选,Whisper GPU 加速)
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Linux 上若 `numpy`/`scikit-learn`/`av` 卡在源码编译(PyAV 编译需系统 ffmpeg 开发库),强制用预编译 wheel:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple --only-binary=av,numpy,scikit-learn
|
||||
```
|
||||
|
||||
首次运行会从 HuggingFace 下载 Whisper 模型;国内建议先设镜像:
|
||||
|
||||
```bash
|
||||
export HF_ENDPOINT=https://hf-mirror.com
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
编辑 `config/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tencentcloud": {
|
||||
"ocr": { "secret_id": "...", "secret_key": "...", "endpoints": ["http://IP1:PORT", "http://IP2:PORT"] },
|
||||
"cos": { "secret_id": "...", "secret_key": "...", "region": "ap-chongqing", "bucket": "..." }
|
||||
},
|
||||
"audio": { "model_size": "small" },
|
||||
"splitframe": {
|
||||
"interval_seconds": 0.33,
|
||||
"download": true,
|
||||
"upload_path": "cos://your-bucket/path",
|
||||
"delete_video": false,
|
||||
"coscli_path": "./coscli-linux-amd64",
|
||||
"cosconfig_path": ".cos.yaml"
|
||||
},
|
||||
"subtitle": {
|
||||
"min_confidence": 50,
|
||||
"similarity_threshold": 0.7,
|
||||
"ocr_workers": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配置项说明
|
||||
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| `tencentcloud.ocr.{secret_id,secret_key}` | OCR 服务凭证 |
|
||||
| `tencentcloud.ocr.endpoints` | OCR 服务 IP 列表(多个时按 QPS 加权分发,失败自动摘除重试);不填回退到内置单端点 |
|
||||
| `tencentcloud.cos.{secret_id,secret_key,region,bucket}` | COS 凭证与桶信息 |
|
||||
| `audio.model_size` | Whisper 模型:`tiny`/`base`/`small`/`medium`/`large-v3`,默认 `small` |
|
||||
| `splitframe.interval_seconds` | 抽帧间隔,`0.33` 表示每秒 3 帧 |
|
||||
| `splitframe.download` | 是否从 COS 下载视频 |
|
||||
| `splitframe.delete_video` | 处理完是否删除本地视频 |
|
||||
| `splitframe.coscli_path` / `cosconfig_path` | coscli 路径 / COS 配置文件 |
|
||||
| `subtitle.min_confidence` | OCR 最低置信度阈值 |
|
||||
| `subtitle.similarity_threshold` | 文本相似度阈值 |
|
||||
| `subtitle.ocr_workers` | getsrt 阶段 OCR 并发线程数(默认 10) |
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 批量处理
|
||||
|
||||
```bash
|
||||
python main.py --source-list ./text2 --max-workers 10
|
||||
```
|
||||
|
||||
### 命令行参数
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--source-list` | `./text2` | 任务 txt 文件所在目录 |
|
||||
| `--max-workers` | 自动(按本机CPU核数) | 最大并发数;不填/传0则自动选(若本机IP在 `tencentcloud.ocr.endpoints` 里,判定为OCR共置机,自动打对折让资源;再按可用磁盘÷每视频预估占用(`dispatch.est_disk_gb_per_video`,默认2G)收紧一次,两者取小;最后还有个硬上限30,超高核数机器也不会自动选出离谱的并发数) |
|
||||
| `--mem-guard-gb` | `2.0` | 可用内存低于此值(GB)时暂停启动新视频,防 OOM;设 `0` 关闭 |
|
||||
| `--disk-guard-gb` | `10.0` | 本地磁盘可用空间低于此值(GB)时暂停启动新视频,给下载/抽帧/合成图预留空间,防止磁盘写满导致级联失败;设 `0` 关闭 |
|
||||
|
||||
启动时会打印一行机器资源(CPU 核数 / 可用内存 / 可用磁盘 / 负载)与当前并发设置,并对不合理的参数(内存/磁盘阈值过高、并发数过大/过小)给出告警,便于跑前判断。内存保护仅在 Linux 生效(读 `/proc/meminfo` 的 `MemAvailable`);磁盘保护跨平台(`shutil.disk_usage`)。机器无 swap 时内存保护尤其重要——可用内存触底会被暂停而非被 OOM 杀进程。
|
||||
|
||||
### 任务列表格式
|
||||
|
||||
在 `--source-list` 目录下创建 `.txt` 文件,**每行一个完整的 COS 路径**(上传目标目录由该路径自动推导,无需 `--cos-target`):
|
||||
|
||||
```
|
||||
cos://bucket/0004/data/XXX/XXX_p1/XXX_p1.mp4
|
||||
cos://bucket/0004/data/XXX/XXX_p2/XXX_p2.mp4
|
||||
```
|
||||
|
||||
每个 txt 处理完会被删除;主循环持续监控目录直到没有 txt。**跨运行续传/去重**通过读取 `数据.jsonl` 的 `video_cos_path` 实现(不依赖 `./success/`)。
|
||||
|
||||
### 压测 OCR 端点 / 多 IP 负载均衡
|
||||
|
||||
OCR 服务有多个 IP 时,先压测每个 IP 的实际 QPS,运行时按 QPS 加权把请求分发到不同 IP:
|
||||
|
||||
```bash
|
||||
# 1) 在 config.json 的 tencentcloud.ocr.endpoints 填上所有 IP
|
||||
# 2) 压测各端点 QPS,结果写入 config/ocr_weights.json
|
||||
python bench_ocr_endpoints.py [样例图片] --requests 60 --concurrency 10
|
||||
# 3) 之后正常跑 main.py,OCR 请求会按权重自动分发;某端点连续失败会被冷却摘除并换端点重试
|
||||
```
|
||||
|
||||
不跑压测时,所有端点等权轮询;压测产出的 `config/ocr_weights.json` 存在则按 QPS 加权(错误率>50% 的端点权重置 0,不参与分发)。
|
||||
|
||||
### 调试 SRT 逻辑
|
||||
|
||||
```bash
|
||||
python tools/rerun_srt.py ./split/<video_name>/composite
|
||||
```
|
||||
|
||||
复用已有 `ocr_result.jsonl` 重生成 SRT,不重新调用 OCR,用于快速迭代 `getsrt.py` 的去重/合并/时间轴逻辑。注意它**不重新抽帧**,无法验证抽帧相关改动。composite 目录现在处理完就随 `split_dir` 一起删了(不再上传/保留到 `./composite/`),所以这条命令只在处理还没走完清理那一步之前有效——比如直接 `import tool.process` 调用 `process_single_video()` 单独调试时(这条路径不经过 `main.py`/`run_worker.py` 的清理逻辑)。
|
||||
|
||||
## 输出说明
|
||||
|
||||
`<video>` 现在是 `tool/process.py::derive_video_name(cos_path)` 算出来的(`<原文件名>_<完整COS路径MD5前8位>`),不是纯文件名——见下面"关键实现要点"里的撞名说明。
|
||||
|
||||
| 目录/文件 | 说明 |
|
||||
|-----------|------|
|
||||
| `./video/<video>.mp4` | 下载的源视频(除非 `delete_video`) |
|
||||
| `./split/<video>/` | 处理中的临时抽帧与 composite。composite(拼接大图、`ocr_result.jsonl` 等)不上传 COS,处理完这个视频后随 `split_dir` 一起整体删除本地 |
|
||||
| `./srt/<video>.srt` | 最终 SRT(上传后移到此处;上传到COS的文件名是 `<video>.ocr.srt`) |
|
||||
| `./info/<video>.json` | 处理成功的元数据(这个视频的 `数据.jsonl` 记录写完后立即删,不等整批跑完) |
|
||||
| `./info_error/<video>.json` | 失败/过滤的元数据(同上,立即删) |
|
||||
| `./log/<video>.json` | 每视频日志:`program_durations`(各阶段耗时)+ `custom_stats`(图片数量)(同上,立即删) |
|
||||
| `数据.jsonl` | 每视频记录:状态、srt 路径、时长、起止时间、`elapsed_seconds`、`image_counts`、`stage_durations` |
|
||||
| `batch_processor.log` | 批量运行日志(Python logging输出)。自动轮转:100M/份,最多留5份 |
|
||||
| `app.log` | 进程整体stdout/stderr(`启动.sh`/`守护重启.sh`用`>>`重定向到这里,比`batch_processor.log`多包含`print()`输出)。不走Python logging,轮转在shell层做:`启动.sh`每次启动前若超过`LOG_MAX_MB`(默认200)就先轮转;`守护重启.sh`的`tick`(每分钟)用copytruncate方式(`cp`+原地截断)独立轮转,因为进程可能还在用append方式往这个文件写,直接`mv`会导致新文件一直是空的 |
|
||||
|
||||
## 过滤规则
|
||||
|
||||
以下情况会过滤视频(不生成字幕):
|
||||
|
||||
- 未检测到字幕区域
|
||||
- 空白拼接图超过一半
|
||||
- 冗余文字过多 / 水印占比过大
|
||||
- 有效字幕数量过少
|
||||
- 文本质量评分不达标(数字/特殊字符占比过高)
|
||||
|
||||
## 关键实现要点(勿回退)
|
||||
|
||||
- **视频本地/COS标识不能只用文件名**:源数据里大量不同父目录复用同样的数字文件名(如 `0021/17688/00046.mp4` 和 `0021/17690/00046.mp4`),如果本地工作目录只按文件名(不含父目录)命名,两个不同视频并发处理时会互相踩踏同一份本地文件(`./split/00046` 同时被两边写/删),引发目录未清空崩溃和连锁失败——实测占某台机器全部错误记录的 100%。现在 `main.py`/`tool/process.py` 都调用 `derive_video_name(cos_path)`(`<文件名>_<完整路径MD5前8位>`)作为唯一标识,两处必须算出同一个值。副作用:最终 SRT 文件名、composite 的 COS 目录名都会带上这个哈希后缀。
|
||||
- **抽帧频率**:每秒 3 帧时 FFmpeg `fps` 必须是 `3.0`,不能用 `1/0.33=3.03`(会比真正 3fps 快约 1%,导致帧内容与 SRT 标注随帧号累积漂移,18 分钟处可差约 10 秒)。
|
||||
- **SRT 合并的空串前缀**:`generate_srt_from_results` 的"渐进蹦字"判断要求 `current_text` 非空,否则空白间隔后的新字幕会继承空白段的起始时间。
|
||||
- **OCR 变体去重**:`_similar` 合并同一句的不同 OCR 损失版本——支持前缀、子序列、标点归一化(`······` vs `......`)、去全部标点后的长公共前缀判定(处理开头稳定、中/尾各自丢失的闪烁)。
|
||||
- **日志线程隔离**:`UnifiedLogger` 的每视频状态是线程本地的;嵌套子线程(如 OCR 并发池)不要直接调 `logger.log`,应把结果/错误回传给视频主线程统一记录。
|
||||
BIN
config/2026-08.lnk
Normal file
BIN
config/2026-08.lnk
Normal file
Binary file not shown.
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
BIN
config/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
config/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
config/__pycache__/config_utils.cpython-311.pyc
Normal file
BIN
config/__pycache__/config_utils.cpython-311.pyc
Normal file
Binary file not shown.
113
config/config.json
Normal file
113
config/config.json
Normal file
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"tencentcloud": {
|
||||
"ocr": {
|
||||
"secret_id": "",
|
||||
"secret_key": "",
|
||||
"max_attempts": 4,
|
||||
"timeout": 30,
|
||||
"dispatch_log_every": 1000,
|
||||
"endpoints": [
|
||||
]
|
||||
},
|
||||
"cos": {
|
||||
"secret_id": "",
|
||||
"secret_key": "",
|
||||
"region": "",
|
||||
"bucket": ""
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"model_size": "small",
|
||||
"device": "cpu",
|
||||
"compute_type": "float16",
|
||||
"device_index": "auto",
|
||||
"cpu_threads": 2
|
||||
},
|
||||
"splitframe": {
|
||||
"interval_seconds": 0.33,
|
||||
"ffmpeg_threads": 3,
|
||||
"use_gpu": true,
|
||||
"gpu_skip_codecs": [
|
||||
"av1"
|
||||
],
|
||||
"gpu_reserve_last_for_whisper": false,
|
||||
"gpu_skip_indices": [],
|
||||
"gpu_auto_select": true,
|
||||
"gpu_busy_mem_mb": 4096,
|
||||
"crop_bottom_ratio": null,
|
||||
"download": true,
|
||||
"root_path": "./video",
|
||||
"upload_path": "",
|
||||
"delete_video": true,
|
||||
"coscli_path": "./coscli-linux-amd64",
|
||||
"cosconfig_path": ".cos.yaml",
|
||||
"ffmpeg_timeout": 3600
|
||||
},
|
||||
"subtitle": {
|
||||
"min_confidence": 50,
|
||||
"similarity_threshold": 0.7,
|
||||
"ocr_workers": 20,
|
||||
"require_chinese": true,
|
||||
"redundant_bands": 12,
|
||||
"redundant_merge_ratio": 0.97,
|
||||
"no_composite": false,
|
||||
"min_duration_seconds": 0,
|
||||
"merge_workers": 4,
|
||||
"cv_detect": true,
|
||||
"cv_use_region": false,
|
||||
"max_tiles_per_composite": 24,
|
||||
"ocr_max_image_side": 4000,
|
||||
"filter_static": true,
|
||||
"static_frac": 0.25,
|
||||
"filter_lowconf": true,
|
||||
"lowconf_thr": 80,
|
||||
"lowconf_filter_frac": 0.6,
|
||||
"lowconf_min_det": 200,
|
||||
"strip_static_prefix": false,
|
||||
"filter_watermark_pos": true,
|
||||
"wm_dominant_frac": 0.25,
|
||||
"wm_min_count": 20,
|
||||
"split_far_boxes": true,
|
||||
"max_duration_seconds": 0,
|
||||
"long_video_list": "./超长待重跑.txt",
|
||||
"ocr_no_drop": true,
|
||||
"ocr_max_passes": 5,
|
||||
"ocr_retry_sleep": 2,
|
||||
"ocr_fail_list": "./OCR失败待重跑.txt",
|
||||
"max_global_ocr": 0,
|
||||
"no_subtitle_reasons": [
|
||||
"字幕太少",
|
||||
"冗余文字过多",
|
||||
"冗余文字太多",
|
||||
"无字幕区域",
|
||||
"非字幕内容",
|
||||
"置信度太低",
|
||||
"未检测",
|
||||
"特殊字体",
|
||||
"视频过短",
|
||||
"视频过长"
|
||||
]
|
||||
},
|
||||
"dispatch": {
|
||||
"enabled": true,
|
||||
"source": "cos",
|
||||
"run_long_video": false,
|
||||
"auto_fallback_long_video": false,
|
||||
"global_dedup_on_startup": false,
|
||||
"machine_id": "",
|
||||
"jobs_root": "",
|
||||
"shard_size": 2000,
|
||||
"keep_alive": true,
|
||||
"idle_poll_seconds": 60,
|
||||
"claim_shuffle": true,
|
||||
"claim_match": "",
|
||||
"heartbeat_seconds": 300,
|
||||
"daily_backup_path": "",
|
||||
"monitor_root": "",
|
||||
"long_list_backup_path": "",
|
||||
"long_list_clear_after_upload": true,
|
||||
"long_list_min_batch": 1000,
|
||||
"daily_backup_hour": 10,
|
||||
"stop_flag_key": ""
|
||||
}
|
||||
}
|
||||
131
config/config_utils.py
Normal file
131
config/config_utils.py
Normal file
@@ -0,0 +1,131 @@
|
||||
# config_utils.py
|
||||
import json
|
||||
import os
|
||||
|
||||
class ConfigManager:
|
||||
_instance = None
|
||||
_config = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(ConfigManager, cls).__new__(cls)
|
||||
cls._instance.load_config()
|
||||
return cls._instance
|
||||
|
||||
def load_config(self, config_path="./config/config.json"):
|
||||
"""加载配置文件"""
|
||||
try:
|
||||
if not os.path.exists(config_path):
|
||||
# 如果配置文件不存在,创建一个默认配置
|
||||
default_config = {
|
||||
"tencentcloud": {
|
||||
"ocr": {
|
||||
"secret_id": "please_set_your_secret_id",
|
||||
"secret_key": "please_set_your_secret_key",
|
||||
"region": "ap-beijing",
|
||||
"endpoint": "ocr.tencentcloudapi.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(default_config, f, indent=4, ensure_ascii=False)
|
||||
print(f"已创建默认配置文件: {config_path}")
|
||||
print("请编辑此文件并填写您的腾讯云OCR密钥")
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
self._config = json.load(f)
|
||||
|
||||
except Exception as e:
|
||||
print(f"加载配置文件失败: {e}")
|
||||
self._config = {}
|
||||
|
||||
def get(self, key, default=None):
|
||||
"""获取配置值"""
|
||||
try:
|
||||
keys = key.split('.')
|
||||
value = self._config
|
||||
for k in keys:
|
||||
value = value[k]
|
||||
return value
|
||||
except (KeyError, TypeError):
|
||||
return default
|
||||
|
||||
def get_tencent_ocr_config(self):
|
||||
"""获取腾讯云OCR配置"""
|
||||
return self.get('tencentcloud.ocr', {})
|
||||
|
||||
def get_splitframe_config(self):
|
||||
"""获取Whisper配置"""
|
||||
return self.get('splitframe', {})
|
||||
|
||||
def get_audio_config(self):
|
||||
"""获取音频配置"""
|
||||
return self.get('audio', {})
|
||||
|
||||
def get_subtitle_config(self):
|
||||
"""获取字幕配置"""
|
||||
return self.get('subtitle', {})
|
||||
|
||||
def get_cos_config(self):
|
||||
"""获取COS配置"""
|
||||
return self.get('tencentcloud.cos', {})
|
||||
|
||||
def get_dispatch_config(self):
|
||||
"""获取 COS 任务分发配置(常驻 worker / 认领锁 / 每日备份)。"""
|
||||
return self.get('dispatch', {})
|
||||
|
||||
def _prepend_local_ffmpeg_to_path():
|
||||
"""若项目根的 bin/ 下有自带 ffmpeg,则把 bin/ 提到 PATH 最前,
|
||||
让所有 ffmpeg/ffprobe 调用(抽帧、音频、pydub 等)都优先用项目自带的,
|
||||
不再依赖系统 PATH。"""
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
bin_dir = os.path.join(root, "bin")
|
||||
ff = os.path.join(bin_dir, "ffmpeg")
|
||||
if os.path.isfile(ff):
|
||||
try:
|
||||
os.chmod(ff, 0o755)
|
||||
ffprobe = os.path.join(bin_dir, "ffprobe")
|
||||
if os.path.isfile(ffprobe):
|
||||
os.chmod(ffprobe, 0o755)
|
||||
except OSError:
|
||||
pass
|
||||
os.environ["PATH"] = bin_dir + os.pathsep + os.environ.get("PATH", "")
|
||||
|
||||
|
||||
def _ensure_coscli_executable():
|
||||
"""给项目根的 coscli-linux-amd64 自动加执行权限(解压后常丢失 +x,导致无法调用)。"""
|
||||
if os.name == "nt":
|
||||
return
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
for name in ("coscli-linux-amd64", "coscli"):
|
||||
p = os.path.join(root, name)
|
||||
if os.path.isfile(p) and not os.access(p, os.X_OK):
|
||||
try:
|
||||
os.chmod(p, 0o755)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
_prepend_local_ffmpeg_to_path()
|
||||
_ensure_coscli_executable()
|
||||
|
||||
|
||||
def _resolve_bin(name):
|
||||
"""返回项目 bin/ 下的可执行文件绝对路径(若存在),否则返回名字本身(回退到 PATH)。"""
|
||||
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
p = os.path.join(root, "bin", name)
|
||||
return p if os.path.isfile(p) else name
|
||||
|
||||
|
||||
def get_ffmpeg():
|
||||
"""ffmpeg 可执行路径:优先项目自带 bin/ffmpeg,否则 PATH 里的 ffmpeg。"""
|
||||
return _resolve_bin("ffmpeg")
|
||||
|
||||
|
||||
def get_ffprobe():
|
||||
"""ffprobe 可执行路径:优先项目自带 bin/ffprobe,否则 PATH 里的 ffprobe。"""
|
||||
return _resolve_bin("ffprobe")
|
||||
|
||||
|
||||
# 全局配置实例
|
||||
config = ConfigManager()
|
||||
BIN
coscli-linux-amd64
Normal file
BIN
coscli-linux-amd64
Normal file
Binary file not shown.
628
main.py
Normal file
628
main.py
Normal file
@@ -0,0 +1,628 @@
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
import cv2
|
||||
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from config.config_utils import config
|
||||
from tool.process import process_single_video, derive_video_name
|
||||
from tool.log.log_manager import logger as steplog
|
||||
from tool import job_cos
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||
handlers=[
|
||||
# 常驻worker一跑就是数天/数周, 普通FileHandler会无限增长; 100M/份, 最多留5份(共500M封顶)
|
||||
RotatingFileHandler('batch_processor.log', maxBytes=100 * 1024 * 1024, backupCount=5, encoding='utf-8'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
|
||||
JSONL_PATH = "数据.jsonl"
|
||||
|
||||
|
||||
# ==================== 工具函数 ====================
|
||||
|
||||
def parse_cos_path(cos_path: str):
|
||||
"""cos://bucket/path/video.mp4 → ('cos://bucket/path/', 'video.mp4')"""
|
||||
cos_path = cos_path.strip()
|
||||
idx = cos_path.rfind('/')
|
||||
return cos_path[:idx + 1], cos_path[idx + 1:]
|
||||
|
||||
|
||||
def get_video_duration(local_path: str) -> float:
|
||||
if not os.path.exists(local_path):
|
||||
return 0.0
|
||||
cap = cv2.VideoCapture(local_path)
|
||||
if not cap.isOpened():
|
||||
return 0.0
|
||||
frames = cap.get(cv2.CAP_PROP_FRAME_COUNT)
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
cap.release()
|
||||
return round(frames / fps, 2) if fps > 0 else 0.0
|
||||
|
||||
|
||||
def _read_log_stats(video_name: str):
|
||||
"""从 log/<video>.json 读取图片数量统计与各阶段耗时。
|
||||
返回 (image_counts: dict|None, stage_durations: dict|None, video_duration: float|None),读取失败返回全 None。"""
|
||||
log_path = os.path.join("log", f"{video_name}.json")
|
||||
if not os.path.exists(log_path):
|
||||
return None, None, None
|
||||
try:
|
||||
with open(log_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
meta = data.get("metadata", {})
|
||||
custom = meta.get("custom_stats", {}) or {}
|
||||
durations = meta.get("program_durations", {}) or {}
|
||||
image_counts = {
|
||||
"frame_count": custom.get("frame_count"),
|
||||
"composite_ocr_count": custom.get("composite_ocr_count"),
|
||||
"single_frame_ocr_count": custom.get("single_frame_ocr_count"),
|
||||
"total_ocr_count": custom.get("total_ocr_count"),
|
||||
}
|
||||
# 各阶段耗时(秒),四舍五入到2位
|
||||
stage_durations = {k: round(v, 2) for k, v in durations.items()}
|
||||
video_duration = custom.get("video_duration") # process.py 在删视频前用 ffprobe 存的(AV1也准)
|
||||
return image_counts, stage_durations, video_duration
|
||||
except (OSError, IOError, json.JSONDecodeError) as e:
|
||||
logging.warning(f"读取日志统计失败 {log_path}: {e}")
|
||||
return None, None, None
|
||||
|
||||
|
||||
def upload_srt(srt_local: str, cos_dir: str, coscli_path: str, cos_config_path: str):
|
||||
"""上传 SRT 文件到视频对应的 COS 目录,返回 COS 路径;失败返回 None"""
|
||||
if not os.path.exists(srt_local):
|
||||
return None
|
||||
cos_dest = cos_dir + os.path.basename(srt_local)
|
||||
cmd = [coscli_path, "-c", cos_config_path, "cp", srt_local, cos_dest]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
if result.returncode == 0:
|
||||
return cos_dest
|
||||
logging.error(f"SRT 上传失败: {result.stderr}")
|
||||
return None
|
||||
|
||||
|
||||
def get_available_mem_gb():
|
||||
"""读取系统可用内存(GB)。Linux 读 /proc/meminfo 的 MemAvailable;
|
||||
非 Linux 或读取失败返回 None(此时内存保护自动失效)。"""
|
||||
try:
|
||||
with open('/proc/meminfo', 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('MemAvailable:'):
|
||||
return float(line.split()[1]) / 1024 / 1024 # kB → GB
|
||||
except (OSError, ValueError, IndexError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def get_available_disk_gb(path: str = "."):
|
||||
"""读取 path 所在磁盘的可用空间(GB)。用 shutil.disk_usage,跨平台(Windows/Linux都行,
|
||||
不像内存检查那样只能读 /proc/meminfo)。读取失败(路径不存在等)返回 None,此时磁盘保护自动失效。"""
|
||||
try:
|
||||
return shutil.disk_usage(path).free / 1024 / 1024 / 1024
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def get_load_avg():
|
||||
"""返回 (1, 5, 15) 分钟负载;非 Unix 返回 None。"""
|
||||
try:
|
||||
return os.getloadavg()
|
||||
except (OSError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def load_processed(jsonl_path: str) -> set:
|
||||
"""从数据.jsonl 读取已处理的 COS 路径集合,用于跨批次断点续传"""
|
||||
processed = set()
|
||||
if not os.path.exists(jsonl_path):
|
||||
return processed
|
||||
with open(jsonl_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
vid = rec.get('video_cos_path')
|
||||
if vid:
|
||||
processed.add(vid)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return processed
|
||||
|
||||
|
||||
def force_rmtree(path):
|
||||
"""强制删除目录(兼容 Windows 和 Linux)"""
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
for attempt in range(3):
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
return
|
||||
except PermissionError:
|
||||
if attempt < 2:
|
||||
time.sleep(1)
|
||||
for root, dirs, files in os.walk(path):
|
||||
for name in files:
|
||||
try:
|
||||
os.chmod(os.path.join(root, name), stat.S_IWUSR | stat.S_IRUSR)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
if os.name == 'nt':
|
||||
os.system(f'rmdir /s /q "{path}"')
|
||||
else:
|
||||
os.system(f'rm -rf "{path}"')
|
||||
|
||||
|
||||
# ==================== 统计 ====================
|
||||
|
||||
class Stats:
|
||||
def __init__(self, total: int):
|
||||
self._lock = threading.Lock()
|
||||
self.success = 0
|
||||
self.no_subtitle = 0
|
||||
self.error = 0
|
||||
self.done = 0
|
||||
self.total = total
|
||||
|
||||
def update(self, status: str):
|
||||
with self._lock:
|
||||
self.done += 1
|
||||
if status == 'success':
|
||||
self.success += 1
|
||||
elif status == 'no_subtitle':
|
||||
self.no_subtitle += 1
|
||||
else:
|
||||
self.error += 1
|
||||
print(
|
||||
f"[进度 {self.done}/{self.total}] "
|
||||
f"成功: {self.success} 无字幕: {self.no_subtitle} 错误: {self.error} "
|
||||
f"剩余: {self.total - self.done}",
|
||||
flush=True
|
||||
)
|
||||
|
||||
|
||||
# ==================== 处理器 ====================
|
||||
|
||||
def _wait_for_memory(video_name: str, mem_guard_gb: float):
|
||||
"""内存可用量低于阈值时阻塞,等已在处理的视频释放内存后再启动新视频,避免 OOM。
|
||||
模块级函数:进程池下每个 worker 进程独立调用。读不到内存时直接放行。"""
|
||||
if mem_guard_gb <= 0:
|
||||
return
|
||||
waited = 0
|
||||
while True:
|
||||
avail = get_available_mem_gb()
|
||||
if avail is None or avail >= mem_guard_gb:
|
||||
return
|
||||
if waited % 15 == 0:
|
||||
logging.warning(
|
||||
f"内存不足(可用 {avail:.1f}G < 阈值 {mem_guard_gb}G),"
|
||||
f"暂停启动 {video_name},等待回收... 已等 {waited}s"
|
||||
)
|
||||
time.sleep(3)
|
||||
waited += 3
|
||||
|
||||
|
||||
def _wait_for_disk(video_name: str, disk_guard_gb: float):
|
||||
"""可用磁盘空间低于阈值时阻塞,等已在处理的视频清理释放空间后再启动新视频。
|
||||
防的是磁盘写满级联失败(下载/抽帧写一半失败、GPU硬解报错误判成"GPU失败"其实是写不进磁盘、
|
||||
info_error都写不进去导致视频异常处理链路整个乱掉——实际发生过的连锁故障)。
|
||||
模块级函数:进程池下每个 worker 进程独立调用。读不到磁盘空间时直接放行。"""
|
||||
if disk_guard_gb <= 0:
|
||||
return
|
||||
waited = 0
|
||||
while True:
|
||||
avail = get_available_disk_gb(".")
|
||||
if avail is None or avail >= disk_guard_gb:
|
||||
return
|
||||
if waited % 15 == 0:
|
||||
logging.warning(
|
||||
f"磁盘空间不足(可用 {avail:.1f}G < 阈值 {disk_guard_gb}G),"
|
||||
f"暂停启动 {video_name},等待清理释放空间... 已等 {waited}s"
|
||||
)
|
||||
time.sleep(3)
|
||||
waited += 3
|
||||
|
||||
|
||||
def _short_reason(text: str) -> str:
|
||||
"""把 info_error 里的长错误串归一成一个短分类标签,便于 数据.jsonl 里按原因统计/筛选。"""
|
||||
if not text:
|
||||
return None
|
||||
for kw in ("视频过长", "视频过短", "字幕太少", "冗余文字过多",
|
||||
"下载失败", "抽帧", "拼接失败", "字幕生成失败", "OCR"):
|
||||
if kw in text:
|
||||
return kw
|
||||
if "置信度" in text or "confidence" in text.lower():
|
||||
return "置信度太低"
|
||||
if "未找到合适的字幕区域" in text or "未检测" in text:
|
||||
return "无字幕区域"
|
||||
# 兜底截断: 之前只截40字符——异常串常见格式是"RuntimeError: 视频<video_name>真正原因",
|
||||
# video_name(文件名+MD5后缀)本身就有二三十字符,40字符的窗口经常在真正原因(冒号后面
|
||||
# 那段)出现之前就被切没了,数据.jsonl里全变成"RuntimeError: 视频<name>"这种没有信息量
|
||||
# 的记录(实测发生过:一小时63条error全部是这个模式)。加大到150,给真实原因留出空间;
|
||||
# 完整未截断的原始异常串仍然保留在 record["error_reason"] 里,这里只是短标签。
|
||||
return text.strip()[:150]
|
||||
|
||||
|
||||
def _read_error_reason(video_name: str) -> str:
|
||||
"""从 ./info_error/<video>.json 读出过滤/错误原因(归类后的短标签)。"""
|
||||
err_json = f"./info_error/{video_name}.json"
|
||||
if not os.path.exists(err_json):
|
||||
return None
|
||||
try:
|
||||
with open(err_json, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return _short_reason((data.get("main_process") or {}).get("error"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# 这些原因是"正常过滤(视频本就没有可用字幕)", 不是程序硬故障 → 应记 no_subtitle 而非 error。
|
||||
# 旧逻辑用 '"被过滤" in json文本' 猜测, 但过滤原因只存在 main_process.error 里(不含"被过滤"),
|
||||
# 于是字幕太少/冗余/置信度/非字幕等全被错记成 error。改为按原因关键词匹配, 清单可在
|
||||
# config.subtitle.no_subtitle_reasons 覆盖。
|
||||
_NO_SUB_REASONS_DEFAULT = [
|
||||
"字幕太少", "冗余文字过多", "冗余文字太多", "无字幕区域",
|
||||
"非字幕内容", "置信度太低", "未检测", "特殊字体",
|
||||
"视频过短", "视频过长",
|
||||
]
|
||||
|
||||
|
||||
def _is_no_subtitle_reason(reason: str) -> bool:
|
||||
"""原因是否属于'无可用字幕的正常过滤'(→ no_subtitle), 而非硬故障(→ error)。"""
|
||||
if not reason:
|
||||
return False
|
||||
try:
|
||||
keys = config.get_subtitle_config().get('no_subtitle_reasons') or _NO_SUB_REASONS_DEFAULT
|
||||
except Exception:
|
||||
keys = _NO_SUB_REASONS_DEFAULT
|
||||
return any(k in reason for k in keys)
|
||||
|
||||
|
||||
def process_video_task(cos_path: str, coscli_path: str, cos_config_path: str,
|
||||
split_cfg: dict, mem_guard_gb: float, disk_guard_gb: float = 0) -> dict:
|
||||
"""处理单个视频,返回完整 record(不触碰共享状态,可被进程池 pickle 调用)。
|
||||
进程池模式下:本函数整体在独立子进程里跑 → 各自的 Python 解释器/GIL/whisper 模型 → 真多核。"""
|
||||
cos_dir, filename = parse_cos_path(cos_path)
|
||||
video_name = derive_video_name(cos_path) # 含路径MD5后缀, 避免不同父目录同名视频本地/COS撞车
|
||||
local_video = os.path.join("./video", video_name + os.path.splitext(filename)[1])
|
||||
|
||||
# 内存/磁盘保护(放在计时前,等待时间不计入 elapsed_seconds)
|
||||
_wait_for_memory(video_name, mem_guard_gb)
|
||||
_wait_for_disk(video_name, disk_guard_gb)
|
||||
|
||||
status = "error"
|
||||
start_dt = datetime.now()
|
||||
start_perf = time.time()
|
||||
record = {
|
||||
"timestamp": start_dt.isoformat(),
|
||||
"video_cos_path": cos_path,
|
||||
"status": None,
|
||||
"reason": None,
|
||||
"srt_cos_path": None,
|
||||
"duration": None,
|
||||
"process_start": start_dt.isoformat(),
|
||||
"process_end": None,
|
||||
"elapsed_seconds": None,
|
||||
"image_counts": None,
|
||||
"stage_durations": None,
|
||||
}
|
||||
|
||||
try:
|
||||
success = process_single_video(cos_path, cos_dir)
|
||||
record["duration"] = get_video_duration(local_video)
|
||||
|
||||
if success:
|
||||
srt_local = f"./split/{video_name}/composite/{video_name}.ocr.srt"
|
||||
srt_cos = upload_srt(srt_local, cos_dir, coscli_path, cos_config_path)
|
||||
status = "success"
|
||||
record["srt_cos_path"] = srt_cos
|
||||
if os.path.exists(srt_local):
|
||||
os.makedirs("./srt", exist_ok=True)
|
||||
shutil.move(srt_local, os.path.join("./srt", f"{video_name}.srt"))
|
||||
steplog.step(video_name, "上传完成", "srt已传" if srt_cos else "srt上传失败")
|
||||
logging.info(f"成功 {filename} → {srt_cos}")
|
||||
else:
|
||||
# 按原因关键词归类(config 可调): 无可用字幕的正常过滤 → no_subtitle, 其余 → error。
|
||||
reason = _read_error_reason(video_name)
|
||||
record["reason"] = reason
|
||||
is_filtered = _is_no_subtitle_reason(reason)
|
||||
status = "no_subtitle" if is_filtered else "error"
|
||||
logging.info(f"{'无字幕' if is_filtered else '失败'} {filename}")
|
||||
|
||||
except Exception as e:
|
||||
reason = _short_reason(f"{type(e).__name__}: {e}")
|
||||
record["error_reason"] = f"{type(e).__name__}: {e}"
|
||||
record["reason"] = reason
|
||||
# 过滤型异常(如 raise '未检测到字幕区域'/'视频过短') 也应归 no_subtitle, 不是硬故障。
|
||||
status = "no_subtitle" if _is_no_subtitle_reason(reason) else "error"
|
||||
logging.error(f"异常 {filename}: {e}")
|
||||
|
||||
finally:
|
||||
# composite(含拼接大图/OCR中间结果)不上传 COS,处理完直接随 split_dir 一起删除本地。
|
||||
split_dir = f"./split/{video_name}"
|
||||
if os.path.exists(split_dir):
|
||||
force_rmtree(split_dir)
|
||||
if split_cfg.get('delete_video') and os.path.exists(local_video):
|
||||
try:
|
||||
os.remove(local_video)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
end_dt = datetime.now()
|
||||
record["status"] = status
|
||||
record["process_end"] = end_dt.isoformat()
|
||||
record["elapsed_seconds"] = round(time.time() - start_perf, 2)
|
||||
image_counts, stage_durations, video_duration = _read_log_stats(video_name)
|
||||
record["image_counts"] = image_counts
|
||||
record["stage_durations"] = stage_durations
|
||||
# 时长优先用 process.py 删视频前 ffprobe 存的(cv2读不了AV1、且delete_video后源视频已删)
|
||||
if (not record.get("duration")) and video_duration:
|
||||
record["duration"] = video_duration
|
||||
|
||||
# 该读的(日志统计/错误原因)都读完了,本视频的临时小文件立即删,不等整批/整片跑完再统一清
|
||||
# (之前是攒到 main.py 主循环/run_worker._process_shard 末尾才批量删,一片上千个视频攒一路)。
|
||||
for f in (f"./log/{video_name}.json", f"./info/{video_name}.json", f"./info_error/{video_name}.json"):
|
||||
try:
|
||||
if os.path.exists(f):
|
||||
os.remove(f)
|
||||
except OSError:
|
||||
pass
|
||||
return record
|
||||
|
||||
|
||||
def _ocr_worker_init(sem):
|
||||
"""进程池 worker 启动时调用:注入【全局 OCR 并发信号量】,使所有进程共享一个硬性并发上限。"""
|
||||
try:
|
||||
from tool.Ocr import tencentcloud_ocr
|
||||
tencentcloud_ocr.set_ocr_semaphore(sem)
|
||||
except Exception as e:
|
||||
logging.warning(f"注入全局OCR信号量失败: {e}")
|
||||
|
||||
|
||||
class VideoProcessor:
|
||||
def __init__(self, max_workers: int = 10, mem_guard_gb: float = 2.0, executor: str = "process",
|
||||
max_tasks_per_child: int = 30, disk_guard_gb: float = 0):
|
||||
self.max_workers = max_workers
|
||||
self.mem_guard_gb = mem_guard_gb # 可用内存低于此值(GB)时暂停启动新视频,<=0 关闭
|
||||
self.disk_guard_gb = disk_guard_gb # 本地磁盘可用空间低于此值(GB)时暂停启动新视频,<=0 关闭
|
||||
self.executor = executor # "process"=进程池(绕开GIL,真多核) / "thread"=线程池
|
||||
self.max_tasks_per_child = max_tasks_per_child # 进程池: worker处理这么多视频后重启释放内存
|
||||
self._jsonl_lock = threading.Lock()
|
||||
|
||||
def _append_jsonl(self, record: dict):
|
||||
with self._jsonl_lock:
|
||||
with open(JSONL_PATH, 'a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
def process_batch(self, tasks: list, coscli_path: str,
|
||||
cos_config_path: str, split_cfg: dict) -> Stats:
|
||||
stats = Stats(total=len(tasks))
|
||||
mode = "进程池(多核,绕GIL)" if self.executor == "process" else "线程池"
|
||||
logging.info(f"开始处理 {len(tasks)} 个视频 ({mode} 并发={self.max_workers})")
|
||||
|
||||
# 后台定时打印进度(每30秒一次,处理完后停止)
|
||||
stop_event = threading.Event()
|
||||
def _heartbeat():
|
||||
while not stop_event.wait(30):
|
||||
with stats._lock:
|
||||
d, t = stats.done, stats.total
|
||||
s, n, e = stats.success, stats.no_subtitle, stats.error
|
||||
print(
|
||||
f"[进行中 {d}/{t}] 成功: {s} 无字幕: {n} 错误: {e} 剩余: {t - d}",
|
||||
flush=True
|
||||
)
|
||||
threading.Thread(target=_heartbeat, daemon=True).start()
|
||||
|
||||
# 进程池: 每个视频在独立子进程跑 → 各自 GIL/whisper → 真多核(getsrt 的 jieba/拼音不再串行)
|
||||
# 线程池: 退化兜底(--executor thread)
|
||||
# 用 forkserver: 从干净的服务进程 fork worker,避免"主进程多线程+fork"继承到被持有的锁而死锁;
|
||||
# 重模块(cv2/whisper)在 forkserver 里只导入一次, worker 共享(COW),启动快。
|
||||
if self.executor == "process":
|
||||
import multiprocessing
|
||||
try:
|
||||
_ctx = multiprocessing.get_context("forkserver")
|
||||
except ValueError:
|
||||
_ctx = multiprocessing.get_context() # 非 POSIX 等回退默认
|
||||
# 全局OCR并发上限: 父进程建跨进程信号量, 经 initializer 注入每个 worker →
|
||||
# 不管 max-workers × ocr_workers 多大, 全局同时在飞的OCR请求被硬性卡在此值, 防打爆端点。
|
||||
_max_global_ocr = int(config.get_subtitle_config().get('max_global_ocr', 0) or 0)
|
||||
_init, _initargs = None, ()
|
||||
if _max_global_ocr > 0:
|
||||
_init = _ocr_worker_init
|
||||
_initargs = (_ctx.Semaphore(_max_global_ocr),)
|
||||
logging.info(f"全局OCR并发上限: {_max_global_ocr}")
|
||||
# max_tasks_per_child: worker 处理这么多视频后【自动重启】释放内存(需 Python≥3.11)。
|
||||
# ⚠️ Python 3.11 该机制有"补不满"的坑: 频繁回收会让 worker 数随时间从 N 缩到个位数甚至0
|
||||
# → as_completed 永久挂起 → 进度冻住。内存充裕时建议【关闭】(设 0/None), 靠整体重启释放内存。
|
||||
_mtpc = self.max_tasks_per_child if (self.max_tasks_per_child and self.max_tasks_per_child > 0) else None
|
||||
if _mtpc is None:
|
||||
logging.info("max_tasks_per_child=关闭(worker不自动回收, 避免3.11回收缩水; 内存靠整体重启/mem-guard控制)")
|
||||
try:
|
||||
pool = ProcessPoolExecutor(max_workers=self.max_workers, mp_context=_ctx,
|
||||
max_tasks_per_child=_mtpc,
|
||||
initializer=_init, initargs=_initargs)
|
||||
except TypeError:
|
||||
logging.warning("当前Python不支持 max_tasks_per_child(需≥3.11), 进程不自动回收, 注意内存")
|
||||
pool = ProcessPoolExecutor(max_workers=self.max_workers, mp_context=_ctx,
|
||||
initializer=_init, initargs=_initargs)
|
||||
else:
|
||||
# 线程池模式: 同进程内, 用 threading 信号量直接限流(可选)
|
||||
_max_global_ocr = int(config.get_subtitle_config().get('max_global_ocr', 0) or 0)
|
||||
if _max_global_ocr > 0:
|
||||
from tool.Ocr import tencentcloud_ocr
|
||||
tencentcloud_ocr.set_ocr_semaphore(threading.Semaphore(_max_global_ocr))
|
||||
logging.info(f"全局OCR并发上限: {_max_global_ocr}")
|
||||
pool = ThreadPoolExecutor(max_workers=self.max_workers)
|
||||
with pool as executor:
|
||||
futures = {
|
||||
executor.submit(
|
||||
process_video_task, cos_path,
|
||||
coscli_path, cos_config_path, split_cfg, self.mem_guard_gb, self.disk_guard_gb
|
||||
): cos_path
|
||||
for cos_path in tasks
|
||||
}
|
||||
# 父进程单线程收集结果 → 统一写 jsonl(单写者,无需跨进程锁) + 更新统计
|
||||
for future in as_completed(futures):
|
||||
cos_path = futures[future]
|
||||
try:
|
||||
record = future.result()
|
||||
except Exception as e:
|
||||
# 子进程崩溃(段错误/被OOM杀)等 → 仍记一条 error
|
||||
logging.error(f"任务异常 {cos_path}: {e}")
|
||||
record = {"video_cos_path": cos_path, "status": "error",
|
||||
"error_reason": f"worker崩溃: {type(e).__name__}: {e}",
|
||||
"process_end": datetime.now().isoformat()}
|
||||
self._append_jsonl(record)
|
||||
stats.update(record.get("status", "error"))
|
||||
|
||||
stop_event.set() # 停止心跳线程
|
||||
logging.info(
|
||||
f"批次完成 — 成功: {stats.success} | "
|
||||
f"无字幕: {stats.no_subtitle} | 错误: {stats.error}"
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
# ==================== 入口 ====================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='视频字幕批量提取(txt 内填 COS 完整路径)')
|
||||
parser.add_argument('--source-list', default='./text2',
|
||||
help='包含 COS 路径列表的 txt 文件目录')
|
||||
parser.add_argument('--max-workers', type=int, default=None,
|
||||
help='最大并发线程数 (不填/0 = 自动按本机CPU核数选)')
|
||||
parser.add_argument('--mem-guard-gb', type=float, default=2.0,
|
||||
help='可用内存低于此值(GB)时暂停启动新视频,防 OOM;设 0 关闭 (默认: 2.0)')
|
||||
parser.add_argument('--disk-guard-gb', type=float, default=10.0,
|
||||
help='本地磁盘可用空间低于此值(GB)时暂停启动新视频,给下载/抽帧/合成图预留空间,'
|
||||
'防止磁盘写满导致级联失败;设 0 关闭 (默认: 10.0)')
|
||||
parser.add_argument('--max-tasks-per-child', type=int, default=30,
|
||||
help='进程池模式: 每个worker处理这么多视频后重启释放内存(默认30, 需Python≥3.11)')
|
||||
parser.add_argument('--executor', choices=['process', 'thread'], default='process',
|
||||
help='并发方式: process=进程池(默认,绕开GIL真多核) / thread=线程池(旧行为,CPU重计算会被GIL串行)')
|
||||
args = parser.parse_args()
|
||||
|
||||
# ---- 启动时打印机器资源与并发设置,便于判断参数是否合理 ----
|
||||
cpu = os.cpu_count() or 0
|
||||
if not args.max_workers:
|
||||
args.max_workers = job_cos.auto_max_workers(cpu, disk_guard_gb=args.disk_guard_gb)
|
||||
logging.info(f"--max-workers 未指定, 自动选为 {args.max_workers}(本机CPU核数={cpu or '未知'}; "
|
||||
f"若本机还跑了OCR服务但不在 tencentcloud.ocr.endpoints 里, 自动探测不到, 请手动传小一点的值; "
|
||||
f"同时按可用磁盘 ÷ 每视频预估占用(dispatch.est_disk_gb_per_video)做了收紧)")
|
||||
# 供子进程(forkserver fork出来的worker)读取: splitframe.ffmpeg_threads=auto 时按此摊分CPU
|
||||
os.environ['OCR_MAX_WORKERS'] = str(args.max_workers)
|
||||
mem = get_available_mem_gb()
|
||||
disk = get_available_disk_gb(".")
|
||||
load = get_load_avg()
|
||||
mem_str = f"{mem:.1f}G" if mem is not None else "未知(非Linux?)"
|
||||
disk_str = f"{disk:.1f}G" if disk is not None else "未知"
|
||||
load_str = (f"{load[0]:.2f}/{load[1]:.2f}/{load[2]:.2f}" if load else "未知")
|
||||
logging.info("=" * 56)
|
||||
logging.info(f"机器资源: CPU {cpu} 核 | 可用内存 {mem_str} | 可用磁盘 {disk_str} | 负载(1/5/15) {load_str}")
|
||||
_exec_str = "进程池(多核,绕GIL)" if args.executor == "process" else "线程池(GIL会串行CPU计算)"
|
||||
logging.info(f"并发设置: {_exec_str} | max-workers={args.max_workers} | "
|
||||
f"内存保护阈值={args.mem_guard_gb}G | 磁盘保护阈值={args.disk_guard_gb}G")
|
||||
if args.executor == "process" and cpu:
|
||||
logging.info(f"提示: 进程池模式建议 max-workers ≈ CPU核数({cpu})上下,每个视频独立进程真多核;"
|
||||
f"内存够的话(每进程约+0.5~1G含whisper模型)可略超")
|
||||
if mem is not None and args.mem_guard_gb >= mem:
|
||||
logging.warning(f"⚠️ 内存保护阈值({args.mem_guard_gb}G) ≥ 当前可用内存({mem:.1f}G),"
|
||||
f"几乎所有视频都会被卡住等待,请调小 --mem-guard-gb 或释放内存")
|
||||
if disk is not None and args.disk_guard_gb >= disk:
|
||||
logging.warning(f"⚠️ 磁盘保护阈值({args.disk_guard_gb}G) ≥ 当前可用磁盘({disk:.1f}G),"
|
||||
f"几乎所有视频都会被卡住等待,请调小 --disk-guard-gb 或清理磁盘")
|
||||
if cpu and args.max_workers > cpu * 3:
|
||||
logging.warning(f"⚠️ max-workers({args.max_workers}) > CPU核数({cpu})×3,"
|
||||
f"可能超额订阅反而变慢")
|
||||
if cpu and args.max_workers < cpu:
|
||||
logging.info(f"提示: max-workers({args.max_workers}) < CPU核数({cpu}),"
|
||||
f"CPU 可能吃不满,可适当调大")
|
||||
logging.info("=" * 56)
|
||||
|
||||
split_cfg = config.get_splitframe_config()
|
||||
coscli_path = split_cfg.get('coscli_path', './coscli-linux-amd64')
|
||||
cos_config_path = split_cfg.get('cosconfig_path', '.cos.yaml')
|
||||
|
||||
processor = VideoProcessor(max_workers=args.max_workers, mem_guard_gb=args.mem_guard_gb,
|
||||
executor=args.executor, max_tasks_per_child=args.max_tasks_per_child,
|
||||
disk_guard_gb=args.disk_guard_gb)
|
||||
|
||||
# 从 数据.jsonl 读取历史,支持跨批次断点续传
|
||||
processed = load_processed(JSONL_PATH)
|
||||
if processed:
|
||||
logging.info(f"已读取 {len(processed)} 条历史记录,将跳过已处理视频")
|
||||
|
||||
os.makedirs("./video", exist_ok=True)
|
||||
|
||||
# 每日备份后台线程: 本地模式也自动上传 数据.jsonl + 超长列表到 COS(与 COS 模式一致)。
|
||||
# 仅在 dispatch 已配置备份地址时启用; 上传函数内部对缺失/失败都做了容错。
|
||||
_backup_stop = threading.Event()
|
||||
_dcfg = config.get_dispatch_config() or {}
|
||||
if _dcfg.get("enabled", True) and (_dcfg.get("daily_backup_path") or _dcfg.get("jobs_root")):
|
||||
_mid = job_cos.get_machine_id()
|
||||
_hour = int(_dcfg.get("daily_backup_hour", 10))
|
||||
threading.Thread(target=job_cos.run_daily_backup_loop,
|
||||
args=(_mid, _hour, _backup_stop, JSONL_PATH),
|
||||
daemon=True).start()
|
||||
logging.info(f"[每日备份] 已启用: 每天{_hour}点上传 数据.jsonl + 超长列表 (机器号={_mid})")
|
||||
|
||||
while True:
|
||||
txt_files = glob.glob(os.path.join(args.source_list, "*.txt"))
|
||||
if not txt_files:
|
||||
break
|
||||
|
||||
file_path = txt_files[0]
|
||||
logging.info(f"开始处理任务文件: {file_path}")
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
all_tasks = [line.strip() for line in f if line.strip()]
|
||||
|
||||
# 跳过已处理
|
||||
tasks = [t for t in all_tasks if t not in processed]
|
||||
skipped = len(all_tasks) - len(tasks)
|
||||
if skipped:
|
||||
logging.info(f"跳过 {skipped} 条已处理记录")
|
||||
|
||||
if tasks:
|
||||
processor.process_batch(tasks, coscli_path, cos_config_path, split_cfg)
|
||||
processed.update(tasks)
|
||||
|
||||
# 清理共享临时目录
|
||||
for d in ["./info", "./info_error", "./log", "./video"]:
|
||||
if os.path.exists(d):
|
||||
force_rmtree(d)
|
||||
|
||||
os.makedirs("./video", exist_ok=True)
|
||||
os.remove(file_path)
|
||||
logging.info(f"已完成并删除任务文件: {file_path}")
|
||||
|
||||
_backup_stop.set()
|
||||
# 收尾: 本地批次跑完即上传一次(不必等到点), 保证哪怕在 backup_hour 之前结束也传到 COS。
|
||||
if _dcfg.get("enabled", True) and (_dcfg.get("daily_backup_path") or _dcfg.get("jobs_root")):
|
||||
try:
|
||||
_mid = job_cos.get_machine_id()
|
||||
_d = job_cos.upload_daily_backup(JSONL_PATH, _mid)
|
||||
if _d:
|
||||
logging.info(f"[收尾备份] 数据.jsonl 已上传 -> {_d}")
|
||||
_l = job_cos.upload_longlist_backup(_mid)
|
||||
if _l:
|
||||
logging.info(f"[收尾备份] 超长待重跑列表已上传 -> {_l}")
|
||||
except Exception as e:
|
||||
logging.error(f"[收尾备份] 上传失败: {e}")
|
||||
logging.info("所有任务处理完成")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
31
pyproject.toml
Normal file
31
pyproject.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[project]
|
||||
name = "ocr-subtitle-extractor"
|
||||
version = "0.1.0"
|
||||
description = "视频字幕提取系统:抽帧 + OCR + 语音识别,输出 SRT"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
|
||||
# 运行时依赖(与 requirements.txt 保持一致)
|
||||
dependencies = [
|
||||
# 图像与数值处理
|
||||
"opencv-python-headless>=4.8.0", # headless 版无需系统 libGL,适合无界面/离线服务器
|
||||
"numpy>=1.24,<3.0",
|
||||
"Pillow>=10.0.0",
|
||||
"scikit-learn>=1.3.0", # DBSCAN 水印聚类
|
||||
# 中文文本处理
|
||||
"jieba>=0.42.1", # 分词
|
||||
"pypinyin>=0.49.0", # 拼音匹配(音频与 OCR 文本对齐)
|
||||
"zhconv>=1.4.3", # 繁简转换
|
||||
# 音频与语音识别
|
||||
"pydub>=0.25.1", # 音频切片(依赖外部 ffmpeg)
|
||||
"av>=11.0,<14.0", # faster-whisper 依赖;钉带 wheel 的版本,免本地编译
|
||||
"faster-whisper>=1.0.0", # Whisper 语音识别(int8 量化,CPU/GPU)
|
||||
# 腾讯云
|
||||
"cos-python-sdk-v5>=1.9.0", # COS 对象存储(import qcloud_cos)
|
||||
"requests>=2.28.0", # 内部 OCR API 调用
|
||||
]
|
||||
|
||||
# 国内镜像(华为云),让 uv sync / uv pip 默认走这里
|
||||
[[tool.uv.index]]
|
||||
url = "https://repo.huaweicloud.com/repository/pypi/simple"
|
||||
default = true
|
||||
23
requirements.txt
Normal file
23
requirements.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
# 视频字幕提取系统依赖
|
||||
# 安装: pip install -r requirements.txt
|
||||
# 另需 PATH 中的外部工具: ffmpeg / ffprobe、coscli(详见 CLAUDE.md)
|
||||
|
||||
# 图像与数值处理
|
||||
opencv-python-headless>=4.8.0 # headless 版无需系统 libGL,适合无界面/离线服务器
|
||||
numpy>=1.24,<3.0
|
||||
Pillow>=10.0.0
|
||||
scikit-learn>=1.3.0 # DBSCAN 水印聚类
|
||||
|
||||
# 中文文本处理
|
||||
jieba>=0.42.1 # 分词
|
||||
pypinyin>=0.49.0 # 拼音匹配(音频与OCR文本对齐)
|
||||
zhconv>=1.4.3 # 繁简转换
|
||||
|
||||
# 音频与语音识别
|
||||
pydub>=0.25.1 # 音频切片(依赖 ffmpeg)
|
||||
av>=11.0,<14.0 # faster-whisper 依赖;钉带预编译wheel的版本,避免本地编译ffmpeg
|
||||
faster-whisper>=1.0.0 # Whisper 语音识别(int8 量化,CPU/GPU)
|
||||
|
||||
# 腾讯云
|
||||
cos-python-sdk-v5>=1.9.0 # COS 对象存储(import qcloud_cos)
|
||||
requests>=2.28.0 # 腾讯云 OCR API 调用
|
||||
394
run_worker.py
Normal file
394
run_worker.py
Normal file
@@ -0,0 +1,394 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""常驻 worker: 从 COS 自动认领任务分片、处理、回写完成标记, 没任务就空闲轮询;
|
||||
每天指定时刻把本地 数据.jsonl 备份到 COS。
|
||||
|
||||
用法:
|
||||
python run_worker.py --max-workers 32 --executor process
|
||||
|
||||
机器ID、取任务地址(jobs_root)、每日备份位置(daily_backup_path)等全部读 config.dispatch。
|
||||
"""
|
||||
import argparse
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from config.config_utils import config
|
||||
from tool import job_cos
|
||||
# 复用 main.py 既有的处理流水线与清理逻辑
|
||||
from main import (VideoProcessor, load_processed,
|
||||
force_rmtree, JSONL_PATH, get_available_disk_gb)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||
handlers=[
|
||||
# 常驻worker一跑就是数天/数周, 普通FileHandler会无限增长; 100M/份, 最多留5份(共500M封顶)
|
||||
RotatingFileHandler('batch_processor.log', maxBytes=100 * 1024 * 1024, backupCount=5, encoding='utf-8'),
|
||||
logging.StreamHandler(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ 取下一片 ----
|
||||
|
||||
def _next_shard(machine_id: str, shuffle: bool, claim_match=None):
|
||||
"""返回下一片要做的 shard 名; 没有可领返回 None。
|
||||
优先级: ①续做我名下未完成的(重启续跑, 不受 claim_match 限制) -> ②原子认领一片新的(受 claim_match 过滤)。"""
|
||||
# ① 粘性续做: 我上次没跑完的分片(别的机器不会碰; 已经是我的, 不再过滤)
|
||||
for shard in job_cos.my_open_claims(machine_id):
|
||||
logging.info(f"[续做] 接回我名下未完成分片: {shard}")
|
||||
return shard
|
||||
# ② 认领新分片
|
||||
todo = job_cos.list_todo()
|
||||
# 排除已完成 / 已被锁的
|
||||
todo = [s for s in todo if not job_cos.is_done(s)]
|
||||
# 只认领名称匹配的(claim_match 留空=全部)
|
||||
if claim_match:
|
||||
todo = [s for s in todo if job_cos.shard_matches(s, claim_match)]
|
||||
if shuffle:
|
||||
random.shuffle(todo)
|
||||
for shard in todo:
|
||||
if job_cos.read_lock(shard) is not None:
|
||||
continue # 已被人锁
|
||||
if job_cos.claim(shard, machine_id):
|
||||
logging.info(f"[认领] 抢到分片: {shard}")
|
||||
return shard
|
||||
# 409: 刚好被别人抢走, 试下一片
|
||||
return None
|
||||
|
||||
|
||||
def _claim_long_batch(machine_id: str, shuffle: bool, min_batch: int):
|
||||
"""长视频队列专用: 一次性多领几片, 累计任务数够 min_batch(或没片可领了)才停手 ——
|
||||
长视频队列每天上传的还是各机器各自攒的小分片(可能就几十条), 这里只是在【领取】这一侧
|
||||
把多个小分片攒成一大批一起处理, 不改上传那边的行为。
|
||||
先照 _next_shard() 同样的优先级续做粘性未完成分片(不受 min_batch 限制, 必须先了结),
|
||||
没有才开始新领; min_batch<=0 视为1(至少领一片,不做批量)。
|
||||
返回 [(shard, tasks), ...];某一片没片可领时提前结束(不会因为凑不够 min_batch 空等)。"""
|
||||
min_batch = max(1, int(min_batch or 1))
|
||||
batch = []
|
||||
total = 0
|
||||
|
||||
# ① 粘性续做: 我名下所有未完成分片先原样接回来, 不受 min_batch 限制(必须先了结)
|
||||
open_claims = job_cos.my_open_claims(machine_id)
|
||||
for shard in open_claims:
|
||||
tasks = job_cos.download_shard(shard)
|
||||
batch.append((shard, tasks))
|
||||
total += len(tasks)
|
||||
if batch:
|
||||
logging.info(f"[长视频批量] 接回 {len(batch)} 片未完成分片, 共{total}条, 直接处理(不再新领)")
|
||||
return batch
|
||||
|
||||
# ② 新领: 不断认领新分片直到攒够 min_batch 条(或没片可领了)
|
||||
todo = job_cos.list_todo()
|
||||
todo = [s for s in todo if not job_cos.is_done(s)]
|
||||
if shuffle:
|
||||
random.shuffle(todo)
|
||||
for shard in todo:
|
||||
if total >= min_batch:
|
||||
break
|
||||
if job_cos.read_lock(shard) is not None:
|
||||
continue # 已被人锁
|
||||
if not job_cos.claim(shard, machine_id):
|
||||
continue # 409: 刚好被别人抢走
|
||||
tasks = job_cos.download_shard(shard)
|
||||
batch.append((shard, tasks))
|
||||
total += len(tasks)
|
||||
logging.info(f"[长视频批量] 认领分片: {shard} ({len(tasks)}条, 累计{total}/{min_batch})")
|
||||
return batch
|
||||
|
||||
|
||||
def _process_shard_batch(batch, processor, machine_id, split_cfg,
|
||||
coscli_path, cos_config_path, hb_interval, global_processed=None):
|
||||
"""处理一批长视频分片(多片任务清单先合并成一个大列表再统一跑一次 process_batch,
|
||||
而不是一片一片分别跑) —— 逻辑和 _process_shard 一致, 只是输入是 [(shard, tasks), ...]。
|
||||
完成后把批次里每一片都各自 mark_done + release(全部标同一份聚合统计, 只是省事,
|
||||
不是精确到每片的独立统计, 不影响正确性)。"""
|
||||
shards = [s for s, _ in batch]
|
||||
tasks = [t for _, ts in batch for t in ts]
|
||||
processed = load_processed(JSONL_PATH)
|
||||
if global_processed:
|
||||
processed = processed | global_processed
|
||||
todo_tasks = [t for t in tasks if t not in processed]
|
||||
skipped = len(tasks) - len(todo_tasks)
|
||||
logging.info(f"长视频批次({len(shards)}片): 共 {len(tasks)} 条, 跳过已处理 {skipped}, "
|
||||
f"待处理 {len(todo_tasks)}")
|
||||
|
||||
hb_stop = threading.Event()
|
||||
def _hb():
|
||||
while not hb_stop.wait(hb_interval):
|
||||
for shard in shards:
|
||||
try:
|
||||
job_cos.heartbeat(shard, machine_id)
|
||||
except Exception as e:
|
||||
logging.warning(f"心跳刷新失败 {shard}: {e}")
|
||||
threading.Thread(target=_hb, daemon=True).start()
|
||||
|
||||
stats_dict = {"total": len(todo_tasks), "success": 0, "no_subtitle": 0, "error": 0}
|
||||
try:
|
||||
if todo_tasks:
|
||||
stats = processor.process_batch(todo_tasks, coscli_path, cos_config_path, split_cfg)
|
||||
stats_dict.update({"success": stats.success,
|
||||
"no_subtitle": stats.no_subtitle, "error": stats.error})
|
||||
finally:
|
||||
hb_stop.set()
|
||||
|
||||
for shard in shards:
|
||||
job_cos.mark_done(shard, machine_id, stats_dict)
|
||||
job_cos.release(shard)
|
||||
logging.info(f"长视频批次({len(shards)}片)完成并释放: {stats_dict}")
|
||||
|
||||
for d in ["./info", "./info_error", "./log", "./video"]:
|
||||
if os.path.exists(d):
|
||||
force_rmtree(d)
|
||||
os.makedirs("./video", exist_ok=True)
|
||||
|
||||
|
||||
def _fetch_global_processed(attempts: int = 3, backoff: float = 10.0) -> set:
|
||||
"""启动时拉全局已处理集合, 失败重试(COS 抖动/网络问题很常见, 直接放弃太可惜)。
|
||||
每次失败等 backoff 秒(线性,不是指数,重试次数少没必要搞复杂)再试; 全部失败返回空集合,
|
||||
调用方会据此退化为只按本机 数据.jsonl 去重, 不阻塞 worker 启动。"""
|
||||
last_err = None
|
||||
for i in range(1, attempts + 1):
|
||||
try:
|
||||
return job_cos.global_processed_set()
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
logging.warning(f"[全局去重] 第{i}/{attempts}次拉取失败: {e}")
|
||||
if i < attempts:
|
||||
time.sleep(backoff)
|
||||
logging.warning(f"[全局去重] 重试{attempts}次仍失败, 本次运行退化为只按本机 数据.jsonl 去重: {last_err}")
|
||||
return set()
|
||||
|
||||
|
||||
def _process_shard(shard, processor, machine_id, split_cfg,
|
||||
coscli_path, cos_config_path, hb_interval, global_processed=None):
|
||||
"""处理一个分片: 下载清单 -> 跳过(本机已处理 ∪ 全局已处理) -> 跑流水线 -> 写 done -> 释放。
|
||||
global_processed: 启动时拉取的全局已处理集合(见 main() 里的 job_cos.global_processed_set()),
|
||||
用于跨机器去重 —— 本机 数据.jsonl 只挡得住"这台机器"重复跑同一个视频, 挡不住这个视频被
|
||||
错误地灌进了另一台机器认领的分片; 传 None/空集合时退化为只按本机去重(旧行为)。"""
|
||||
tasks = job_cos.download_shard(shard)
|
||||
processed = load_processed(JSONL_PATH) # 本机已处理(续跑去重)
|
||||
if global_processed:
|
||||
processed = processed | global_processed # 并上全局已处理(跨机器去重)
|
||||
todo_tasks = [t for t in tasks if t not in processed]
|
||||
skipped = len(tasks) - len(todo_tasks)
|
||||
logging.info(f"分片 {shard}: 共 {len(tasks)} 条, 跳过已处理 {skipped}, 待处理 {len(todo_tasks)}")
|
||||
|
||||
# 心跳线程: 处理期间周期刷新锁的 hb_ts(留作监控/未来回收用)
|
||||
hb_stop = threading.Event()
|
||||
def _hb():
|
||||
while not hb_stop.wait(hb_interval):
|
||||
try:
|
||||
job_cos.heartbeat(shard, machine_id)
|
||||
except Exception as e:
|
||||
logging.warning(f"心跳刷新失败 {shard}: {e}")
|
||||
threading.Thread(target=_hb, daemon=True).start()
|
||||
|
||||
stats_dict = {"total": len(todo_tasks), "success": 0, "no_subtitle": 0, "error": 0}
|
||||
try:
|
||||
if todo_tasks:
|
||||
stats = processor.process_batch(todo_tasks, coscli_path, cos_config_path, split_cfg)
|
||||
stats_dict.update({"success": stats.success,
|
||||
"no_subtitle": stats.no_subtitle, "error": stats.error})
|
||||
finally:
|
||||
hb_stop.set()
|
||||
|
||||
# 完成: 写 done -> 删 todo + lock
|
||||
job_cos.mark_done(shard, machine_id, stats_dict)
|
||||
job_cos.release(shard)
|
||||
logging.info(f"分片 {shard} 完成并释放: {stats_dict}")
|
||||
|
||||
# 清理共享临时目录(沿用 main 的批次后清理)
|
||||
for d in ["./info", "./info_error", "./log", "./video"]:
|
||||
if os.path.exists(d):
|
||||
force_rmtree(d)
|
||||
os.makedirs("./video", exist_ok=True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="常驻 worker: 从 COS 认领任务并处理")
|
||||
ap.add_argument('--max-workers', type=int, default=None,
|
||||
help='不填/0 = 自动按本机CPU核数选')
|
||||
ap.add_argument('--mem-guard-gb', type=float, default=2.0)
|
||||
ap.add_argument('--disk-guard-gb', type=float, default=10.0,
|
||||
help='本地磁盘可用空间低于此值(GB)时暂停启动新视频,给数据处理预留空间;设 0 关闭')
|
||||
ap.add_argument('--max-tasks-per-child', type=int, default=30)
|
||||
ap.add_argument('--executor', choices=['process', 'thread'], default='process')
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.max_workers:
|
||||
cpu = os.cpu_count() or 0
|
||||
args.max_workers = job_cos.auto_max_workers(cpu, disk_guard_gb=args.disk_guard_gb)
|
||||
logging.info(f"--max-workers 未指定, 自动选为 {args.max_workers}(本机CPU核数={cpu or '未知'}; "
|
||||
f"若本机还跑了OCR服务但不在 tencentcloud.ocr.endpoints 里, 自动探测不到, 请手动传小一点的值; "
|
||||
f"同时按可用磁盘 ÷ 每视频预估占用(dispatch.est_disk_gb_per_video)做了收紧)")
|
||||
# 供子进程(forkserver fork出来的worker)读取: splitframe.ffmpeg_threads=auto 时按此摊分CPU
|
||||
os.environ['OCR_MAX_WORKERS'] = str(args.max_workers)
|
||||
|
||||
dcfg = config.get_dispatch_config() or {}
|
||||
if not dcfg.get("enabled", True):
|
||||
logging.error("config.dispatch.enabled=false, 退出。如需启用请改 config。")
|
||||
return
|
||||
|
||||
machine_id = job_cos.get_machine_id()
|
||||
keep_alive = bool(dcfg.get("keep_alive", True))
|
||||
idle_poll = int(dcfg.get("idle_poll_seconds", 60))
|
||||
shuffle = bool(dcfg.get("claim_shuffle", True))
|
||||
hb_interval = int(dcfg.get("heartbeat_seconds", 300))
|
||||
backup_hour = int(dcfg.get("daily_backup_hour", 10))
|
||||
claim_match = dcfg.get("claim_match") or None # 只领匹配的分片(留空=全部)
|
||||
|
||||
run_long = bool(dcfg.get("run_long_video"))
|
||||
long_root = dcfg.get("long_list_backup_path") or None
|
||||
# 只有"本来跑主队列"的机器(非专职长视频机)、且配了长视频队列地址、且没有被显式关掉时,
|
||||
# 才会在主队列空闲时顺手兼跑长视频队列 —— 专职长视频机本来就一直在长视频队列里, 不涉及
|
||||
# 这个开关。auto_fallback_long_video 默认 true(维持原行为); 想让某台机器只专心跑主队列、
|
||||
# 空闲就等着(不去碰长视频)的话, 在这台机器的 config.json 里配 false 即可, 不影响它自己
|
||||
# 超长待重跑清单的每日备份(那个走 job_cos.upload_longlist_backup 各自独立)。
|
||||
fallback_enabled = dcfg.get("auto_fallback_long_video", True)
|
||||
auto_fallback_long = (not run_long) and bool(long_root) and bool(fallback_enabled)
|
||||
active_root = job_cos._active_jobs_root()
|
||||
logging.info("=" * 56)
|
||||
logging.info(f"机器ID: {machine_id} | {'【专跑长视频】队列' if run_long else '主队列'}: {active_root}")
|
||||
if auto_fallback_long:
|
||||
logging.info(f"主队列空闲时自动兼跑长视频队列: {long_root}")
|
||||
if claim_match:
|
||||
logging.info(f"只认领匹配分片: {claim_match}")
|
||||
logging.info(f"常驻: {keep_alive} 空闲轮询: {idle_poll}s | 每日备份: 每天{backup_hour}点")
|
||||
disk = get_available_disk_gb(".")
|
||||
disk_str = f"{disk:.1f}G" if disk is not None else "未知"
|
||||
logging.info(f"磁盘保护阈值: {args.disk_guard_gb}G | 当前可用磁盘: {disk_str}")
|
||||
if disk is not None and args.disk_guard_gb >= disk:
|
||||
logging.warning(f"⚠️ 磁盘保护阈值({args.disk_guard_gb}G) ≥ 当前可用磁盘({disk:.1f}G),"
|
||||
f"几乎所有视频都会被卡住等待,请调小 --disk-guard-gb 或清理磁盘")
|
||||
logging.info("=" * 56)
|
||||
|
||||
# 启动时拉一次全局已处理集合(各机器最新一天的已处理清单, 见 job_cos.global_processed_set),
|
||||
# 跟本机 数据.jsonl 一起用于分片去重 —— 挡住"这个视频被错误地灌进了另一台机器分片"的情况。
|
||||
# 只在启动时拉一次、缓存到本次运行结束, 不是每领一片分片都重新拉(清单本身也就每天更新一次,
|
||||
# 没必要频繁重拉); 失败会重试(见 _fetch_global_processed), 重试仍失败才退化为只按本机去重。
|
||||
# global_dedup_on_startup=false 时直接跳过这一步、只按本机 数据.jsonl 去重 —— 每台机器的
|
||||
# 已处理清单可能有几MB, 20多台机器挨个拉下来这一步可能耗时较久(且下载期间没有中间日志输出,
|
||||
# 看起来像卡住了), 网络不好/机器多的场合可以关掉。
|
||||
global_processed = set()
|
||||
if dcfg.get("daily_backup_path") and dcfg.get("global_dedup_on_startup", True):
|
||||
global_processed = _fetch_global_processed()
|
||||
if global_processed:
|
||||
logging.info(f"[全局去重] 启动时拉取全局已处理集合: {len(global_processed)} 条")
|
||||
else:
|
||||
logging.info("[全局去重] 已关闭(global_dedup_on_startup=false), 只按本机 数据.jsonl 去重")
|
||||
|
||||
split_cfg = config.get_splitframe_config()
|
||||
coscli_path = split_cfg.get('coscli_path', './coscli-linux-amd64')
|
||||
cos_config_path = split_cfg.get('cosconfig_path', '.cos.yaml')
|
||||
processor = VideoProcessor(max_workers=args.max_workers, mem_guard_gb=args.mem_guard_gb,
|
||||
executor=args.executor, max_tasks_per_child=args.max_tasks_per_child,
|
||||
disk_guard_gb=args.disk_guard_gb)
|
||||
|
||||
os.makedirs("./video", exist_ok=True)
|
||||
|
||||
# 每日备份后台线程(数据.jsonl + 超长列表)
|
||||
stop = threading.Event()
|
||||
threading.Thread(target=job_cos.run_daily_backup_loop,
|
||||
args=(machine_id, backup_hour, stop, JSONL_PATH),
|
||||
daemon=True).start()
|
||||
|
||||
# 每小时监控摘要上传线程(常态化监控用, 见 docs/superpowers/specs/2026-07-08-fleet-monitoring-design.md)
|
||||
threading.Thread(target=job_cos.run_hourly_digest_loop,
|
||||
args=(machine_id, stop, JSONL_PATH),
|
||||
daemon=True).start()
|
||||
|
||||
# 长视频队列每次【领取】攒够这么多条任务才开始处理(领的时候多片一起领, 上传那边还是各机器
|
||||
# 各自每天传小分片不受影响) —— 见 _claim_long_batch。<=0 视为1(不做批量, 领一片处理一片)。
|
||||
min_long_batch = int(dcfg.get("long_list_min_batch", 1000) or 1)
|
||||
|
||||
try:
|
||||
while True:
|
||||
if job_cos.stop_requested():
|
||||
logging.info("检测到 COS STOP 标记, 优雅停机。")
|
||||
break
|
||||
|
||||
# 专职长视频机: 全程都在长视频队列, 直接批量领+批量处理, 不走主队列那套单片逻辑
|
||||
if run_long:
|
||||
try:
|
||||
batch = _claim_long_batch(machine_id, shuffle, min_long_batch)
|
||||
except Exception as e:
|
||||
logging.error(f"✗ 访问长视频队列失败(稍后重试): {e}")
|
||||
time.sleep(min(idle_poll, 30))
|
||||
continue
|
||||
if not batch:
|
||||
if keep_alive:
|
||||
logging.info(f"长视频队列无可领任务, 空闲等待 {idle_poll}s...")
|
||||
time.sleep(idle_poll)
|
||||
continue
|
||||
else:
|
||||
logging.info("长视频队列无可领任务, keep_alive=false, 退出。")
|
||||
break
|
||||
try:
|
||||
_process_shard_batch(batch, processor, machine_id, split_cfg,
|
||||
coscli_path, cos_config_path, hb_interval, global_processed)
|
||||
except Exception as e:
|
||||
logging.error(f"✗ 长视频批次处理出错(锁保留, 重启可续做): {e}")
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
# ① 先看主队列(单片领取, 行为不变)
|
||||
try:
|
||||
shard = _next_shard(machine_id, shuffle, claim_match)
|
||||
except Exception as e:
|
||||
logging.error(f"✗ 访问 COS 队列失败(稍后重试): {e}")
|
||||
time.sleep(min(idle_poll, 30))
|
||||
continue
|
||||
|
||||
# ② 主队列没活 -> 顺手看看长视频队列有没有能领的(批量领, 不是单片)
|
||||
used_long = False
|
||||
long_batch = None
|
||||
if shard is None and auto_fallback_long:
|
||||
job_cos.set_active_root_override(long_root)
|
||||
try:
|
||||
long_batch = _claim_long_batch(machine_id, shuffle, min_long_batch)
|
||||
except Exception as e:
|
||||
logging.error(f"✗ 访问长视频队列失败(稍后重试): {e}")
|
||||
job_cos.set_active_root_override(None)
|
||||
time.sleep(min(idle_poll, 30))
|
||||
continue
|
||||
if not long_batch:
|
||||
job_cos.set_active_root_override(None) # 长视频队列也没活, 恢复主队列上下文
|
||||
else:
|
||||
used_long = True
|
||||
logging.info(f"主队列空闲, 转去处理长视频队列批次: {len(long_batch)}片")
|
||||
|
||||
if shard is None and not long_batch:
|
||||
# 两个队列都真·没活了
|
||||
if keep_alive:
|
||||
logging.info(f"无可领任务, 空闲等待 {idle_poll}s(加新任务到 todo/ 会自动接上)...")
|
||||
time.sleep(idle_poll)
|
||||
continue
|
||||
else:
|
||||
logging.info("无可领任务, keep_alive=false, 退出。")
|
||||
break
|
||||
|
||||
try:
|
||||
if long_batch:
|
||||
_process_shard_batch(long_batch, processor, machine_id, split_cfg,
|
||||
coscli_path, cos_config_path, hb_interval, global_processed)
|
||||
else:
|
||||
_process_shard(shard, processor, machine_id, split_cfg,
|
||||
coscli_path, cos_config_path, hb_interval, global_processed)
|
||||
except Exception as e:
|
||||
logging.error(f"✗ 分片处理出错(锁保留, 重启可续做): {e}")
|
||||
time.sleep(5)
|
||||
finally:
|
||||
if used_long:
|
||||
job_cos.set_active_root_override(None) # 这批长视频处理完/出错, 下一轮先回去看主队列
|
||||
finally:
|
||||
stop.set()
|
||||
logging.info("worker 退出。")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
167
scripts/apply_update.sh
Normal file
167
scripts/apply_update.sh
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env bash
|
||||
# 一键更新脚本(在线上【项目根目录】运行)。
|
||||
# 作用: 用 _new/ 里的新代码覆盖改过的文件, 并把新配置项【合并】进现有 config.json
|
||||
# (只加缺失的键, 绝不动你已有的密钥/路径/已调过的值), 然后自检。
|
||||
# 用法: 解压更新包到项目根目录后, 执行 bash apply_update.sh
|
||||
set -u
|
||||
|
||||
ROOT="$(pwd)"
|
||||
TS="$(date +%Y%m%d_%H%M%S)"
|
||||
BAK="_backup_${TS}"
|
||||
NEW="_new"
|
||||
|
||||
echo "==> 项目目录: $ROOT"
|
||||
|
||||
if [ ! -d "tool" ] || [ ! -f "config/config.json" ]; then
|
||||
echo "✗ 当前目录不像项目根(缺 tool/ 或 config/config.json)。请 cd 到项目根再运行。"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$NEW" ]; then
|
||||
echo "✗ 找不到 $NEW/ 目录。请先把更新包解压到项目根目录。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 0. 更新前先把本机 数据.jsonl + 超长待重跑列表 备份到 COS(不等每天10点的自动备份)。
|
||||
# 更新代码本身不会碰 数据.jsonl, 这一步纯粹是"顺手让全局数据保持最新"(比如全局去重要用),
|
||||
# COS 访问失败不阻断更新, 只提示。
|
||||
echo "==> 更新前备份 数据.jsonl / 超长待重跑列表 到 COS"
|
||||
python3 tools/upload_daily.py || echo " ⚠ 备份失败(不影响本次更新继续), 稍后可手动重跑: python3 tools/upload_daily.py"
|
||||
|
||||
FILES="main.py run_worker.py tool/process.py tool/merge.py tool/getsrt.py tool/whisper_manager.py tool/VideoProcessing/multithread_algo.py tool/Ocr/tencentcloud_ocr.py tool/job_cos.py config/config_utils.py"
|
||||
|
||||
# 1. 备份
|
||||
echo "==> 备份到 $BAK/"
|
||||
mkdir -p "$BAK/tool/VideoProcessing" "$BAK/tool/Ocr" "$BAK/config"
|
||||
for f in $FILES; do
|
||||
[ -f "$f" ] && cp -p "$f" "$BAK/$f"
|
||||
done
|
||||
cp -p config/config.json "$BAK/config/config.json"
|
||||
|
||||
# 2. 覆盖代码文件
|
||||
echo "==> 更新代码文件"
|
||||
for f in $FILES; do
|
||||
if [ ! -f "$NEW/$f" ]; then
|
||||
echo "✗ 更新包缺少 $NEW/$f, 中止(已备份, 未改动)"; exit 1
|
||||
fi
|
||||
cp -p "$NEW/$f" "$f"
|
||||
echo " ✓ $f"
|
||||
done
|
||||
|
||||
# 2b. 附带的助手脚本(有就拷, 没有不报错)
|
||||
for f in tools/拆分超长.py tools/seed_jobs.py tools/upload_daily.py tools/release_machine.py tools/patch_config.py; do
|
||||
if [ -f "$NEW/$f" ]; then
|
||||
mkdir -p "$(dirname "$f")"
|
||||
cp -p "$NEW/$f" "$f" && echo " ✓ $f"
|
||||
fi
|
||||
done
|
||||
|
||||
# 3. 合并配置(只加缺失键, 不覆盖已有值; 报告与推荐值不同的键)
|
||||
echo "==> 合并 config.json 新配置项(保留你已有的密钥/路径/设置)"
|
||||
python3 - "$ROOT/config/config.json" <<'PYEOF'
|
||||
import json, sys
|
||||
p = sys.argv[1]
|
||||
d = json.load(open(p, encoding="utf-8"))
|
||||
au = d.setdefault("audio", {})
|
||||
sf = d.setdefault("splitframe", {})
|
||||
sub = d.setdefault("subtitle", {})
|
||||
ocr = d.setdefault("tencentcloud", {}).setdefault("ocr", {})
|
||||
dp = d.setdefault("dispatch", {})
|
||||
_bkt = (d.get("tencentcloud", {}).get("cos", {}) or {}).get("bucket", "asr-dig-data-1253181912")
|
||||
|
||||
NEW_KEYS = [
|
||||
(ocr, "max_attempts", 4),
|
||||
(ocr, "timeout", 30),
|
||||
(ocr, "dispatch_log_every", 1000),
|
||||
(au, "cpu_threads", 2),
|
||||
(sf, "crop_bottom_ratio", None),
|
||||
(sub, "min_duration_seconds", 300),
|
||||
(sub, "no_composite", False),
|
||||
(sub, "merge_workers", 8),
|
||||
(sub, "max_tiles_per_composite", 24),
|
||||
(sub, "ocr_max_image_side", 4000),
|
||||
(sub, "cv_detect", True),
|
||||
(sub, "cv_use_region", False),
|
||||
(sub, "filter_static", True),
|
||||
(sub, "static_frac", 0.25),
|
||||
(sub, "filter_lowconf", True),
|
||||
(sub, "lowconf_thr", 80),
|
||||
(sub, "lowconf_filter_frac", 0.6),
|
||||
(sub, "lowconf_min_det", 200),
|
||||
(sub, "strip_static_prefix", False),
|
||||
(sub, "filter_watermark_pos", True),
|
||||
(sub, "wm_dominant_frac", 0.25),
|
||||
(sub, "wm_min_count", 20),
|
||||
(sub, "split_far_boxes", True),
|
||||
(sub, "max_duration_seconds", 5400),
|
||||
(sub, "long_video_list", "./超长待重跑.txt"),
|
||||
(sub, "ocr_no_drop", True),
|
||||
(sub, "ocr_max_passes", 5),
|
||||
(sub, "ocr_retry_sleep", 2),
|
||||
(sub, "ocr_fail_list", "./OCR失败待重跑.txt"),
|
||||
(sub, "no_subtitle_reasons", ["字幕太少","冗余文字过多","冗余文字太多","无字幕区域","非字幕内容","置信度太低","未检测","特殊字体","视频过短","视频过长"]),
|
||||
(sf, "ffmpeg_timeout", 3600),
|
||||
(sf, "ffmpeg_threads", "auto"),
|
||||
(sf, "gpu_skip_indices", []),
|
||||
(sf, "gpu_auto_select", True),
|
||||
(sf, "gpu_busy_mem_mb", 4096),
|
||||
(dp, "enabled", True),
|
||||
(dp, "source", "cos"),
|
||||
(dp, "run_long_video", False),
|
||||
(dp, "auto_fallback_long_video", True),
|
||||
(dp, "global_dedup_on_startup", True),
|
||||
(dp, "machine_id", ""),
|
||||
(dp, "jobs_root", "cos://%s/_jobs" % _bkt),
|
||||
(dp, "shard_size", 2000),
|
||||
(dp, "keep_alive", True),
|
||||
(dp, "idle_poll_seconds", 60),
|
||||
(dp, "claim_shuffle", True),
|
||||
(dp, "claim_match", ""),
|
||||
(dp, "heartbeat_seconds", 300),
|
||||
(dp, "daily_backup_path", "cos://%s/_jobs/daily" % _bkt),
|
||||
(dp, "long_list_backup_path", "cos://%s/_jobs/long_video" % _bkt),
|
||||
(dp, "monitor_root", "cos://%s/_jobs/monitor" % _bkt),
|
||||
(dp, "long_list_clear_after_upload", True),
|
||||
(dp, "long_list_min_batch", 1000),
|
||||
(dp, "daily_backup_hour", 10),
|
||||
(dp, "stop_flag_key", "_jobs/STOP"),
|
||||
]
|
||||
added, differ = [], []
|
||||
for sec, k, v in NEW_KEYS:
|
||||
if k not in sec:
|
||||
sec[k] = v; added.append((k, v))
|
||||
elif sec[k] != v:
|
||||
differ.append((k, sec[k], v))
|
||||
|
||||
json.dump(d, open(p, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print(" 新增键:", ", ".join(f"{k}={v}" for k, v in added) or "(无, 已都存在)")
|
||||
if differ:
|
||||
print(" ⚠ 以下键你线上已有值, 未改动(如想用推荐值请手动改):")
|
||||
for k, cur, rec in differ:
|
||||
print(f" {k}: 当前={cur} 推荐={rec}")
|
||||
PYEOF
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "✗ 配置合并失败, 正在回滚 config.json"
|
||||
cp -p "$BAK/config/config.json" config/config.json
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. 自检
|
||||
echo "==> 自检 import"
|
||||
if python3 -c "import main, run_worker, tool.job_cos, tool.merge, tool.process, tool.getsrt, tool.whisper_manager, tool.VideoProcessing.multithread_algo; print(' ✓ import OK')"; then
|
||||
:
|
||||
else
|
||||
echo "✗ import 失败! 正在回滚所有改动"
|
||||
for f in $FILES; do cp -p "$BAK/$f" "$f"; done
|
||||
cp -p "$BAK/config/config.json" config/config.json
|
||||
echo "已回滚到更新前。备份在 $BAK/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "============================================="
|
||||
echo "✓ 更新完成。备份在: $BAK/"
|
||||
echo " 回退: 把 $BAK/ 里的文件拷回原位即可。"
|
||||
echo ""
|
||||
echo "下一步: 先小批量验证, 没问题再上量。"
|
||||
echo " python main.py --source-list ./text2 --max-workers 32 --executor process"
|
||||
echo "============================================="
|
||||
108
scripts/benchmark.sh
Normal file
108
scripts/benchmark.sh
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# 新机器上线前的算力/IO摸底测试(跟 scripts/diag.sh 不是一回事 —— diag.sh 是排查"已经在跑但
|
||||
# 出问题"的机器;这个是给"还没开始跑正式任务、想先估算这台机器大概能跑多快"的新机器用的)。
|
||||
# 测: CPU/内存/GPU(含真实硬解测试)/磁盘顺序读写速度/到OCR端点的网络延迟, 汇总成一份文件。
|
||||
#
|
||||
# 用法(项目根目录): bash scripts/benchmark.sh
|
||||
# 产物: benchmark_YYYYmmdd_HHMMSS.txt (把这个文件发回来)
|
||||
|
||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || exit 1
|
||||
OUT="benchmark_$(date +%Y%m%d_%H%M%S).txt"
|
||||
PY="$(command -v python || command -v python3)"
|
||||
FFMPEG="$($PY -c "from config.config_utils import get_ffmpeg; print(get_ffmpeg())" 2>/dev/null || command -v ffmpeg)"
|
||||
|
||||
exec > >(tee "$OUT") 2>&1
|
||||
|
||||
echo "================ 算力/IO摸底 $(date) ================"
|
||||
echo "主机: $(hostname)"
|
||||
echo ""
|
||||
|
||||
echo "========== 1. CPU =========="
|
||||
echo "[核数] $(nproc)"
|
||||
if command -v lscpu >/dev/null 2>&1; then
|
||||
lscpu | grep -E "Model name|CPU\(s\)|MHz|Thread|Socket"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "========== 2. 内存 =========="
|
||||
free -h
|
||||
echo ""
|
||||
|
||||
echo "========== 3. GPU =========="
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
echo "[列表]"
|
||||
nvidia-smi --query-gpu=index,name,memory.total,memory.used,utilization.gpu,driver_version --format=csv
|
||||
echo ""
|
||||
echo "[真实硬解测试: ffmpeg合成测试图案, 用CPU软编码生成素材(不碰GPU), 再用 -hwaccel cuda 解码,"
|
||||
echo " 每张卡各测一遍 —— 生产流程只用GPU【解码】下载好的视频, 从不用GPU编码, 所以这里故意不测nvenc编码,"
|
||||
echo " 只测跟生产完全一致的硬解路径]"
|
||||
if [ -x "$FFMPEG" ] || command -v "$FFMPEG" >/dev/null 2>&1; then
|
||||
n_gpu=$(nvidia-smi --query-gpu=index --format=csv,noheader | wc -l)
|
||||
testfile="/tmp/_bench_src.mp4"
|
||||
"$FFMPEG" -hide_banner -loglevel error -f lavfi -i "testsrc=size=1920x1080:rate=30:duration=10" \
|
||||
-c:v libx264 -preset veryfast -f mp4 "$testfile" -y 2>&1 | tail -3
|
||||
if [ -f "$testfile" ]; then
|
||||
for dev in $(seq 0 $((n_gpu - 1))); do
|
||||
echo " -- GPU $dev --"
|
||||
t0=$(date +%s.%N)
|
||||
"$FFMPEG" -hide_banner -loglevel error -hwaccel cuda -hwaccel_device "$dev" \
|
||||
-i "$testfile" -f null - 2>&1 | tail -5
|
||||
t1=$(date +%s.%N)
|
||||
echo " 硬解读回耗时: $(echo "$t1 - $t0" | bc 2>/dev/null || echo "?")s (10秒素材, 越接近0越好, 明显>10s说明硬解没吃上或GPU很忙; 有报错说明硬解不可用, 会自动回退CPU软解)"
|
||||
done
|
||||
rm -f "$testfile"
|
||||
else
|
||||
echo " CPU软编码生成测试素材都失败了(跟GPU无关, 检查ffmpeg本身/libx264), 跳过硬解测试"
|
||||
fi
|
||||
else
|
||||
echo " 找不到 ffmpeg, 跳过硬解测试"
|
||||
fi
|
||||
else
|
||||
echo " 没有 nvidia-smi(不是GPU机器, 或驱动没装)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "========== 4. 磁盘顺序读写速度(当前目录所在盘) =========="
|
||||
TESTFILE="./_bench_io.tmp"
|
||||
echo "[写] 顺序写1GB"
|
||||
sync
|
||||
dd if=/dev/zero of="$TESTFILE" bs=1M count=1024 conv=fdatasync 2>&1 | tail -3
|
||||
echo "[读] 顺序读(先清缓存再读更准, 没权限清缓存就直接读)"
|
||||
( sync && echo 3 > /proc/sys/vm/drop_caches ) 2>/dev/null
|
||||
dd if="$TESTFILE" of=/dev/null bs=1M 2>&1 | tail -3
|
||||
rm -f "$TESTFILE"
|
||||
echo ""
|
||||
echo "[磁盘剩余空间]"
|
||||
df -h .
|
||||
echo ""
|
||||
|
||||
echo "========== 5. 网络: 到OCR端点的延迟/连通率 =========="
|
||||
if [ -f config/config.json ] && [ -n "$PY" ]; then
|
||||
"$PY" tools/查端点存活.py --tcp --timeout 3 2>&1
|
||||
else
|
||||
echo " 跳过(找不到 python 或 config/config.json)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "========== 6. 网络: 到COS的连通性 =========="
|
||||
if command -v coscli >/dev/null 2>&1 || [ -x ./coscli-linux-amd64 ]; then
|
||||
COSCLI="$(command -v coscli || echo ./coscli-linux-amd64)"
|
||||
COSCFG="$($PY -c "from config.config_utils import config;print((config.get_splitframe_config() or {}).get('cosconfig_path','.cos.yaml'))" 2>/dev/null || echo .cos.yaml)"
|
||||
JOBS_ROOT="$($PY -c "from config.config_utils import config;print((config.get_dispatch_config() or {}).get('jobs_root',''))" 2>/dev/null)"
|
||||
if [ -n "$JOBS_ROOT" ]; then
|
||||
echo "[列 $JOBS_ROOT 耗时]"
|
||||
t0=$(date +%s.%N)
|
||||
"$COSCLI" -c "$COSCFG" ls "$JOBS_ROOT/" 2>&1 | tail -5
|
||||
t1=$(date +%s.%N)
|
||||
echo " 耗时: $(echo "$t1 - $t0" | bc 2>/dev/null || echo "?")s (纯网络往返延迟参考, 不是下载带宽)"
|
||||
else
|
||||
echo " dispatch.jobs_root 未配置, 跳过"
|
||||
fi
|
||||
else
|
||||
echo " 找不到 coscli, 跳过"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "================ 摸底结束 ================"
|
||||
echo ""
|
||||
echo ">>> 报告已保存到: $OUT (把这个文件发回来)"
|
||||
48
scripts/build_bundle.sh
Normal file
48
scripts/build_bundle.sh
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 一键构建离线部署包(在【能联网】的打包机上跑)
|
||||
#
|
||||
# setup_server.sh(装环境) → make_portable.sh(自包含) → pack.sh(打包) 三步合一
|
||||
#
|
||||
# 用法:
|
||||
# bash build_bundle.sh cpu # CPU 版包:johnvansickle ffmpeg,老 glibc 也能跑
|
||||
# bash build_bundle.sh gpu # GPU 版包:BtbN cuda ffmpeg,目标机需 glibc>=2.28
|
||||
# bash build_bundle.sh gpu --whisper-gpu # GPU 版 + 把 CUDA 库打进包(目标机无系统 CUDA 也能 whisper GPU)
|
||||
#
|
||||
# 产物: /tmp/ocr_offline_bundle.tar.gz
|
||||
# 拷到目标机后: bash deploy.sh /tmp/ocr_offline_bundle.tar.gz /opt
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
VARIANT="${1:-cpu}" # cpu | gpu
|
||||
WHISPER_GPU_FLAG=0
|
||||
[ "${2:-}" = "--whisper-gpu" ] && WHISPER_GPU_FLAG=1
|
||||
|
||||
log() { echo -e "\n\033[1;36m##### $* #####\033[0m"; }
|
||||
|
||||
case "$VARIANT" in
|
||||
cpu) FFV=cpu ;;
|
||||
gpu) FFV=gpu ;;
|
||||
*) echo "用法: bash build_bundle.sh [cpu|gpu] [--whisper-gpu]"; exit 1 ;;
|
||||
esac
|
||||
|
||||
SD="$(cd "$(dirname "$0")" && pwd)"
|
||||
log "构建 $VARIANT 版离线包 (whisper_gpu=$WHISPER_GPU_FLAG)"
|
||||
|
||||
# 1) 装基础环境(已装过会跳过大部分)
|
||||
log "1/3 setup_server.sh(装 gcc/uv/python/依赖)"
|
||||
bash "$SD/setup_server.sh"
|
||||
|
||||
# 2) 转自包含(python+模型+ffmpeg 进项目)
|
||||
log "2/3 make_portable.sh(FFMPEG_VARIANT=$FFV)"
|
||||
FFMPEG_VARIANT="$FFV" WHISPER_GPU="$WHISPER_GPU_FLAG" bash "$SD/make_portable.sh"
|
||||
|
||||
# 3) 打包(按变体命名,cpu/gpu 两个包不会互相覆盖)
|
||||
OUT="/tmp/ocr_bundle_${VARIANT}.tar.gz"
|
||||
log "3/3 pack.sh(打成 tar → $OUT)"
|
||||
bash "$SD/pack.sh" "$OUT"
|
||||
|
||||
echo
|
||||
echo "✅ 完成。产物: $OUT"
|
||||
echo " 拷到目标机后: bash $SD/deploy.sh $OUT /opt"
|
||||
75
scripts/deploy.sh
Normal file
75
scripts/deploy.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 离线机部署脚本(在【不能联网】的机器上跑)
|
||||
#
|
||||
# 自动识别打包模式:
|
||||
# A) 自包含包(含项目内 python/):解压到【任意目标路径】,默认当前目录。
|
||||
# bash deploy.sh 包.tar.gz [目标父目录] # 例: bash deploy.sh bundle.tar.gz /opt
|
||||
# B) venv 包:解压到 / 还原到相同绝对路径(要求同路径/同用户/同架构)。
|
||||
# bash deploy.sh 包.tar.gz
|
||||
#
|
||||
# 前提:与打包机【同 CPU 架构(x86_64)】且【glibc 不比打包机老】。
|
||||
# 离线机不需要 uv / python / gcc / 联网。
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
BUNDLE="${1:-/tmp/ocr_offline_bundle.tar.gz}"
|
||||
TARGET="${2:-$(pwd)}" # 自包含模式的解压目标父目录
|
||||
log() { echo -e "\n\033[1;32m==> $*\033[0m"; }
|
||||
warn() { echo -e "\033[1;33m[警告] $*\033[0m"; }
|
||||
|
||||
[ -f "$BUNDLE" ] || { echo "找不到包: $BUNDLE"; exit 1; }
|
||||
|
||||
log "[1/3] 兼容性 + 模式识别"
|
||||
echo " 架构: $(uname -m)(需与打包机一致,一般 x86_64)"
|
||||
echo " glibc: $(ldd --version 2>/dev/null | head -1 | awk '{print $NF}')(不能比打包机老)"
|
||||
|
||||
# 看包里第一个条目:以 / 或 data/ 之类绝对路径开头 = venv 模式;以纯目录名开头 = 自包含
|
||||
# 注意:head 关管道会让 tar 收到 SIGPIPE 返回非零,配合 set -e/pipefail 会误退出,故加 || true
|
||||
FIRST="$(tar tzf "$BUNDLE" 2>/dev/null | head -1 || true)"
|
||||
echo " 包内首条目: $FIRST"
|
||||
|
||||
PROJDIR=""
|
||||
if printf '%s' "$FIRST" | grep -qE '^(/|root/|home/|data/|usr/|opt/)'; then
|
||||
# ---------- venv 模式:解压到 / ----------
|
||||
log "[2/3] venv 模式 → 解压到 / (还原到原绝对路径)"
|
||||
tar xzf "$BUNDLE" -C /
|
||||
# 项目目录 = 第一条目里到 ocr_data 那一段的绝对路径(取含项目名的目录)
|
||||
PROJDIR="/$(printf '%s' "$FIRST" | sed -E 's#(.*/[^/]*ocr[_-]?data)/.*#\1#')"
|
||||
warn "venv 模式:必须解压到相同路径/同用户,否则 venv 失效"
|
||||
else
|
||||
# ---------- 自包含模式:解压到目标目录 ----------
|
||||
log "[2/3] 自包含模式 → 解压到 $TARGET"
|
||||
mkdir -p "$TARGET"
|
||||
tar xzf "$BUNDLE" -C "$TARGET"
|
||||
TOP="$(printf '%s' "$FIRST" | cut -d/ -f1)"
|
||||
PROJDIR="$TARGET/$TOP"
|
||||
fi
|
||||
echo " 项目目录: $PROJDIR"
|
||||
|
||||
log "[3/3] 离线自检"
|
||||
cd "$PROJDIR" || { echo "进项目目录失败: $PROJDIR"; exit 1; }
|
||||
# shellcheck disable=SC1091
|
||||
source activate.sh
|
||||
|
||||
ok=1
|
||||
ck() { if eval "$2" >/dev/null 2>&1; then echo " ✓ $1"; else echo " ✗ $1"; ok=0; fi; }
|
||||
ck "python" "python --version"
|
||||
ck "import cv2(headless)" "python -c 'import cv2'"
|
||||
ck "import numpy" "python -c 'import numpy'"
|
||||
ck "import faster_whisper" "python -c 'import faster_whisper'"
|
||||
ck "import qcloud_cos" "python -c 'import qcloud_cos'"
|
||||
ck "ffmpeg(bin/)" "ffmpeg -version"
|
||||
ck "ffmpeg 支持 AV1" "ffmpeg -hide_banner -decoders | grep -iE 'dav1d|av1'"
|
||||
ck "coscli" "./coscli-linux-amd64 --help"
|
||||
MODEL="$(python -c "import json;print(json.load(open('config/config.json'))['audio'].get('model_size','small'))" 2>/dev/null || echo small)"
|
||||
ck "whisper 模型离线加载" "python -c \"from faster_whisper import WhisperModel; WhisperModel('$MODEL',device='cpu',compute_type='int8')\""
|
||||
|
||||
echo
|
||||
if [ "$ok" -eq 1 ]; then
|
||||
log "全部就绪 ✅ 可离线运行"
|
||||
echo " cd $PROJDIR && source activate.sh"
|
||||
echo " python main.py --source-list ./text2 --max-workers 12"
|
||||
else
|
||||
warn "有项目未通过(见上面 ✗)。常见原因:架构/glibc 不匹配;venv 模式下路径/用户不一致"
|
||||
fi
|
||||
152
scripts/diag.sh
Normal file
152
scripts/diag.sh
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env bash
|
||||
# 一键诊断: 收集机器/进程/内存/OOM/日志/数据 全部信息到一个文件, 发回给我分析。
|
||||
# 用法(在项目根目录): bash scripts/diag.sh
|
||||
# 产物: diag_YYYYmmdd_HHMMSS.txt
|
||||
|
||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || exit 1
|
||||
OUT="diag_$(date +%Y%m%d_%H%M%S).txt"
|
||||
PY="$(command -v python || command -v python3)"
|
||||
|
||||
# 所有输出同时进文件
|
||||
exec > >(tee "$OUT") 2>&1
|
||||
|
||||
echo "================ 诊断报告 $(date) ================"
|
||||
echo "主机: $(hostname) 目录: $(pwd)"
|
||||
echo ""
|
||||
|
||||
echo "========== 1. 机器资源 =========="
|
||||
echo "[内存 free -g]"; free -g
|
||||
echo ""
|
||||
echo "[CPU核数] $(nproc)"
|
||||
echo "[负载 uptime] $(uptime)"
|
||||
echo ""
|
||||
|
||||
echo "========== 2. OOM 检查(夜里有没有被系统杀) =========="
|
||||
echo "[dmesg killed process]"
|
||||
( dmesg -T 2>/dev/null || dmesg 2>/dev/null ) | grep -iE "killed process|out of memory|oom-kill" | tail -20 || echo " (无 / dmesg无权限)"
|
||||
echo "[/var/log/messages oom]"
|
||||
grep -iE "killed process|out of memory|oom" /var/log/messages 2>/dev/null | tail -10 || echo " (无 / 无权限)"
|
||||
echo ""
|
||||
|
||||
echo "========== 3. 进程状态 =========="
|
||||
echo "[main.py 进程数] $(ps -eo args 2>/dev/null | grep -c '[m]ain.py')"
|
||||
echo "[ffmpeg 进程数] $(ps -eo comm 2>/dev/null | grep -c '[f]fmpeg')"
|
||||
echo "[forkserver/worker] $(ps -eo args 2>/dev/null | grep -c '[m]ultiprocessing\|[f]orkserver')"
|
||||
echo ""
|
||||
echo "[吃内存最多的10个进程 RSS]"
|
||||
ps -eo pid,rss,etime,args --sort=-rss 2>/dev/null | head -11
|
||||
echo ""
|
||||
|
||||
echo "========== 4. Python / 环境 =========="
|
||||
echo "[python] $($PY --version 2>&1) 路径: $PY"
|
||||
echo "[which python] $(command -v python 2>/dev/null)"
|
||||
echo "[自包含python在不在] $(ls python/bin/python3 2>/dev/null && echo yes || echo no)"
|
||||
echo ""
|
||||
|
||||
echo "========== 5. config 关键值 =========="
|
||||
if [ -f config/config.json ]; then
|
||||
$PY - <<'PYEOF'
|
||||
import json
|
||||
try:
|
||||
d=json.load(open("config/config.json",encoding="utf-8"))
|
||||
s=d.get("subtitle",{}); sf=d.get("splitframe",{}); au=d.get("audio",{})
|
||||
print(" ocr_workers=%s no_composite=%s merge_workers=%s max_tiles=%s"%(
|
||||
s.get("ocr_workers"),s.get("no_composite"),s.get("merge_workers"),s.get("max_tiles_per_composite")))
|
||||
print(" cv_detect=%s filter_static=%s filter_watermark_pos=%s split_far_boxes=%s"%(
|
||||
s.get("cv_detect"),s.get("filter_static"),s.get("filter_watermark_pos"),s.get("split_far_boxes")))
|
||||
print(" min_duration=%s crop_bottom_ratio=%s ffmpeg_threads=%s use_gpu=%s"%(
|
||||
s.get("min_duration_seconds"),sf.get("crop_bottom_ratio"),sf.get("ffmpeg_threads"),sf.get("use_gpu")))
|
||||
print(" audio.model_size=%s cpu_threads=%s endpoints数=%s"%(
|
||||
au.get("model_size"),au.get("cpu_threads"),len(d.get("tencentcloud",{}).get("ocr",{}).get("endpoints",[]))))
|
||||
except Exception as e:
|
||||
print(" 读config失败:",e)
|
||||
PYEOF
|
||||
else
|
||||
echo " config/config.json 不存在"
|
||||
fi
|
||||
echo "[启动命令] $(ps -eo args 2>/dev/null | grep '[m]ain.py' | head -1)"
|
||||
echo ""
|
||||
|
||||
echo "========== 6. app.log 分析 =========="
|
||||
if [ -f app.log ]; then
|
||||
echo "[总行数] $(wc -l < app.log)"
|
||||
echo "[关键词计数]"
|
||||
for kw in 成功 无字幕 失败 暂停启动 抽帧完成 拼接完成 字幕完成 内存不足 BrokenProcess 崩溃 worker崩 Traceback Error 超时 timeout 冷却 端点; do
|
||||
c=$(grep -c "$kw" app.log 2>/dev/null)
|
||||
[ "${c:-0}" -gt 0 ] && printf " %-14s %s\n" "$kw" "$c"
|
||||
done
|
||||
echo ""
|
||||
echo "[最近 40 行]"
|
||||
tail -40 app.log
|
||||
else
|
||||
echo " app.log 不存在"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "========== 7. 数据.jsonl 分析 =========="
|
||||
if [ -f 数据.jsonl ]; then
|
||||
$PY - <<'PYEOF'
|
||||
import json,statistics
|
||||
from collections import Counter,defaultdict
|
||||
from datetime import datetime,timedelta
|
||||
latest={}
|
||||
for line in open("数据.jsonl",encoding="utf-8"):
|
||||
line=line.strip()
|
||||
if not line: continue
|
||||
try: r=json.loads(line)
|
||||
except: continue
|
||||
v=r.get("video_cos_path")
|
||||
if v: latest[v]=r
|
||||
n=len(latest)
|
||||
st=Counter(r.get("status") for r in latest.values())
|
||||
print(" 去重视频 %d | 状态 %s"%(n,dict(st)))
|
||||
if n: print(" 成功率 %.1f%%"%(st.get("success",0)/n*100))
|
||||
# 阶段耗时(成功)
|
||||
tot=defaultdict(float); te=0
|
||||
for r in latest.values():
|
||||
if r.get("status")!="success": continue
|
||||
sd=r.get("stage_durations") or {}; te+=r.get("elapsed_seconds") or 0
|
||||
for k in ["split_frame","merge","getsrt"]:
|
||||
if k in sd: tot[k]+=sd[k]
|
||||
if te:
|
||||
print(" 阶段占比: 抽帧%.0f%% 拼接%.0f%% OCR+字幕%.0f%%"%(tot["split_frame"]/te*100,tot["merge"]/te*100,tot["getsrt"]/te*100))
|
||||
# OCR/帧
|
||||
rs=[ic.get("total_ocr_count")/ic.get("frame_count") for r in latest.values() if (ic:=r.get("image_counts") or {}).get("frame_count") and ic.get("total_ocr_count")]
|
||||
if rs: print(" OCR/帧 中位 %.3f"%statistics.median(rs))
|
||||
# 时长
|
||||
durs=sorted((r.get("duration") or 0) for r in latest.values() if r.get("duration"))
|
||||
if durs: print(" 视频时长 中位%.0fs max%.0fs | >20分钟 %d个"%(statistics.median(durs),max(durs),sum(1 for d in durs if d>1200)))
|
||||
# 单视频平均耗时
|
||||
els=[r.get("elapsed_seconds") or 0 for r in latest.values() if r.get("status")=="success"]
|
||||
if els: print(" 成功视频平均耗时 %.0fs 中位%.0fs"%(sum(els)/len(els),statistics.median(els)))
|
||||
# 每小时吞吐 + 有效并发(重叠法)
|
||||
busy=defaultdict(float); cnt=defaultdict(int)
|
||||
for r in latest.values():
|
||||
try: s=datetime.fromisoformat(r["process_start"]); e=datetime.fromisoformat(r["process_end"])
|
||||
except: continue
|
||||
if e<=s: continue
|
||||
cnt[s.strftime("%m-%d %H")]+=1
|
||||
cur=s
|
||||
while cur<e:
|
||||
hk=cur.strftime("%m-%d %H"); nx=cur.replace(minute=0,second=0,microsecond=0)+timedelta(hours=1)
|
||||
seg=min(e,nx); busy[hk]+=(seg-cur).total_seconds(); cur=seg
|
||||
print(" 每小时(完成数 / 有效并发):")
|
||||
for hk in sorted(busy)[-8:]:
|
||||
print(" %s : %d条 / 并发%.1f"%(hk,cnt[hk],busy[hk]/3600))
|
||||
PYEOF
|
||||
else
|
||||
echo " 数据.jsonl 不存在"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "========== 8. 磁盘 / GPU =========="
|
||||
echo "[磁盘 df -h .]"; df -h . 2>/dev/null
|
||||
echo "[split/ 大小] $(du -sh split 2>/dev/null || echo '无')"
|
||||
echo "[正在处理的视频数 split/] $(ls split 2>/dev/null | wc -l)"
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
echo "[GPU]"; nvidia-smi --query-gpu=index,memory.used,memory.total,utilization.gpu --format=csv,noheader 2>/dev/null
|
||||
fi
|
||||
echo ""
|
||||
echo "================ 诊断结束 ================"
|
||||
echo ""
|
||||
echo ">>> 报告已保存到: $OUT (把这个文件发回来)"
|
||||
127
scripts/make_portable.sh
Normal file
127
scripts/make_portable.sh
Normal file
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 把环境改造成"项目内自包含"——Python、第三方库、模型全放进项目目录,
|
||||
# 之后整个项目文件夹可以【随便放到任意路径、任意用户】,不再依赖 /root 或固定路径。
|
||||
#
|
||||
# 在【能联网】的机器上跑一次(已 setup_server.sh 装好 .venv 后):
|
||||
# bash make_portable.sh # 默认 ffmpeg 用 GPU 版(BtbN,含 cuda/nvdec,也兼容 CPU)
|
||||
# # 需运行机 glibc>=2.28;GPU 机器自动硬解、CPU 机器软解
|
||||
# FFMPEG_VARIANT=cpu bash make_portable.sh # ffmpeg 用 CPU 版(johnvansickle,全静态,老 glibc 也能跑)
|
||||
# 之后:
|
||||
# bash pack.sh # 打包(此时只需打项目目录,已自包含)
|
||||
# # 离线机解压到【任意路径】即可: source activate.sh && python main.py ...
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PROJ="$(pwd)"
|
||||
PIP_MIRROR="${PIP_MIRROR:-https://repo.huaweicloud.com/repository/pypi/simple}"
|
||||
|
||||
log() { echo -e "\n\033[1;32m==> $*\033[0m"; }
|
||||
warn() { echo -e "\033[1;33m[警告] $*\033[0m"; }
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
[ -d .venv ] || { echo "没有 .venv,请先 bash setup_server.sh"; exit 1; }
|
||||
# shellcheck disable=SC1091
|
||||
source .venv/bin/activate
|
||||
have uv || { echo "需要 uv(本机改造用,离线机不需要)"; exit 1; }
|
||||
|
||||
# 0) 确保 bin/ffmpeg 可用。两种变体(用环境变量 FFMPEG_VARIANT 选):
|
||||
# cpu (默认) = johnvansickle 静态版,含 dav1d(CPU 软解 AV1),兼容老 glibc,哪都能跑。
|
||||
# gpu = BtbN 版,含 cuda/nvdec(GPU 硬解),给 Ampere+ 卡的机器用;需较新 glibc(>=2.28)。
|
||||
# 用法: FFMPEG_VARIANT=gpu bash make_portable.sh
|
||||
# 默认下 GPU 版(含 cuda/nvdec,也兼容 CPU 软解)。要纯 CPU 版用 FFMPEG_VARIANT=cpu。
|
||||
FFMPEG_VARIANT="${FFMPEG_VARIANT:-gpu}"
|
||||
FF_NEED=1
|
||||
if [ "$FFMPEG_VARIANT" = "gpu" ]; then
|
||||
FF_URL="${FFMPEG_GPU_URL:-https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz}"
|
||||
# GPU 版(BtbN)要 glibc>=2.28 才能运行;本机若太老仅警告(可能在给别的新机器打包)
|
||||
# 注意:管道里 head 会让前面命令 SIGPIPE,配合 set -e/pipefail 会误退出,故加 || true
|
||||
_glibc="$(ldd --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1 || true)"
|
||||
if [ -n "$_glibc" ] && [ "$(printf '%s\n2.28\n' "$_glibc" | sort -V | head -1)" != "2.28" ]; then
|
||||
warn "本机 glibc=$_glibc < 2.28,GPU 版 ffmpeg 在本机跑不了(GLIBC_2.28 not found)。"
|
||||
warn "仅当打包给 glibc>=2.28 的机器时才用 GPU 版;本机自用请: FFMPEG_VARIANT=cpu bash make_portable.sh"
|
||||
fi
|
||||
# 用 grep(不带 -q)读完整输出再判,避免 grep -q 提前关管道触发 SIGPIPE+pipefail 误判
|
||||
if [ -x "$PROJ/bin/ffmpeg" ] && "$PROJ/bin/ffmpeg" -hide_banner -hwaccels 2>/dev/null | grep -i cuda >/dev/null; then
|
||||
log "[0/4] bin/ffmpeg 已是 GPU 版(支持 cuda),跳过"; FF_NEED=0
|
||||
else
|
||||
log "[0/4] 下载 GPU 版 ffmpeg (BtbN,含 cuda/nvdec) → ./bin"
|
||||
fi
|
||||
else
|
||||
FF_URL="${FFMPEG_STATIC_URL:-https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz}"
|
||||
if [ -x "$PROJ/bin/ffmpeg" ] && "$PROJ/bin/ffmpeg" -hide_banner -decoders 2>/dev/null | grep -iE 'dav1d|av1' >/dev/null; then
|
||||
log "[0/4] bin/ffmpeg 已支持 AV1(CPU 版),跳过"; FF_NEED=0
|
||||
else
|
||||
log "[0/4] 下载 CPU 版 ffmpeg (johnvansickle,含 dav1d) → ./bin"
|
||||
fi
|
||||
fi
|
||||
if [ "$FF_NEED" -eq 1 ]; then
|
||||
mkdir -p "$PROJ/bin"; tmp="$(mktemp -d)"
|
||||
if have curl; then curl -L "$FF_URL" -o "$tmp/ff.tar.xz"; else wget -O "$tmp/ff.tar.xz" "$FF_URL"; fi
|
||||
tar xf "$tmp/ff.tar.xz" -C "$tmp"
|
||||
# johnvansickle 在 <dir>/ffmpeg,BtbN 在 <dir>/bin/ffmpeg —— 直接全树找,两种都兼容
|
||||
ffb="$(find "$tmp" -type f -name ffmpeg | head -1)"
|
||||
ffp="$(find "$tmp" -type f -name ffprobe | head -1)"
|
||||
if [ -n "$ffb" ] && [ -n "$ffp" ]; then
|
||||
cp "$ffb" "$ffp" "$PROJ/bin/" && chmod +x "$PROJ/bin/ffmpeg" "$PROJ/bin/ffprobe"
|
||||
log "ffmpeg/ffprobe 已装到 ./bin($FFMPEG_VARIANT 版)"
|
||||
else
|
||||
echo "解压后没找到 ffmpeg/ffprobe,请手动放入 ./bin"; exit 1
|
||||
fi
|
||||
rm -rf "$tmp"
|
||||
fi
|
||||
|
||||
# 1) 把 uv 的独立 Python 复制进项目 ./python(python-build-standalone 本身可重定位)
|
||||
log "[1/4] 复制独立 Python 到项目内 ./python"
|
||||
PYSRC="$(dirname "$(dirname "$(readlink -f .venv/bin/python)")")"
|
||||
echo " 源: $PYSRC"
|
||||
rm -rf "$PROJ/python"
|
||||
cp -a "$PYSRC" "$PROJ/python"
|
||||
PYBIN="$PROJ/python/bin/python3"
|
||||
[ -x "$PYBIN" ] || PYBIN="$(ls "$PROJ"/python/bin/python3.* | head -1)"
|
||||
echo " 项目内 Python: $PYBIN"
|
||||
# 去掉 uv 的"外部管理(EXTERNALLY-MANAGED)"标记,否则 uv 拒绝往这个 Python 里装包
|
||||
rm -f "$PROJ"/python/lib/python3.*/EXTERNALLY-MANAGED
|
||||
|
||||
# 2) 把依赖装进这个项目内 Python(脱离 .venv,用 headless opencv)
|
||||
log "[2/4] 安装依赖到项目内 Python"
|
||||
uv pip uninstall --python "$PYBIN" --break-system-packages opencv-python 2>/dev/null || true
|
||||
uv pip install --python "$PYBIN" --break-system-packages -r requirements.txt --index-url "$PIP_MIRROR" \
|
||||
--only-binary numpy,scipy,scikit-learn,opencv-python-headless,Pillow,av \
|
||||
|| uv pip install --python "$PYBIN" --break-system-packages -r requirements.txt --index-url "$PIP_MIRROR"
|
||||
|
||||
# WHISPER_GPU=1:把 CUDA 库(cublas/cudnn)也装进项目 Python,这样目标机【无需系统 CUDA】
|
||||
# 也能让 whisper 跑 GPU(ctranslate2 从 venv 里找库;activate.sh 会把它加进 LD_LIBRARY_PATH)。
|
||||
if [ "${WHISPER_GPU:-0}" = "1" ]; then
|
||||
log " 额外装 CUDA 库(nvidia-cublas/cudnn-cu12)给 whisper GPU 用"
|
||||
uv pip install --python "$PYBIN" --break-system-packages \
|
||||
nvidia-cublas-cu12 nvidia-cudnn-cu12 --index-url "$PIP_MIRROR" \
|
||||
|| warn "CUDA 库安装失败;目标机若无系统 CUDA,whisper 将回退 CPU"
|
||||
fi
|
||||
|
||||
# 3) 把 whisper 模型缓存搬进项目 ./models(用 HF_HOME 指过去)
|
||||
log "[3/4] 预下载 whisper 模型到项目内 ./models"
|
||||
MODEL="$(python -c "import json;print(json.load(open('config/config.json'))['audio'].get('model_size','small'))")"
|
||||
mkdir -p "$PROJ/models"
|
||||
HF_HOME="$PROJ/models" HF_HUB_OFFLINE=0 TRANSFORMERS_OFFLINE=0 \
|
||||
HF_ENDPOINT="${HF_ENDPOINT:-https://hf-mirror.com}" \
|
||||
HF_HUB_DISABLE_XET=1 \
|
||||
"$PYBIN" -c "from faster_whisper import WhisperModel; WhisperModel('$MODEL',device='cpu',compute_type='int8'); print('模型已放进 ./models')"
|
||||
|
||||
# 4) 删掉旧 .venv(不再需要,已被项目内 Python 取代)
|
||||
log "[4/4] 清理旧 .venv"
|
||||
deactivate 2>/dev/null || true
|
||||
rm -rf "$PROJ/.venv"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
完成 ✅ 现在项目已自包含,可随便放任意路径/用户:
|
||||
./python/ 独立 Python + 所有第三方库
|
||||
./bin/ ffmpeg / ffprobe
|
||||
./models/ whisper 模型
|
||||
coscli-linux-amd64
|
||||
|
||||
验证: source activate.sh && python -c "import cv2,faster_whisper;print('OK')"
|
||||
打包: bash pack.sh
|
||||
EOF
|
||||
33
scripts/monitor.sh
Normal file
33
scripts/monitor.sh
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# 每隔 N 秒采集 CPU/IO/负载/网络,带时间戳写入一个文件。
|
||||
# 用法: bash monitor.sh [间隔秒,默认5] [输出文件,默认 monitor.log]
|
||||
# 停止: Ctrl-C 或 kill $(cat monitor.pid)
|
||||
INTERVAL="${1:-5}"
|
||||
OUT="${2:-monitor.log}"
|
||||
echo $$ > monitor.pid
|
||||
|
||||
echo "===== 监控开始 $(date) 间隔${INTERVAL}s 输出 $OUT =====" | tee -a "$OUT"
|
||||
command -v iostat >/dev/null 2>&1 && HAS_IOSTAT=1 || HAS_IOSTAT=0
|
||||
command -v sar >/dev/null 2>&1 && HAS_SAR=1 || HAS_SAR=0
|
||||
[ "$HAS_IOSTAT" = 0 ] && echo "(无 iostat, 只记 vmstat; 装: yum/apt install sysstat)" | tee -a "$OUT"
|
||||
|
||||
trap 'echo "===== 监控停止 $(date) =====" >> "$OUT"; rm -f monitor.pid; exit 0' INT TERM
|
||||
|
||||
while true; do
|
||||
{
|
||||
echo "----- $(date '+%Y-%m-%d %H:%M:%S') -----"
|
||||
echo "[vmstat] r=运行队列 b=阻塞(等IO) wa=iowait% id=空闲%"
|
||||
vmstat 1 2 | tail -1
|
||||
if [ "$HAS_IOSTAT" = 1 ]; then
|
||||
echo "[iostat] %util>90=磁盘吃满 w/s=写次数 w_await=写延迟ms"
|
||||
# 取第二份报告(间隔值, 非开机累计), 只留设备行
|
||||
iostat -dx 1 2 | awk '/Device/{c++} c==2'
|
||||
fi
|
||||
if [ "$HAS_SAR" = 1 ]; then
|
||||
echo "[net] 网卡收发 kB/s:"
|
||||
sar -n DEV 1 1 | awk '/Average:/ && $2!="lo" && $2!="IFACE" {printf " %s rx=%s tx=%s kB/s\n",$2,$5,$6}'
|
||||
fi
|
||||
echo ""
|
||||
} >> "$OUT" 2>&1
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
84
scripts/pack.sh
Normal file
84
scripts/pack.sh
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 离线部署打包脚本(在【能联网】的机器上跑)
|
||||
#
|
||||
# 两种模式自动识别:
|
||||
# A) 自包含模式(已跑过 make_portable.sh,项目内有 ./python + ./models)
|
||||
# → 打包里只含项目这一个文件夹,离线机解压到【任意路径/任意用户】即可。
|
||||
# B) venv 模式(只有 .venv)
|
||||
# → 打项目 + /root 下的 uv Python + HF 缓存,离线机要解压到相同路径。
|
||||
#
|
||||
# 用法: bash pack.sh
|
||||
# 产物: /tmp/ocr_offline_bundle.tar.gz
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PROJECT_DIR="$(pwd)"
|
||||
OUT="${1:-/tmp/ocr_offline_bundle.tar.gz}"
|
||||
|
||||
log() { echo -e "\n\033[1;32m==> $*\033[0m"; }
|
||||
warn() { echo -e "\033[1;33m[警告] $*\033[0m"; }
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
PBASE="$(basename "$PROJECT_DIR")"
|
||||
PPARENT="$(dirname "$PROJECT_DIR")"
|
||||
|
||||
# 运行时数据排除(部署不需要,打进去又慢又大)。用项目目录名作前缀,两种模式通用。
|
||||
RT_DIRS=(split video composite coscli_output srt info info_error log)
|
||||
EXCLUDES=(--exclude='*.log' --exclude='__pycache__' --exclude='*.pyc'
|
||||
--exclude="$PBASE/数据.jsonl")
|
||||
for d in "${RT_DIRS[@]}"; do EXCLUDES+=(--exclude="$PBASE/$d"); done
|
||||
|
||||
# 选最快的压缩器:pigz 多线程(输出仍是标准 gzip,离线机普通 tar xzf 就能解)
|
||||
if have pigz; then
|
||||
COMPRESS="pigz"; log "用 pigz 多线程压缩(快)"
|
||||
else
|
||||
COMPRESS="gzip -1"; warn "未装 pigz,用 gzip -1;想更快可先 yum install -y pigz"
|
||||
fi
|
||||
|
||||
if [ -x "$PROJECT_DIR/python/bin/python3" ]; then
|
||||
# ---------- A) 自包含:只打项目目录(用相对名),可解压到任意路径 ----------
|
||||
log "自包含模式(项目内含 ./python + ./models)"
|
||||
[ -d "$PROJECT_DIR/models" ] || warn "没看到 ./models,确认 make_portable.sh 跑完整了"
|
||||
tar "${EXCLUDES[@]}" --use-compress-program="$COMPRESS" -cf "$OUT" \
|
||||
-C "$PPARENT" "$PBASE"
|
||||
MODE="自包含(解压到任意路径/用户)"
|
||||
else
|
||||
# ---------- B) venv:打项目(绝对路径) + 外部 Python + HF 缓存 ----------
|
||||
[ -d .venv ] || { echo "既没有 ./python 也没有 .venv,请先 setup_server.sh 或 make_portable.sh"; exit 1; }
|
||||
# shellcheck disable=SC1091
|
||||
source .venv/bin/activate
|
||||
|
||||
log "venv 模式:确保 headless opencv + 预下载模型"
|
||||
uv pip uninstall opencv-python 2>/dev/null || true
|
||||
uv pip install opencv-python-headless \
|
||||
--index-url "${PIP_MIRROR:-https://repo.huaweicloud.com/repository/pypi/simple}" 2>/dev/null \
|
||||
|| echo "(headless 可能已装,忽略)"
|
||||
MODEL="$(python -c "import json;print(json.load(open('config/config.json'))['audio'].get('model_size','small'))")"
|
||||
HF_HUB_OFFLINE=0 TRANSFORMERS_OFFLINE=0 HF_ENDPOINT="${HF_ENDPOINT:-https://hf-mirror.com}" \
|
||||
python -c "from faster_whisper import WhisperModel; WhisperModel('$MODEL',device='cpu',compute_type='int8'); print('模型已缓存')"
|
||||
|
||||
PYDIR="$(dirname "$(dirname "$(readlink -f .venv/bin/python)")")"
|
||||
HFCACHE="${HF_HOME:-$HOME/.cache/huggingface}"
|
||||
echo " uv Python: $PYDIR 模型缓存: $HFCACHE"
|
||||
|
||||
log "打包项目(绝对路径) + Python + 模型缓存"
|
||||
# 项目用绝对路径(还原到原位),外部两目录同理
|
||||
EXABS=(--exclude='*.log' --exclude='__pycache__' --exclude='*.pyc'
|
||||
--exclude="${PROJECT_DIR#/}/数据.jsonl")
|
||||
for d in "${RT_DIRS[@]}"; do EXABS+=(--exclude="${PROJECT_DIR#/}/$d"); done
|
||||
tar "${EXABS[@]}" --use-compress-program="$COMPRESS" -cf "$OUT" \
|
||||
-C / "${PROJECT_DIR#/}" -C / "${PYDIR#/}" -C / "${HFCACHE#/}"
|
||||
MODE="venv(离线机需解压到相同路径/用户)"
|
||||
fi
|
||||
|
||||
echo
|
||||
log "完成 ✅ $OUT ($(du -h "$OUT" | cut -f1))"
|
||||
echo "模式: $MODE"
|
||||
cat <<EOF
|
||||
|
||||
下一步:
|
||||
scp $OUT root@离线机:/tmp/
|
||||
bash deploy.sh /tmp/$(basename "$OUT") # 自包含模式可加目标路径: bash deploy.sh 包 /opt
|
||||
EOF
|
||||
26
scripts/set_ffmpeg.sh
Normal file
26
scripts/set_ffmpeg.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# 一键切换项目 bin/ffmpeg 的版本(本机自用 cpu / 打 GPU 包 gpu)
|
||||
# bash temp/set_ffmpeg.sh cpu # johnvansickle 全静态,任何 glibc 都能跑(本机自用)
|
||||
# bash temp/set_ffmpeg.sh gpu # BtbN 含 cuda/nvdec,需 glibc>=2.28(给 GPU 机器)
|
||||
set -euo pipefail
|
||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # 项目根
|
||||
V="${1:-cpu}"
|
||||
BIN="$(pwd)/bin"; mkdir -p "$BIN"
|
||||
have(){ command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
case "$V" in
|
||||
cpu) URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" ;;
|
||||
gpu) URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz" ;;
|
||||
*) echo "用法: bash temp/set_ffmpeg.sh [cpu|gpu]"; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo "下载 $V 版 ffmpeg ..."
|
||||
tmp="$(mktemp -d)"
|
||||
if have curl; then curl -L "$URL" -o "$tmp/ff.tar.xz"; else wget -O "$tmp/ff.tar.xz" "$URL"; fi
|
||||
tar xf "$tmp/ff.tar.xz" -C "$tmp"
|
||||
ffb="$(find "$tmp" -type f -name ffmpeg | head -1)"
|
||||
ffp="$(find "$tmp" -type f -name ffprobe | head -1)"
|
||||
cp "$ffb" "$ffp" "$BIN/"
|
||||
chmod +x "$BIN/ffmpeg" "$BIN/ffprobe"
|
||||
rm -rf "$tmp"
|
||||
echo "已切到 $V 版: $("$BIN/ffmpeg" -version 2>&1 | head -1 || echo '本机跑不了(若是 gpu 版+老 glibc,正常)')"
|
||||
244
scripts/setup_server.sh
Normal file
244
scripts/setup_server.sh
Normal file
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 视频字幕提取系统 — 服务器一键环境配置脚本
|
||||
#
|
||||
# 安装/配置:gcc 编译工具链、ffmpeg(含 AV1/dav1d 解码)、uv、Python 虚拟环境、
|
||||
# 项目依赖,并处理常见环境坑(opencv libGL、UTF-8 locale、HF/pip 镜像、
|
||||
# OMP 线程、文件句柄上限)。
|
||||
#
|
||||
# 用法(在项目根目录):
|
||||
# bash setup_server.sh
|
||||
# 跑完后:
|
||||
# source activate.sh # 激活环境(设置 venv + 各种环境变量)
|
||||
# python main.py --source-list ./text2 --max-workers 12
|
||||
#
|
||||
# 幂等:可重复运行,已装的会跳过。
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# ----------------------------- 可调参数 --------------------------------------
|
||||
PYTHON_VERSION="${PYTHON_VERSION:-3.11}"
|
||||
PIP_MIRROR="${PIP_MIRROR:-https://repo.huaweicloud.com/repository/pypi/simple}"
|
||||
HF_ENDPOINT_URL="${HF_ENDPOINT_URL:-https://hf-mirror.com}"
|
||||
FFMPEG_STATIC_URL="${FFMPEG_STATIC_URL:-https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz}"
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VENV_DIR="$PROJECT_DIR/.venv"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# 让本脚本(及自检)优先用项目 bin/ 里的 ffmpeg(我们装的静态版)
|
||||
export PATH="$PROJECT_DIR/bin:$PATH"
|
||||
|
||||
# ----------------------------- 工具函数 --------------------------------------
|
||||
log() { echo -e "\n\033[1;32m==> $*\033[0m"; }
|
||||
warn() { echo -e "\033[1;33m[警告] $*\033[0m"; }
|
||||
err() { echo -e "\033[1;31m[错误] $*\033[0m" >&2; }
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# root 则不用 sudo,否则用 sudo
|
||||
if [ "$(id -u)" -eq 0 ]; then SUDO=""; else SUDO="sudo"; fi
|
||||
|
||||
# 检测包管理器
|
||||
PKG=""
|
||||
if have dnf; then PKG="dnf"
|
||||
elif have yum; then PKG="yum"
|
||||
elif have apt-get; then PKG="apt"
|
||||
else warn "未识别的包管理器,跳过系统包安装(请确保已手动装好 gcc/ffmpeg 等)"; fi
|
||||
|
||||
# 解压后 coscli/bin 里的二进制常丢失 +x,先无条件加执行权限(放最前,确保一定执行)
|
||||
for b in coscli-linux-amd64 coscli bin/ffmpeg bin/ffprobe; do
|
||||
[ -f "$PROJECT_DIR/$b" ] && chmod +x "$PROJECT_DIR/$b" 2>/dev/null || true
|
||||
done
|
||||
log "已给 coscli / bin 二进制加执行权限"
|
||||
|
||||
# =============================================================================
|
||||
log "[1/7] 安装系统依赖(gcc 工具链 + opencv/运行库)"
|
||||
# =============================================================================
|
||||
# 关键环境坑:
|
||||
# - opencv-python 在无图形界面的服务器上 import 会报 libGL.so.1 缺失 → 装 mesa-libGL/libgl1
|
||||
# - 还需要 glib2(libgthread-2.0) 否则 opencv 也可能报错
|
||||
# - gcc/gcc-c++/make:少数包没有 wheel 时要本地编译
|
||||
case "$PKG" in
|
||||
dnf|yum)
|
||||
# openblas-devel:万一 scipy/numpy 走源码编译时需要(优先还是用 wheel)
|
||||
$SUDO $PKG install -y gcc gcc-c++ gcc-gfortran make git tar xz which \
|
||||
mesa-libGL glib2 openblas-devel || warn "部分系统包安装失败,继续"
|
||||
;;
|
||||
apt)
|
||||
$SUDO apt-get update -y || true
|
||||
$SUDO apt-get install -y build-essential gfortran git tar xz-utils curl \
|
||||
libgl1 libglib2.0-0 libopenblas-dev || warn "部分系统包安装失败,继续"
|
||||
;;
|
||||
esac
|
||||
|
||||
# =============================================================================
|
||||
log "[2/7] 安装 ffmpeg(含 AV1/dav1d 解码) → 项目目录 bin/"
|
||||
# =============================================================================
|
||||
# 直接下载静态构建(johnvansickle,自带 dav1d)到项目的 bin/,代码会自动优先用它
|
||||
# (config_utils 启动时把 bin/ 提到 PATH 最前)。不依赖系统 ffmpeg,也不用 sudo。
|
||||
# 注意:CPU 版(无 NVDEC);要 GPU 硬解需另装 cuda 版 ffmpeg。
|
||||
FFMPEG_DIR="$PROJECT_DIR/bin"
|
||||
LOCAL_FFMPEG="$FFMPEG_DIR/ffmpeg"
|
||||
need_ffmpeg=1
|
||||
if [ -x "$LOCAL_FFMPEG" ] && "$LOCAL_FFMPEG" -hide_banner -decoders 2>/dev/null | grep -qiE 'dav1d|av1'; then
|
||||
log "项目自带 ffmpeg 已存在且支持 AV1: $LOCAL_FFMPEG,跳过下载"
|
||||
need_ffmpeg=0
|
||||
fi
|
||||
if [ "$need_ffmpeg" -eq 1 ]; then
|
||||
if have curl || have wget; then
|
||||
mkdir -p "$FFMPEG_DIR"
|
||||
tmp="$(mktemp -d)"
|
||||
log "下载 ffmpeg 静态包到 $FFMPEG_DIR ..."
|
||||
if have curl; then curl -L "$FFMPEG_STATIC_URL" -o "$tmp/ff.tar.xz"
|
||||
else wget -O "$tmp/ff.tar.xz" "$FFMPEG_STATIC_URL"; fi
|
||||
tar -xf "$tmp/ff.tar.xz" -C "$tmp"
|
||||
ffdir="$(find "$tmp" -maxdepth 1 -type d -name 'ffmpeg-*' | head -1)"
|
||||
if [ -n "$ffdir" ]; then
|
||||
cp "$ffdir/ffmpeg" "$ffdir/ffprobe" "$FFMPEG_DIR/"
|
||||
chmod +x "$FFMPEG_DIR/ffmpeg" "$FFMPEG_DIR/ffprobe"
|
||||
log "ffmpeg/ffprobe 已装到 $FFMPEG_DIR"
|
||||
else
|
||||
err "解压后未找到 ffmpeg 目录,请手动安装 ffmpeg"
|
||||
fi
|
||||
rm -rf "$tmp"
|
||||
else
|
||||
err "没有 curl/wget,无法下载 ffmpeg,请手动安装"
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
log "[3/7] 安装 uv"
|
||||
# =============================================================================
|
||||
if ! have uv; then
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# uv 默认装到 ~/.local/bin 或 ~/.cargo/bin
|
||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
||||
fi
|
||||
have uv || { err "uv 安装失败,请检查网络"; exit 1; }
|
||||
log "uv 版本: $(uv --version)"
|
||||
|
||||
# =============================================================================
|
||||
log "[4/7] 用 uv 安装 Python ${PYTHON_VERSION}"
|
||||
# =============================================================================
|
||||
uv python install "$PYTHON_VERSION" || warn "uv python install 失败(可能已装)"
|
||||
|
||||
# =============================================================================
|
||||
log "[5/7] 创建虚拟环境并安装依赖"
|
||||
# =============================================================================
|
||||
# 优先用 pyproject.toml + uv.lock(规范、可复现);没有则回退 requirements.txt。
|
||||
# pyproject 的 [tool.uv] 已配置:禁止 av/numpy/sklearn 本地编译 + 清华镜像。
|
||||
# 用 uv venv + uv pip install(现代解析器)。--only-binary 让它自动挑有 wheel 的版本,
|
||||
# 避免某些受限镜像把新版本只给源码包导致本地编译/报错(不像 uv sync 的 no-build 会硬失败)。
|
||||
[ -d "$VENV_DIR" ] || uv venv --python "$PYTHON_VERSION" "$VENV_DIR"
|
||||
# shellcheck disable=SC1091
|
||||
source "$VENV_DIR/bin/activate"
|
||||
if [ -f requirements.txt ]; then
|
||||
uv pip install -r requirements.txt --index-url "$PIP_MIRROR" \
|
||||
--only-binary numpy,scipy,scikit-learn,opencv-python-headless,Pillow,av \
|
||||
|| uv pip install -r requirements.txt --index-url "$PIP_MIRROR"
|
||||
else
|
||||
warn "未找到 requirements.txt,跳过依赖安装"
|
||||
fi
|
||||
|
||||
# 激活 venv 供后续自检使用
|
||||
# shellcheck disable=SC1091
|
||||
[ -f "$VENV_DIR/bin/activate" ] && source "$VENV_DIR/bin/activate"
|
||||
log "Python: $(python --version 2>&1) 位置: $(command -v python)"
|
||||
|
||||
# =============================================================================
|
||||
log "[6/7] 写出环境变量脚本 activate.sh"
|
||||
# =============================================================================
|
||||
# 常见环境坑统一在这里处理,跑项目前 source 一下即可
|
||||
# 用单引号 heredoc 让脚本体保持字面(不在生成时展开),实现"自动定位所在目录",
|
||||
# 这样整个项目可以搬到任意路径,activate.sh 仍能找到自己的 .venv / bin。
|
||||
cat > "$PROJECT_DIR/activate.sh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
# 跑项目前先: source activate.sh —— 自动定位项目目录,项目可放任意路径
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Python:优先项目内自包含的 ./python(make_portable.sh 产物,可随便放);否则用 .venv
|
||||
if [ -x "$HERE/python/bin/python3" ]; then
|
||||
export PATH="$HERE/bin:$HERE/python/bin:$PATH"
|
||||
else
|
||||
export VIRTUAL_ENV="$HERE/.venv"
|
||||
export PATH="$HERE/bin:$VIRTUAL_ENV/bin:$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
||||
fi
|
||||
|
||||
# UTF-8:项目有中文文件名(数据.jsonl)。优先选系统存在的 UTF-8 locale,避免 setlocale 警告;
|
||||
# 并强制 Python 用 UTF-8(即使系统没有 UTF-8 locale 也能正确处理中文文件名)。
|
||||
if locale -a 2>/dev/null | grep -qiE '^C\.(utf-?8|UTF-?8)$'; then
|
||||
export LANG=C.UTF-8 LC_ALL=C.UTF-8
|
||||
elif locale -a 2>/dev/null | grep -qiE 'en_US\.(utf-?8|UTF-?8)'; then
|
||||
export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
|
||||
fi
|
||||
export PYTHONUTF8=1
|
||||
export PYTHONIOENCODING=utf-8
|
||||
|
||||
# HuggingFace 镜像(联网下模型时用)
|
||||
export HF_ENDPOINT=__HF_ENDPOINT__
|
||||
# 模型缓存:项目内有 ./models 就用它(自包含包),否则用默认 ~/.cache/huggingface
|
||||
[ -d "$HERE/models" ] && export HF_HOME="$HERE/models"
|
||||
# 只在模型【确实已缓存】时才强制离线,否则允许联网下载(避免"没模型又禁网"的死锁)
|
||||
if ls "${HF_HOME:-$HOME/.cache/huggingface}"/hub/models--*faster-whisper* >/dev/null 2>&1; then
|
||||
export HF_HUB_OFFLINE=1
|
||||
export TRANSFORMERS_OFFLINE=1
|
||||
fi
|
||||
|
||||
# 若项目自带 CUDA 库(WHISPER_GPU=1 打包),加进 LD_LIBRARY_PATH,让 ctranslate2 找到(whisper GPU)
|
||||
_nvlibs="$(ls -d "$HERE"/python/lib/python*/site-packages/nvidia/*/lib 2>/dev/null | tr '\n' ':')"
|
||||
[ -n "$_nvlibs" ] && export LD_LIBRARY_PATH="${_nvlibs}${LD_LIBRARY_PATH:-}"
|
||||
|
||||
# 限制数值库线程:防止 whisper/numpy/openblas 每个都抢满核(多视频并发时互相踩)
|
||||
export OMP_NUM_THREADS=2
|
||||
export OPENBLAS_NUM_THREADS=2
|
||||
export MKL_NUM_THREADS=2
|
||||
|
||||
echo "环境已激活: $(python --version 2>&1) | $HERE"
|
||||
EOF
|
||||
sed -i "s|__HF_ENDPOINT__|$HF_ENDPOINT_URL|" "$PROJECT_DIR/activate.sh"
|
||||
chmod +x "$PROJECT_DIR/activate.sh"
|
||||
|
||||
# 提高文件句柄上限(高并发 + 海量小帧文件),当前会话生效;永久需改 /etc/security/limits.conf
|
||||
ulimit -n 65535 2>/dev/null || warn "ulimit 提升失败(非致命),如需永久生效请改 /etc/security/limits.conf"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
log "[7/7] 环境自检"
|
||||
# =============================================================================
|
||||
ok=1
|
||||
check() { if eval "$2" >/dev/null 2>&1; then echo " ✓ $1"; else echo " ✗ $1"; ok=0; fi; }
|
||||
|
||||
check "gcc" "gcc --version"
|
||||
check "ffmpeg" "ffmpeg -version"
|
||||
check "ffprobe" "ffprobe -version"
|
||||
check "ffmpeg 支持 AV1" "ffmpeg -hide_banner -decoders | grep -iE 'dav1d|av1'"
|
||||
check "uv" "uv --version"
|
||||
check "python" "python --version"
|
||||
check "import cv2" "python -c 'import cv2'"
|
||||
check "import numpy" "python -c 'import numpy'"
|
||||
check "import sklearn" "python -c 'import sklearn'"
|
||||
check "import av(PyAV)" "python -c 'import av'"
|
||||
check "import faster_whisper" "python -c 'import faster_whisper'"
|
||||
check "import qcloud_cos" "python -c 'import qcloud_cos'"
|
||||
|
||||
# GPU 检测(可选,有则抽帧能硬解加速)
|
||||
if have nvidia-smi && nvidia-smi >/dev/null 2>&1; then
|
||||
echo " ✓ 检测到 NVIDIA GPU(抽帧可硬解加速)"
|
||||
else
|
||||
echo " · 未检测到 GPU(抽帧用 CPU,正常)"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$ok" -eq 1 ]; then
|
||||
log "全部就绪 ✅"
|
||||
else
|
||||
warn "有项目未通过(见上面 ✗),请按提示排查"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
下一步:
|
||||
source activate.sh
|
||||
python main.py --source-list ./text2 --max-workers 12
|
||||
|
||||
EOF
|
||||
52
scripts/stop.sh
Normal file
52
scripts/stop.sh
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# 停止本项目所有进程(主程序 + 进程池worker + ffmpeg子进程 + coscli)。
|
||||
# 用法: bash stop.sh 普通停止
|
||||
# bash stop.sh -y 不确认直接停
|
||||
set -u
|
||||
|
||||
echo "==> 即将停止的进程:"
|
||||
ps -eo pid,etime,rss,args 2>/dev/null | grep -E "main\.py|run_worker\.py|multiprocessing|forkserver|[f]fmpeg|[c]oscli" | grep -v grep | grep -v "stop.sh" || echo " (没找到运行中的进程)"
|
||||
echo ""
|
||||
|
||||
if [ "${1:-}" != "-y" ]; then
|
||||
read -r -p "确认全部杀掉? [y/N] " ans
|
||||
case "$ans" in
|
||||
y|Y) ;;
|
||||
*) echo "已取消"; exit 0 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo "==> 1. 杀主程序 + 进程池worker"
|
||||
pkill -9 -f "main.py" 2>/dev/null && echo " ✓ main.py"
|
||||
pkill -9 -f "run_worker.py" 2>/dev/null && echo " ✓ run_worker.py"
|
||||
pkill -9 -f "multiprocessing" 2>/dev/null && echo " ✓ multiprocessing worker"
|
||||
pkill -9 -f "forkserver" 2>/dev/null && echo " ✓ forkserver"
|
||||
|
||||
echo "==> 2. 杀 ffmpeg 子进程(不会自动死, 必须单独杀, 否则占GPU/CPU)"
|
||||
pkill -9 ffmpeg 2>/dev/null && echo " ✓ ffmpeg"
|
||||
pkill -9 ffprobe 2>/dev/null && echo " ✓ ffprobe"
|
||||
|
||||
echo "==> 3. 杀残留 coscli(正在传输的)"
|
||||
pkill -9 -f "coscli" 2>/dev/null && echo " ✓ coscli"
|
||||
|
||||
sleep 1
|
||||
echo ""
|
||||
echo "==> 检查残留:"
|
||||
LEFT=$(ps -eo pid,args 2>/dev/null | grep -E "main\.py|run_worker\.py|multiprocessing|forkserver|[f]fmpeg|[c]oscli" | grep -v grep | grep -v "stop.sh")
|
||||
if [ -z "$LEFT" ]; then
|
||||
echo " ✓ 全部已停止"
|
||||
else
|
||||
echo " ⚠️ 仍有残留, 再杀一次:"
|
||||
echo "$LEFT"
|
||||
echo "$LEFT" | awk '{print $1}' | xargs -r kill -9 2>/dev/null
|
||||
echo " 已强制杀掉"
|
||||
fi
|
||||
|
||||
# 可选: 看GPU是否释放
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "==> GPU 占用(确认ffmpeg/whisper已释放):"
|
||||
nvidia-smi --query-gpu=index,memory.used,utilization.gpu --format=csv,noheader 2>/dev/null
|
||||
fi
|
||||
echo ""
|
||||
echo "停止完成。重启: bash scripts/启动.sh 30"
|
||||
164
scripts/启动.sh
Normal file
164
scripts/启动.sh
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 一键启动(防 SIGHUP/外部kill 脱离终端 + 调好参数 + 启动前自检 + 防重复启动)
|
||||
#
|
||||
# 两种模式(MODE 环境变量切换):
|
||||
# MODE=worker (默认) —— 常驻 worker, 从 COS 自动认领任务(run_worker.py)。不需要本地 text2。
|
||||
# MODE=local —— 旧模式, 处理本地 ./text2 下的任务txt(main.py)。
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/启动.sh # 默认 worker 模式启动
|
||||
# bash scripts/启动.sh 32 # 临时把 max-workers 改成 32
|
||||
# MODE=local bash scripts/启动.sh # 用旧的本地 text2 模式
|
||||
# MAX_WORKERS=20 MEM_GUARD=40 bash scripts/启动.sh
|
||||
#
|
||||
# 停止: bash scripts/stop.sh -y
|
||||
# =============================================================================
|
||||
set -u
|
||||
cd "$(cd "$(dirname "$0")/.." && pwd)"
|
||||
ROOT="$(pwd)"
|
||||
|
||||
# ---- 可调参数(命令行第1个参数 / 环境变量 均可覆盖) ----
|
||||
# MODE 未显式指定时, 读 config.dispatch.source 决定: local -> 本地text2, 其余 -> COS常驻
|
||||
if [ -z "${MODE:-}" ]; then
|
||||
_src="$(python -c "from config.config_utils import config; print((config.get_dispatch_config() or {}).get('source','cos'))" 2>/dev/null || echo cos)"
|
||||
[ "$_src" = "local" ] && MODE="local" || MODE="worker"
|
||||
fi
|
||||
MODE="${MODE:-worker}" # worker=COS认领常驻 / local=本地text2
|
||||
MAX_WORKERS="${1:-${MAX_WORKERS:-25}}" # 0=不传给python,由main.py/run_worker.py自动按本机CPU核数选
|
||||
# 共享机/跟OCR服务同机部署的, 建议显式传小一点的值(如24), 别用自动
|
||||
MAX_TASKS="${MAX_TASKS:-0}" # 0=不回收worker(避免3.11回收缩水把并发缩到0)
|
||||
MEM_GUARD="${MEM_GUARD:-30}" # 可用内存低于此(G)暂停启动新视频
|
||||
SOURCE_LIST="${SOURCE_LIST:-./text2}" # 仅 local 模式: 任务txt目录
|
||||
EXECUTOR="${EXECUTOR:-process}" # process=进程池(绕GIL) / thread
|
||||
LOG="${LOG:-app.log}"
|
||||
NICE="${NICE:-15}" # 处理进程CPU优先级(0~19,越大越低)
|
||||
LOG_MAX_MB="${LOG_MAX_MB:-200}" # $LOG 超过这个大小(MB)就在启动前轮转一次, 0=不轮转
|
||||
|
||||
# ---- 启动前轮转一次日志(常驻进程一跑数天/数周, >>追加写不会自己变小) ----
|
||||
_rotate_log() {
|
||||
local f="$1" maxmb="$2"
|
||||
[ "$maxmb" -le 0 ] && return 0
|
||||
[ -f "$f" ] || return 0
|
||||
local sz_mb=$(( $(stat -c%s "$f" 2>/dev/null || echo 0) / 1024 / 1024 ))
|
||||
if [ "$sz_mb" -ge "$maxmb" ]; then
|
||||
local ts; ts="$(date +%Y%m%d_%H%M%S)"
|
||||
mv "$f" "${f}.${ts}" 2>/dev/null && echo "[日志轮转] $f (${sz_mb}M) → ${f}.${ts}, 重新开始写"
|
||||
ls -t "${f}".[0-9]* 2>/dev/null | tail -n +6 | xargs -r rm -f # 只留最近5份轮转备份
|
||||
fi
|
||||
}
|
||||
_rotate_log "$LOG" "$LOG_MAX_MB"
|
||||
|
||||
# 按模式确定入口与进程匹配模式
|
||||
if [ "$MODE" = "worker" ]; then
|
||||
ENTRY="run_worker.py"
|
||||
PROC_PAT='python .*run_worker\.py'
|
||||
else
|
||||
ENTRY="main.py"
|
||||
PROC_PAT='python .*main\.py'
|
||||
fi
|
||||
|
||||
MAX_WORKERS_DISP="$MAX_WORKERS"
|
||||
[ "$MAX_WORKERS" = "0" ] && MAX_WORKERS_DISP="0(自动按CPU核数选, 实际值见下面python启动日志)"
|
||||
|
||||
echo "=================================================="
|
||||
echo " 启动 $ENTRY (MODE=$MODE)"
|
||||
echo " max-workers=$MAX_WORKERS_DISP max-tasks-per-child=$MAX_TASKS mem-guard=${MEM_GUARD}G"
|
||||
echo " executor=$EXECUTOR nice=$NICE(让OCR服务优先) 日志→$LOG"
|
||||
[ "$MODE" = "local" ] && echo " source-list=$SOURCE_LIST"
|
||||
echo "=================================================="
|
||||
|
||||
# ---- 1. 防重复启动 ----
|
||||
if pgrep -af "$PROC_PAT" 2>/dev/null | grep -v 'multiprocessing' | grep -v 'grep' >/dev/null; then
|
||||
echo "✗ 已经有 $ENTRY 在运行了, 不重复启动。"
|
||||
echo " 查看: pgrep -af '$PROC_PAT' | grep -v multiprocessing"
|
||||
echo " 如要重启: 先 bash scripts/stop.sh -y"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 2. 启动前自检 ----
|
||||
fail=0
|
||||
need() { command -v "$1" >/dev/null 2>&1 && echo " ✓ $1" || { echo " ✗ 缺少 $1 (需在 PATH 或项目目录)"; fail=1; }; }
|
||||
echo "[依赖检查]"
|
||||
need python
|
||||
need ffmpeg
|
||||
need ffprobe
|
||||
if command -v coscli >/dev/null 2>&1; then echo " ✓ coscli"
|
||||
elif [ -x "./coscli-linux-amd64" ]; then echo " ✓ ./coscli-linux-amd64"
|
||||
else echo " ✗ 找不到 coscli / ./coscli-linux-amd64"; fail=1; fi
|
||||
|
||||
echo "[配置/任务检查]"
|
||||
[ -f config/config.json ] && echo " ✓ config/config.json" || { echo " ✗ 缺 config/config.json"; fail=1; }
|
||||
|
||||
if [ "$MODE" = "worker" ]; then
|
||||
# worker 模式: 任务来自 COS, 不需要本地 text2; 检查 dispatch 配置与机器ID + COS 队列可达
|
||||
chk=$(python - <<'PY' 2>&1
|
||||
import sys
|
||||
try:
|
||||
from config.config_utils import config
|
||||
from tool import job_cos
|
||||
dp = config.get_dispatch_config() or {}
|
||||
if not dp.get("jobs_root"):
|
||||
print("NO_JOBS_ROOT"); sys.exit(0)
|
||||
mid = job_cos.get_machine_id()
|
||||
todo = job_cos.list_todo() # 顺带验证 COS 队列可达
|
||||
print("OK\t%s\t%d" % (mid, len(todo)))
|
||||
except Exception as e:
|
||||
print("ERR\t%s" % e)
|
||||
PY
|
||||
)
|
||||
case "$chk" in
|
||||
OK*) mid=$(echo "$chk" | cut -f2); n=$(echo "$chk" | cut -f3)
|
||||
echo " ✓ dispatch 配置OK 机器ID=$mid todo剩余分片=$n"
|
||||
[ "$n" = "0" ] && echo " ⚠ todo 队列为空(worker会空闲等待; 先 python tools/seed_jobs.py 灌任务)";;
|
||||
NO_JOBS_ROOT) echo " ✗ config.dispatch.jobs_root 未配置(取任务地址)"; fail=1;;
|
||||
*) echo " ✗ 访问 COS 任务队列失败: ${chk#ERR }"; fail=1;;
|
||||
esac
|
||||
else
|
||||
if ls "$SOURCE_LIST"/*.txt >/dev/null 2>&1; then
|
||||
n=$(ls "$SOURCE_LIST"/*.txt 2>/dev/null | wc -l)
|
||||
echo " ✓ $SOURCE_LIST 有 $n 个任务txt"
|
||||
else
|
||||
echo " ✗ $SOURCE_LIST 下没有 .txt 任务文件"; fail=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo ""
|
||||
echo "✗ 自检未通过, 已中止(没启动)。修好上面 ✗ 的项再跑。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 3. 启动(setsid+nohup+disown 三保险, SSH断开也杀不到) ----
|
||||
echo ""
|
||||
echo "[启动中...]"
|
||||
if [ "$MODE" = "worker" ]; then
|
||||
setsid nohup nice -n "$NICE" python run_worker.py \
|
||||
--max-workers "$MAX_WORKERS" \
|
||||
--max-tasks-per-child "$MAX_TASKS" \
|
||||
--mem-guard-gb "$MEM_GUARD" \
|
||||
--executor "$EXECUTOR" >> "$LOG" 2>&1 &
|
||||
else
|
||||
setsid nohup nice -n "$NICE" python main.py \
|
||||
--source-list "$SOURCE_LIST" \
|
||||
--max-workers "$MAX_WORKERS" \
|
||||
--max-tasks-per-child "$MAX_TASKS" \
|
||||
--mem-guard-gb "$MEM_GUARD" \
|
||||
--executor "$EXECUTOR" >> "$LOG" 2>&1 &
|
||||
fi
|
||||
disown 2>/dev/null || true
|
||||
|
||||
# ---- 4. 等几秒确认起来了 ----
|
||||
sleep 6
|
||||
if pgrep -af "$PROC_PAT" 2>/dev/null | grep -v 'multiprocessing' | grep -v 'grep' >/dev/null; then
|
||||
echo "✓ 已启动。日志末尾:"
|
||||
tail -n 5 "$LOG" 2>/dev/null | sed 's/^/ /'
|
||||
echo ""
|
||||
echo " 实时看日志: tail -f $LOG"
|
||||
echo " 数worker: pgrep -af multiprocessing | wc -l (稍等会接近 $MAX_WORKERS_DISP)"
|
||||
echo " 停止: bash scripts/stop.sh -y"
|
||||
else
|
||||
echo "✗ 启动后没检测到进程, 查日志看原因:"
|
||||
tail -n 20 "$LOG" 2>/dev/null | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
121
scripts/守护重启.sh
Normal file
121
scripts/守护重启.sh
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 守护重启: main.py 被外部 kill / 异常退出后, 自动拉起(共享机上挡不住别人kill, 就让它自己回来)。
|
||||
# 【带明确开关, 想关随时关, 不会和你抢】:
|
||||
# 关闭原理 = 放一个"暂停标记文件" .guard_paused, 守护看到它就什么都不做。
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/守护重启.sh install # 安装(每分钟检测一次, 没在跑就启动)
|
||||
# bash scripts/守护重启.sh off # ★想停就用这个★ 暂停自动重启 + 停掉当前进程
|
||||
# bash scripts/守护重启.sh on # 重新开启自动重启(下一分钟内会被拉起)
|
||||
# bash scripts/守护重启.sh status # 看状态(装没装/开没开/在没在跑)
|
||||
# bash scripts/守护重启.sh uninstall # 彻底卸载(删掉定时任务, 进程不动)
|
||||
# bash scripts/守护重启.sh tick # (cron 内部调用, 别手动跑)
|
||||
#
|
||||
# 启动参数想改, 改下面 START= 这一行即可。
|
||||
# =============================================================================
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
PAUSE="$ROOT/.guard_paused" # 这个文件存在 = 暂停自动重启
|
||||
LOCK="$ROOT/.guard.lock"
|
||||
GLOG="$ROOT/guard.log"
|
||||
CRON_TAG="# ocr-guard" # crontab 里的标识, 用于增删去重
|
||||
|
||||
# ★ 启动命令(按之前调优建议; 想改并发/参数改这里) ★
|
||||
# MODE=worker(默认): 常驻 worker 从 COS 认领任务; MODE=local: 旧的本地 text2 模式。
|
||||
# nice -n 15: 让 co-located 的 OCR 服务优先抢 CPU; max-tasks-per-child 20: 本机内存偏紧
|
||||
# (44G可用/184G总量), 定期回收worker释放常驻内存; 3.11回收有缩水风险(见CLAUDE.md), 但20这个
|
||||
# 量级配合 --mem-guard-gb 观察下来风险可控, 内存不紧的机器可以改回0(不回收)。
|
||||
if [ -z "${MODE:-}" ]; then
|
||||
_src="$(python -c "from config.config_utils import config; print((config.get_dispatch_config() or {}).get('source','cos'))" 2>/dev/null || echo cos)"
|
||||
[ "$_src" = "local" ] && MODE="local" || MODE="worker"
|
||||
fi
|
||||
MODE="${MODE:-worker}"
|
||||
if [ "$MODE" = "worker" ]; then
|
||||
START='nice -n 15 python run_worker.py --max-workers 30 --max-tasks-per-child 20 --mem-guard-gb 30 --executor process'
|
||||
PROC_PAT='python .*run_worker\.py'
|
||||
else
|
||||
START='nice -n 15 python main.py --source-list ./text2 --max-workers 30 --max-tasks-per-child 20 --mem-guard-gb 30 --executor process'
|
||||
PROC_PAT='python .*main\.py'
|
||||
fi
|
||||
|
||||
# 判断父进程是否在跑(forkserver worker 命令行不含入口文件名, 不会误判)
|
||||
running() {
|
||||
pgrep -af "$PROC_PAT" 2>/dev/null | grep -v 'multiprocessing' | grep -v 'grep' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# app.log 轮转: 进程可能正在往它里面 >> 追加写, 不能直接 mv(会导致进程还在写旧inode, 新文件
|
||||
# 看起来永远是空的) —— 用 copytruncate: 先 cp 一份出去, 再原地截断; O_APPEND 的fd是按"当前
|
||||
# 文件大小"找写入位置的, 原地截断后进程会自动从offset 0继续写这个(同一个)文件, 不受影响。
|
||||
_rotate_applog() {
|
||||
local f="$ROOT/app.log" maxmb=200
|
||||
[ -f "$f" ] || return 0
|
||||
local sz_mb=$(( $(stat -c%s "$f" 2>/dev/null || echo 0) / 1024 / 1024 ))
|
||||
if [ "$sz_mb" -ge "$maxmb" ]; then
|
||||
local ts; ts="$(date +%Y%m%d_%H%M%S)"
|
||||
cp "$f" "${f}.${ts}" 2>/dev/null && : > "$f" \
|
||||
&& echo "$(date '+%F %T') [日志轮转] app.log(${sz_mb}M) → app.log.${ts}, 原地清空续写" >> "$GLOG"
|
||||
ls -t "${f}".[0-9]* 2>/dev/null | tail -n +6 | xargs -r rm -f # 只留最近5份
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
install)
|
||||
( crontab -l 2>/dev/null | grep -vF "$CRON_TAG"
|
||||
echo "* * * * * cd $ROOT && bash scripts/守护重启.sh tick $CRON_TAG" ) | crontab -
|
||||
rm -f "$PAUSE"
|
||||
echo "✓ 守护已安装(每分钟检测), 自动重启: 开启"
|
||||
echo " 想停: bash scripts/守护重启.sh off"
|
||||
;;
|
||||
|
||||
uninstall)
|
||||
crontab -l 2>/dev/null | grep -vF "$CRON_TAG" | crontab -
|
||||
echo "✓ 已卸载守护定时任务(不再自动重启)。当前进程未动。"
|
||||
echo " 如要同时停进程: bash scripts/stop.sh -y"
|
||||
;;
|
||||
|
||||
off)
|
||||
touch "$PAUSE"
|
||||
echo "✓ 已暂停自动重启(标记 $PAUSE)。正在停止当前进程..."
|
||||
bash "$ROOT/scripts/stop.sh" -y
|
||||
echo "✓ 已停止, 且守护不会再把它拉起。恢复运行: bash scripts/守护重启.sh on"
|
||||
;;
|
||||
|
||||
on)
|
||||
rm -f "$PAUSE"
|
||||
echo "✓ 自动重启已开启。若当前没在跑, 一分钟内会被拉起。"
|
||||
;;
|
||||
|
||||
tick)
|
||||
# 暂停中 → 什么都不做(这就是 off 之所以能稳关掉的原因)
|
||||
[ -f "$PAUSE" ] && exit 0
|
||||
# 防并发: 同一时刻只允许一个 tick 启动
|
||||
exec 9>"$LOCK"
|
||||
flock -n 9 || exit 0
|
||||
_rotate_applog # 每分钟顺带检查一次, 跟要不要重启无关(常驻不重启时全靠这个兜底)
|
||||
running && exit 0
|
||||
echo "$(date '+%F %T') 检测到未运行 → 启动" >> "$GLOG"
|
||||
setsid nohup $START >> "$ROOT/app.log" 2>&1 &
|
||||
;;
|
||||
|
||||
status)
|
||||
echo "项目目录: $ROOT"
|
||||
if crontab -l 2>/dev/null | grep -qF "$CRON_TAG"; then
|
||||
echo "守护定时任务: ✓ 已安装(每分钟检测)"
|
||||
else
|
||||
echo "守护定时任务: ✗ 未安装"
|
||||
fi
|
||||
if [ -f "$PAUSE" ]; then
|
||||
echo "自动重启: ⏸ 已暂停 (.guard_paused 存在 → 不会拉起)"
|
||||
else
|
||||
echo "自动重启: ▶ 开启"
|
||||
fi
|
||||
if running; then echo "main.py: 运行中"; else echo "main.py: 未运行"; fi
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "用法: install | on | off | uninstall | status | tick"
|
||||
;;
|
||||
esac
|
||||
0
tool/Ocr/__init__.py
Normal file
0
tool/Ocr/__init__.py
Normal file
372
tool/Ocr/tencentcloud_ocr.py
Normal file
372
tool/Ocr/tencentcloud_ocr.py
Normal file
@@ -0,0 +1,372 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
'''
|
||||
@File : tencentcloud_ocr.py
|
||||
@Time : @
|
||||
@Author : eason
|
||||
@Version : 1.0
|
||||
@Contact :
|
||||
@License : (C)Copyright 2021-2024
|
||||
@Desc : 将图片转换为base64编码,并使用内部Youtu OCR服务进行文字识别(app_id自动生成)
|
||||
'''
|
||||
|
||||
import json
|
||||
import os
|
||||
import base64
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
import requests
|
||||
from config.config_utils import config # 如果不再需要配置可以移除,但保留其他配置可能使用
|
||||
|
||||
|
||||
# ---- OCR 端点(多 IP 负载均衡)配置 ----
|
||||
OCR_PATH = "/youtu/ocrapi/hpgeneralocr_v3"
|
||||
OCR_DEFAULT_PORT = 18081 # config 里只填 IP 时自动补的端口
|
||||
DEFAULT_ENDPOINTS = ["http://asr.ocr.general.smart.l20.production.polaris:18081"]
|
||||
WEIGHTS_FILE = "./config/ocr_weights.json"
|
||||
|
||||
|
||||
def normalize_endpoint(e):
|
||||
"""把 'IP' / 'IP:port' / 'http://IP:port' 统一成 'http://IP:port'。
|
||||
config 里只填裸 IP 时,自动补 http:// 和默认端口。"""
|
||||
if not e:
|
||||
return None
|
||||
e = str(e).strip().rstrip("/")
|
||||
if not e:
|
||||
return None
|
||||
if not (e.startswith("http://") or e.startswith("https://")):
|
||||
e = "http://" + e
|
||||
host = e.split("://", 1)[1].split("/", 1)[0] # 取 host[:port] 部分
|
||||
if ":" not in host: # 没写端口就补默认端口
|
||||
e = e + f":{OCR_DEFAULT_PORT}"
|
||||
return e
|
||||
|
||||
|
||||
def img2base64(img_path: str) -> str:
|
||||
"""将图片转换为base64编码"""
|
||||
with open(img_path, 'rb') as f:
|
||||
image_base64 = base64.b64encode(f.read()).decode()
|
||||
return image_base64
|
||||
|
||||
|
||||
def get_ocr_endpoints():
|
||||
"""从配置读取 OCR 端点列表,自动归一化(只填 IP 也行);缺省回退到内置默认。"""
|
||||
ocr_cfg = config.get_tencent_ocr_config() or {}
|
||||
eps = ocr_cfg.get("endpoints")
|
||||
if isinstance(eps, str):
|
||||
eps = [eps]
|
||||
if not eps:
|
||||
eps = DEFAULT_ENDPOINTS
|
||||
return [u for u in (normalize_endpoint(e) for e in eps) if u]
|
||||
|
||||
|
||||
def load_endpoint_weights():
|
||||
"""读取压测产出的 QPS 权重文件 {base_url: qps};不存在则返回 {}。"""
|
||||
if not os.path.exists(WEIGHTS_FILE):
|
||||
return {}
|
||||
try:
|
||||
with open(WEIGHTS_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return {k.rstrip("/"): float(v) for k, v in data.items() if float(v) > 0}
|
||||
except (json.JSONDecodeError, ValueError, TypeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
class OcrDispatcher:
|
||||
"""线程安全的 OCR 端点分发器:用 P-of-d-Choices(默认d=3)做【负载/延迟感知】派发——
|
||||
随机取 sample_size 个端点, 把请求发给其中【在途更少 × 更快】的那个。慢/积压端点自动少分。
|
||||
每个进程独立按后端真实快慢反应 → 无需任何协调/中心LB, 也能在 120 台 OCR 上涌现出全局均衡。
|
||||
d 从 2 提到 3(甚至更多): 端点数越多, 随机抽2个都恰好较差的概率越高, 抽样数多一个能明显
|
||||
收窄这个方差, 之后收益递减, 不用无限往上加。
|
||||
失败端点冷却摘除(冷却时长带随机抖动, 防止多个进程对同一端点同时解冷却又一起打回去,
|
||||
造成"整体一起失败-一起恢复-一起打爆"的同步震荡)。多个并发 OCR 线程共享一个实例。"""
|
||||
|
||||
def __init__(self, endpoints, weights=None, cooldown=30.0, sample_size=3):
|
||||
self.endpoints = list(endpoints)
|
||||
# PID 打乱(历史原因保留): 让各进程初始顺序不同; 现在 P-of-d 是随机取样, 影响不大但无害。
|
||||
try:
|
||||
random.Random(os.getpid()).shuffle(self.endpoints)
|
||||
except Exception:
|
||||
pass
|
||||
weights = weights or {}
|
||||
# 压测权重(QPS): 作为"处理能力", 强端点 score 更低 → 多分。无权重默认 1.0。
|
||||
self.weights = {e: max(float(weights.get(e, 1.0)), 0.0001) for e in self.endpoints}
|
||||
self.cooldown = cooldown
|
||||
self.sample_size = max(2, int(sample_size)) # 每次候选抽样个数(P-of-d 的 d)
|
||||
self._lock = threading.Lock()
|
||||
self._inflight = {e: 0 for e in self.endpoints} # 当前在途请求数(本进程视角)
|
||||
self._lat = {e: 0.05 for e in self.endpoints} # 延迟 EWMA(秒), 初始乐观→新端点也会被试
|
||||
self._cooldown_until = {e: 0.0 for e in self.endpoints}
|
||||
self._rng = random.Random(os.getpid() ^ 0x9E3779B1)
|
||||
# —— 派发计数(验证分发是否均匀): 每端点累计派发数, 每 _dump_every 次落盘一次本进程文件 ——
|
||||
self._count = {e: 0 for e in self.endpoints}
|
||||
self._since_dump = 0
|
||||
try:
|
||||
self._dump_every = int((config.get_tencent_ocr_config() or {}).get('dispatch_log_every', 0) or 0)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
self._dump_every = 0
|
||||
|
||||
def _score(self, e):
|
||||
"""负载分: (在途+1) × 延迟EWMA / 权重。越低越优先。"""
|
||||
return (self._inflight[e] + 1) * self._lat[e] / self.weights[e]
|
||||
|
||||
def pick(self):
|
||||
"""P-of-d: 随机不重复抽 sample_size(默认3) 个可用端点, 选负载分最低的;
|
||||
选中即占一个在途名额(必须配对 done())。全部冷却时退化为全部可用。"""
|
||||
now = time.time()
|
||||
snapshot = None
|
||||
with self._lock:
|
||||
available = [e for e in self.endpoints if self._cooldown_until[e] <= now]
|
||||
if not available:
|
||||
available = list(self.endpoints) # 全挂了也得发, 给一次机会
|
||||
if len(available) == 1:
|
||||
chosen = available[0]
|
||||
else:
|
||||
k = min(self.sample_size, len(available))
|
||||
cands = self._rng.sample(available, k) # 不重复抽样, 比各自 choice() 更有效(不会抽到同一个)
|
||||
chosen = min(cands, key=self._score)
|
||||
self._inflight[chosen] = self._inflight.get(chosen, 0) + 1
|
||||
self._count[chosen] = self._count.get(chosen, 0) + 1
|
||||
if self._dump_every > 0:
|
||||
self._since_dump += 1
|
||||
if self._since_dump >= self._dump_every:
|
||||
self._since_dump = 0
|
||||
snapshot = (dict(self._count),
|
||||
{e: round(self._lat[e], 4) for e in self._lat})
|
||||
if snapshot is not None: # 落盘在锁外, 不阻塞其它线程派发
|
||||
self._write_dump(snapshot[0], snapshot[1])
|
||||
return chosen
|
||||
|
||||
def _write_dump(self, counts, lats):
|
||||
"""把本进程的累计派发计数写到 ./ocr_dispatch/<pid>.json(覆盖式, 单进程单文件无竞争)。"""
|
||||
try:
|
||||
d = "./ocr_dispatch"
|
||||
os.makedirs(d, exist_ok=True)
|
||||
path = os.path.join(d, f"{os.getpid()}.json")
|
||||
data = {"pid": os.getpid(), "ts": time.time(),
|
||||
"total": sum(counts.values()), "counts": counts, "lat_ewma": lats}
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False)
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def done(self, endpoint, latency=None, ok=True):
|
||||
"""请求结束回调: 释放在途名额; 成功则更新延迟EWMA, 失败则冷却摘除(冷却时长带±30%抖动,
|
||||
避免同一时刻失败的多个进程在同一时刻解冷却、一起冲回去打同一个刚失败的端点)。"""
|
||||
with self._lock:
|
||||
if self._inflight.get(endpoint, 0) > 0:
|
||||
self._inflight[endpoint] -= 1
|
||||
if endpoint not in self._cooldown_until:
|
||||
return
|
||||
if ok:
|
||||
if latency is not None:
|
||||
self._lat[endpoint] = 0.7 * self._lat[endpoint] + 0.3 * max(latency, 0.001)
|
||||
self._cooldown_until[endpoint] = 0.0
|
||||
else:
|
||||
jitter = self._rng.uniform(0.7, 1.3)
|
||||
self._cooldown_until[endpoint] = time.time() + self.cooldown * jitter
|
||||
|
||||
# —— 兼容旧接口(内部都走 done) ——
|
||||
def report_failure(self, endpoint):
|
||||
self.done(endpoint, ok=False)
|
||||
|
||||
def report_success(self, endpoint):
|
||||
self.done(endpoint, ok=True)
|
||||
|
||||
|
||||
_dispatcher = None
|
||||
_dispatcher_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_dispatcher():
|
||||
"""惰性单例分发器(首次调用时读取端点+权重)。"""
|
||||
global _dispatcher
|
||||
if _dispatcher is None:
|
||||
with _dispatcher_lock:
|
||||
if _dispatcher is None:
|
||||
eps = get_ocr_endpoints()
|
||||
_dispatcher = OcrDispatcher(eps, load_endpoint_weights())
|
||||
return _dispatcher
|
||||
|
||||
|
||||
def _build_payload(image_base64: str) -> dict:
|
||||
timestamp = int(time.time() * 1000)
|
||||
rand_suffix = random.randint(100000, 999999)
|
||||
app_id = f"{timestamp}{rand_suffix}"
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"image": image_base64,
|
||||
"ocr_template": "OCR",
|
||||
"options": {"pdf_page_index": 0, "detect_text": True},
|
||||
}
|
||||
|
||||
|
||||
# 跨进程【全局 OCR 并发上限】信号量。进程池下由 main 在父进程创建、经 initializer 注入每个 worker;
|
||||
# 不管多少进程×多少线程,全局同时在飞的 OCR 请求数被这个信号量硬性卡住,防止打爆端点。
|
||||
# 为 None 时不限流(线程池模式 / 未注入)。
|
||||
_ocr_semaphore = None
|
||||
|
||||
|
||||
def set_ocr_semaphore(sem):
|
||||
"""由 worker 进程初始化时调用,注入全局并发信号量。"""
|
||||
global _ocr_semaphore
|
||||
_ocr_semaphore = sem
|
||||
|
||||
|
||||
# 每个进程一个持久 Session(连接池/keep-alive 复用连接)。
|
||||
# 原来用 requests.post() 每次新建+关闭连接 → 高并发下海量 TIME_WAIT → 本地临时端口耗尽
|
||||
# → "Cannot assign requested address" / 连接超时(表现成"所有端点超时", 其实是本机端口用完)。
|
||||
# 复用连接后, 到每个端点只保持少量长连接, 端口不再churn。惰性创建 → 进程池下各进程独立 Session。
|
||||
_session = None
|
||||
_session_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_session():
|
||||
global _session
|
||||
if _session is None:
|
||||
with _session_lock:
|
||||
if _session is None:
|
||||
s = requests.Session()
|
||||
adapter = requests.adapters.HTTPAdapter(
|
||||
pool_connections=256, # 连接池数(≥端点数, 每端点一个池)
|
||||
pool_maxsize=64, # 每端点最大并发连接(复用)
|
||||
max_retries=0,
|
||||
pool_block=False)
|
||||
s.mount("http://", adapter)
|
||||
s.mount("https://", adapter)
|
||||
_session = s
|
||||
return _session
|
||||
|
||||
|
||||
def ocr_request_once(base_url: str, image_base64: str, timeout: int = 30) -> str:
|
||||
"""向指定端点发一次 OCR 请求,返回原始响应文本;失败抛异常。
|
||||
受全局信号量限流;用持久 Session 复用连接(防端口耗尽)。"""
|
||||
url = base_url.rstrip("/") + OCR_PATH
|
||||
headers = {"Content-Type": "application/json"}
|
||||
sem = _ocr_semaphore
|
||||
if sem is not None:
|
||||
sem.acquire()
|
||||
try:
|
||||
resp = _get_session().post(url, headers=headers, json=_build_payload(image_base64),
|
||||
timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
finally:
|
||||
if sem is not None:
|
||||
sem.release()
|
||||
|
||||
|
||||
def _normalize_youtu_to_tencent(raw: str) -> str:
|
||||
"""将 Youtu OCR 返回格式转为项目其他模块期望的腾讯云 OCR 格式。"""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return raw
|
||||
|
||||
# 已经是腾讯云格式则直接返回
|
||||
if "TextDetections" in data:
|
||||
return raw
|
||||
|
||||
if data.get("error_code") != 0:
|
||||
return json.dumps({"TextDetections": [], "Language": "zh",
|
||||
"RequestId": data.get("session_id", "")}, ensure_ascii=False)
|
||||
|
||||
text_detections = []
|
||||
for recognize_item in data.get("recognize_list", []):
|
||||
item_list = recognize_item.get("item_content", {}).get("item_list", [])
|
||||
for item in item_list:
|
||||
content = item.get("content", "")
|
||||
confidence = int(item.get("conf", 0) * 100) # 0-1 → 0-100
|
||||
|
||||
vertex = item.get("vertex", {})
|
||||
if vertex:
|
||||
polygon = [
|
||||
{"X": vertex.get("x1", 0), "Y": vertex.get("y1", 0)},
|
||||
{"X": vertex.get("x2", 0), "Y": vertex.get("y2", 0)},
|
||||
{"X": vertex.get("x3", 0), "Y": vertex.get("y3", 0)},
|
||||
{"X": vertex.get("x4", 0), "Y": vertex.get("y4", 0)},
|
||||
]
|
||||
else:
|
||||
coord = item.get("coord", {})
|
||||
x, y = coord.get("x", 0), coord.get("y", 0)
|
||||
w, h = coord.get("width", 0), coord.get("height", 0)
|
||||
polygon = [
|
||||
{"X": x, "Y": y},
|
||||
{"X": x + w, "Y": y},
|
||||
{"X": x + w, "Y": y + h},
|
||||
{"X": x, "Y": y + h},
|
||||
]
|
||||
|
||||
text_detections.append({
|
||||
"DetectedText": content,
|
||||
"Confidence": confidence,
|
||||
"Polygon": polygon,
|
||||
})
|
||||
|
||||
return json.dumps({
|
||||
"TextDetections": text_detections,
|
||||
"Language": "zh",
|
||||
"RequestId": data.get("session_id", ""),
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _get_ocr_call_params():
|
||||
"""从 config 读【单次OCR的重试上限/超时】, 带默认。
|
||||
封顶重试是为了防"卡1小时": 端点都超时时, 一张图最坏耗时 = max_attempts × timeout,
|
||||
而不是原来的 len(endpoints)(119) × 30s ≈ 3570s。"""
|
||||
try:
|
||||
cfg = config.get_tencent_ocr_config() or {}
|
||||
max_attempts = int(cfg.get("max_attempts", 4) or 4)
|
||||
timeout = int(cfg.get("timeout", 12) or 12)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
max_attempts, timeout = 4, 12
|
||||
return max(1, max_attempts), max(1, timeout)
|
||||
|
||||
|
||||
def youtu_ocr_api(image_base64: str) -> str:
|
||||
"""
|
||||
调用内部 Youtu OCR 服务进行文字识别。
|
||||
按 QPS 权重自动在多个 OCR IP 间分发;某端点失败时摘除冷却并换下一个端点重试。
|
||||
重试次数封顶(config: ocr.max_attempts), 单次超时可配(ocr.timeout), 避免端点全超时时拖死 worker。
|
||||
"""
|
||||
dispatcher = get_dispatcher()
|
||||
max_attempts, timeout = _get_ocr_call_params()
|
||||
# 最多试 max_attempts 个端点(且不超过端点总数), 而不是把 119 个全试一遍
|
||||
attempts = min(max(1, len(dispatcher.endpoints)), max_attempts)
|
||||
last_err = None
|
||||
for _ in range(attempts):
|
||||
url = dispatcher.pick()
|
||||
t0 = time.time()
|
||||
try:
|
||||
raw = ocr_request_once(url, image_base64, timeout=timeout)
|
||||
# 把真实延迟反馈给分发器 → 慢端点延迟EWMA升高 → 后续自动少分(负载感知核心)
|
||||
dispatcher.done(url, latency=time.time() - t0, ok=True)
|
||||
return _normalize_youtu_to_tencent(raw)
|
||||
except requests.exceptions.RequestException as e:
|
||||
last_err = e
|
||||
dispatcher.done(url, latency=time.time() - t0, ok=False)
|
||||
continue
|
||||
print(f"Youtu OCR 请求失败(已尝试 {attempts} 个端点, 单次超时 {timeout}s): {last_err}")
|
||||
return ""
|
||||
|
||||
|
||||
# 为了保持原函数名兼容,将新接口赋值给原名称
|
||||
tencentcloud_ocr_api = youtu_ocr_api
|
||||
|
||||
|
||||
def tencent_ocr_video_all_frames(pic_dir_path: str,
|
||||
jsonl_save_path: str = 'frames_ocr.jsonl') -> list:
|
||||
"""
|
||||
对一个视频抽帧后的全部图片进行 ocr处理并保存起来
|
||||
"""
|
||||
with open(jsonl_save_path, "w", encoding='utf-8') as f:
|
||||
for root, _, files in os.walk(pic_dir_path):
|
||||
for pic_name in sorted(files):
|
||||
if pic_name.endswith(('jpg')):
|
||||
pic_path = os.path.join(root, pic_name)
|
||||
img_base64 = img2base64(img_path=pic_path)
|
||||
json_string = tencentcloud_ocr_api(image_base64=img_base64)
|
||||
f.write("pic_path: {}\n".format(pic_path))
|
||||
f.write(json_string + "\n")
|
||||
0
tool/VideoProcessing/__init__.py
Normal file
0
tool/VideoProcessing/__init__.py
Normal file
480
tool/VideoProcessing/multithread_algo.py
Normal file
480
tool/VideoProcessing/multithread_algo.py
Normal file
@@ -0,0 +1,480 @@
|
||||
from tool.log.log_manager import logger
|
||||
from config.config_utils import config, get_ffmpeg, get_ffprobe
|
||||
import shutil
|
||||
import subprocess
|
||||
import json
|
||||
import cv2
|
||||
import os
|
||||
import itertools
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def extract_frames(
|
||||
video_path,
|
||||
video_name,
|
||||
start_frame,
|
||||
end_frame,
|
||||
output_dir,
|
||||
frames_to_extract,
|
||||
frame_prefix=""
|
||||
):
|
||||
"""
|
||||
进行抽帧
|
||||
:param video_path: 视频地址
|
||||
:param video_name: 视频名称
|
||||
:param start_frame: 开始的帧
|
||||
:param end_frame: 结束的帧
|
||||
:param output_dir: 输出地址
|
||||
:param frames_to_extract: 帧时间列表
|
||||
:param frame_prefix:
|
||||
:return: 抽取的帧数
|
||||
"""
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
try:
|
||||
extracted_count = 0
|
||||
for current_time in frames_to_extract:
|
||||
# 只抽取在当前线程范围内且预定义的帧
|
||||
if start_frame <= current_time < end_frame:
|
||||
current_ms = 1000 * current_time
|
||||
cap.set(cv2.CAP_PROP_POS_MSEC, current_ms)
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
print(f"{current_time}无ret")
|
||||
break
|
||||
frame_filename = os.path.join(
|
||||
output_dir,
|
||||
f"{video_name}{frame_prefix}_{3 * int(current_time) + int((current_time - int(current_time) + 0.1) / 0.33)}.jpg"
|
||||
)
|
||||
cv2.imwrite(frame_filename, frame)
|
||||
extracted_count += 1
|
||||
logger.log(f"线程 {start_frame}-{end_frame}: 抽取了{extracted_count}帧", program="split_frame")
|
||||
return extracted_count
|
||||
except Exception as e:
|
||||
logger.log(f"线程 {start_frame}-{end_frame} 抽帧异常: {e}", level="error", program="split_frame")
|
||||
raise
|
||||
finally:
|
||||
cap.release()
|
||||
|
||||
|
||||
def _run_extract_frames(result_container, index, *args, **kwargs):
|
||||
"""包装函数,捕获子线程异常并存储到共享容器"""
|
||||
try:
|
||||
result_container[index] = extract_frames(*args, **kwargs)
|
||||
except Exception as e:
|
||||
result_container[index] = e
|
||||
|
||||
|
||||
def split_video_get_frame_images(
|
||||
video_path,
|
||||
video_name,
|
||||
num_segments,
|
||||
output_dir,
|
||||
interval_seconds=1.0
|
||||
):
|
||||
"""
|
||||
拆分视频,获取视频的帧图片
|
||||
:param video_path:
|
||||
:param video_name:
|
||||
:param num_segments:
|
||||
:param output_dir:
|
||||
:param interval_seconds:
|
||||
:return:
|
||||
"""
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
total_frames = cap.get(cv2.CAP_PROP_FRAME_COUNT)
|
||||
cap_fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
print("total frames:", total_frames)
|
||||
print("fps:", cap_fps)
|
||||
video_duration = total_frames / cap_fps
|
||||
cap.release()
|
||||
frames_to_extract = []
|
||||
if interval_seconds == 0.33:
|
||||
total_seconds = int(video_duration)
|
||||
for seconds in range(total_seconds + 1):
|
||||
for i in range(3):
|
||||
target_time = seconds + i * 0.33
|
||||
if target_time < total_frames:
|
||||
frames_to_extract.append(target_time)
|
||||
# 去重排序
|
||||
frames_to_extract = sorted(set(frames_to_extract))
|
||||
else:
|
||||
t = 0.0
|
||||
while t < video_duration:
|
||||
frames_to_extract.append(t)
|
||||
t += interval_seconds
|
||||
frames_to_extract = sorted(set(frames_to_extract))
|
||||
frames_to_extract = [tt for tt in frames_to_extract if tt < video_duration]
|
||||
expected_frames = len(frames_to_extract)
|
||||
logger.log(f"【视频统计】 视频时长:{video_duration:.2f}秒, 统一计算抽帧位置:{expected_frames}", program="split_frame")
|
||||
|
||||
# 分段处理
|
||||
frames_per_segment = video_duration // num_segments
|
||||
threads = []
|
||||
thread_results = {}
|
||||
|
||||
for i in range(num_segments):
|
||||
start_frame = frames_per_segment * i
|
||||
end_frame = (i + 1) * frames_per_segment if i < num_segments - 1 else video_duration
|
||||
thread = threading.Thread(
|
||||
target=_run_extract_frames,
|
||||
args=(thread_results, i, video_path, video_name, start_frame, end_frame, output_dir, frames_to_extract, "_first")
|
||||
)
|
||||
threads.append(thread)
|
||||
thread.start()
|
||||
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
# 检查子线程异常
|
||||
for i, result in thread_results.items():
|
||||
if isinstance(result, Exception):
|
||||
raise RuntimeError(f"线程 {i} 抽帧失败: {result}") from result
|
||||
|
||||
return expected_frames, video_duration
|
||||
|
||||
|
||||
def get_video_codec(video_path: str) -> str:
|
||||
"""通过 ffprobe 获取视频编码格式"""
|
||||
cmd = [
|
||||
get_ffprobe(), '-v', 'error', '-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_name',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1', video_path
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
return result.stdout.strip().lower()
|
||||
|
||||
|
||||
# ==================== GPU 硬解检测(结果缓存,只查一次)====================
|
||||
_gpu_lock = threading.Lock()
|
||||
_gpu_checked = False
|
||||
_gpu_available = False
|
||||
_gpu_count = 0 # 检测到的 GPU 张数(多卡轮询用)
|
||||
_gpu_rr = itertools.count() # 多卡轮询计数器(next() 在 CPython 下原子)
|
||||
# GPU 无法解码的编码:首次失败后记下,后续该编码直接走 CPU。
|
||||
# 假设多卡同型号(同样支持/不支持某编码),故按编码全局标记。
|
||||
_gpu_bad_codecs = set()
|
||||
|
||||
|
||||
def gpu_decode_available() -> bool:
|
||||
"""检测本机是否有可用的 NVIDIA GPU 硬解(nvidia-smi 可用 + ffmpeg 支持 cuda),
|
||||
并数出 GPU 张数。结果缓存,整个进程只真正检测一次。"""
|
||||
global _gpu_checked, _gpu_available, _gpu_count
|
||||
if _gpu_checked:
|
||||
return _gpu_available
|
||||
with _gpu_lock:
|
||||
if _gpu_checked:
|
||||
return _gpu_available
|
||||
avail = False
|
||||
count = 0
|
||||
try:
|
||||
r = subprocess.run(['nvidia-smi', '-L'], capture_output=True, text=True, timeout=10)
|
||||
if r.returncode == 0:
|
||||
count = sum(1 for ln in r.stdout.splitlines() if ln.strip().startswith('GPU '))
|
||||
r2 = subprocess.run([get_ffmpeg(), '-hide_banner', '-hwaccels'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
avail = count > 0 and 'cuda' in (r2.stdout or '').lower()
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||
avail, count = False, 0
|
||||
_gpu_available = avail
|
||||
_gpu_count = count
|
||||
_gpu_checked = True
|
||||
logger.log(
|
||||
f"GPU 硬解检测: {'可用,抽帧优先用 GPU(cuda)' if avail else '不可用,使用 CPU 软解'}"
|
||||
f"(检测到 {count} 张卡)", program="split_frame")
|
||||
return _gpu_available
|
||||
|
||||
|
||||
# —— 自动探测空闲 GPU(避开被 co-located OCR 服务占用的卡)——
|
||||
_gpu_free_cache = {"ts": 0.0, "free": None} # free=空闲卡索引集合; None=查不到(让上层回退)
|
||||
_gpu_free_lock = threading.Lock()
|
||||
|
||||
|
||||
def _query_gpu_mem_used() -> dict:
|
||||
"""nvidia-smi 查每张卡【已用显存(MB)】, 返回 {index: mem_mb}; 失败返回 {}。"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['nvidia-smi', '--query-gpu=index,memory.used',
|
||||
'--format=csv,noheader,nounits'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
out = {}
|
||||
for ln in r.stdout.splitlines():
|
||||
parts = [p.strip() for p in ln.split(',')]
|
||||
if len(parts) >= 2 and parts[0].isdigit():
|
||||
try:
|
||||
out[int(parts[0])] = float(parts[1])
|
||||
except ValueError:
|
||||
pass
|
||||
return out
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _get_free_gpus(busy_mem_mb: float, ttl: float = 20.0):
|
||||
"""返回【空闲】(已用显存 < busy_mem_mb)的 GPU 索引集合; 带 TTL 缓存, 不频繁调 nvidia-smi。
|
||||
返回 None 表示查不到(nvidia-smi 失败), 让上层回退到原有逻辑。"""
|
||||
now = time.time()
|
||||
with _gpu_free_lock:
|
||||
if _gpu_free_cache["free"] is not None and (now - _gpu_free_cache["ts"]) < ttl:
|
||||
return _gpu_free_cache["free"]
|
||||
mem = _query_gpu_mem_used()
|
||||
free = {i for i, m in mem.items() if m < busy_mem_mb} if mem else None
|
||||
_gpu_free_cache["free"] = free
|
||||
_gpu_free_cache["ts"] = now
|
||||
return free
|
||||
|
||||
|
||||
def _next_gpu_device() -> int:
|
||||
"""多卡轮询取下一张 GPU 索引;单卡或未知时返回 0。
|
||||
- gpu_reserve_last_for_whisper: 留最后一张给 whisper(抽帧只用前 N-1 张)
|
||||
- gpu_skip_indices: 手动【跳过】这些卡
|
||||
- gpu_auto_select: 自动探测, 只往【空闲卡】(显存占用 < gpu_busy_mem_mb)上分配,
|
||||
自动避开被 OCR 服务占着、抽帧慢25倍的卡。查不到/全忙时回退正常轮询。"""
|
||||
if _gpu_count <= 1:
|
||||
return 0
|
||||
n = _gpu_count
|
||||
skip = set()
|
||||
auto = False
|
||||
busy_mem = 4096.0
|
||||
try:
|
||||
cfg = config.get_splitframe_config()
|
||||
if cfg.get('gpu_reserve_last_for_whisper', True):
|
||||
n = _gpu_count - 1 # 留最后一张给 whisper
|
||||
for i in (cfg.get('gpu_skip_indices') or []):
|
||||
try:
|
||||
skip.add(int(i))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
auto = bool(cfg.get('gpu_auto_select', False))
|
||||
busy_mem = float(cfg.get('gpu_busy_mem_mb', 4096))
|
||||
except Exception:
|
||||
pass
|
||||
# 候选 = 前 n 张里去掉手动跳过的卡
|
||||
candidates = [i for i in range(max(1, n)) if i not in skip]
|
||||
# 自动探测: 进一步只保留空闲卡(若探测到至少一张空闲); 全忙或查不到则维持原候选
|
||||
if auto:
|
||||
free = _get_free_gpus(busy_mem)
|
||||
if free:
|
||||
free_cands = [i for i in candidates if i in free]
|
||||
if free_cands:
|
||||
candidates = free_cands
|
||||
if not candidates:
|
||||
candidates = list(range(max(1, n))) # 兜底, 不至于不抽帧
|
||||
return candidates[next(_gpu_rr) % len(candidates)]
|
||||
|
||||
|
||||
def _clean_temp(temp_pattern: str):
|
||||
"""清理某次抽帧产生的临时帧(GPU 失败回退前用)。"""
|
||||
d = os.path.dirname(temp_pattern)
|
||||
prefix = os.path.basename(temp_pattern).split('%')[0]
|
||||
try:
|
||||
for f in os.listdir(d):
|
||||
if f.startswith(prefix):
|
||||
try:
|
||||
os.remove(os.path.join(d, f))
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_ffmpeg_threads(raw) -> int:
|
||||
"""splitframe.ffmpeg_threads 支持字面数字,或 "auto":按 CPU核数/并发进程数 自动摊分,
|
||||
避免 30 台核数不一样的机器都得手改这个数。并发进程数从环境变量 OCR_MAX_WORKERS 读
|
||||
(main.py/run_worker.py 启动时按实际生效的 --max-workers 写入,进程池 forkserver 子进程会继承);
|
||||
读不到(如手动 import 调试)按 4 估算。0/未配置 = 不限制(交给 ffmpeg 默认行为)。"""
|
||||
if isinstance(raw, str) and raw.strip().lower() == 'auto':
|
||||
cpu = os.cpu_count() or 4
|
||||
try:
|
||||
workers = int(os.environ.get('OCR_MAX_WORKERS', '0') or 0)
|
||||
except ValueError:
|
||||
workers = 0
|
||||
workers = workers if workers > 0 else 4
|
||||
return max(1, cpu // workers)
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _run_ffmpeg_extract(video_path: str, fps: float, temp_pattern: str, codec: str) -> str:
|
||||
"""执行抽帧:有 GPU 且该编码可硬解则用 GPU,失败/不支持自动回退 CPU。返回 'gpu' 或 'cpu'。"""
|
||||
cfg = config.get_splitframe_config()
|
||||
use_gpu = cfg.get('use_gpu', True)
|
||||
# 明确不让 GPU 解的编码(如 V100 解不了 AV1 → 配 ["av1"] 直接走 CPU,省掉一次失败探测)
|
||||
skip_codecs = {str(c).lower() for c in (cfg.get('gpu_skip_codecs') or [])}
|
||||
ffmpeg_threads = _resolve_ffmpeg_threads(cfg.get('ffmpeg_threads', 0))
|
||||
|
||||
# 字幕固定在下半屏时,抽帧阶段就裁掉上半部,帧变小 → 下游每帧处理(merge读帧/CV/OCR)成本按比例下降。
|
||||
# crop_bottom_ratio=保留底部比例(如 0.55=保留下 55%);null/缺省=不裁(全帧)。
|
||||
vf = f'fps={fps:.6f}'
|
||||
crop_r = cfg.get('crop_bottom_ratio')
|
||||
try:
|
||||
crop_r = float(crop_r) if crop_r is not None else None
|
||||
except (TypeError, ValueError):
|
||||
crop_r = None
|
||||
if crop_r is not None and 0.0 < crop_r < 1.0:
|
||||
vf = f'{vf},crop=iw:ih*{crop_r:.4f}:0:ih*{1.0 - crop_r:.4f}'
|
||||
out_opts = ['-vf', vf, '-q:v', '2', '-y', temp_pattern, '-loglevel', 'error']
|
||||
|
||||
# ① GPU 硬解:支持该编码的卡上用 GPU 解码(多卡轮询分配到不同卡)。
|
||||
# 解码在 GPU、自动下载到内存,fps/JPEG 编码在 CPU。
|
||||
# 跳过条件:配置黑名单(gpu_skip_codecs,如 av1) 或 之前解失败过的编码(_gpu_bad_codecs)。
|
||||
# 其他能解的编码(如 H264/HEVC)走 GPU;某编码首次解失败也会记下、之后转 CPU。
|
||||
# ffmpeg 抽帧超时(秒): 防止坏视频/网络读卡死时 worker 永久阻塞。默认1小时,够超长视频抽帧。
|
||||
try:
|
||||
ff_timeout = int(cfg.get('ffmpeg_timeout', 3600))
|
||||
except (TypeError, ValueError):
|
||||
ff_timeout = 3600
|
||||
|
||||
if (use_gpu and codec not in skip_codecs and codec not in _gpu_bad_codecs
|
||||
and gpu_decode_available()):
|
||||
dev = _next_gpu_device()
|
||||
gpu_cmd = [get_ffmpeg()]
|
||||
if ffmpeg_threads > 0:
|
||||
gpu_cmd += ['-threads', str(ffmpeg_threads)]
|
||||
gpu_cmd += ['-hwaccel', 'cuda', '-hwaccel_device', str(dev),
|
||||
'-i', video_path] + out_opts
|
||||
try:
|
||||
subprocess.run(gpu_cmd, check=True, capture_output=True, timeout=ff_timeout)
|
||||
return f'gpu{dev}'
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.log(f"GPU 抽帧超时(>{ff_timeout}s),回退 CPU: {os.path.basename(video_path)}",
|
||||
level="warning", program="split_frame")
|
||||
_clean_temp(temp_pattern)
|
||||
except subprocess.CalledProcessError as e:
|
||||
with _gpu_lock:
|
||||
_gpu_bad_codecs.add(codec)
|
||||
err = (e.stderr or b'')
|
||||
err = err.decode(errors='ignore') if isinstance(err, bytes) else str(err)
|
||||
logger.log(f"GPU 硬解失败(编码 {codec or '未知'}),该编码后续改用 CPU: {err[:200]}",
|
||||
level="warning", program="split_frame")
|
||||
_clean_temp(temp_pattern) # 清掉半成品再回退
|
||||
|
||||
# ② CPU 软解(限解码线程,避免多视频并发互抢)
|
||||
cmd = [get_ffmpeg()]
|
||||
if ffmpeg_threads > 0:
|
||||
cmd += ['-threads', str(ffmpeg_threads)]
|
||||
cmd += ['-i', video_path] + out_opts
|
||||
# 捕获 stderr:失败时把 ffmpeg 真实报错带出来(否则只看到 exit 255 不知道原因)
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=ff_timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
_clean_temp(temp_pattern)
|
||||
raise RuntimeError(f"ffmpeg(cpu) 抽帧超时(>{ff_timeout}s): {os.path.basename(video_path)}")
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"ffmpeg(cpu) 退出码 {r.returncode}: {(r.stderr or '').strip()[-600:]}")
|
||||
return 'cpu'
|
||||
|
||||
|
||||
def split_frame_ffmpeg(video_path: str, video_name: str, output_dir: str,
|
||||
interval_seconds: float) -> tuple:
|
||||
"""
|
||||
使用 FFmpeg 提取帧(有 GPU 则硬解加速,否则软解;用于 AV1 等 OpenCV 无法解码的格式)。
|
||||
输出文件命名与 OpenCV 方案保持一致。
|
||||
"""
|
||||
# 获取视频时长与编码格式
|
||||
probe = subprocess.run([
|
||||
get_ffprobe(), '-v', 'error', '-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=duration,codec_name',
|
||||
'-of', 'json', video_path
|
||||
], capture_output=True, text=True)
|
||||
# ffprobe 失败(返回码非0或无输出)时给清楚的错,避免后面 json.loads("") 报看不懂的
|
||||
# "Expecting value"。常见原因:bin/ffprobe 是 GPU 版但本机 glibc 太老(<2.28)跑不起来。
|
||||
if probe.returncode != 0 or not (probe.stdout or "").strip():
|
||||
raise RuntimeError(
|
||||
f"ffprobe 执行失败(返回码 {probe.returncode});"
|
||||
f"常见原因:bin/ffprobe 与本机不兼容(如 GPU 版 ffmpeg 需 glibc>=2.28)。"
|
||||
f"stderr: {(probe.stderr or '').strip()[:300]}")
|
||||
info = json.loads(probe.stdout)
|
||||
stream0 = info['streams'][0] if info.get('streams') else {}
|
||||
video_duration = float(stream0.get('duration', 0) or 0)
|
||||
codec = (stream0.get('codec_name') or '').lower()
|
||||
# 流时长缺失(某些封装(尤其AV1/部分mp4)只在容器层有duration, 流里没有)时兜底查容器时长——
|
||||
# 不然 video_duration=0 会导致 frames_to_extract 算成空列表(下面 for 循环整个不执行), 但
|
||||
# 实际 ffmpeg 硬解还是会正常跑完整个视频(耗时不受这个影响), 抽完的帧因为"期望帧数=0"在
|
||||
# 后面的重命名循环里被当成多余的全部删掉 —— 表现为"抽帧完成(0帧, 耗时正常, 视频0s)",
|
||||
# 花了几分钟GPU时间白干。跟 tool/process.py 的 _probe_duration() 是同一个坑、同一个修法。
|
||||
if video_duration <= 0:
|
||||
try:
|
||||
fmt_probe = subprocess.run([
|
||||
get_ffprobe(), '-v', 'error',
|
||||
'-show_entries', 'format=duration', '-of', 'json', video_path
|
||||
], capture_output=True, text=True)
|
||||
fmt_info = json.loads(fmt_probe.stdout or '{}')
|
||||
video_duration = float((fmt_info.get('format') or {}).get('duration', 0) or 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 计算目标时间点(与 OpenCV 方案一致)
|
||||
frames_to_extract = []
|
||||
if interval_seconds == 0.33:
|
||||
for sec in range(int(video_duration) + 1):
|
||||
for i in range(3):
|
||||
t = sec + i * 0.33
|
||||
if t < video_duration:
|
||||
frames_to_extract.append(t)
|
||||
else:
|
||||
t = 0.0
|
||||
while t < video_duration:
|
||||
frames_to_extract.append(t)
|
||||
t += interval_seconds
|
||||
frames_to_extract = sorted(set(frames_to_extract))
|
||||
expected = len(frames_to_extract)
|
||||
|
||||
# 一次性用 FFmpeg 提取所有帧(fps 滤镜)
|
||||
# 注意:interval 0.33 是"每秒3帧"的约定值,真正频率应为 3.0。
|
||||
# 若用 1/0.33=3.0303,会比真正 3fps 快约1%,导致第N帧实际抽自 N×0.33 秒、
|
||||
# 而 SRT 按 N/3≈N×0.3333 秒标注,产生随帧号累积的时间漂移(18分钟处可差~10秒)。
|
||||
fps = 3.0 if interval_seconds == 0.33 else 1.0 / interval_seconds
|
||||
temp_pattern = os.path.join(output_dir, f"__tmp_{video_name}_%08d.jpg")
|
||||
|
||||
# 有 GPU 用 GPU 硬解,否则 CPU 软解(CPU 路径按 ffmpeg_threads 限解码线程)
|
||||
backend = _run_ffmpeg_extract(video_path, fps, temp_pattern, codec)
|
||||
|
||||
# 按期望时间点重命名
|
||||
temp_files = sorted(
|
||||
f for f in os.listdir(output_dir) if f.startswith(f"__tmp_{video_name}_")
|
||||
)
|
||||
extracted = 0
|
||||
for i, tmp in enumerate(temp_files):
|
||||
if i >= len(frames_to_extract):
|
||||
os.remove(os.path.join(output_dir, tmp))
|
||||
continue
|
||||
ct = frames_to_extract[i]
|
||||
frame_idx = 3 * int(ct) + int((ct - int(ct) + 0.1) / 0.33)
|
||||
new_name = os.path.join(output_dir, f"{video_name}_first_{frame_idx}.jpg")
|
||||
os.rename(os.path.join(output_dir, tmp), new_name)
|
||||
extracted += 1
|
||||
|
||||
logger.log(f"FFmpeg 抽帧完成({backend}): 应抽{expected}帧, 实际{extracted}帧", program="split_frame")
|
||||
return expected, video_duration, backend
|
||||
|
||||
|
||||
def split_frame(video_path, extract_interval=0.33):
|
||||
"""
|
||||
进行视频分割
|
||||
:param video_path: 视频本地位置
|
||||
:param extract_interval: 分割间隔,默认0.33间隔,1秒3张图
|
||||
:return:
|
||||
"""
|
||||
logger.start_program("split_frame")
|
||||
video_name = os.path.basename(video_path).partition(".")[0]
|
||||
to_save_dir = os.path.join(os.getcwd(), "split", video_name)
|
||||
os.makedirs(to_save_dir, exist_ok=True)
|
||||
|
||||
logger.log(f"【 正在抽帧处理 】{video_name}, 抽帧间隔:{extract_interval}秒", program="split_frame")
|
||||
start = time.time()
|
||||
|
||||
# 统一使用 FFmpeg 单次顺序解码(比 OpenCV 逐帧 seek 快 3-5 倍)
|
||||
expected_frames, video_duration, backend = split_frame_ffmpeg(
|
||||
video_path, video_name, to_save_dir, extract_interval)
|
||||
|
||||
end = time.time()
|
||||
|
||||
actual_frames = len([f for f in os.listdir(to_save_dir) if f.endswith('.jpg')])
|
||||
logger.log(f"【抽帧完成】: 应抽{expected_frames}帧, 实际抽取{actual_frames}帧, 耗时{end - start:.2f}秒", program="split_frame")
|
||||
logger.end_program("split_frame")
|
||||
return video_duration, expected_frames, actual_frames, backend
|
||||
|
||||
0
tool/__init__.py
Normal file
0
tool/__init__.py
Normal file
BIN
tool/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
tool/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tool/__pycache__/job_cos.cpython-311.pyc
Normal file
BIN
tool/__pycache__/job_cos.cpython-311.pyc
Normal file
Binary file not shown.
2024
tool/getsrt.py
Normal file
2024
tool/getsrt.py
Normal file
File diff suppressed because it is too large
Load Diff
814
tool/job_cos.py
Normal file
814
tool/job_cos.py
Normal file
@@ -0,0 +1,814 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""COS 任务分发 / 成功记录采集。
|
||||
|
||||
核心原语: COS PutObject 带 ForbidOverwrite(x-cos-forbid-overwrite:true) —— 同名对象
|
||||
只能被创建一次, 并发写同一个锁文件只有第一个成功(其余 409)。以此实现跨机器原子认领,
|
||||
无需任何额外服务(Redis/DB)。
|
||||
|
||||
策略: 粘性认领(sticky)。认领某分片的机器是它的唯一所有者;机器重启后只续做自己名下
|
||||
未完成的分片, 别的机器永不抢占 -> 零重复。机器永久报废时, 人工跑 tools/release_machine.py
|
||||
把它名下的任务放回队列。
|
||||
|
||||
COS 布局(jobs_root 下):
|
||||
todo/<shard>.txt 待处理分片(每行一个视频 COS 路径)。完成才删, 是"任务存在性"的唯一凭证。
|
||||
claimed/<shard>.lock 认领锁, 内容 JSON {machine_id, host, claim_ts, hb_ts}。
|
||||
done/<shard>.done 完成标记, 内容 JSON {machine_id, stats, end_ts}。
|
||||
独立位置(各自 config 字段, 可指向别处):
|
||||
daily_backup_path/<日期>/数据_<机器号>.jsonl 每日 数据.jsonl 全量快照备份。
|
||||
daily_backup_path/<日期>/已处理_<机器号>.txt 上面jsonl的轻量版(只有video_cos_path,
|
||||
一行一个), 供 global_processed_set() 全局去重用。
|
||||
long_list_backup_path/todo/超长待重跑_<机器号>_<日期>_<时分秒>.txt 超长视频重跑队列(本身就是另一套 jobs_root)。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from qcloud_cos import CosConfig, CosS3Client
|
||||
from qcloud_cos.cos_exception import CosServiceError
|
||||
|
||||
from config.config_utils import config
|
||||
|
||||
# 老版本 cos-python-sdk-v5 的 put_object 不认识 ForbidOverwrite 参数(其 maplist 没有该映射),
|
||||
# 直接传会报 "No Parameter Named ForbidOverwrite"。这里把映射补进去, 使 ForbidOverwrite='true'
|
||||
# 注入 x-cos-forbid-overwrite:true 请求头 —— 认领锁的原子性(同名对象只能创建一次)全靠它。
|
||||
# 不升级 SDK(线上离线机不便升级), 版本无关, 幂等。
|
||||
try:
|
||||
from qcloud_cos import cos_comm as _cos_comm
|
||||
_cos_comm.maplist.setdefault("ForbidOverwrite", "x-cos-forbid-overwrite")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------- 客户端 ----
|
||||
|
||||
_CLIENT = None
|
||||
|
||||
|
||||
def client():
|
||||
"""模块级单例 CosS3Client(复用项目现有 COS 凭据)。"""
|
||||
global _CLIENT
|
||||
if _CLIENT is None:
|
||||
c = config.get_cos_config()
|
||||
_CLIENT = CosS3Client(CosConfig(
|
||||
Region=c["region"], SecretId=c["secret_id"], SecretKey=c["secret_key"],
|
||||
))
|
||||
return _CLIENT
|
||||
|
||||
|
||||
def parse_cos_url(url: str):
|
||||
"""cos://bucket/a/b -> ('bucket', 'a/b')。无 cos:// 前缀时按 bucket 配置补全。"""
|
||||
url = url.strip()
|
||||
if url.startswith("cos://"):
|
||||
rest = url[len("cos://"):]
|
||||
bucket, _, key = rest.partition("/")
|
||||
return bucket, key.strip("/")
|
||||
return config.get_cos_config()["bucket"], url.strip("/")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- 配置 ----
|
||||
|
||||
def _cfg():
|
||||
return config.get_dispatch_config() or {}
|
||||
|
||||
|
||||
def _detect_ip() -> str:
|
||||
"""取本机对外默认路由的网卡IP:用 UDP socket connect 一个外部地址触发内核选路由
|
||||
(不会真的发包), 拿到的本地地址天然跳过 docker 网桥(如172.17.0.1)/回环这类非默认路由
|
||||
接口 —— 通常就是 SSH 连进来那张网卡的IP(前提:机器只有一条默认路由出口, 常见的
|
||||
'一张主网卡+docker网桥'部署; 若有多张真实网卡且SSH走的不是默认路由那张, 不适用,
|
||||
建议改用 `ip route get 8.8.8.8` 在目标机上核实后再决定)。
|
||||
取不到再退化到 `hostname -I` 第一个; 都拿不到返回空串, 上层回退主机名。
|
||||
仅在 IP 固定(非 DHCP)的机器上用它兜底身份才安全。"""
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
finally:
|
||||
s.close()
|
||||
if ip and not ip.startswith("127."):
|
||||
return ip
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
r = subprocess.run(['hostname', '-I'], capture_output=True, text=True, timeout=5)
|
||||
ips = (r.stdout or '').split()
|
||||
return ips[0] if ips else ''
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ''
|
||||
|
||||
|
||||
def get_machine_id() -> str:
|
||||
"""机器ID 取值优先级: config.dispatch.machine_id > 环境变量 MACHINE_ID >
|
||||
本机固定IP(默认路由网卡, 见 _detect_ip) > 主机名。
|
||||
必须【唯一且重启后稳定】—— 锁靠它判断'这片是不是我的'。克隆机场景下主机名常常相同,
|
||||
固定IP各机器天然不同,可以直接兜底;但如果 IP 是 DHCP 会变的,不要依赖这个,
|
||||
换IP等于换身份,旧身份名下未完成的锁会变孤儿——那种环境请在 config 里显式填 machine_id。"""
|
||||
mid = (_cfg().get("machine_id") or "").strip()
|
||||
if mid:
|
||||
return mid
|
||||
mid = (os.environ.get("MACHINE_ID") or "").strip()
|
||||
if mid:
|
||||
return mid
|
||||
ip = _detect_ip()
|
||||
if ip:
|
||||
return ip
|
||||
return socket.gethostname()
|
||||
|
||||
|
||||
def is_ocr_colocated() -> bool:
|
||||
"""本机是不是 OCR 服务共置机: 本机默认路由IP是否出现在 config.tencentcloud.ocr.endpoints
|
||||
列表里。这批端点IP本来就是各台跑OCR服务的机器, 如果本机自己也在这个列表里, 说明这台机器
|
||||
除了跑抽帧/OCR客户端之外, 本身还扛着一个OCR服务, 需要多留CPU给它, 不能被抽帧吃满。
|
||||
只覆盖"OCR端点=本机IP"这一种共置方式; 其它不出现在这个列表里的共享方式(比如机器上还跑
|
||||
了别的无关服务)探测不到, 那种情况还是得手动传小一点的 --max-workers。"""
|
||||
try:
|
||||
endpoints = set((config.get_tencent_ocr_config() or {}).get("endpoints") or [])
|
||||
except Exception:
|
||||
return False
|
||||
if not endpoints:
|
||||
return False
|
||||
ip = _detect_ip()
|
||||
return bool(ip) and ip in endpoints
|
||||
|
||||
|
||||
MAX_AUTO_WORKERS = 30 # auto_max_workers() 的硬上限, 见函数docstring③
|
||||
|
||||
|
||||
def auto_max_workers(cpu: int, disk_guard_gb: float = 0.0) -> int:
|
||||
"""--max-workers 未指定时的自动选择:
|
||||
① 默认按CPU核数; 如果 is_ocr_colocated() 判断本机还跑着OCR服务, 打对折让出一半CPU。
|
||||
② 再按本地磁盘算一次上限, 取①②里更小的那个 —— 核多盘小的机器(比如高核数但磁盘只有
|
||||
100多G)按纯CPU数选出的并发数,同时下载/抽帧/拼合成图会把磁盘写爆;`--disk-guard-gb`只挡
|
||||
"新启动"挡不住"已经在跑的", 真正决定磁盘峰值占用的是并发数本身, 所以从源头把并发数卡住:
|
||||
磁盘上限 = (可用磁盘 - disk_guard_gb这层安全垫) ÷ 每视频预估峰值占用(GB)
|
||||
每视频预估占用读 config.dispatch.est_disk_gb_per_video, 默认 2.0G —— 按 subtitle 配置常见的
|
||||
30~2400秒短视频估的(2400秒=3fps≈7200帧, 裁剪后的帧+合成图+源视频下载, 峰值同时存在时
|
||||
量级在1~2G上下; 视频普遍更短/分辨率更低可以调小, 更长/更高清要调大, 保守起见默认按上限估)。
|
||||
disk_guard_gb 传0(不用磁盘保护)时不再额外扣这层安全垫, 直接用当前可用磁盘算。
|
||||
③ 最后再套一个硬上限 MAX_AUTO_WORKERS(30) —— 超高核数机器(如64/128核)按纯CPU数选出的
|
||||
并发数会远超实际有意义的范围(单机瓶颈通常先卡在OCR接口/磁盘IO/内存, 而不是CPU), 且并发
|
||||
数太高会让 ffmpeg_threads="auto" 摊出来的每进程线程数过低(见 multithread_algo.py)。
|
||||
cpu<=0(测不到核数, 比如非Linux)时按10兜底。"""
|
||||
cpu = cpu if cpu and cpu > 0 else 10
|
||||
workers = cpu
|
||||
if is_ocr_colocated():
|
||||
workers = max(1, cpu // 2)
|
||||
logging.info(f"[资源探测] 本机IP在 tencentcloud.ocr.endpoints 里, 判断为OCR共置机, "
|
||||
f"max-workers 自动打对折让资源给OCR服务: {cpu}核 → {workers}")
|
||||
|
||||
try:
|
||||
free_gb = shutil.disk_usage(".").free / 1024 / 1024 / 1024
|
||||
budget_gb = free_gb - max(0.0, disk_guard_gb)
|
||||
est_gb = float(_cfg().get("est_disk_gb_per_video", 2.0) or 2.0)
|
||||
if budget_gb <= 0:
|
||||
logging.warning(f"[资源探测] 可用磁盘({free_gb:.1f}G)已经不够留给磁盘保护垫"
|
||||
f"({disk_guard_gb}G), max-workers 收紧到1, 建议先清理磁盘")
|
||||
workers = 1
|
||||
elif est_gb > 0:
|
||||
disk_cap = max(1, int(budget_gb / est_gb))
|
||||
if disk_cap < workers:
|
||||
logging.info(f"[资源探测] 按磁盘算的并发上限更小, max-workers 收紧: "
|
||||
f"{workers} → {disk_cap}(可用磁盘{free_gb:.1f}G - 保护垫{disk_guard_gb}G = "
|
||||
f"{budget_gb:.1f}G预算 ÷ 每视频预估{est_gb}G)")
|
||||
workers = disk_cap
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if workers > MAX_AUTO_WORKERS:
|
||||
logging.info(f"[资源探测] max-workers 超过硬上限, 收紧: {workers} → {MAX_AUTO_WORKERS}")
|
||||
workers = MAX_AUTO_WORKERS
|
||||
return workers
|
||||
|
||||
|
||||
_root_override = {"value": None} # run_worker.py 用: 主队列空闲时临时切去认领长视频队列
|
||||
|
||||
|
||||
def set_active_root_override(root):
|
||||
"""临时覆盖 _active_jobs_root() 的返回值(仅当前进程内生效, 不改config/不用重启)。
|
||||
传 None 清除覆盖、恢复按 config 判断。给 run_worker.py 做'主队列没活时顺手兼跑长视频队列'用:
|
||||
覆盖生效期间, claim/list_todo/download_shard/mark_done/release 等全部原语都会转而操作
|
||||
覆盖指向的这个队列根, 跟专职长视频机(run_long_video=true)走的是同一套代码路径, 只是切换是
|
||||
动态的、按需的, 而不是启动时定死。"""
|
||||
_root_override["value"] = root
|
||||
|
||||
|
||||
def _active_jobs_root():
|
||||
"""本 worker 实际认领的队列根, 优先级:
|
||||
_root_override(运行时临时切换) > run_long_video=true(专跑超长视频, 用 long_list_backup_path)
|
||||
> jobs_root(默认主队列)。"""
|
||||
if _root_override["value"]:
|
||||
return _root_override["value"]
|
||||
c = _cfg()
|
||||
if c.get("run_long_video"):
|
||||
root = c.get("long_list_backup_path")
|
||||
if root:
|
||||
return root
|
||||
return c.get("jobs_root") or ""
|
||||
|
||||
|
||||
def _bucket_base():
|
||||
"""从【当前生效的队列根】解析 (bucket, base_prefix)。base 末尾无 '/'。
|
||||
只给 _key()(todo/claimed/done 分片路径)用——这个"当前生效"是有意跟着
|
||||
run_long_video/_root_override 变的(分片本来就该落在当前认领的那个队列下)。"""
|
||||
root = _active_jobs_root()
|
||||
if not root:
|
||||
raise RuntimeError("config.dispatch 未配置队列地址(jobs_root / long_list_backup_path)")
|
||||
bucket, key = parse_cos_url(root)
|
||||
return bucket, key.strip("/")
|
||||
|
||||
|
||||
def _primary_bucket_base():
|
||||
"""固定解析自 jobs_root(主队列), 不受 run_long_video / 运行时 _root_override 影响 ——
|
||||
daily_backup_path / monitor_root 没显式配置时的兜底基准必须用这个, 不能用 _bucket_base()
|
||||
(它会跟 _active_jobs_root() 一起变): 专职长视频机(run_long_video=true)跑起来后
|
||||
_active_jobs_root() 长期指向 long_list_backup_path, 如果兜底基准也跟着它变, 算出来的
|
||||
monitor_root/daily_backup_path 就会落到 .../long_video/monitor、.../long_video/daily 这种
|
||||
跟其它机器不一样的路径下 —— fleet_monitor.py 和 global_processed_set() 只认标准的
|
||||
monitor_root/daily_backup_path, 读不到这些"另起炉灶"的路径, 表现为这台机器的小时摘要/
|
||||
去重快照对外"消失了"(实际数据都在, 只是传错地方)。"""
|
||||
root = _cfg().get("jobs_root") or ""
|
||||
if not root:
|
||||
raise RuntimeError("config.dispatch.jobs_root 未配置")
|
||||
bucket, key = parse_cos_url(root)
|
||||
return bucket, key.strip("/")
|
||||
|
||||
|
||||
def _key(kind: str, shard: str = "") -> str:
|
||||
"""拼某类对象的 key。kind ∈ {todo, claimed, done}。"""
|
||||
_, base = _bucket_base()
|
||||
ext = {"todo": ".txt", "claimed": ".lock", "done": ".done"}[kind]
|
||||
if shard:
|
||||
return f"{base}/{kind}/{shard}{ext}"
|
||||
return f"{base}/{kind}/"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- COS 原语 ----
|
||||
|
||||
def _put(key: str, data: bytes, forbid_overwrite: bool = False):
|
||||
bkt, _ = _bucket_base()
|
||||
kwargs = {}
|
||||
if forbid_overwrite:
|
||||
kwargs["ForbidOverwrite"] = "true" # 经 maplist 注入 x-cos-forbid-overwrite 头
|
||||
client().put_object(Bucket=bkt, Body=data, Key=key, **kwargs)
|
||||
|
||||
|
||||
def _get_bytes(key: str):
|
||||
bkt, _ = _bucket_base()
|
||||
resp = client().get_object(Bucket=bkt, Key=key)
|
||||
return resp["Body"].get_raw_stream().read()
|
||||
|
||||
|
||||
def _exists(key: str) -> bool:
|
||||
bkt, _ = _bucket_base()
|
||||
return client().object_exists(Bucket=bkt, Key=key)
|
||||
|
||||
|
||||
def _delete(key: str):
|
||||
bkt, _ = _bucket_base()
|
||||
try:
|
||||
client().delete_object(Bucket=bkt, Key=key)
|
||||
except CosServiceError as e:
|
||||
if e.get_status_code() != 404:
|
||||
raise
|
||||
|
||||
|
||||
def _list_shards(kind: str):
|
||||
"""列出某前缀下所有分片名(去掉前缀和扩展名)。"""
|
||||
bkt, _ = _bucket_base()
|
||||
prefix = _key(kind)
|
||||
ext = {"todo": ".txt", "claimed": ".lock", "done": ".done"}[kind]
|
||||
out, marker = [], ""
|
||||
while True:
|
||||
resp = client().list_objects(Bucket=bkt, Prefix=prefix, Marker=marker, MaxKeys=1000)
|
||||
for c in resp.get("Contents", []) or []:
|
||||
k = c["Key"]
|
||||
if k.endswith(ext):
|
||||
out.append(k[len(prefix):-len(ext)])
|
||||
if resp.get("IsTruncated") in ("true", True):
|
||||
marker = resp.get("NextMarker") or (resp["Contents"][-1]["Key"] if resp.get("Contents") else "")
|
||||
if not marker:
|
||||
break
|
||||
else:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- 接口 ----
|
||||
|
||||
def list_todo():
|
||||
return _list_shards("todo")
|
||||
|
||||
|
||||
def shard_matches(name: str, patterns) -> bool:
|
||||
"""分片名是否匹配 patterns(glob 通配, 如 'cos_urls_8*')。
|
||||
patterns 为空/None -> 认领全部(返回 True)。可传单个字符串或列表。
|
||||
匹配时对 '<名>' 和 '<名>.txt' 都试一遍, 写不写 .txt 都行。"""
|
||||
import fnmatch
|
||||
if not patterns:
|
||||
return True
|
||||
if isinstance(patterns, str):
|
||||
patterns = [patterns]
|
||||
cands = (name, name + ".txt")
|
||||
return any(fnmatch.fnmatch(c, p) for p in patterns for c in cands)
|
||||
|
||||
|
||||
def is_done(shard: str) -> bool:
|
||||
return _exists(_key("done", shard))
|
||||
|
||||
|
||||
def read_lock(shard: str):
|
||||
"""读认领锁内容(dict)。不存在返回 None。"""
|
||||
try:
|
||||
return json.loads(_get_bytes(_key("claimed", shard)).decode("utf-8"))
|
||||
except CosServiceError as e:
|
||||
if e.get_status_code() == 404:
|
||||
return None
|
||||
raise
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def claim(shard: str, machine_id: str) -> bool:
|
||||
"""原子认领: 写 claimed/<shard>.lock(禁止覆盖)。成功=True 表示这片归我;
|
||||
409(已被别人锁) -> False。"""
|
||||
now = datetime.now()
|
||||
body = json.dumps({
|
||||
"machine_id": machine_id, "host": socket.gethostname(),
|
||||
"claim_ts": now.isoformat(), "hb_ts": now.isoformat(),
|
||||
}, ensure_ascii=False).encode("utf-8")
|
||||
try:
|
||||
_put(_key("claimed", shard), body, forbid_overwrite=True)
|
||||
return True
|
||||
except CosServiceError as e:
|
||||
if e.get_status_code() == 409:
|
||||
return False # 已被别的机器抢到
|
||||
raise
|
||||
|
||||
|
||||
def heartbeat(shard: str, machine_id: str):
|
||||
"""刷新锁的心跳时间(允许覆盖, 仍写回 machine_id 以保持所有权信息)。"""
|
||||
lk = read_lock(shard) or {}
|
||||
lk.update({"machine_id": machine_id, "host": socket.gethostname(),
|
||||
"hb_ts": datetime.now().isoformat()})
|
||||
lk.setdefault("claim_ts", lk["hb_ts"])
|
||||
_put(_key("claimed", shard), json.dumps(lk, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
|
||||
def download_shard(shard: str):
|
||||
"""读 todo/<shard>.txt, 返回视频 COS 路径列表。"""
|
||||
text = _get_bytes(_key("todo", shard)).decode("utf-8")
|
||||
return [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||
|
||||
|
||||
def mark_done(shard: str, machine_id: str, stats: dict):
|
||||
_put(_key("done", shard), json.dumps({
|
||||
"machine_id": machine_id, "stats": stats,
|
||||
"end_ts": datetime.now().isoformat(),
|
||||
}, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
|
||||
def release(shard: str):
|
||||
"""跑完后清理: 删 todo 和 lock(保留 done 作为完成账)。"""
|
||||
_delete(_key("todo", shard))
|
||||
_delete(_key("claimed", shard))
|
||||
|
||||
|
||||
def my_open_claims(machine_id: str):
|
||||
"""我名下、尚未完成(无 done)的分片 —— 重启后续做用。"""
|
||||
out = []
|
||||
for shard in _list_shards("claimed"):
|
||||
lk = read_lock(shard)
|
||||
if lk and lk.get("machine_id") == machine_id and not is_done(shard):
|
||||
out.append(shard)
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------- 优雅停机 ----
|
||||
|
||||
def stop_requested() -> bool:
|
||||
"""COS 上存在 stop_flag_key(默认 _jobs/STOP)则请求优雅停机。"""
|
||||
flag = _cfg().get("stop_flag_key")
|
||||
if not flag:
|
||||
_, base = _bucket_base()
|
||||
flag = f"{base}/STOP"
|
||||
bkt, _ = _bucket_base()
|
||||
try:
|
||||
return client().object_exists(Bucket=bkt, Key=flag.strip("/"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ----------------------------------------------------- 每日 数据.jsonl 备份 ----
|
||||
|
||||
# put_object 是"一次性读进内存 + 单个 HTTP 请求"上传, 底层 send_request 默认超时 30 秒 ——
|
||||
# 数据.jsonl 是全量快照, 产能高的机器跑几天就上百 MB/上 GB, 30 秒内传不完就
|
||||
# "The write operation timed out", 然后每几分钟重试又超时, 无限循环从没成功(实测 21.13.80.229)。
|
||||
# 超过这个大小的文件改用 coscli 上传(它支持分块/断点续传, 几 GB 也没问题); 小文件(如几KB的
|
||||
# 小时摘要)仍走 put_object —— 起 coscli 子进程有开销, 小文件没必要。
|
||||
_COSCLI_UPLOAD_THRESHOLD = 50 * 1024 * 1024 # 50MB
|
||||
|
||||
|
||||
def _coscli_put(local_path: str, cos_url: str) -> bool:
|
||||
"""用 coscli 把 local_path 上传到 cos_url(完整 cos:// 路径)。成功 True, 失败 False。
|
||||
coscli 支持分块上传, 大文件不像 put_object 那样受单请求超时限制。
|
||||
coscli 路径/配置从 splitframe 配置读(跟主流程上传 SRT 用的是同一个二进制和配置)。"""
|
||||
try:
|
||||
sf = config.get_splitframe_config() or {}
|
||||
coscli = sf.get("coscli_path", "./coscli-linux-amd64")
|
||||
coscfg = sf.get("cosconfig_path", ".cos.yaml")
|
||||
cmd = [coscli, "-c", coscfg, "cp", local_path, cos_url]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
|
||||
if r.returncode == 0:
|
||||
return True
|
||||
logging.warning(f"[备份] coscli 上传失败(将回退 put_object): {(r.stderr or '').strip()[:200]}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.warning(f"[备份] coscli 上传异常(将回退 put_object): {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _upload_dated_file(local_path: str, dest_root: str, fname: str) -> str:
|
||||
"""把 local_path 上传到 dest_root/<日期>/fname。
|
||||
local_path 不存在 / dest_root 为空 -> 返回 ''。返回上传后的 cos:// 路径。
|
||||
大文件(> _COSCLI_UPLOAD_THRESHOLD)走 coscli 分块上传, 避免 put_object 30 秒超时;
|
||||
coscli 失败(二进制不在/权限等)自动回退 put_object 再试一次 —— 保证引入 coscli 不会
|
||||
比原来更差(最坏情况就是退回原行为)。"""
|
||||
if not local_path or not dest_root or not os.path.exists(local_path):
|
||||
return ""
|
||||
bkt, base_key = parse_cos_url(dest_root)
|
||||
date = datetime.now().strftime("%Y%m%d") # 按日期建文件夹
|
||||
key = f"{base_key.strip('/')}/{date}/{fname}"
|
||||
cos_url = f"cos://{bkt}/{key}"
|
||||
|
||||
size = os.path.getsize(local_path)
|
||||
if size > _COSCLI_UPLOAD_THRESHOLD:
|
||||
logging.info(f"[备份] {fname} 有 {size / 1024 / 1024:.0f}MB, 超过 "
|
||||
f"{_COSCLI_UPLOAD_THRESHOLD // 1024 // 1024}MB, 用 coscli 分块上传")
|
||||
if _coscli_put(local_path, cos_url):
|
||||
return cos_url
|
||||
# coscli 没成: 回退 put_object(大文件很可能还是超时, 但至少不比原来差, 且日志会记清楚)
|
||||
|
||||
with open(local_path, "rb") as f:
|
||||
data = f.read()
|
||||
client().put_object(Bucket=bkt, Body=data, Key=key)
|
||||
return cos_url
|
||||
|
||||
|
||||
def upload_daily_backup(jsonl_path: str, machine_id: str) -> str:
|
||||
"""把本地 数据.jsonl 上传到 dispatch.daily_backup_path/<日期>/数据_<机器号>.jsonl,
|
||||
顺带生成并上传一份轻量的"已处理清单"(见 upload_processed_list) 供 global_processed_set()
|
||||
用——两者是同一次备份动作的副产物,调这一个函数就够了。"""
|
||||
dest_root = _cfg().get("daily_backup_path")
|
||||
if not dest_root:
|
||||
bucket, base = _bucket_base()
|
||||
dest_root = f"cos://{bucket}/{base}/daily"
|
||||
dst = _upload_dated_file(jsonl_path, dest_root, f"数据_{machine_id}.jsonl")
|
||||
if dst:
|
||||
try:
|
||||
upload_processed_list(jsonl_path, machine_id)
|
||||
except Exception as e:
|
||||
logging.warning(f"[每日备份] 已处理清单上传失败(不影响 数据.jsonl 备份): {e}")
|
||||
return dst
|
||||
|
||||
|
||||
def upload_longlist_backup(machine_id: str) -> str:
|
||||
"""把本地超长待重跑列表(subtitle.long_video_list)上传到
|
||||
dispatch.long_list_backup_path/todo/<原文件名>_<机器号>_<日期>_<时分秒>.txt。
|
||||
放进 todo/ 子目录, 使其直接成为可认领的任务分片 —— 把别的机器的 jobs_root
|
||||
指向 long_list_backup_path, 它们就能自动认领这些超长视频重跑。
|
||||
文件名带【机器号+日期+时分秒】: 机器号保证各机器不互相覆盖; 时分秒是因为本函数是
|
||||
"传完即清空本地已传部分"的增量式设计(见下), 一天内不管自动备份触发几次、或人工额外
|
||||
手动跑几次(如 tools/upload_daily.py / apply_update.sh 更新前那一下), 每次快照的都只是
|
||||
"上次传完之后新增的那一小段" —— 如果 key 只精确到天, 同一天第二次上传会用 put_object
|
||||
直接覆盖同名对象, 把第一次已传但可能还没被任何机器认领处理的内容连本地带COS一起冲没。
|
||||
精确到秒后每次上传各自独立成对象, 不会互相覆盖, 只是分片数量会随调用次数变多(不影响使用,
|
||||
worker 一样能各自认领)。"""
|
||||
# 专跑长视频的机器(run_long_video)不再把超长列表传回同一队列, 防回环。
|
||||
if _cfg().get("run_long_video"):
|
||||
return ""
|
||||
local = (config.get_subtitle_config() or {}).get("long_video_list", "./超长待重跑.txt")
|
||||
if not local or not os.path.exists(local):
|
||||
return ""
|
||||
# 快照当前内容(文件 append-only); 全空则不传
|
||||
with open(local, "r", encoding="utf-8") as f:
|
||||
snapshot = f.readlines()
|
||||
if not any(ln.strip() for ln in snapshot):
|
||||
return ""
|
||||
dest_root = _cfg().get("long_list_backup_path")
|
||||
if not dest_root:
|
||||
bucket, base = _bucket_base()
|
||||
dest_root = f"cos://{bucket}/{base}/long_video"
|
||||
bkt, base_key = parse_cos_url(dest_root)
|
||||
stem = os.path.splitext(os.path.basename(local))[0] # 超长待重跑
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
key = f"{base_key.strip('/')}/todo/{stem}_{machine_id}_{ts}.txt"
|
||||
client().put_object(Bucket=bkt, Body="".join(snapshot).encode("utf-8"), Key=key)
|
||||
# 上传成功后清空已上传部分: 只删前 len(snapshot) 行, 保留上传期间新追加的(append 在末尾),
|
||||
# 这样每天只传增量、跑完即了, 不会和已完成分片重叠。开关 long_list_clear_after_upload(默认开)。
|
||||
if _cfg().get("long_list_clear_after_upload", True):
|
||||
try:
|
||||
with open(local, "r", encoding="utf-8") as f:
|
||||
cur = f.readlines()
|
||||
with open(local, "w", encoding="utf-8") as f:
|
||||
f.writelines(cur[len(snapshot):])
|
||||
except OSError:
|
||||
pass
|
||||
return f"cos://{bkt}/{key}"
|
||||
|
||||
|
||||
def _daily_backup_bucket_base():
|
||||
"""daily_backup_path 配置解析为 (bucket, base_key),缺省时与 upload_daily_backup 保持一致。
|
||||
兜底基准用 _primary_bucket_base()(固定挂 jobs_root), 不能用 _bucket_base() —— 见后者docstring
|
||||
里的解释, 否则专职长视频机会把每日备份传到 long_video 队列自己底下, 全局去重读不到。"""
|
||||
dest_root = _cfg().get("daily_backup_path")
|
||||
if not dest_root:
|
||||
bucket, base = _primary_bucket_base()
|
||||
dest_root = f"cos://{bucket}/{base}/daily"
|
||||
bucket, base = parse_cos_url(dest_root)
|
||||
return bucket, base.strip("/")
|
||||
|
||||
|
||||
def _extract_processed_paths(jsonl_path: str) -> list:
|
||||
"""从本地 数据.jsonl 逐行解析出 video_cos_path 列表(顺序不去重, 上传前用)。"""
|
||||
paths = []
|
||||
try:
|
||||
with open(jsonl_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
vid = json.loads(line).get("video_cos_path")
|
||||
if vid:
|
||||
paths.append(vid)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return paths
|
||||
|
||||
|
||||
def upload_processed_list(jsonl_path: str, machine_id: str) -> str:
|
||||
"""从本地 数据.jsonl 提取 video_cos_path, 生成一份纯文本清单(一行一个路径, 不含状态/耗时等
|
||||
其它字段)上传到 daily_backup_path/<日期>/已处理_<机器号>.txt —— 跟 upload_daily_backup 上传的
|
||||
完整 jsonl 是同一份数据的两种形态, 前者给 global_processed_set() 用: 只关心"这个视频是否
|
||||
处理过"时, 拉这个纯清单比拉完整jsonl再挨行解析JSON 小得多、快得多(jsonl里每条记录还带
|
||||
timestamp/duration/image_counts/stage_durations 等一堆跟去重无关的字段)。"""
|
||||
dest_root = _cfg().get("daily_backup_path")
|
||||
if not dest_root:
|
||||
bucket, base = _bucket_base()
|
||||
dest_root = f"cos://{bucket}/{base}/daily"
|
||||
paths = _extract_processed_paths(jsonl_path)
|
||||
if not paths:
|
||||
return ""
|
||||
bkt, base_key = parse_cos_url(dest_root)
|
||||
date = datetime.now().strftime("%Y%m%d")
|
||||
key = f"{base_key.strip('/')}/{date}/已处理_{machine_id}.txt"
|
||||
client().put_object(Bucket=bkt, Body=("\n".join(paths) + "\n").encode("utf-8"), Key=key)
|
||||
return f"cos://{bkt}/{key}"
|
||||
|
||||
|
||||
def list_processed_list_files():
|
||||
"""列出 daily_backup_path 下所有 <日期>/已处理_<机器号>.txt,返回 [(日期, 机器号, key), ...]。"""
|
||||
bkt, base = _daily_backup_bucket_base()
|
||||
prefix = base + "/"
|
||||
out, marker = [], ""
|
||||
while True:
|
||||
resp = client().list_objects(Bucket=bkt, Prefix=prefix, Marker=marker, MaxKeys=1000)
|
||||
for c in resp.get("Contents", []) or []:
|
||||
rel = c["Key"][len(prefix):] # <日期>/已处理_<机器号>.txt
|
||||
parts = rel.split("/")
|
||||
if len(parts) == 2 and parts[1].startswith("已处理_") and parts[1].endswith(".txt"):
|
||||
out.append((parts[0], parts[1][len("已处理_"):-len(".txt")], c["Key"]))
|
||||
if resp.get("IsTruncated") in ("true", True):
|
||||
marker = resp.get("NextMarker") or (resp["Contents"][-1]["Key"] if resp.get("Contents") else "")
|
||||
if not marker:
|
||||
break
|
||||
else:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def global_processed_set(verbose: bool = False, all_dates: bool = False) -> set:
|
||||
"""聚合"已处理清单"(已处理_<机器号>.txt, 由 upload_processed_list 在每次备份时跟 数据.jsonl
|
||||
一起生成), 合并成全局已处理 video_cos_path 集合。只拉轻量txt清单、不下载/解析完整jsonl,
|
||||
省流量也省CPU。语义与本机 load_processed() 一致:不分 success/no_subtitle/error,只要出现过
|
||||
就算处理过。用于灌任务前过滤,避免同一视频被 30 台机器重复处理。
|
||||
|
||||
all_dates=False(默认, 日常灌任务用): 每台机器只取【最新一天】——数据.jsonl 是全量快照(本地
|
||||
从不清空), 取最新一份即覆盖该机器全部历史, 省得把每天的备份都下载一遍; 不同机器最新日期
|
||||
可能不同(比如有机器当天没开机备份), 各自独立取最新, 不要求同一天。
|
||||
|
||||
all_dates=True(机群已停机/一次性核对时用): 不做"每机器只取最新"这个假设, 把【所有日期】
|
||||
的清单全部下载合并——机群都停了就没有"之后还会补传更晚日期"这回事, 更保险的做法是把能拿到
|
||||
的所有历史清单全并起来判断"处理过没有", 而不是赌某一天的快照就是最全的(万一那台机器最新
|
||||
一天的快照因为某种原因不完整/被截断过, 只取最新反而会漏)。代价是下载的文件数从"机器数"
|
||||
变成"机器数×历史天数", 视频清单量小(轻量txt)时这个代价可以接受。"""
|
||||
files = list_processed_list_files()
|
||||
if not files:
|
||||
return set()
|
||||
|
||||
if all_dates:
|
||||
targets = [(date_str, machine_id, key) for date_str, machine_id, key in files]
|
||||
else:
|
||||
latest = {} # machine_id -> (日期, key)
|
||||
for date_str, machine_id, key in files:
|
||||
if machine_id not in latest or date_str > latest[machine_id][0]:
|
||||
latest[machine_id] = (date_str, key)
|
||||
targets = [(date_str, machine_id, key) for machine_id, (date_str, key) in latest.items()]
|
||||
|
||||
bkt, _ = _daily_backup_bucket_base()
|
||||
processed = set()
|
||||
for date_str, machine_id, key in targets:
|
||||
try:
|
||||
data = client().get_object(Bucket=bkt, Key=key)["Body"].get_raw_stream().read()
|
||||
except Exception as e:
|
||||
logging.warning(f"[全局去重] 读取 {key} 失败, 跳过: {e}")
|
||||
continue
|
||||
n = 0
|
||||
for line in data.decode("utf-8", errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
processed.add(line)
|
||||
n += 1
|
||||
if verbose:
|
||||
logging.info(f"[全局去重] 机器 {machine_id}({date_str}): {n} 条")
|
||||
return processed
|
||||
|
||||
|
||||
def run_daily_backup_loop(machine_id: str, hour: int, stop, jsonl_path: str = "数据.jsonl",
|
||||
check_interval: int = 120):
|
||||
"""后台线程: 每天到 hour 点把 数据.jsonl + 超长列表上传 COS, 每天只传一次。
|
||||
main.py(本地模式) 和 run_worker.py(COS模式) 通用。stop 为 threading.Event, set() 即退出。"""
|
||||
last_date = None
|
||||
while not stop.wait(check_interval): # 每 check_interval 秒检查一次
|
||||
now = datetime.now()
|
||||
today = now.strftime("%Y%m%d")
|
||||
if now.hour >= hour and last_date != today:
|
||||
try:
|
||||
dst = upload_daily_backup(jsonl_path, machine_id)
|
||||
if dst:
|
||||
logging.info(f"[每日备份] 数据.jsonl 已上传 -> {dst}")
|
||||
else:
|
||||
logging.info("[每日备份] 本地无 数据.jsonl, 跳过")
|
||||
ll = upload_longlist_backup(machine_id)
|
||||
if ll:
|
||||
logging.info(f"[每日备份] 超长待重跑列表已上传 -> {ll}")
|
||||
last_date = today # 当天标记完成, 避免反复触发
|
||||
except Exception as e:
|
||||
logging.error(f"[每日备份] 上传失败(稍后重试): {e}")
|
||||
|
||||
|
||||
# ----------------------------------------------------- 每小时监控摘要 ----
|
||||
|
||||
def _monitor_bucket_base():
|
||||
"""monitor_root 配置解析为 (bucket, base_key),缺省时 = jobs_root 的同级 monitor 目录
|
||||
(跟 _daily_backup_bucket_base 同一个派生模式)。兜底基准同样必须用 _primary_bucket_base()
|
||||
而不是 _bucket_base() —— 这就是本函数曾经的实际bug: 专职长视频机(run_long_video=true)
|
||||
没显式配 monitor_root 时, 旧版用 _bucket_base() 兜底会解析到【当前生效队列根】
|
||||
(_active_jobs_root(), 长视频机上长期等于 long_list_backup_path), 算出
|
||||
.../long_video/monitor/... 这种独立于其它机器的路径, fleet_monitor.py 只读标准
|
||||
monitor_root, 找不到这些机器的小时摘要, 表现为"全队机器数对不上"(实际数据都传了,
|
||||
只是传错地方, 数据没丢, 改这个函数后新上传的会去对的地方, 旧的仍留在错的路径下)。"""
|
||||
dest_root = _cfg().get("monitor_root")
|
||||
if not dest_root:
|
||||
bucket, base = _primary_bucket_base()
|
||||
dest_root = f"cos://{bucket}/{base}/monitor"
|
||||
bucket, base = parse_cos_url(dest_root)
|
||||
return bucket, base.strip("/")
|
||||
|
||||
|
||||
def compute_hourly_digest(jsonl_path: str, hour_dt: datetime):
|
||||
"""从本地 数据.jsonl 里筛出 process_start(缺失回退timestamp) 落在 hour_dt 所在整点小时的记录,
|
||||
算出这个小时的摘要。hour_dt 的分钟/秒会被忽略, 只取年月日时。
|
||||
本地jsonl不存在/读取失败 -> 返回 None(调用方应跳过上传)。
|
||||
这一小时确实一条记录都没处理(count==0, 比如COS任务队列暂时被抢空、worker在idle轮询)
|
||||
-> 仍然返回一份全0的摘要, 不当 None 处理 —— 队列见底idle是这套分布式调度系统的正常状态
|
||||
(见 run_worker.py 的 keep_alive/idle_poll_seconds), 如果这种情况也不传摘要, fleet_monitor.py
|
||||
的"机器掉线"检测会把"确认活着只是没活干"误判成"可能停机", 制造假警报。"""
|
||||
if not os.path.exists(jsonl_path):
|
||||
return None
|
||||
hour_prefix = hour_dt.strftime("%Y-%m-%dT%H") # 匹配 ISO 格式 process_start 的前13位
|
||||
count = success = no_subtitle = error = 0
|
||||
success_hours = 0.0
|
||||
try:
|
||||
with open(jsonl_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
ts = rec.get("process_start") or rec.get("timestamp") or ""
|
||||
if ts[:13] != hour_prefix:
|
||||
continue
|
||||
count += 1
|
||||
status = rec.get("status")
|
||||
if status == "success":
|
||||
success += 1
|
||||
try:
|
||||
success_hours += float(rec.get("duration") or 0) / 3600.0
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
elif status == "no_subtitle":
|
||||
no_subtitle += 1
|
||||
else:
|
||||
error += 1
|
||||
except OSError:
|
||||
return None
|
||||
return {
|
||||
"machine_id": None, # upload_hourly_digest 里填
|
||||
"hour": hour_dt.strftime("%Y-%m-%dT%H"),
|
||||
"count": count, "success": success, "no_subtitle": no_subtitle, "error": error,
|
||||
"success_hours": round(success_hours, 2),
|
||||
"uploaded_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def upload_hourly_digest(machine_id: str, jsonl_path: str, hour_dt: datetime) -> str:
|
||||
"""算好 hour_dt 那个整点小时的摘要并上传到
|
||||
monitor_root/hourly/<日期YYYYMMDD>/<小时HH>/<机器号>.json。
|
||||
即使这一小时count==0(队列见底idle)也会正常上传一份全0摘要——只有本地 数据.jsonl
|
||||
根本不存在/读取失败(digest为None, 比如worker刚起来还没写过jsonl)才跳过, 返回''。"""
|
||||
digest = compute_hourly_digest(jsonl_path, hour_dt)
|
||||
if digest is None:
|
||||
return ""
|
||||
digest["machine_id"] = machine_id
|
||||
bkt, base = _monitor_bucket_base()
|
||||
date_str = hour_dt.strftime("%Y%m%d")
|
||||
hour_str = hour_dt.strftime("%H")
|
||||
key = f"{base}/hourly/{date_str}/{hour_str}/{machine_id}.json"
|
||||
client().put_object(Bucket=bkt, Body=json.dumps(digest, ensure_ascii=False).encode("utf-8"), Key=key)
|
||||
return f"cos://{bkt}/{key}"
|
||||
|
||||
|
||||
def list_hourly_digest_files_for_hour(hour_dt: datetime) -> list:
|
||||
"""列出 monitor_root/hourly/<日期>/<小时>/ 下所有机器的摘要文件, 返回 [(机器号, key), ...]。"""
|
||||
bkt, base = _monitor_bucket_base()
|
||||
date_str = hour_dt.strftime("%Y%m%d")
|
||||
hour_str = hour_dt.strftime("%H")
|
||||
prefix = f"{base}/hourly/{date_str}/{hour_str}/"
|
||||
out, marker = [], ""
|
||||
while True:
|
||||
resp = client().list_objects(Bucket=bkt, Prefix=prefix, Marker=marker, MaxKeys=1000)
|
||||
for c in resp.get("Contents", []) or []:
|
||||
rel = c["Key"][len(prefix):]
|
||||
if rel.endswith(".json"):
|
||||
out.append((rel[:-len(".json")], c["Key"]))
|
||||
if resp.get("IsTruncated") in ("true", True):
|
||||
marker = resp.get("NextMarker") or (resp["Contents"][-1]["Key"] if resp.get("Contents") else "")
|
||||
if not marker:
|
||||
break
|
||||
else:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def download_hourly_digest(key: str) -> dict:
|
||||
"""下载解析单份小时摘要json, 返回dict。失败抛异常, 调用方负责捕获。"""
|
||||
bkt, _ = _monitor_bucket_base()
|
||||
data = client().get_object(Bucket=bkt, Key=key)["Body"].get_raw_stream().read()
|
||||
return json.loads(data.decode("utf-8"))
|
||||
|
||||
|
||||
def upload_monitor_report(local_path: str, hour_dt: datetime) -> str:
|
||||
"""把生成好的监控报告文件上传到 monitor_root/reports/<日期YYYYMMDD>_<小时HH>.md。"""
|
||||
if not os.path.exists(local_path):
|
||||
return ""
|
||||
bkt, base = _monitor_bucket_base()
|
||||
date_str = hour_dt.strftime("%Y%m%d")
|
||||
hour_str = hour_dt.strftime("%H")
|
||||
key = f"{base}/reports/{date_str}_{hour_str}.md"
|
||||
with open(local_path, "rb") as f:
|
||||
data = f.read()
|
||||
client().put_object(Bucket=bkt, Body=data, Key=key)
|
||||
return f"cos://{bkt}/{key}"
|
||||
|
||||
|
||||
def run_hourly_digest_loop(machine_id: str, stop, jsonl_path: str = "数据.jsonl",
|
||||
check_interval: int = 120):
|
||||
"""后台线程: 每小时把刚结束的那个整点小时的处理摘要上传COS, 每小时只传一次。
|
||||
main.py(本地模式) 不需要接入(一次性批处理没有"常驻小时"的概念, 见设计文档)。
|
||||
stop 为 threading.Event, set() 即退出。"""
|
||||
last_uploaded_hour = None
|
||||
while not stop.wait(check_interval):
|
||||
now = datetime.now()
|
||||
prev_hour_dt = now.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
||||
prev_hour_key = prev_hour_dt.strftime("%Y%m%d%H")
|
||||
if last_uploaded_hour == prev_hour_key:
|
||||
continue
|
||||
try:
|
||||
dst = upload_hourly_digest(machine_id, jsonl_path, prev_hour_dt)
|
||||
if dst:
|
||||
logging.info(f"[小时摘要] {prev_hour_key} 已上传 -> {dst}")
|
||||
else:
|
||||
logging.info(f"[小时摘要] {prev_hour_key} 本地 数据.jsonl 尚不存在, 跳过上传")
|
||||
last_uploaded_hour = prev_hour_key
|
||||
except Exception as e:
|
||||
logging.error(f"[小时摘要] {prev_hour_key} 上传失败(下次继续重试): {e}")
|
||||
0
tool/log/__init__.py
Normal file
0
tool/log/__init__.py
Normal file
218
tool/log/log_manager.py
Normal file
218
tool/log/log_manager.py
Normal file
@@ -0,0 +1,218 @@
|
||||
# log_manager.py
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
|
||||
class ProgramFilter(logging.Filter):
|
||||
"""自定义日志过滤器,为日志记录添加程序名称属性"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if not hasattr(record, 'program'):
|
||||
record.program = "UNKNOWN"
|
||||
return True
|
||||
|
||||
|
||||
class UnifiedLogger:
|
||||
"""统一日志管理器(线程安全单例),支持多程序日志收集"""
|
||||
|
||||
_instance: Optional['UnifiedLogger'] = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls) -> 'UnifiedLogger':
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
# 避免重复初始化
|
||||
if hasattr(self, '_initialized') and self._initialized:
|
||||
return
|
||||
|
||||
# 每视频状态使用线程本地存储:批处理多线程并发时,每个视频在各自线程内
|
||||
# 独立记录日志/计时/统计,互不串扰。(嵌套子线程如OCR并发worker不应直接
|
||||
# 调用本logger的状态方法——它们会落到worker线程的独立状态而丢失。)
|
||||
self._local = threading.local()
|
||||
|
||||
# 是否打印详细日志到控制台。默认 False:控制台只显示"视频→步骤"里程碑,
|
||||
# 详细日志仍完整存进每视频 JSON(log/<video>.json)。设 LOG_VERBOSE=1 恢复全部。
|
||||
self.verbose = os.environ.get("LOG_VERBOSE", "0") == "1"
|
||||
|
||||
# 自定义 logger(避免污染根日志器)
|
||||
self.logger = logging.getLogger("UnifiedLogger")
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
self.logger.propagate = False # 不传播到 root,避免每条日志重复打印
|
||||
self.logger.addFilter(ProgramFilter())
|
||||
|
||||
# 控制台 Handler:非 verbose 时只放行 WARNING+,把 INFO 细节挡在控制台外(仍进 JSON)
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.DEBUG if self.verbose else logging.WARNING)
|
||||
console_formatter = logging.Formatter(
|
||||
'%(asctime)s [%(levelname)s] [%(program)s] %(message)s'
|
||||
)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
self.logger.addHandler(console_handler)
|
||||
|
||||
# 第三方库降噪:jieba 的 DEBUG("Building prefix dict...")太吵
|
||||
logging.getLogger("jieba").setLevel(logging.WARNING)
|
||||
# qcloud_cos SDK 噪音大: 每次 get/put/head object 都打一条 INFO(带完整url/headers),
|
||||
# 而且它把"对象不存在"(HEAD 404, 本项目里大量用来判断分片是否已完成/锁是否存在,
|
||||
# 属于正常控制流)也当 WARNING 打一条 —— 两种都没信息量, 真正的失败走异常
|
||||
# (CosServiceError), 本项目自己的 try/except 会记录, 不受这里调级影响。
|
||||
logging.getLogger("qcloud_cos").setLevel(logging.ERROR)
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def step(self, video: str, step: str, extra: str = "") -> None:
|
||||
"""打印一条简洁的"视频 → 步骤"里程碑(始终显示,不受 verbose 影响)。"""
|
||||
line = f"[{datetime.now().strftime('%H:%M:%S')}] {video} → {step}"
|
||||
if extra:
|
||||
line += f" ({extra})"
|
||||
print(line, flush=True)
|
||||
try:
|
||||
self._state()["log_buffer"].append({
|
||||
"timestamp": datetime.now().isoformat(), "level": "STEP",
|
||||
"program": "step", "message": f"{step} {extra}".strip(),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _state(self) -> dict:
|
||||
"""获取当前线程的日志状态,惰性初始化。"""
|
||||
st = getattr(self._local, 'state', None)
|
||||
if st is None:
|
||||
st = {
|
||||
"log_buffer": deque(maxlen=10000),
|
||||
"log_file": "unified_log.json",
|
||||
"program_sequence": [],
|
||||
"program_start_times": {},
|
||||
"program_end_times": {},
|
||||
"custom_stats": {},
|
||||
}
|
||||
self._local.state = st
|
||||
return st
|
||||
|
||||
def init_logging(self, log_file: str = "unified_log.json") -> None:
|
||||
"""初始化日志系统(可重新指定日志文件路径)"""
|
||||
self._state()["log_file"] = log_file
|
||||
log_dir = os.path.dirname(log_file)
|
||||
if log_dir:
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""重置当前线程的日志状态(用于多线程场景下每个视频独立记录)"""
|
||||
st = self._state()
|
||||
st["log_buffer"].clear()
|
||||
st["program_sequence"].clear()
|
||||
st["program_start_times"].clear()
|
||||
st["program_end_times"].clear()
|
||||
st["custom_stats"].clear()
|
||||
|
||||
def set_stat(self, key: str, value: object) -> None:
|
||||
"""记录一项自定义统计,会写入日志 metadata.custom_stats"""
|
||||
self._state()["custom_stats"][key] = value
|
||||
|
||||
def _get_level_int(self, level: Union[str, int]) -> int:
|
||||
"""将日志级别转换为 logging 模块的整数常量"""
|
||||
if isinstance(level, int):
|
||||
return level
|
||||
level_map = {
|
||||
"DEBUG": logging.DEBUG,
|
||||
"INFO": logging.INFO,
|
||||
"WARNING": logging.WARNING,
|
||||
"ERROR": logging.ERROR,
|
||||
"CRITICAL": logging.CRITICAL
|
||||
}
|
||||
return level_map.get(level.upper(), logging.INFO)
|
||||
|
||||
def log(self, message: str, level: Union[str, int] = "info", program: str = "A") -> None:
|
||||
"""记录一条日志"""
|
||||
timestamp = datetime.now().isoformat()
|
||||
level_str = level if isinstance(level, str) else logging.getLevelName(level)
|
||||
log_entry = {
|
||||
"timestamp": timestamp,
|
||||
"level": level_str,
|
||||
"program": program,
|
||||
"message": message
|
||||
}
|
||||
|
||||
self._state()["log_buffer"].append(log_entry)
|
||||
|
||||
# 输出到自定义 logger
|
||||
level_int = self._get_level_int(level)
|
||||
self.logger.log(level_int, message, extra={"program": program})
|
||||
|
||||
def start_program(self, program_name: str) -> None:
|
||||
"""记录程序开始执行"""
|
||||
st = self._state()
|
||||
if program_name in st["program_start_times"]:
|
||||
self.logger.warning(f"程序 {program_name} 已开始,忽略重复 start 调用", extra={"program": "SYSTEM"})
|
||||
return
|
||||
|
||||
st["program_start_times"][program_name] = time.time()
|
||||
st["program_sequence"].append(program_name)
|
||||
|
||||
self.log(f"程序 {program_name} 开始执行", program=program_name)
|
||||
|
||||
def end_program(self, program_name: str) -> None:
|
||||
"""记录程序执行结束"""
|
||||
st = self._state()
|
||||
if program_name not in st["program_start_times"]:
|
||||
self.logger.error(f"程序 {program_name} 未调用 start_program,无法记录结束", extra={"program": "SYSTEM"})
|
||||
return
|
||||
|
||||
end_time = time.time()
|
||||
st["program_end_times"][program_name] = end_time
|
||||
duration = end_time - st["program_start_times"][program_name]
|
||||
|
||||
self.log(f"程序 {program_name} 执行完成 (耗时: {duration:.2f}s)", program=program_name)
|
||||
|
||||
def save_logs(self) -> None:
|
||||
"""将当前线程的所有日志(含元数据)保存到 JSON 文件"""
|
||||
st = self._state()
|
||||
logs_copy = list(st["log_buffer"])
|
||||
start_times_copy = dict(st["program_start_times"])
|
||||
end_times_copy = dict(st["program_end_times"])
|
||||
sequence_copy = list(st["program_sequence"])
|
||||
stats_copy = dict(st["custom_stats"])
|
||||
log_file = st["log_file"]
|
||||
|
||||
# 计算程序执行耗时
|
||||
durations = {}
|
||||
for prog, start in start_times_copy.items():
|
||||
end = end_times_copy.get(prog, time.time())
|
||||
durations[prog] = end - start
|
||||
|
||||
metadata = {
|
||||
"log_created": datetime.now().isoformat(),
|
||||
"program_sequence": sequence_copy,
|
||||
"total_entries": len(logs_copy),
|
||||
"program_durations": durations,
|
||||
"custom_stats": stats_copy
|
||||
}
|
||||
|
||||
full_log = {
|
||||
"metadata": metadata,
|
||||
"logs": logs_copy
|
||||
}
|
||||
|
||||
try:
|
||||
with open(log_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(full_log, f, ensure_ascii=False, indent=2)
|
||||
self.logger.info(f"所有日志已保存到: {log_file}", extra={"program": "SYSTEM"})
|
||||
except (OSError, IOError) as e:
|
||||
self.logger.error(f"保存日志失败: {e}", extra={"program": "SYSTEM"})
|
||||
|
||||
def get_logs(self) -> List[dict]:
|
||||
"""获取当前线程缓冲区中的所有日志副本"""
|
||||
return list(self._state()["log_buffer"])
|
||||
|
||||
|
||||
# 全局单例实例
|
||||
logger = UnifiedLogger()
|
||||
885
tool/merge.py
Normal file
885
tool/merge.py
Normal file
@@ -0,0 +1,885 @@
|
||||
import os
|
||||
import json
|
||||
import cv2
|
||||
import numpy as np
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
import math
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from difflib import SequenceMatcher
|
||||
from PIL import Image
|
||||
from typing import Optional, List, Tuple, Dict, Any, Union
|
||||
# 假设这些外部模块已正确导入
|
||||
from tool.Ocr.tencentcloud_ocr import tencentcloud_ocr_api, img2base64
|
||||
from tool.whisper_manager import whisper_manager
|
||||
from tool.log.log_manager import logger
|
||||
from config.config_utils import config
|
||||
|
||||
|
||||
# ==================== 辅助函数 ====================
|
||||
def natural_sort_key(filename: str) -> List[Union[int, str]]:
|
||||
"""
|
||||
自然排序键函数,将文件名中的数字部分转换为整数进行排序。
|
||||
"""
|
||||
return [int(part) if part.isdigit() else part.lower()
|
||||
for part in re.split(r'(\d+)', filename)]
|
||||
|
||||
|
||||
def has_chinese(text: str) -> bool:
|
||||
"""文本是否含中文字符(CJK 统一表意文字)。"""
|
||||
return any('一' <= ch <= '鿿' for ch in (text or ""))
|
||||
|
||||
|
||||
def calculate_english_ratio(text: str) -> float:
|
||||
"""计算文本中英文字符(字母、数字、常见标点)的占比。"""
|
||||
if not text:
|
||||
return 0.0
|
||||
total_chars = len(text)
|
||||
if total_chars == 0:
|
||||
return 0.0
|
||||
|
||||
english_chars = 0
|
||||
for ch in text:
|
||||
if ch.isalpha() and ch.isascii():
|
||||
english_chars += 1
|
||||
elif ch.isdigit():
|
||||
english_chars += 1
|
||||
elif ch in ' .,!?;:\'"-()[]{}':
|
||||
english_chars += 1
|
||||
return english_chars / total_chars
|
||||
|
||||
|
||||
def check_subtitle_quality_enhanced(best_subtitle: str, strict_mode: bool = True) -> bool:
|
||||
"""
|
||||
增强版字幕质量检测。
|
||||
|
||||
:param best_subtitle: 待检测的文本
|
||||
:param strict_mode: 是否使用严格模式
|
||||
:return: 若文本质量合格则返回 True
|
||||
"""
|
||||
if not best_subtitle or not best_subtitle.strip():
|
||||
return True
|
||||
|
||||
subtitle = best_subtitle.strip()
|
||||
total_chars = len(subtitle)
|
||||
if total_chars == 0:
|
||||
return True
|
||||
|
||||
digit_count = sum(1 for ch in subtitle if ch.isdigit())
|
||||
symbol_count = sum(1 for ch in subtitle if ch in string.punctuation)
|
||||
special_ratio = (digit_count + symbol_count) / total_chars
|
||||
english_ratio = calculate_english_ratio(subtitle)
|
||||
|
||||
if strict_mode:
|
||||
digit_ratio = digit_count / total_chars
|
||||
if total_chars >= 10 and digit_ratio <= 0.9 and special_ratio <= 0.9:
|
||||
return True
|
||||
# 数字占比 ≤ 70% 且特殊字符占比 ≤ 80% 且英文占比 ≤ 90%
|
||||
return digit_ratio <= 0.7 and special_ratio <= 0.8 and english_ratio <= 0.9
|
||||
else:
|
||||
# 普通模式仅检查特殊字符占比
|
||||
return special_ratio <= 0.5
|
||||
|
||||
|
||||
def detect_main_region(image_files: List[str],
|
||||
input_dir: str,
|
||||
sample_size: int = 10,
|
||||
motion_threshold: int = 30) -> Tuple[int, int, int, int]:
|
||||
"""
|
||||
通过运动检测确定主画面区域。
|
||||
|
||||
:return: 边界框 (x, y, width, height)
|
||||
"""
|
||||
if not image_files:
|
||||
raise ValueError("图片列表为空")
|
||||
|
||||
first_img_path = os.path.join(input_dir, image_files[0])
|
||||
prev_frame = cv2.imread(first_img_path)
|
||||
if prev_frame is None:
|
||||
raise ValueError(f"无法读取第一帧: {first_img_path}")
|
||||
prev_gray = cv2.cvtColor(prev_frame, cv2.COLOR_BGR2GRAY)
|
||||
height, width = prev_gray.shape
|
||||
|
||||
# 随机采样中间帧(避开开头和结尾)
|
||||
mid_start = len(image_files) // 4
|
||||
mid_end = 3 * len(image_files) // 4
|
||||
sample_indices = random.sample(range(mid_start, mid_end),
|
||||
min(sample_size, mid_end - mid_start))
|
||||
|
||||
motion_accumulator = np.zeros((height, width), dtype=np.float32)
|
||||
|
||||
for idx in sample_indices:
|
||||
img_path = os.path.join(input_dir, image_files[idx])
|
||||
curr_frame = cv2.imread(img_path)
|
||||
if curr_frame is None:
|
||||
continue
|
||||
curr_gray = cv2.cvtColor(curr_frame, cv2.COLOR_BGR2GRAY)
|
||||
frame_diff = cv2.absdiff(curr_gray, prev_gray)
|
||||
_, thresh = cv2.threshold(frame_diff, motion_threshold, 255, cv2.THRESH_BINARY)
|
||||
motion_accumulator += thresh.astype(np.float32)
|
||||
prev_gray = curr_gray
|
||||
|
||||
motion_accumulator = cv2.normalize(motion_accumulator, None, 0, 255, cv2.NORM_MINMAX)
|
||||
_, motion_mask = cv2.threshold(motion_accumulator.astype(np.uint8), 100, 255, cv2.THRESH_BINARY)
|
||||
|
||||
contours, _ = cv2.findContours(motion_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
if not contours:
|
||||
# 无运动区域时返回中心偏下区域
|
||||
return (0, height // 4, width, height // 2)
|
||||
|
||||
main_contour = max(contours, key=cv2.contourArea)
|
||||
x, y, w, h = cv2.boundingRect(main_contour)
|
||||
|
||||
# 合理性检查
|
||||
if w < width * 0.1 or h < height * 0.1:
|
||||
return (0, height // 4, width, height // 2)
|
||||
|
||||
# 考虑下方空间
|
||||
bottom_space = height - (y + h)
|
||||
if bottom_space > height * 0.1:
|
||||
extend = int(0.8 * bottom_space)
|
||||
h += extend
|
||||
# 调整 y 使区域居中于原运动区域下方
|
||||
y = y + h // 2
|
||||
h //= 2
|
||||
else:
|
||||
h += bottom_space
|
||||
|
||||
return (x, y, w, h)
|
||||
|
||||
|
||||
# ==================== 字幕区域检测 ====================
|
||||
def _sample_indices_uniform(total: int, sample_size: int) -> List[int]:
|
||||
"""均匀采样索引,确保帧间有间隔。"""
|
||||
print(total, sample_size)
|
||||
if sample_size <= 1:
|
||||
return [total // 2]
|
||||
step = total // (sample_size - 1)
|
||||
indices = [i * step for i in range(sample_size)]
|
||||
# 确保不超出范围
|
||||
indices = [min(i, total - 1) for i in indices]
|
||||
return indices
|
||||
|
||||
|
||||
def _ocr_frames_concurrent(image_files: List[str], input_dir: str,
|
||||
indices: List[int], workers: int) -> Dict[int, str]:
|
||||
"""并发对指定帧索引做 OCR,返回 {idx: 原始响应字符串}。
|
||||
在视频主线程内开子线程池,子线程只做网络请求、不写日志(满足日志线程隔离要求)。"""
|
||||
results: Dict[int, str] = {}
|
||||
if not indices:
|
||||
return results
|
||||
|
||||
def _one(idx):
|
||||
try:
|
||||
img_path = os.path.join(input_dir, image_files[idx])
|
||||
return idx, tencentcloud_ocr_api(img2base64(img_path=img_path))
|
||||
except Exception:
|
||||
return idx, None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max(1, min(workers, len(indices)))) as ex:
|
||||
for idx, res in ex.map(_one, indices):
|
||||
results[idx] = res
|
||||
return results
|
||||
|
||||
|
||||
def cv_locate_subtitle(image_files: List[str], input_dir: str,
|
||||
height: int, width: int, sample: int = 80):
|
||||
"""纯 CV(零 OCR)定位硬字幕带。读已抽好的 JPEG 帧(cv2.imread,与 AV1 解码无关)。
|
||||
返回 (verdict, region, info):
|
||||
'subtitle' (x,y,w,h) —— 找到下部动态字幕带
|
||||
'none' None —— 整体静止+无动态文字带 = PPT/文档/截图,可直接跳过
|
||||
'uncertain'None —— 拿不准,回退原 OCR 检测(不改变原行为)
|
||||
安全准则: 只有【整体静止且无任何动态文字带】才判 none,绝不误杀真字幕视频。"""
|
||||
n = len(image_files)
|
||||
if n < 10:
|
||||
return "uncertain", None, {"reason": "帧太少"}
|
||||
idxs = np.linspace(n * 0.1, n * 0.9, min(sample, n)).astype(int)
|
||||
grays = []
|
||||
for i in idxs:
|
||||
g = cv2.imread(os.path.join(input_dir, image_files[int(i)]), cv2.IMREAD_GRAYSCALE)
|
||||
if g is not None:
|
||||
grays.append(g)
|
||||
if len(grays) < 8:
|
||||
return "uncertain", None, {"reason": "读帧失败"}
|
||||
H, W = grays[0].shape
|
||||
x0, x1 = int(0.15 * W), int(0.85 * W) # 只看横向中间70%,避开角落logo
|
||||
|
||||
edge_acc = np.zeros(H); present = np.zeros(H)
|
||||
for g in grays:
|
||||
e = cv2.Canny(g, 80, 200)
|
||||
row = e[:, x0:x1].sum(axis=1) / 255.0
|
||||
edge_acc += row
|
||||
present += (row > (x1 - x0) * 0.02)
|
||||
edge_profile = edge_acc / len(grays)
|
||||
presence = present / len(grays)
|
||||
diff_acc = np.zeros(H)
|
||||
for k in range(1, len(grays)):
|
||||
diff_acc += cv2.absdiff(grays[k], grays[k - 1])[:, x0:x1].mean(axis=1)
|
||||
temporal = diff_acc / max(1, len(grays) - 1)
|
||||
|
||||
TEMP_HI = 8.0 # 带变化≥此 = 字幕在翻动(动态)
|
||||
TEMP_LO = 4.0 # 文字带变化<此 = 静止
|
||||
STATIC_MED = 3.0 # 全画面 temporal 中位数<此 = 整体静止(PPT/文档)
|
||||
PRES = 0.4
|
||||
med = float(np.median(temporal))
|
||||
|
||||
cand = (presence > PRES) & (edge_profile > edge_profile.max() * 0.25)
|
||||
rows = np.where(cand)[0]
|
||||
if len(rows) == 0:
|
||||
if med < STATIC_MED:
|
||||
return "none", None, {"reason": "无文字带且画面静止", "med": round(med, 1)}
|
||||
return "uncertain", None, {"reason": "无稳定文字带"}
|
||||
|
||||
bands = []
|
||||
s = p = rows[0]
|
||||
for r in rows[1:]:
|
||||
if r - p > H * 0.03:
|
||||
bands.append((s, p)); s = r
|
||||
p = r
|
||||
bands.append((s, p))
|
||||
MIN_H = int(H * 0.012)
|
||||
bands = [b for b in bands if (b[1] - b[0]) >= MIN_H]
|
||||
|
||||
def bchg(b):
|
||||
return float(temporal[b[0]:b[1] + 1].mean())
|
||||
|
||||
dyn = [b for b in bands if bchg(b) >= TEMP_HI] if bands else []
|
||||
if not dyn:
|
||||
# 无动态字幕带: 整体静止 或 所有文字带都静止 → PPT跳过; 否则交OCR
|
||||
if med < STATIC_MED:
|
||||
return "none", None, {"reason": "无动态字幕带且画面静止(PPT)", "med": round(med, 1)}
|
||||
if bands and max(bchg(b) for b in bands) < TEMP_LO:
|
||||
return "none", None, {"reason": "所有文字带静止", "max_chg": round(max(bchg(b) for b in bands), 1)}
|
||||
return "uncertain", None, {"reason": "拿不准(交OCR)", "med": round(med, 1)}
|
||||
|
||||
dyn.sort(key=lambda b: presence[b[0]:b[1] + 1].mean() * (0.4 + (b[0] + b[1]) / 2 / H), reverse=True)
|
||||
ys, ye = dyn[0]
|
||||
band_h = (ye - ys) / H; cy = (ys + ye) / 2 / H
|
||||
# 若抽帧已裁到下半屏(crop_bottom_ratio),帧内"偏上"不再代表标题,放宽位置判定。
|
||||
crop_r = config.get_splitframe_config().get('crop_bottom_ratio')
|
||||
try:
|
||||
crop_r = float(crop_r) if crop_r is not None else None
|
||||
except (TypeError, ValueError):
|
||||
crop_r = None
|
||||
min_cy = 0.15 if (crop_r and 0.0 < crop_r < 1.0) else 0.45
|
||||
# 有动态带=【不是PPT】。带太宽/偏上只代表不敢直接给区域 → 回退OCR,绝不判none。
|
||||
if band_h > 0.35:
|
||||
return "uncertain", None, {"reason": "动态带太宽,交OCR", "band_h": round(band_h, 2)}
|
||||
if cy < min_cy:
|
||||
return "uncertain", None, {"reason": "带偏上,交OCR", "cy": round(cy, 2)}
|
||||
pad = int(H * 0.012)
|
||||
y = max(0, ys - pad); h = min(H, ye + pad) - y
|
||||
return "subtitle", (0, int(y), int(W), int(h)), {"cy": round(cy, 2), "chg": round(bchg((ys, ye)), 1)}
|
||||
|
||||
|
||||
def detect_subtitle_region(image_files: List[str],
|
||||
input_dir: str,
|
||||
sample_size: int = 15,
|
||||
keep_num: int = 9) -> Tuple[Optional[Tuple[int, int, int, int]],
|
||||
List[int],
|
||||
List[str],
|
||||
List[Dict]]:
|
||||
"""
|
||||
使用 OCR 检测字幕区域。
|
||||
|
||||
:return: (subtitle_region, sample_indices, watermark_logs, candidate_regions)
|
||||
subtitle_region 为 (x, y, width, height);若无字幕则返回 None
|
||||
"""
|
||||
_sub_cfg = config.get_subtitle_config()
|
||||
require_chinese = _sub_cfg.get('require_chinese', True) # 只要中文字幕
|
||||
bands_thr = int(_sub_cfg.get('redundant_bands', 12)) # 冗余阈值(垂直位置簇数),越大越宽松
|
||||
|
||||
# 读取第一张图片获取尺寸
|
||||
first_img_path = os.path.join(input_dir, image_files[0])
|
||||
img = cv2.imread(first_img_path)
|
||||
if img is None:
|
||||
raise ValueError(f"无法读取图片: {first_img_path}")
|
||||
height, width = img.shape[:2]
|
||||
|
||||
# === CV 快速闸门(零 OCR,~1.4s)===
|
||||
# 'none' → 静态/PPT,直接跳过(省掉对这类视频的几千次 OCR);
|
||||
# 'subtitle' 且开启 cv_use_region → 直接用 CV 区域(跳过 OCR 检测);
|
||||
# 否则 → 继续走下面的 OCR 检测(行为不变)。
|
||||
if _sub_cfg.get('cv_detect', True):
|
||||
try:
|
||||
cv_verdict, cv_region, cv_info = cv_locate_subtitle(image_files, input_dir, height, width)
|
||||
except Exception as _e:
|
||||
cv_verdict, cv_region, cv_info = "uncertain", None, {"reason": f"CV异常:{_e}"}
|
||||
if cv_verdict == "none":
|
||||
logger.log(f"CV定位: 无动态字幕(静态/PPT),跳过 [{cv_info}]", level="warning", program="merge")
|
||||
return None, [], [], []
|
||||
if cv_verdict == "subtitle" and _sub_cfg.get('cv_use_region', False):
|
||||
logger.log(f"CV定位: 字幕区域 {cv_region} (零OCR) [{cv_info}]", program="merge")
|
||||
x, y, w, h = cv_region
|
||||
cand = [{"x_min": x, "y_min": y, "x_max": x + w, "y_max": y + h, "width": w, "height": h}]
|
||||
return cv_region, [], [], cand
|
||||
logger.log(f"CV定位: {cv_verdict} [{cv_info}] → 走OCR检测", program="merge")
|
||||
|
||||
# 均匀采样帧(扩大到 10%-90% 区间,提高命中字幕帧的概率)
|
||||
mid_start = len(image_files) // 10
|
||||
mid_end = 9 * len(image_files) // 10
|
||||
sample_range = mid_end - mid_start
|
||||
sample_indices = [mid_start + idx for idx in _sample_indices_uniform(sample_range, sample_size)]
|
||||
|
||||
# 存储候选区域和水印信息
|
||||
candidate_regions = [] # 每个元素为 dict: {x_min, y_min, x_max, y_max, width, height}
|
||||
text_positions = defaultdict(list) # {text: [{'x':..., 'y':..., 'idx':...}]}
|
||||
watermark_logs = []
|
||||
|
||||
# 并发预取采样帧的 OCR(原为逐帧串行,~15帧×2s 全在等网络)
|
||||
ocr_workers = config.get_subtitle_config().get('ocr_workers', 10)
|
||||
ocr_cache = _ocr_frames_concurrent(image_files, input_dir, sample_indices, ocr_workers)
|
||||
|
||||
for idx in sample_indices:
|
||||
ocr_result = ocr_cache.get(idx)
|
||||
if not ocr_result:
|
||||
continue
|
||||
ocr_data = json.loads(ocr_result)
|
||||
if "TextDetections" not in ocr_data:
|
||||
continue
|
||||
|
||||
for detection in ocr_data["TextDetections"]:
|
||||
if "Polygon" not in detection:
|
||||
continue
|
||||
polygon = detection["Polygon"]
|
||||
xs = [point["X"] for point in polygon]
|
||||
ys = [point["Y"] for point in polygon]
|
||||
x_min, x_max = min(xs), max(xs)
|
||||
y_min, y_max = min(ys), max(ys)
|
||||
w = x_max - x_min
|
||||
h = y_max - y_min
|
||||
center_x = (x_min + x_max) // 2
|
||||
center_y = (y_min + y_max) // 2
|
||||
|
||||
# 过滤左右边缘区域的文字(可能是水印或logo)
|
||||
if center_x > width * 0.85 or center_x < width * 0.15:
|
||||
continue
|
||||
if detection.get('Confidence', 0) <= 75:
|
||||
continue
|
||||
|
||||
detected_text = detection.get("DetectedText", "")
|
||||
if not check_subtitle_quality_enhanced(detected_text) and detection.get('Confidence', 0) <= 90:
|
||||
continue
|
||||
if len(detected_text) < 5:
|
||||
continue
|
||||
# 只要中文字幕:没有中文字符的(纯英文/数字)不当作候选字幕区域
|
||||
if require_chinese and not has_chinese(detected_text):
|
||||
continue
|
||||
|
||||
# 水印检测:文本相似度>0.7且位置相近
|
||||
is_watermark = False
|
||||
for existing_text, pos_list in text_positions.items():
|
||||
similarity = SequenceMatcher(None, detected_text, existing_text).ratio()
|
||||
if similarity > 0.7:
|
||||
# 检查位置是否相近
|
||||
for pos in pos_list:
|
||||
if pos["idx"] != idx and abs(pos["y"] - center_y) < h:
|
||||
is_watermark = True
|
||||
watermark_logs.append(
|
||||
f"第{idx}帧可能的水印: {detected_text} (位置: {center_x}, {center_y})")
|
||||
logger.log(f"检测到可能的水印: {detected_text} (位置: {center_x}, {center_y})",
|
||||
program="merge")
|
||||
# 标记原文本为水印
|
||||
pos["is_watermark"] = True
|
||||
break
|
||||
if is_watermark:
|
||||
break
|
||||
|
||||
if is_watermark:
|
||||
continue
|
||||
|
||||
# 记录文本位置
|
||||
text_positions[detected_text].append({
|
||||
"text": detected_text,
|
||||
"x": center_x,
|
||||
"y": center_y,
|
||||
"is_watermark": False,
|
||||
"idx": idx
|
||||
})
|
||||
|
||||
# 存储候选区域
|
||||
candidate_regions.append({
|
||||
"x_min": x_min,
|
||||
"y_min": y_min,
|
||||
"x_max": x_max,
|
||||
"y_max": y_max,
|
||||
"width": w,
|
||||
"height": h
|
||||
})
|
||||
|
||||
# 如果没有候选区域,检查首尾帧是否有可能存在字幕
|
||||
if not candidate_regions:
|
||||
# 检查前 keep_num 张和后 keep_num 张是否有文字(并发)
|
||||
keep_num = 9
|
||||
n = len(image_files)
|
||||
check_indices = sorted(set(
|
||||
list(range(min(keep_num, n))) + list(range(max(0, n - keep_num), n))
|
||||
))
|
||||
fb_cache = _ocr_frames_concurrent(image_files, input_dir, check_indices, ocr_workers)
|
||||
has_text = False
|
||||
for idx in check_indices:
|
||||
ocr_result = fb_cache.get(idx)
|
||||
if ocr_result:
|
||||
ocr_data = json.loads(ocr_result)
|
||||
if "TextDetections" in ocr_data and ocr_data["TextDetections"]:
|
||||
has_text = True
|
||||
break
|
||||
|
||||
if not has_text:
|
||||
logger.log("保留的前后9张图片均未检测到任何文字,认为该视频没有字幕", level="warning", program="merge")
|
||||
return None, sample_indices, watermark_logs, candidate_regions
|
||||
|
||||
# 无候选区域但有文字:回退到主区域检测
|
||||
main_region = detect_main_region(image_files, input_dir)
|
||||
x, y, w, h = main_region
|
||||
# 只取主区域下半部分
|
||||
return (0, y + h // 2, width, h // 2), sample_indices, watermark_logs, candidate_regions
|
||||
|
||||
# 逆向清理:只清理画面上半部分(y < height*0.5)的水印候选区域
|
||||
# 下半部分即便被误判为水印也保留,避免把字幕字幕一并删掉
|
||||
confirmed_watermark_ys = {
|
||||
pos["y"]
|
||||
for pos_list in text_positions.values()
|
||||
for pos in pos_list
|
||||
if pos.get("is_watermark")
|
||||
}
|
||||
top_watermark_ys = {wy for wy in confirmed_watermark_ys if wy < height * 0.5}
|
||||
if top_watermark_ys:
|
||||
before = len(candidate_regions)
|
||||
candidate_regions = [
|
||||
r for r in candidate_regions
|
||||
if not any(
|
||||
abs((r["y_min"] + r["y_max"]) // 2 - wy) < 80
|
||||
for wy in top_watermark_ys
|
||||
)
|
||||
]
|
||||
removed = before - len(candidate_regions)
|
||||
if removed:
|
||||
logger.log(f"逆向移除 {removed} 个上半区水印候选区域", program="merge")
|
||||
|
||||
# 过滤过小的区域(高度<15)
|
||||
candidate_regions = [r for r in candidate_regions if r["height"] >= 15]
|
||||
|
||||
# 水印移除/高度过滤后可能把候选全部清空 → 判无字幕(否则下面 min() 会因空序列崩成"拼接失败")
|
||||
if not candidate_regions:
|
||||
logger.log("候选字幕区域被过滤后为空(高度过小/全是水印),判无字幕", level="warning", program="merge")
|
||||
return None, sample_indices, watermark_logs, candidate_regions
|
||||
|
||||
# 冗余判定:用"不同垂直位置簇"的数量,而非候选框总数。
|
||||
# 正常字幕会在多帧的同一位置重复出现,框数随采样帧数增长但集中在少数垂直位置;
|
||||
# 纯文本/水印过多的视频才会把文字散布到很多不同的垂直位置。
|
||||
region_cys = sorted((r["y_min"] + r["y_max"]) // 2 for r in candidate_regions)
|
||||
distinct_bands = 1 if region_cys else 0
|
||||
for _i in range(1, len(region_cys)):
|
||||
if region_cys[_i] - region_cys[_i - 1] > 80:
|
||||
distinct_bands += 1
|
||||
if distinct_bands >= bands_thr:
|
||||
logger.log(f"冗余文字过多(文字分散在{distinct_bands}个垂直位置,共{len(candidate_regions)}个候选区域),视频将被过滤",
|
||||
level="warning", program="merge")
|
||||
return None, sample_indices, watermark_logs, candidate_regions
|
||||
|
||||
# 合并所有候选区域
|
||||
all_y_min = min(r["y_min"] for r in candidate_regions)
|
||||
all_y_max = max(r["y_max"] for r in candidate_regions)
|
||||
merged_h = all_y_max - all_y_min
|
||||
# 扩展边界
|
||||
expanded_y_min = max(0, int(all_y_min - 65))
|
||||
expanded_y_max = min(height, int(all_y_max + 65))
|
||||
final_h = expanded_y_max - expanded_y_min
|
||||
|
||||
# 如果合并区域过高(>60% 画面高度),用聚类找最大候选簇,避免退回全帧
|
||||
if final_h >= height * 0.6 and len(candidate_regions) >= 4:
|
||||
logger.log("合并区域过高,聚类寻找最大字幕区域簇", program="merge")
|
||||
best_cluster = []
|
||||
for region in candidate_regions:
|
||||
cy = (region["y_min"] + region["y_max"]) // 2
|
||||
cluster = [r for r in candidate_regions
|
||||
if abs((r["y_min"] + r["y_max"]) // 2 - cy) < 200]
|
||||
# 优先更大的簇;同等大小时选中心 y 更大的(靠近底部的字幕)
|
||||
if len(cluster) > len(best_cluster) or (
|
||||
len(cluster) == len(best_cluster) and best_cluster and
|
||||
sum((r["y_min"] + r["y_max"]) // 2 for r in cluster) >
|
||||
sum((r["y_min"] + r["y_max"]) // 2 for r in best_cluster)):
|
||||
best_cluster = cluster
|
||||
|
||||
if best_cluster:
|
||||
c_y_min = min(r["y_min"] for r in best_cluster)
|
||||
c_y_max = max(r["y_max"] for r in best_cluster)
|
||||
exp_y_min = max(0, c_y_min - 65)
|
||||
exp_y_max = min(height, c_y_max + 65)
|
||||
logger.log(f"聚类选定字幕区域: y={exp_y_min}-{exp_y_max}", program="merge")
|
||||
return (0, exp_y_min, width, exp_y_max - exp_y_min), sample_indices, watermark_logs, candidate_regions
|
||||
|
||||
main_region = detect_main_region(image_files, input_dir)
|
||||
x, y, w, h = main_region
|
||||
return (0, y, width, h), sample_indices, watermark_logs, candidate_regions
|
||||
|
||||
return (0, expanded_y_min, width, final_h), sample_indices, watermark_logs, candidate_regions
|
||||
|
||||
|
||||
# ==================== 拼图配置计算 ====================
|
||||
def calculate_optimal_tile_configuration(subtitle_region: Tuple[int, int, int, int],
|
||||
tile_width: int,
|
||||
tile_height: int,
|
||||
first_img_path: str,
|
||||
logger,
|
||||
max_base64_size: int = 10 * 1024 * 1024) -> Tuple[int, int]:
|
||||
"""
|
||||
计算最优的拼图配置(横向和纵向数量)。
|
||||
|
||||
:return: (tile_width, tile_height)
|
||||
"""
|
||||
x, y, w, h = subtitle_region
|
||||
BORDER_SIZE = 40
|
||||
tile_width_px = w + 2 * BORDER_SIZE
|
||||
tile_height_px = h + 2 * BORDER_SIZE
|
||||
|
||||
# 估算单个图块的大小(基于第一张图片)
|
||||
img = cv2.imread(first_img_path)
|
||||
if img is None:
|
||||
raise ValueError(f"无法读取图片: {first_img_path}")
|
||||
img_h, img_w = img.shape[:2]
|
||||
img_size = os.path.getsize(first_img_path)
|
||||
single_tile_size = (tile_width_px * tile_height_px) / (img_w * img_h) * img_size * 3 # RGB估算
|
||||
single_tile_base64 = single_tile_size * 4 // 3 # base64 增加 33%
|
||||
|
||||
# 每张合成图最多拼几块:硬上限(可配置) 与 base64 10MB 限制 取小。
|
||||
# 加大上限 → OCR 请求数下降(每张装更多帧),但单次 OCR 图更大、且文字条更密,
|
||||
# 过大有 OCR 漏识别/超分辨率上限的风险,调大后务必抽查 SRT 质量。
|
||||
hard_cap = config.get_subtitle_config().get('max_tiles_per_composite', 40)
|
||||
try:
|
||||
hard_cap = int(hard_cap)
|
||||
except (TypeError, ValueError):
|
||||
hard_cap = 40
|
||||
max_tiles = min(hard_cap, int(max_base64_size // single_tile_base64))
|
||||
max_tiles = max(1, max_tiles)
|
||||
# 横向块数上限随总块数放宽(块多时仍想接近 0.5 的宽高比,避免拼成超细长条)
|
||||
w_cap = max(7, int(round((max_tiles * 0.5) ** 0.5)) + 1)
|
||||
logger.log(f"单个图块 base64 大小: {single_tile_base64 / 1024:.1f} KB", program="merge")
|
||||
logger.log(f"最大允许图块数量: {max_tiles} (硬上限 {hard_cap})", program="merge")
|
||||
|
||||
# 寻找最佳宽高比(接近 0.5,即横向多于纵向)
|
||||
best_config = (tile_width, tile_height) # 默认
|
||||
best_ratio_diff = float('inf')
|
||||
for w_tiles in range(1, min(max_tiles, w_cap) + 1):
|
||||
h_tiles = int(max_tiles / w_tiles)
|
||||
aspect = w_tiles / h_tiles
|
||||
diff = abs(aspect - 0.5)
|
||||
if diff < best_ratio_diff:
|
||||
best_ratio_diff = diff
|
||||
best_config = (w_tiles, h_tiles)
|
||||
|
||||
logger.log(f"推荐拼图配置: {best_config[0]} x {best_config[1]}", program="merge")
|
||||
return best_config
|
||||
|
||||
|
||||
# ==================== 音频提取(可复用) ====================
|
||||
def extract_audio_text(video_path: str, logger) -> Dict[str, str]:
|
||||
"""
|
||||
从视频中提取开头和结尾的音频文字。
|
||||
|
||||
:return: {'first_3s': str, 'last_3s': str}
|
||||
"""
|
||||
audio_text = {}
|
||||
if not video_path or not os.path.exists(video_path):
|
||||
return audio_text
|
||||
|
||||
logger.log("开始提取视频中的音频文字...", program="merge")
|
||||
# 从配置读取 whisper 模型大小(默认 small)
|
||||
model_size = config.get_audio_config().get('model_size', 'small')
|
||||
whisper_manager.load_model(model_size)
|
||||
|
||||
temp_audio_path = os.path.join(tempfile.gettempdir(),
|
||||
f"temp_audio_{os.path.basename(video_path)}.wav")
|
||||
try:
|
||||
total_duration = whisper_manager.extract_audio_from_video(video_path, temp_audio_path, logger)
|
||||
if total_duration is None:
|
||||
logger.log("音频提取失败,跳过音频文字识别", program="merge")
|
||||
return audio_text
|
||||
|
||||
if total_duration >= 5:
|
||||
first_5s_text = whisper_manager.extract_audio_text_with_whisper(
|
||||
temp_audio_path, 0, min(5, total_duration), logger
|
||||
)
|
||||
audio_text["first_3s"] = first_5s_text
|
||||
logger.log(f"前五秒音频文字: {first_5s_text}", program="merge")
|
||||
|
||||
if total_duration >= 10:
|
||||
last_5s_start = int(total_duration - 5)
|
||||
last_5s_text = whisper_manager.extract_audio_text_with_whisper(
|
||||
temp_audio_path, last_5s_start, total_duration, logger
|
||||
)
|
||||
audio_text["last_3s"] = last_5s_text
|
||||
logger.log(f"后五秒音频文字: {last_5s_text}", program="merge")
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temp_audio_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return audio_text
|
||||
|
||||
|
||||
# ==================== 主处理函数 ====================
|
||||
def process_images(input_dir, output_dir, logger, tile_width=3, tile_height=6, keep_num=9, interval_seconds=None,
|
||||
video_path=None):
|
||||
"""
|
||||
处理图片拼接并生成元数据,自动计算最优拼图配置
|
||||
|
||||
参数:
|
||||
input_dir: 输入图片目录
|
||||
output_dir: 输出目录
|
||||
keep_num: 保留的首尾图片数量
|
||||
"""
|
||||
# 创建输出目录
|
||||
logger.start_program("merge")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
composite_dir = os.path.join(output_dir, "composite")
|
||||
os.makedirs(composite_dir, exist_ok=True)
|
||||
|
||||
# 获取所有图片文件并排序
|
||||
image_files = [f for f in os.listdir(input_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg'))]
|
||||
image_files.sort(key=natural_sort_key)
|
||||
total_images = len(image_files)
|
||||
duration = total_images / 3
|
||||
# 确定字幕区域(通过OCR检测)
|
||||
subtitle_region, sample_indices, watermark, candidate_regions = detect_subtitle_region(image_files, input_dir)
|
||||
|
||||
if subtitle_region is None:
|
||||
logger.end_program("merge")
|
||||
return duration, None, None, None, None, None, sample_indices, watermark, candidate_regions
|
||||
logger.log(f"通过OCR检测确定字幕区域: {subtitle_region}", program="merge")
|
||||
first_img_path = os.path.join(input_dir, image_files[0])
|
||||
|
||||
# 提取音频文字(如果提供了视频路径)
|
||||
audio_text = {}
|
||||
if video_path and os.path.exists(video_path):
|
||||
logger.log("开始提取视频中的音频文字...", program="merge")
|
||||
model_size = config.get_audio_config().get('model_size', 'small')
|
||||
whisper_manager.load_model(model_size)
|
||||
# 创建临时音频文件
|
||||
temp_audio_path = os.path.join(tempfile.gettempdir(), f"temp_audio_{os.path.basename(video_path)}.wav")
|
||||
total_duration = whisper_manager.extract_audio_from_video(video_path, temp_audio_path, logger)
|
||||
# 从视频提取音频
|
||||
if total_duration is not None:
|
||||
|
||||
# 提取前五秒文字
|
||||
if total_duration >= 5:
|
||||
first_5s_text = whisper_manager.extract_audio_text_with_whisper(temp_audio_path, 0,
|
||||
min(5, total_duration), logger)
|
||||
audio_text["first_3s"] = first_5s_text
|
||||
logger.log(f"前五秒音频文字: {first_5s_text}", program="merge")
|
||||
|
||||
# 提取后五秒文字
|
||||
if total_duration >= 10:
|
||||
last_5s_start = int(total_duration - 5)
|
||||
last_5s_text = whisper_manager.extract_audio_text_with_whisper(temp_audio_path, last_5s_start,
|
||||
total_duration, logger)
|
||||
audio_text["last_3s"] = last_5s_text
|
||||
logger.log(f"后五秒音频文字: {last_5s_text}", program="merge")
|
||||
|
||||
# 清理临时音频文件
|
||||
try:
|
||||
os.unlink(temp_audio_path)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
logger.log("音频提取失败,跳过音频文字识别", program="merge")
|
||||
# 边框设置
|
||||
BORDER_SIZE = 40
|
||||
|
||||
# 保留前keep_num张和最后keep_num张图片
|
||||
kept_first = image_files[:keep_num]
|
||||
kept_last = image_files[-keep_num:] if len(image_files) > 2 * keep_num else []
|
||||
|
||||
# 需要处理的图片(中间部分)
|
||||
process_files = image_files[keep_num:-keep_num] if len(image_files) > 2 * keep_num else image_files[keep_num:]
|
||||
|
||||
# 归一化图块像素预算:高分辨率帧或过高的字幕区域会让单个图块的 base64 过大,
|
||||
# 撑到 10MB 上限后每张合成图能塞的块数骤减(从~32 掉到 5~8),导致合成图(=OCR请求)
|
||||
# 数量激增 3~6 倍。按基准像素量(约 1080p 单字幕条)等比缩小裁剪后的字幕条,使每张
|
||||
# 合成图的块数稳定在~32,OCR 成本与源分辨率/区域高度无关;文字分辨率仍足够 OCR。
|
||||
x0, y0, w0, h0 = subtitle_region
|
||||
no_composite = config.get_subtitle_config().get('no_composite', False)
|
||||
if no_composite:
|
||||
# 不拼接:每个字幕条单独成图(tile 1×1)、不降采样(单条 base64 很小,全分辨率最准)。
|
||||
# 下游 OCR 映射/网格搜索/去重完全复用,只是每张图=1 条、OCR 次数=帧数。
|
||||
tile_scale = 1.0
|
||||
w_scaled, h_scaled = w0, h0
|
||||
tile_width, tile_height = 1, 1
|
||||
logger.log("不拼接模式:每个字幕条单独 OCR(tile 1×1,全分辨率)", program="merge")
|
||||
else:
|
||||
# 归一化图块像素预算:高分辨率帧或过高的字幕区域会让单个图块的 base64 过大,
|
||||
# 撑到 10MB 上限后每张合成图能塞的块数骤减(从~32 掉到 5~8),导致合成图(=OCR请求)
|
||||
# 数量激增 3~6 倍。按基准像素量(约 1080p 单字幕条)等比缩小裁剪后的字幕条,使每张
|
||||
# 合成图的块数稳定在~32,OCR 成本与源分辨率/区域高度无关;文字分辨率仍足够 OCR。
|
||||
TARGET_TILE_AREA = 1920 * 240
|
||||
tile_scale = min(1.0, math.sqrt(TARGET_TILE_AREA / float(max(1, w0 * h0))))
|
||||
w_scaled = max(1, int(round(w0 * tile_scale)))
|
||||
h_scaled = max(1, int(round(h0 * tile_scale)))
|
||||
if tile_scale < 1.0:
|
||||
logger.log(f"字幕条降采样: {w0}x{h0} → {w_scaled}x{h_scaled} (scale={tile_scale:.2f})", program="merge")
|
||||
tile_width, tile_height = calculate_optimal_tile_configuration(
|
||||
(x0, y0, w_scaled, h_scaled), tile_width, tile_height, first_img_path, logger)
|
||||
|
||||
# 初始化元数据
|
||||
metadata = {
|
||||
"tile_width": tile_width,
|
||||
"tile_height": tile_height,
|
||||
"subtitle_region": subtitle_region,
|
||||
"border_size": BORDER_SIZE,
|
||||
"interval_seconds": interval_seconds,
|
||||
"audio_text": audio_text,
|
||||
"kept_first": [],
|
||||
"kept_last": [],
|
||||
"composites": []
|
||||
}
|
||||
|
||||
# 复制保留的图片到composite文件夹
|
||||
kept_first_new_names = []
|
||||
for i, img in enumerate(kept_first):
|
||||
src = os.path.join(input_dir, img)
|
||||
dst_filename = f"001_{i + 1:02d}_original_{img}"
|
||||
dst = os.path.join(composite_dir, dst_filename)
|
||||
|
||||
if not os.path.exists(dst):
|
||||
with Image.open(src) as im:
|
||||
bordered_img = Image.new('RGB',
|
||||
(im.width + 2 * BORDER_SIZE, im.height + 2 * BORDER_SIZE),
|
||||
(255, 255, 255))
|
||||
bordered_img.paste(im, (BORDER_SIZE, BORDER_SIZE))
|
||||
|
||||
if img.lower().endswith('.png'):
|
||||
bordered_img.save(dst, format='PNG')
|
||||
else:
|
||||
bordered_img.save(dst, format='JPEG', quality=95)
|
||||
|
||||
kept_first_new_names.append(dst_filename)
|
||||
|
||||
metadata["kept_first"] = kept_first_new_names
|
||||
|
||||
# 处理中间图片进行拼接
|
||||
if process_files:
|
||||
x, y, w, h = subtitle_region # 原始帧上的裁剪坐标
|
||||
ws, hs = w_scaled, h_scaled # 降采样后的图块尺寸(拼接/坐标用此)
|
||||
|
||||
# 计算图块和合成图尺寸(基于降采样后的尺寸)
|
||||
tile_img_width = ws + 2 * BORDER_SIZE
|
||||
tile_img_height = hs + 2 * BORDER_SIZE
|
||||
composite_width = tile_width * tile_img_width
|
||||
composite_height = tile_height * tile_img_height
|
||||
|
||||
# 分批:每批 = 一张合成图
|
||||
tiles_per_composite = tile_width * tile_height
|
||||
batches = []
|
||||
for i in range(0, len(process_files), tiles_per_composite):
|
||||
batches.append((len(batches) + 1, process_files[i:i + tiles_per_composite]))
|
||||
|
||||
def _build_composite(batch):
|
||||
"""处理一张合成图:读源帧→裁剪→贴块→写盘。返回 (序号, composite_data)。
|
||||
热点:逐帧读 1080p JPEG + 裁剪 + 写盘,原为单线程;这里并行化(PIL 编解码
|
||||
在 C 层释放 GIL,线程池能真正并行,按核数提速)。"""
|
||||
composite_num, batch_files = batch
|
||||
composite_img = Image.new('RGB', (composite_width, composite_height), (255, 255, 255))
|
||||
composite_filename = f"002_{composite_num:04d}_composite.jpg"
|
||||
composite_data = {
|
||||
"composite_id": f"composite_{composite_num:04d}",
|
||||
"file_name": composite_filename,
|
||||
"tiles": []
|
||||
}
|
||||
for pos in range(tiles_per_composite):
|
||||
row = pos // tile_width
|
||||
col = pos % tile_width
|
||||
x_pos = col * tile_img_width
|
||||
y_pos = row * tile_img_height
|
||||
|
||||
if pos < len(batch_files):
|
||||
img_name = batch_files[pos]
|
||||
img_path = os.path.join(input_dir, img_name)
|
||||
with Image.open(img_path) as img:
|
||||
subtitle_img = img.crop((x, y, x + w, y + h))
|
||||
if tile_scale < 1.0:
|
||||
subtitle_img = subtitle_img.resize((ws, hs))
|
||||
# 合成图背景已是白色,直接把字幕条贴到带边框的偏移位置
|
||||
composite_img.paste(subtitle_img, (x_pos + BORDER_SIZE, y_pos + BORDER_SIZE))
|
||||
composite_data["tiles"].append({
|
||||
"position": pos,
|
||||
"x": x_pos + BORDER_SIZE,
|
||||
"y": y_pos + BORDER_SIZE,
|
||||
"width": ws,
|
||||
"height": hs,
|
||||
"source_image": img_name,
|
||||
"is_blank": False
|
||||
})
|
||||
else:
|
||||
# 空白块:合成图背景本就是白色,无需粘贴
|
||||
composite_data["tiles"].append({
|
||||
"position": pos,
|
||||
"x": x_pos + BORDER_SIZE,
|
||||
"y": y_pos + BORDER_SIZE,
|
||||
"width": ws,
|
||||
"height": hs,
|
||||
"source_image": None,
|
||||
"is_blank": True
|
||||
})
|
||||
|
||||
output_path = os.path.join(composite_dir, composite_filename)
|
||||
composite_img.save(output_path, format='JPEG', quality=95)
|
||||
return composite_num, composite_data
|
||||
|
||||
# 并行处理所有合成图,结果按序号排序回填(顺序不能乱,元数据要对得上时间轴)
|
||||
merge_workers = config.get_subtitle_config().get('merge_workers') or (os.cpu_count() or 8)
|
||||
merge_workers = max(1, min(int(merge_workers), len(batches)))
|
||||
results = []
|
||||
with ThreadPoolExecutor(max_workers=merge_workers) as executor:
|
||||
for r in executor.map(_build_composite, batches):
|
||||
results.append(r)
|
||||
results.sort(key=lambda t: t[0])
|
||||
metadata["composites"] = [cd for _, cd in results]
|
||||
|
||||
# 处理最后保留的图片
|
||||
kept_last_new_names = []
|
||||
for i, img in enumerate(kept_last):
|
||||
src = os.path.join(input_dir, img)
|
||||
dst_filename = f"003_{i + 1:02d}_original_{img}"
|
||||
dst = os.path.join(composite_dir, dst_filename)
|
||||
|
||||
if not os.path.exists(dst):
|
||||
with Image.open(src) as im:
|
||||
bordered_img = Image.new('RGB',
|
||||
(im.width + 2 * BORDER_SIZE, im.height + 2 * BORDER_SIZE),
|
||||
(255, 255, 255))
|
||||
bordered_img.paste(im, (BORDER_SIZE, BORDER_SIZE))
|
||||
|
||||
if img.lower().endswith('.png'):
|
||||
bordered_img.save(dst, format='PNG')
|
||||
else:
|
||||
bordered_img.save(dst, format='JPEG', quality=95)
|
||||
|
||||
kept_last_new_names.append(dst_filename)
|
||||
|
||||
metadata["kept_last"] = kept_last_new_names
|
||||
|
||||
# 保存元数据(保存到 composite_dir,避免被清理时删除)
|
||||
metadata_path = os.path.join(composite_dir, "metadata.json")
|
||||
with open(metadata_path, 'w') as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
logger.log(f"处理完成,拼图配置: {tile_width}x{tile_height}", program="merge")
|
||||
logger.log(f"共生成 {len(metadata['composites'])} 张合成图", program="merge")
|
||||
logger.log(f"元数据已保存到: {metadata_path}", program="merge")
|
||||
logger.end_program("merge")
|
||||
return duration, subtitle_region, tile_width, tile_height, len(
|
||||
metadata['composites']), audio_text, sample_indices, watermark, candidate_regions
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 示例用法
|
||||
input_directory = "input_images"
|
||||
output_directory = "output"
|
||||
from log.log_manager import logger # 假设已有全局 logger
|
||||
|
||||
result = process_images(input_directory, output_directory, logger)
|
||||
471
tool/process.py
Normal file
471
tool/process.py
Normal file
@@ -0,0 +1,471 @@
|
||||
from config.config_utils import config, get_ffprobe
|
||||
import argparse
|
||||
import hashlib
|
||||
import logging
|
||||
import subprocess
|
||||
import shutil
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from tool.log.log_manager import UnifiedLogger
|
||||
from tool.VideoProcessing.multithread_algo import split_frame
|
||||
|
||||
|
||||
def derive_video_name(cos_path: str) -> str:
|
||||
"""把 COS 完整路径变成本地工作目录/文件名用的唯一标识:
|
||||
<原文件名(不含扩展名)>_<完整路径MD5前8位>。
|
||||
|
||||
不能只用文件名 —— 源数据里大量不同父目录复用同样的数字文件名(如
|
||||
cos://bkt/0021/17688/00046.mp4 和 cos://bkt/0021/17690/00046.mp4), 纯文件名当本地
|
||||
标识会导致两个不同视频被并发处理到时互相踩踏同一份本地文件(下载/抽帧/拼接的中间文件
|
||||
全挤在同一个 ./split/00046 目录), 引发 "Directory not empty" 崩溃和一连串连锁失败
|
||||
(实测占某台机器全部 error 记录的 100%)。
|
||||
|
||||
保留原文件名前缀是为了日志/目录名还能看出是哪个视频, 不至于变成纯哈希、丧失可读性;
|
||||
main.py 里 process_video_task() 必须调用同一个函数(而不是各自按文件名重算), 两处
|
||||
对同一个 cos_path 才会算出同一个 video_name。"""
|
||||
filename = os.path.basename(cos_path.strip())
|
||||
stem = os.path.splitext(filename)[0]
|
||||
digest = hashlib.md5(cos_path.strip().encode("utf-8")).hexdigest()[:8]
|
||||
return f"{stem}_{digest}"
|
||||
|
||||
|
||||
def _probe_duration(video_path):
|
||||
"""用 ffprobe 取视频时长(秒);失败返回 None。
|
||||
先取流时长, 再取容器(format)时长——有些封装(尤其AV1/部分mp4)流里没duration、只在format里。"""
|
||||
# ① 流时长
|
||||
try:
|
||||
r = subprocess.run([
|
||||
get_ffprobe(), '-v', 'error', '-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=duration', '-of', 'json', video_path
|
||||
], capture_output=True, text=True)
|
||||
d = float(json.loads(r.stdout)['streams'][0].get('duration', 0) or 0)
|
||||
if d > 0:
|
||||
return d
|
||||
except Exception:
|
||||
pass
|
||||
# ② 容器时长(兜底)
|
||||
try:
|
||||
r = subprocess.run([
|
||||
get_ffprobe(), '-v', 'error',
|
||||
'-show_entries', 'format=duration', '-of', 'json', video_path
|
||||
], capture_output=True, text=True)
|
||||
d = float(json.loads(r.stdout).get('format', {}).get('duration', 0) or 0)
|
||||
if d > 0:
|
||||
return d
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _append_long_video(cos_path, list_path):
|
||||
"""把被"视频过长"过滤掉的视频 COS 路径追加到一份 txt,供以后(GPU机器/放大上限)补跑。
|
||||
进程池下多个 worker 进程会并发写同一文件:用 O_APPEND 单行追加(POSIX 下小写原子),
|
||||
失败也只记日志、绝不影响主流程。"""
|
||||
if not cos_path or not list_path:
|
||||
return
|
||||
try:
|
||||
with open(list_path, 'a', encoding='utf-8') as f:
|
||||
f.write(cos_path.rstrip('\n') + '\n')
|
||||
except Exception:
|
||||
pass
|
||||
from tool.merge import process_images
|
||||
from tool.getsrt import processsrt, OcrIncompleteError
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('video_processing.log'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def delete_local_folder(folder_path):
|
||||
"""
|
||||
删除本地文件夹,但保留子文件夹composite及其内容
|
||||
:param folder_path: 文件夹路径
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
if not os.path.exists(folder_path):
|
||||
logging.warning(f"文件夹不存在,无需删除: {folder_path}")
|
||||
return True
|
||||
|
||||
# 定义要保留的子文件夹路径
|
||||
composite_path = os.path.join(folder_path, 'composite')
|
||||
|
||||
# 如果composite子文件夹存在,先临时保存它
|
||||
temp_dir = None
|
||||
if os.path.exists(composite_path):
|
||||
# 创建一个临时目录来保存composite文件夹
|
||||
temp_dir = os.path.join(os.path.dirname(folder_path), f"temp_composite_{os.getpid()}")
|
||||
shutil.move(composite_path, temp_dir)
|
||||
|
||||
# 删除整个文件夹
|
||||
shutil.rmtree(folder_path)
|
||||
|
||||
# 如果之前有composite文件夹,将其移回原位
|
||||
if temp_dir and os.path.exists(temp_dir):
|
||||
# rmtree 删除了父目录本身,需要先重建
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
shutil.move(temp_dir, composite_path)
|
||||
logging.info(f"已删除本地文件夹内容,但保留了composite子文件夹: {folder_path}")
|
||||
else:
|
||||
logging.info(f"已删除本地文件夹: {folder_path}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"删除文件夹失败: {e}")
|
||||
# 尝试恢复临时文件夹以防出错
|
||||
if temp_dir and os.path.exists(temp_dir):
|
||||
try:
|
||||
os.makedirs(folder_path, exist_ok=True)
|
||||
shutil.move(temp_dir, os.path.join(folder_path, 'composite'))
|
||||
except Exception as restore_error:
|
||||
logging.error(f"恢复composite文件夹失败: {restore_error}")
|
||||
return False
|
||||
|
||||
|
||||
def download_from_cos(cos_target, cos_source, coscli_path, cos_config_path, local_dest_path):
|
||||
"""
|
||||
从COS下载文件到 local_dest_path。
|
||||
:param cos_target: 目录
|
||||
:param cos_source: 完整 COS 路径
|
||||
:param coscli_path: cos exe文件地址
|
||||
:param cos_config_path: cos 配置文件
|
||||
:param local_dest_path: 下载到的本地目标路径 —— 必须是调用方(process_single_video)按
|
||||
derive_video_name() 算出的 collision-safe 路径, 不能在这里自己按 os.path.basename(cos_source)
|
||||
重新拼一个 './video/<文件名>' —— 之前就是这么写的, 跟调用方检查是否存在用的 local_video_path
|
||||
(带路径MD5后缀)对不上, 下载明明成功了、下一步却判"文件不存在", 100%必现。
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
# cos_source 是完整 COS 路径时直接使用,否则拼接目录
|
||||
full_cos_path = cos_source if cos_source.startswith("cos://") else f"{cos_target}{cos_source}"
|
||||
cmd = [f"{coscli_path}", "-c", f"{cos_config_path}", "cp",
|
||||
full_cos_path, local_dest_path]
|
||||
# 执行命令(带超时:网络卡住时不会永久阻塞 worker,超时即判下载失败)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
|
||||
|
||||
if result.returncode == 0:
|
||||
logging.info(f"成功下载到: {local_dest_path}")
|
||||
return cos_source
|
||||
else:
|
||||
logging.error(f"下载失败: {result.stderr}")
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logging.error(f"下载超时(>1800s),跳过: {cos_source}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logging.error(f"下载过程中出错: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def process_single_video(cos_source, cos_target):
|
||||
"""
|
||||
单个视频处理流程
|
||||
:param cos_source: 单个视频名称
|
||||
:param cos_target: 上传位置
|
||||
:return: True 处理成功, False 处理失败
|
||||
"""
|
||||
# 1. 读取参数
|
||||
split_frame_config = config.get_splitframe_config()
|
||||
download_flag = split_frame_config.get('download')
|
||||
coscli_path = split_frame_config.get('coscli_path')
|
||||
cos_config_path = split_frame_config.get('cosconfig_path')
|
||||
filename = os.path.basename(cos_source)
|
||||
root_path = "./video"
|
||||
video_name = derive_video_name(cos_source)
|
||||
local_video_path = os.path.join(root_path, video_name + os.path.splitext(filename)[1])
|
||||
|
||||
# 使用线程安全的日志器,避免多线程下重置单例导致状态混乱
|
||||
logger = UnifiedLogger()
|
||||
logger.reset()
|
||||
logger.init_logging(f"log/{video_name}.json")
|
||||
logger.start_program("main")
|
||||
|
||||
try:
|
||||
return _process_video_pipeline(
|
||||
cos_source, cos_target, video_name, local_video_path,
|
||||
split_frame_config, download_flag, coscli_path, cos_config_path, logger
|
||||
)
|
||||
except OcrIncompleteError as e:
|
||||
# 不丢帧: OCR没全成功 → 不产出带洞SRT, 把视频记到"OCR失败待重跑"列表, 判失败(以后用另一jsonl补跑)
|
||||
try:
|
||||
fail_list = config.get_subtitle_config().get('ocr_fail_list', './OCR失败待重跑.txt')
|
||||
_append_long_video(cos_source, fail_list)
|
||||
except Exception:
|
||||
pass
|
||||
logger.log(f"视频{video_name} OCR未完成(不丢帧→不产出带洞SRT, 已记入待重跑): {e}",
|
||||
level="error", program="main")
|
||||
_save_error_metadata(
|
||||
cos_source, video_name, error_reason=f"OcrIncomplete: {e}",
|
||||
download_flag=download_flag, interval_seconds=split_frame_config.get('interval_seconds', 0.33)
|
||||
)
|
||||
logger.end_program("main")
|
||||
logger.save_logs()
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.log(f"视频{video_name}处理流程异常: {type(e).__name__}: {e}", level="error", program="main")
|
||||
_save_error_metadata(
|
||||
cos_source, video_name, error_reason=f"{type(e).__name__}: {e}",
|
||||
download_flag=download_flag, interval_seconds=split_frame_config.get('interval_seconds', 0.33)
|
||||
)
|
||||
logger.end_program("main")
|
||||
logger.save_logs()
|
||||
return False
|
||||
|
||||
|
||||
def _process_video_pipeline(cos_source, cos_target, video_name, local_video_path,
|
||||
split_frame_config, download_flag, coscli_path, cos_config_path, logger):
|
||||
"""
|
||||
视频处理内部流水线,将各阶段错误统一记录并抛出
|
||||
"""
|
||||
# 2. 进行数据下载
|
||||
logger.step(video_name, "开始")
|
||||
if download_flag:
|
||||
logger.log("从COS下载视频...", program="main")
|
||||
if not os.path.exists(local_video_path):
|
||||
if not download_from_cos(cos_target, cos_source, coscli_path, cos_config_path, local_video_path):
|
||||
raise RuntimeError(f"视频{video_name}下载失败")
|
||||
logger.step(video_name, "下载完成")
|
||||
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(local_video_path):
|
||||
raise FileNotFoundError(f"视频{video_name}不存在: {local_video_path}")
|
||||
|
||||
# 判断字幕文件是否存在
|
||||
name = os.path.basename(local_video_path).split(".")[0]
|
||||
if os.path.exists(f"./split/{name}/composite/{name}.srt"):
|
||||
return True
|
||||
|
||||
# 视频时长过滤(抽帧前 ffprobe 取时长,省掉抽帧/拼接/OCR;也避免超长视频撑爆内存/卡死worker)
|
||||
_sub_cfg = config.get_subtitle_config()
|
||||
min_dur = _sub_cfg.get('min_duration_seconds', 300)
|
||||
max_dur = _sub_cfg.get('max_duration_seconds', 5400) # 默认1.5小时上限
|
||||
vdur = _probe_duration(local_video_path)
|
||||
# 在【删视频前】把时长存进日志stat: ① ffprobe能处理AV1(cv2不行) ② delete_video删源视频后main.py就读不到了
|
||||
if vdur is not None and vdur > 0:
|
||||
logger.set_stat("video_duration", round(vdur, 2))
|
||||
if vdur is not None and 0 < vdur < min_dur:
|
||||
logger.step(video_name, "已过滤", f"视频过短 {vdur:.0f}s < {min_dur}s")
|
||||
raise ValueError(f"视频{video_name}被过滤: 视频过短({vdur:.0f}秒, 阈值{min_dur}秒)")
|
||||
if vdur is not None and max_dur and vdur > max_dur:
|
||||
logger.step(video_name, "已过滤", f"视频过长 {vdur:.0f}s > {max_dur}s")
|
||||
# 把超长视频的 COS 路径单独留一份,供以后在 GPU 机器/放大上限后补跑
|
||||
# (当前 run 仍记 数据.jsonl 跳过它,不占 worker)
|
||||
long_list = _sub_cfg.get('long_video_list', './超长待重跑.txt')
|
||||
_append_long_video(cos_source, long_list)
|
||||
raise ValueError(f"视频{video_name}被过滤: 视频过长({vdur:.0f}秒, 阈值{max_dur}秒)")
|
||||
|
||||
# 3. 进行视频抽帧
|
||||
interval_seconds = split_frame_config.get('interval_seconds', 0.33)
|
||||
try:
|
||||
_t = time.time()
|
||||
duration, planned_frames, actual_frames, backend = split_frame(local_video_path, interval_seconds)
|
||||
logger.set_stat("frame_count", actual_frames)
|
||||
logger.log("抽帧成功", program="main")
|
||||
logger.step(video_name, "抽帧完成",
|
||||
f"{actual_frames}帧, {backend}, {time.time()-_t:.0f}s, 视频{duration:.0f}s")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"视频{video_name}抽帧处理失败: {e}") from e
|
||||
|
||||
# 抽帧成功但一帧没出: 视频没被 min/max_duration_seconds 过滤掉、真去抽了、但结果是空——
|
||||
# 不是"没字幕"的正常情况, 是抽帧本身出了问题(比如时长探测失败导致期望帧数算成0, 见
|
||||
# split_frame_ffmpeg 的时长兜底), 应该报错让人查, 而不是让空帧列表流进 process_images()
|
||||
# 炸出一个看不懂的 "list index out of range"。
|
||||
if actual_frames == 0:
|
||||
raise RuntimeError(f"视频{video_name}抽帧结果为0帧(时长{duration:.0f}s), 抽帧本身有问题, 非正常无字幕")
|
||||
|
||||
try:
|
||||
_t = time.time()
|
||||
result = process_images(
|
||||
f"./split/{video_name}", f"./split/{video_name}", logger,
|
||||
interval_seconds=interval_seconds, video_path=local_video_path)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"视频{video_name}图片拼接失败: {e}") from e
|
||||
|
||||
duration, subtitle_region, tile_width, tile_height, compositenum, audio_text, sample_indices, watermark, candidate_regions = result
|
||||
if subtitle_region is None:
|
||||
logger.step(video_name, "已过滤", "未检测到字幕区域")
|
||||
raise ValueError(f"视频{video_name}被过滤: 未检测到字幕区域")
|
||||
logger.step(video_name, "拼接完成", f"{compositenum}张合成图, {time.time()-_t:.0f}s")
|
||||
|
||||
# 4. 删除本地抽帧文件(保留 composite 子文件夹)
|
||||
# delete_folder = os.path.join("./split", video_name)
|
||||
# if not delete_local_folder(delete_folder):
|
||||
# logger.log("本地文件夹删除失败", level="warning", program="main")
|
||||
|
||||
delete_flag = split_frame_config.get('delete_video')
|
||||
if delete_flag:
|
||||
try:
|
||||
if os.path.exists(local_video_path):
|
||||
os.remove(local_video_path)
|
||||
logger.log(f"已删除本地视频文件: {local_video_path}", program="main")
|
||||
except OSError as e:
|
||||
logger.log(f"删除视频文件失败: {e}", level="warning", program="main")
|
||||
|
||||
# 5. 转字幕
|
||||
composite_path = os.path.join("./split", video_name)
|
||||
os.makedirs(composite_path, exist_ok=True)
|
||||
if not os.path.exists(composite_path):
|
||||
raise FileNotFoundError(f"视频{video_name}的 composite 文件夹不存在: {composite_path}")
|
||||
|
||||
try:
|
||||
_t = time.time()
|
||||
score, conf, srt_path, contents, best_grids, audio_frame, h = processsrt(
|
||||
composite_path, "ocr_result.jsonl", f"{video_name}.ocr.srt", interval_seconds, logger)
|
||||
_getsrt_elapsed = time.time() - _t
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"视频{video_name}字幕生成失败: {e}") from e
|
||||
|
||||
# 7. 记录元数据
|
||||
if score is None:
|
||||
filter_reason = srt_path if isinstance(srt_path, str) else "未知原因"
|
||||
logger.step(video_name, "已过滤", str(filter_reason))
|
||||
logger.log(f"视频{video_name}被过滤,原因:{filter_reason}", level="warning", program="main")
|
||||
_save_filter_metadata(
|
||||
cos_source, video_name, filter_reason=filter_reason,
|
||||
download_flag=download_flag, interval_seconds=interval_seconds,
|
||||
duration=duration, subtitle_region=subtitle_region,
|
||||
tile_width=tile_width, tile_height=tile_height, compositenum=compositenum,
|
||||
audio_text=audio_text, sample_indices=sample_indices,
|
||||
watermark=watermark, candidate_regions=candidate_regions,
|
||||
contents=contents, best_grids=best_grids, audio_frame=audio_frame,
|
||||
h=h, score=score, conf=conf
|
||||
)
|
||||
logger.end_program("main")
|
||||
logger.save_logs()
|
||||
return False
|
||||
|
||||
_save_success_metadata(
|
||||
cos_source, video_name, srt_path=srt_path,
|
||||
download_flag=download_flag, interval_seconds=interval_seconds,
|
||||
duration=duration, subtitle_region=subtitle_region,
|
||||
tile_width=tile_width, tile_height=tile_height, compositenum=compositenum,
|
||||
audio_text=audio_text, sample_indices=sample_indices,
|
||||
watermark=watermark, candidate_regions=candidate_regions,
|
||||
contents=contents, best_grids=best_grids, audio_frame=audio_frame,
|
||||
h=h, score=score, conf=conf
|
||||
)
|
||||
logger.step(video_name, "字幕完成",
|
||||
f"质量分{score:.0f}, {_getsrt_elapsed:.0f}s, 等待上传")
|
||||
logger.log(f"视频{video_name}处理完成", program="main")
|
||||
logger.end_program("main")
|
||||
logger.save_logs()
|
||||
return True
|
||||
|
||||
|
||||
def _build_metadata_common(download_flag, interval_seconds, duration, subtitle_region,
|
||||
tile_width, tile_height, compositenum, audio_text,
|
||||
sample_indices, watermark, candidate_regions,
|
||||
contents, best_grids, audio_frame, h):
|
||||
"""构建元数据的公共部分,避免代码重复"""
|
||||
best_grid_data = best_grids.get("composite") if best_grids else None
|
||||
return {
|
||||
"download_phase": {
|
||||
"download_flag": download_flag
|
||||
},
|
||||
"frame_extract": {
|
||||
"interval": interval_seconds,
|
||||
"duration": duration,
|
||||
"success": True
|
||||
},
|
||||
"subtitle_localize": {
|
||||
"sample_indices": sample_indices,
|
||||
"watermark": watermark,
|
||||
"candidate_regions": candidate_regions,
|
||||
"subtitle_position": {
|
||||
"dilation": "65",
|
||||
"final_region": subtitle_region
|
||||
},
|
||||
"audio_extract": audio_text
|
||||
},
|
||||
"composite_build": {
|
||||
"BORDER_SIZE": 40,
|
||||
"tile_width": tile_width,
|
||||
"tile_height": tile_height,
|
||||
"composite_num": compositenum,
|
||||
"success": True
|
||||
},
|
||||
"ocr_process": {
|
||||
"ocr_result": contents,
|
||||
"best_grid": {
|
||||
"composite": {
|
||||
"grid_position": best_grid_data[0] if best_grid_data else None,
|
||||
"grid_width": best_grid_data[1] if best_grid_data else None,
|
||||
"grid_height": best_grid_data[2] if best_grid_data else None,
|
||||
"grid_top_bound": best_grid_data[3] if best_grid_data else None,
|
||||
"grid_left_bound": best_grid_data[4] if best_grid_data else None
|
||||
}
|
||||
}
|
||||
},
|
||||
"frame_processing": {
|
||||
"audio_frame": audio_frame,
|
||||
"average_height": h,
|
||||
"merge_params": {
|
||||
"confidence_bottom_line": 75,
|
||||
"merge_srt_similarity_bottom_line": 0.75,
|
||||
"audio_similarity_bottom_line": 0.8
|
||||
}
|
||||
},
|
||||
"custom_score": None,
|
||||
"ocr_confidence": None
|
||||
}
|
||||
|
||||
|
||||
def _save_success_metadata(cos_source, video_name, srt_path, **kwargs):
|
||||
"""保存成功的元数据到 info 目录"""
|
||||
os.makedirs("./info", exist_ok=True)
|
||||
score = kwargs.pop("score")
|
||||
conf = kwargs.pop("conf")
|
||||
common = _build_metadata_common(**kwargs)
|
||||
common["custom_score"] = score
|
||||
common["ocr_confidence"] = conf
|
||||
common["srt_output"] = srt_path
|
||||
|
||||
metadata = {"main_process": {"input_video": cos_source}}
|
||||
metadata["main_process"].update(common)
|
||||
|
||||
metadata_js = os.path.join("./info", f"{video_name}.json")
|
||||
with open(metadata_js, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _save_filter_metadata(cos_source, video_name, filter_reason, **kwargs):
|
||||
"""保存被过滤视频的元数据到 info_error 目录"""
|
||||
os.makedirs("./info_error", exist_ok=True)
|
||||
score = kwargs.pop("score")
|
||||
conf = kwargs.pop("conf")
|
||||
common = _build_metadata_common(**kwargs)
|
||||
common["custom_score"] = score
|
||||
common["ocr_confidence"] = conf
|
||||
common["error"] = filter_reason
|
||||
|
||||
metadata = {"main_process": {"input_video": cos_source}}
|
||||
metadata["main_process"].update(common)
|
||||
|
||||
metadata_js = os.path.join("./info_error", f"{video_name}.json")
|
||||
with open(metadata_js, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _save_error_metadata(cos_source, video_name, error_reason, download_flag, interval_seconds):
|
||||
"""保存处理异常视频的元数据到 info_error 目录"""
|
||||
os.makedirs("./info_error", exist_ok=True)
|
||||
metadata = {
|
||||
"main_process": {
|
||||
"input_video": cos_source,
|
||||
"download_phase": {"download_flag": download_flag},
|
||||
"frame_extract": {"interval": interval_seconds},
|
||||
"error": error_reason
|
||||
}
|
||||
}
|
||||
metadata_js = os.path.join("./info_error", f"{video_name}.json")
|
||||
with open(metadata_js, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
||||
227
tool/whisper_manager.py
Normal file
227
tool/whisper_manager.py
Normal file
@@ -0,0 +1,227 @@
|
||||
import os
|
||||
import hashlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from typing import Optional
|
||||
|
||||
from pydub import AudioSegment
|
||||
|
||||
from config.config_utils import get_ffmpeg, get_ffprobe
|
||||
|
||||
# 让 pydub 也用项目自带的 ffmpeg/ffprobe(显式指定,不依赖 PATH)
|
||||
AudioSegment.converter = get_ffmpeg()
|
||||
AudioSegment.ffmpeg = get_ffmpeg()
|
||||
AudioSegment.ffprobe = get_ffprobe()
|
||||
|
||||
_MAX_CACHE_SIZE = 512
|
||||
|
||||
|
||||
class WhisperManager:
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not self._initialized:
|
||||
self.model = None
|
||||
self.model_name = None
|
||||
self._load_lock = threading.Lock() # 仅保护模型加载,推理不加锁
|
||||
self._cache = OrderedDict()
|
||||
self._cache_lock = threading.Lock()
|
||||
self._initialized = True
|
||||
|
||||
@staticmethod
|
||||
def _cuda_available() -> bool:
|
||||
"""nvidia-smi 可用即认为有可用 GPU。"""
|
||||
try:
|
||||
r = subprocess.run(["nvidia-smi"], capture_output=True, timeout=10)
|
||||
return r.returncode == 0
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _gpu_count() -> int:
|
||||
"""数 GPU 张数(nvidia-smi -L 行数)。"""
|
||||
try:
|
||||
r = subprocess.run(["nvidia-smi", "-L"], capture_output=True, text=True, timeout=10)
|
||||
if r.returncode == 0:
|
||||
return sum(1 for ln in r.stdout.splitlines() if ln.strip().startswith("GPU "))
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
return 0
|
||||
|
||||
def _resolve_device(self):
|
||||
"""决定 whisper 跑在 cpu 还是 gpu。读 config.audio.device:auto/cuda/gpu/cpu。
|
||||
device_index=auto/last 时用【最后一张卡】,与抽帧(用前 N-1 张)错开。
|
||||
返回 (device, compute_type, device_index)。"""
|
||||
try:
|
||||
from config.config_utils import config
|
||||
acfg = config.get_audio_config() or {}
|
||||
except Exception:
|
||||
acfg = {}
|
||||
dev = str(acfg.get("device", "auto")).lower()
|
||||
idx_cfg = acfg.get("device_index", "auto")
|
||||
ctype = str(acfg.get("compute_type", "float16"))
|
||||
if dev in ("cuda", "gpu") or (dev == "auto" and self._cuda_available()):
|
||||
if str(idx_cfg).lower() in ("auto", "last"):
|
||||
idx = max(0, self._gpu_count() - 1) # 用最后一张
|
||||
else:
|
||||
try:
|
||||
idx = int(idx_cfg)
|
||||
except (TypeError, ValueError):
|
||||
idx = 0
|
||||
return "cuda", ctype, idx
|
||||
return "cpu", "int8", 0
|
||||
|
||||
def load_model(self, model_name: str = "base"):
|
||||
"""加载 faster-whisper 模型:有 GPU 用 cuda(float16),否则 CPU(int8);GPU 失败自动回退 CPU。"""
|
||||
with self._load_lock:
|
||||
if self.model is None or self.model_name != model_name:
|
||||
from faster_whisper import WhisperModel
|
||||
device, ctype, idx = self._resolve_device()
|
||||
# CPU 推理限制内部线程数:防止进程池下每个 worker 的 whisper 各抓一堆核 → 线程爆炸。
|
||||
# whisper 只识别首尾各5秒,2 线程足够;可用 config.audio.cpu_threads 调整。
|
||||
try:
|
||||
from config.config_utils import config as _cfg
|
||||
cpu_threads = int((_cfg.get_audio_config() or {}).get("cpu_threads", 2) or 2)
|
||||
except Exception:
|
||||
cpu_threads = 2
|
||||
cpu_threads = max(1, cpu_threads)
|
||||
try:
|
||||
print(f"[WhisperManager] 加载 faster-whisper '{model_name}' (device={device}, {ctype})...")
|
||||
if device == "cuda":
|
||||
self.model = WhisperModel(model_name, device="cuda",
|
||||
device_index=idx, compute_type=ctype)
|
||||
else:
|
||||
self.model = WhisperModel(model_name, device="cpu",
|
||||
compute_type="int8", cpu_threads=cpu_threads)
|
||||
except Exception as e:
|
||||
print(f"[WhisperManager] GPU 加载失败({type(e).__name__}: {e}),回退 CPU(int8, cpu_threads={cpu_threads})")
|
||||
self.model = WhisperModel(model_name, device="cpu",
|
||||
compute_type="int8", cpu_threads=cpu_threads)
|
||||
self.model_name = model_name
|
||||
print(f"[WhisperManager] 模型 '{model_name}' 加载完成。")
|
||||
return self.model
|
||||
|
||||
def extract_audio_from_video(
|
||||
self,
|
||||
video_path: str,
|
||||
output_audio_path: str,
|
||||
logger: Optional[object] = None
|
||||
) -> Optional[float]:
|
||||
if not os.path.exists(video_path):
|
||||
if logger:
|
||||
logger.log(f"视频文件不存在: {video_path}", level="error")
|
||||
return None
|
||||
|
||||
cmd = [
|
||||
get_ffmpeg(), "-loglevel", "error",
|
||||
"-i", video_path,
|
||||
"-vn", "-acodec", "pcm_s16le",
|
||||
"-ar", "16000", "-ac", "1",
|
||||
"-y", output_audio_path
|
||||
]
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
if logger:
|
||||
logger.log(f"ffmpeg 提取音频失败: {e.stderr}", level="error")
|
||||
return None
|
||||
|
||||
try:
|
||||
audio = AudioSegment.from_file(output_audio_path)
|
||||
duration = len(audio) / 1000.0
|
||||
if logger:
|
||||
logger.log(f"音频提取成功,时长: {duration:.2f} 秒")
|
||||
return duration
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.log(f"获取音频时长失败: {e}", level="error")
|
||||
return None
|
||||
|
||||
def extract_audio_text_with_whisper(
|
||||
self,
|
||||
audio_path: str,
|
||||
start_time: float,
|
||||
end_time: float,
|
||||
logger: Optional[object] = None,
|
||||
use_cache: bool = True
|
||||
) -> str:
|
||||
if self.model is None:
|
||||
raise RuntimeError("Whisper 模型未加载,请先调用 load_model()")
|
||||
|
||||
if not os.path.exists(audio_path):
|
||||
if logger:
|
||||
logger.log(f"音频文件不存在: {audio_path}", level="error")
|
||||
return ""
|
||||
|
||||
cache_key = None
|
||||
if use_cache:
|
||||
mtime = os.path.getmtime(audio_path)
|
||||
cache_key = hashlib.md5(
|
||||
f"{audio_path}_{mtime}_{start_time}_{end_time}".encode()
|
||||
).hexdigest()
|
||||
with self._cache_lock:
|
||||
if cache_key in self._cache:
|
||||
self._cache.move_to_end(cache_key)
|
||||
if logger:
|
||||
logger.log("使用缓存识别结果")
|
||||
return self._cache[cache_key]
|
||||
|
||||
try:
|
||||
full_audio = AudioSegment.from_file(audio_path)
|
||||
start_ms = int(start_time * 1000)
|
||||
end_ms = int(end_time * 1000) + 500
|
||||
segment = full_audio[start_ms:end_ms]
|
||||
|
||||
if len(segment) == 0:
|
||||
if logger:
|
||||
logger.log("音频片段为空,跳过识别")
|
||||
return ""
|
||||
|
||||
segment = segment.set_frame_rate(16000).set_channels(1).set_sample_width(2)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
temp_path = tmp.name
|
||||
segment.export(temp_path, format="wav")
|
||||
|
||||
try:
|
||||
# faster-whisper CPU 模式线程安全,无需加锁,多线程并发推理
|
||||
segments, _ = self.model.transcribe(
|
||||
temp_path, language="zh", beam_size=5
|
||||
)
|
||||
text = "".join(seg.text for seg in segments).strip()
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if use_cache and cache_key:
|
||||
with self._cache_lock:
|
||||
self._cache[cache_key] = text
|
||||
self._cache.move_to_end(cache_key)
|
||||
while len(self._cache) > _MAX_CACHE_SIZE:
|
||||
self._cache.popitem(last=False)
|
||||
|
||||
if logger:
|
||||
logger.log(f"音频片段识别结果: {text}")
|
||||
|
||||
return text
|
||||
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.log(f"音频识别失败: {e}", level="error")
|
||||
return ""
|
||||
|
||||
|
||||
whisper_manager = WhisperManager()
|
||||
36
tools/_dl.py
Normal file
36
tools/_dl.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json, os, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from qcloud_cos import CosConfig, CosS3Client
|
||||
from config.config_utils import config
|
||||
|
||||
cos = config.get_cos_config()
|
||||
client = CosS3Client(CosConfig(Region=cos["region"], SecretId=cos["secret_id"],
|
||||
SecretKey=cos["secret_key"]))
|
||||
bucket = cos["bucket"]
|
||||
|
||||
rows = []
|
||||
for line in open("数据.jsonl", encoding="utf-8"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if r.get("status") == "success" and r.get("srt_cos_path"):
|
||||
rows.append(r)
|
||||
recent = rows[-15:]
|
||||
|
||||
os.makedirs("srt_check", exist_ok=True)
|
||||
ok = 0
|
||||
for r in recent:
|
||||
p = r["srt_cos_path"]
|
||||
key = p.split("://", 1)[1].split("/", 1)[1] # 去掉 cos://bucket/
|
||||
name = os.path.basename(key)
|
||||
try:
|
||||
client.download_file(Bucket=bucket, Key=key, DestFilePath=os.path.join("srt_check", name))
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
print("下载失败", name, e)
|
||||
print(f"下载成功 {ok}/{len(recent)} → srt_check/")
|
||||
100
tools/analyze_ocr_result.py
Normal file
100
tools/analyze_ocr_result.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
分析一个 ocr_result.jsonl,判断"冗余文字过多"是真冗余还是误杀。
|
||||
复现过滤逻辑:看文字的垂直位置分布(distinct_bands)、每帧文字框数、有没有稳定字幕行。
|
||||
|
||||
用法: python temp/analyze_ocr_result.py "ocr_result (1).jsonl"
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
PATH = sys.argv[1] if len(sys.argv) > 1 else "ocr_result.jsonl"
|
||||
|
||||
|
||||
def parse(path):
|
||||
"""解析 'pic_path: X' + json 交替格式,返回 [(frame, detections), ...]。"""
|
||||
pairs = []
|
||||
cur_frame = None
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.rstrip("\n")
|
||||
if line.startswith("pic_path:"):
|
||||
cur_frame = line.split("pic_path:", 1)[1].strip()
|
||||
elif line.strip():
|
||||
try:
|
||||
data = json.loads(line)
|
||||
pairs.append((cur_frame, data.get("TextDetections", [])))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return pairs
|
||||
|
||||
|
||||
def center_y(det):
|
||||
poly = det.get("Polygon", [])
|
||||
ys = [p.get("Y", 0) for p in poly]
|
||||
return sum(ys) / len(ys) if ys else 0
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
pairs = parse(PATH)
|
||||
except FileNotFoundError:
|
||||
print(f"找不到文件: {PATH}")
|
||||
return
|
||||
if not pairs:
|
||||
print("没解析到数据(格式不对?)")
|
||||
return
|
||||
|
||||
n_frames = len(pairs)
|
||||
boxes_per_frame = [len(d) for _, d in pairs]
|
||||
total_boxes = sum(boxes_per_frame)
|
||||
frames_with_text = sum(1 for b in boxes_per_frame if b)
|
||||
|
||||
# 所有文字框的中心 Y
|
||||
all_cy = []
|
||||
for _, dets in pairs:
|
||||
for det in dets:
|
||||
all_cy.append(center_y(det))
|
||||
|
||||
print(f"文件: {PATH}")
|
||||
print("=" * 60)
|
||||
print(f"帧/图数: {n_frames}")
|
||||
print(f"有文字的: {frames_with_text}")
|
||||
print(f"文字框总数: {total_boxes}")
|
||||
print(f"平均每帧框数: {total_boxes / n_frames:.1f}")
|
||||
print(f"最多一帧框数: {max(boxes_per_frame) if boxes_per_frame else 0}")
|
||||
|
||||
# distinct vertical bands(复现 merge.py 的冗余判定:中心Y间隔>80算一个新垂直簇)
|
||||
cys = sorted(all_cy)
|
||||
bands = 1 if cys else 0
|
||||
for i in range(1, len(cys)):
|
||||
if cys[i] - cys[i - 1] > 80:
|
||||
bands += 1
|
||||
print(f"不同垂直位置簇: {bands} (>=8 会被判冗余)")
|
||||
|
||||
# Y 分布直方图(每 100px 一档),看文字集中在哪还是散布全屏
|
||||
hist = defaultdict(int)
|
||||
for y in all_cy:
|
||||
hist[int(y // 100) * 100] += 1
|
||||
print("\n文字垂直分布(Y每100px一档,看文字集中度):")
|
||||
if hist:
|
||||
mx = max(hist.values())
|
||||
for y in sorted(hist):
|
||||
bar = "#" * int(hist[y] / mx * 40)
|
||||
print(f" y={y:>5}-{y+99:<5} {hist[y]:>6} {bar}")
|
||||
|
||||
# 判定
|
||||
print("\n" + "=" * 60)
|
||||
if bands >= 8:
|
||||
print(f"❌ 真冗余:文字散布在 {bands} 个不同垂直位置(满屏文字/弹幕/文档类)。")
|
||||
elif total_boxes / n_frames > 10:
|
||||
print(f"⚠️ 偏冗余:平均每帧 {total_boxes/n_frames:.0f} 个文字框,画面文字很多。")
|
||||
else:
|
||||
topy = max(hist, key=hist.get) if hist else 0
|
||||
print(f"✅ 可能误杀:文字主要集中在 y={topy}~{topy+99},像是有固定字幕行的。")
|
||||
print(" 若确实有正常字幕,可放宽 merge.py 的 distinct_bands 阈值。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
463
tools/bench_ocr_endpoints.py
Normal file
463
tools/bench_ocr_endpoints.py
Normal file
@@ -0,0 +1,463 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
压测每个 OCR 端点(IP)的实际 QPS,并把结果写成权重文件 config/ocr_weights.json,
|
||||
供运行时按 QPS 加权分发请求(见 tool/Ocr/tencentcloud_ocr.py 的 OcrDispatcher)。
|
||||
|
||||
做法:对每个端点用固定并发、固定请求数发同一张样例图,
|
||||
QPS = 成功请求数 / 墙钟耗时;同时报告平均时延和错误率。
|
||||
|
||||
本脚本完全自包含:单文件,不读 config、不 import 项目任何模块,拷过去就能跑。
|
||||
|
||||
用法:
|
||||
python bench_ocr_endpoints.py [样例图片] [--requests N] [--concurrency C] [--no-write]
|
||||
python bench_ocr_endpoints.py [样例图片] --url http://IP:PORT [--url http://IP2:PORT ...]
|
||||
|
||||
样例图片不给时,自动在 ./composite、./split 下找一张 .jpg。
|
||||
端点:默认内置 Polaris 地址;要测别的 IP 用 --url(可多次)。
|
||||
--requests 每个端点发的总请求数(默认 60)
|
||||
--concurrency 每个端点的并发线程数(默认 10)
|
||||
--no-write 只打印,不写 ocr_weights.json
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import glob
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import requests
|
||||
|
||||
OCR_PATH = "/youtu/ocrapi/hpgeneralocr_v3"
|
||||
OCR_PORT = 18081
|
||||
|
||||
# 内置的多台 OCR 服务 IP(自动负载均衡测试用)
|
||||
DEFAULT_IPS = [
|
||||
"30.154.78.83"
|
||||
]
|
||||
DEFAULT_ENDPOINTS = [f"http://{ip}:{OCR_PORT}" for ip in DEFAULT_IPS]
|
||||
WEIGHTS_FILE = "./ocr_weights.json"
|
||||
|
||||
|
||||
def get_endpoints(cli_urls):
|
||||
"""命令行 --url 优先,否则用内置默认端点。"""
|
||||
if cli_urls:
|
||||
return [u.rstrip("/") for u in cli_urls]
|
||||
return list(DEFAULT_ENDPOINTS)
|
||||
|
||||
|
||||
def find_sample_image(given):
|
||||
if given:
|
||||
if os.path.exists(given):
|
||||
return given
|
||||
print(f"指定的图片不存在: {given}")
|
||||
return None
|
||||
for root in ("./composite", "./split"):
|
||||
hits = glob.glob(os.path.join(root, "**", "*.jpg"), recursive=True)
|
||||
if hits:
|
||||
return hits[0]
|
||||
return None
|
||||
|
||||
|
||||
def img2base64(img_path):
|
||||
with open(img_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode()
|
||||
|
||||
|
||||
def ocr_request_once(base_url, image_b64, timeout=60):
|
||||
"""向指定端点发一次 OCR 请求,返回原始响应文本;失败抛异常。"""
|
||||
url = base_url.rstrip("/") + OCR_PATH
|
||||
app_id = f"{int(time.time() * 1000)}{random.randint(100000, 999999)}"
|
||||
payload = {
|
||||
"app_id": app_id,
|
||||
"image": image_b64,
|
||||
"ocr_template": "OCR",
|
||||
"options": {"pdf_page_index": 0, "detect_text": True},
|
||||
}
|
||||
resp = requests.post(url, headers={"Content-Type": "application/json"},
|
||||
json=payload, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
def _ok(raw):
|
||||
"""判断一次响应是否算成功:有内容且不是 error。"""
|
||||
if not raw:
|
||||
return False
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return False
|
||||
if "TextDetections" in data:
|
||||
return True
|
||||
return data.get("error_code", 0) == 0
|
||||
|
||||
|
||||
def bench_endpoint(base_url, image_b64, requests_n, concurrency):
|
||||
"""对单个端点压测,返回统计 dict(含一个失败原因采样 sample_err)。"""
|
||||
latencies = []
|
||||
ok = 0
|
||||
err = 0
|
||||
sample_err = [None] # 采样第一个失败原因
|
||||
|
||||
def one_call():
|
||||
t0 = time.time()
|
||||
try:
|
||||
raw = ocr_request_once(base_url, image_b64, timeout=60)
|
||||
dt = time.time() - t0
|
||||
if _ok(raw):
|
||||
return (True, dt)
|
||||
# HTTP 200 但内容是错误:采样响应体片段
|
||||
if sample_err[0] is None:
|
||||
sample_err[0] = f"响应非成功: {raw[:200]}"
|
||||
return (False, dt)
|
||||
except Exception as e:
|
||||
if sample_err[0] is None:
|
||||
sample_err[0] = f"{type(e).__name__}: {e}"
|
||||
return (False, time.time() - t0)
|
||||
|
||||
t_start = time.time()
|
||||
with ThreadPoolExecutor(max_workers=concurrency) as ex:
|
||||
futures = [ex.submit(one_call) for _ in range(requests_n)]
|
||||
for fut in as_completed(futures):
|
||||
success, dt = fut.result()
|
||||
latencies.append(dt)
|
||||
if success:
|
||||
ok += 1
|
||||
else:
|
||||
err += 1
|
||||
wall = time.time() - t_start
|
||||
|
||||
return {
|
||||
"qps": ok / wall if wall > 0 else 0.0,
|
||||
"ok": ok,
|
||||
"err": err,
|
||||
"wall": wall,
|
||||
"avg_latency": sum(latencies) / len(latencies) if latencies else 0.0,
|
||||
"err_rate": err / requests_n if requests_n else 0.0,
|
||||
"sample_err": sample_err[0],
|
||||
}
|
||||
|
||||
|
||||
def soak_endpoint(base_url, image_pool, concurrency, report_interval=5):
|
||||
"""对单个端点持续满速压测:固定 concurrency 个线程不停发请求,
|
||||
每次从 image_pool 随机抽一张图(避免被服务端缓存),每 report_interval 秒打印滚动 QPS,
|
||||
直到 Ctrl+C。image_pool 可以是单张或几千张。"""
|
||||
stop = threading.Event()
|
||||
lock = threading.Lock()
|
||||
st = {"ok": 0, "err": 0, "lat": 0.0, "n": 0}
|
||||
sample_err = [None]
|
||||
|
||||
def worker():
|
||||
while not stop.is_set():
|
||||
img = random.choice(image_pool) # 每次随机抽一张
|
||||
t0 = time.time()
|
||||
try:
|
||||
raw = ocr_request_once(base_url, img, timeout=60)
|
||||
success = _ok(raw)
|
||||
if not success and sample_err[0] is None:
|
||||
sample_err[0] = f"响应非成功: {raw[:160]}"
|
||||
except Exception as e:
|
||||
success = False
|
||||
if sample_err[0] is None:
|
||||
sample_err[0] = f"{type(e).__name__}: {e}"
|
||||
dt = time.time() - t0
|
||||
with lock:
|
||||
st["n"] += 1
|
||||
st["lat"] += dt
|
||||
if success:
|
||||
st["ok"] += 1
|
||||
else:
|
||||
st["err"] += 1
|
||||
|
||||
threads = [threading.Thread(target=worker, daemon=True) for _ in range(concurrency)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
print(f"持续满速压测 {base_url} 并发={concurrency} 每{report_interval}s 报一次 (Ctrl+C 停止)")
|
||||
print(f"{'用时s':>6} {'累计OK':>9} {'瞬时QPS':>9} {'平均QPS':>9} {'时延ms':>8} {'错误率':>7}")
|
||||
print("-" * 56)
|
||||
t_start = time.time()
|
||||
last_ok, last_t = 0, t_start
|
||||
try:
|
||||
while True:
|
||||
time.sleep(report_interval)
|
||||
now = time.time()
|
||||
with lock:
|
||||
ok, err, n, lat = st["ok"], st["err"], st["n"], st["lat"]
|
||||
inst = (ok - last_ok) / (now - last_t) if now > last_t else 0
|
||||
avg = ok / (now - t_start) if now > t_start else 0
|
||||
avg_lat = (lat / n * 1000) if n else 0
|
||||
er = err / n * 100 if n else 0
|
||||
print(f"{now-t_start:6.0f} {ok:>9} {inst:>9.1f} {avg:>9.1f} {avg_lat:>8.0f} {er:>6.0f}%", flush=True)
|
||||
last_ok, last_t = ok, now
|
||||
except KeyboardInterrupt:
|
||||
stop.set()
|
||||
el = time.time() - t_start
|
||||
with lock:
|
||||
ok, n = st["ok"], st["n"]
|
||||
print("-" * 56)
|
||||
print(f"停止。{el:.0f}s 内成功 {ok} / 共发 {n},平均 {ok/el if el else 0:.1f} QPS")
|
||||
if sample_err[0]:
|
||||
print(f"失败采样: {sample_err[0]}")
|
||||
|
||||
|
||||
def health_check(endpoints, image_b64):
|
||||
"""给每个端点发一次请求,返回 (存活列表, 死亡列表)。"""
|
||||
alive, dead = [], []
|
||||
|
||||
def _check(ep):
|
||||
try:
|
||||
return ep, _ok(ocr_request_once(ep, image_b64, timeout=15))
|
||||
except Exception:
|
||||
return ep, False
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(32, len(endpoints))) as ex:
|
||||
for ep, ok in ex.map(_check, endpoints):
|
||||
(alive if ok else dead).append(ep)
|
||||
return alive, dead
|
||||
|
||||
|
||||
def bench_aggregate(endpoints, image_b64, requests_n, concurrency):
|
||||
"""把请求轮询分发到所有端点(模拟运行时自动负载均衡),测合并 QPS。"""
|
||||
rr = itertools.cycle(endpoints)
|
||||
rr_lock = threading.Lock()
|
||||
latencies = []
|
||||
ok = 0
|
||||
err = 0
|
||||
stat_lock = threading.Lock()
|
||||
|
||||
def next_ep():
|
||||
with rr_lock:
|
||||
return next(rr)
|
||||
|
||||
def one_call(_):
|
||||
ep = next_ep()
|
||||
t0 = time.time()
|
||||
try:
|
||||
raw = ocr_request_once(ep, image_b64, timeout=60)
|
||||
success = _ok(raw)
|
||||
except Exception:
|
||||
success = False
|
||||
dt = time.time() - t0
|
||||
with stat_lock:
|
||||
latencies.append(dt)
|
||||
return success
|
||||
|
||||
t_start = time.time()
|
||||
with ThreadPoolExecutor(max_workers=concurrency) as ex:
|
||||
for success in ex.map(one_call, range(requests_n)):
|
||||
if success:
|
||||
ok += 1
|
||||
else:
|
||||
err += 1
|
||||
wall = time.time() - t_start
|
||||
|
||||
return {
|
||||
"qps": ok / wall if wall > 0 else 0.0,
|
||||
"ok": ok,
|
||||
"err": err,
|
||||
"wall": wall,
|
||||
"avg_latency": sum(latencies) / len(latencies) if latencies else 0.0,
|
||||
"err_rate": err / requests_n if requests_n else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def run_aggregate(endpoints, image_b64, requests_n, concurrency):
|
||||
"""先健康检查筛掉死端点,再在存活端点上做聚合负载测试。"""
|
||||
print(f"健康检查 {len(endpoints)} 个端点 ...", flush=True)
|
||||
alive, dead = health_check(endpoints, image_b64)
|
||||
print(f" 存活: {len(alive)} 死亡: {len(dead)}")
|
||||
if dead:
|
||||
for ep in dead:
|
||||
print(f" ✗ {ep}")
|
||||
if not alive:
|
||||
print("没有存活端点,无法测试。")
|
||||
return
|
||||
print("=" * 64)
|
||||
print(f"聚合负载测试: {len(alive)} 个存活端点轮询分发,"
|
||||
f"总请求 {requests_n},总并发 {concurrency}")
|
||||
s = bench_aggregate(alive, image_b64, requests_n, concurrency)
|
||||
print("-" * 64)
|
||||
print(f" 合并 QPS = {s['qps']:.2f} 成功={s['ok']} 失败={s['err']}"
|
||||
f" 平均时延={s['avg_latency']*1000:.0f}ms 错误率={s['err_rate']*100:.0f}%")
|
||||
if alive:
|
||||
print(f" 单端点平均 ≈ {s['qps']/len(alive):.2f} QPS"
|
||||
f" ({len(alive)} 台合计 {s['qps']:.0f} QPS)")
|
||||
print("=" * 64)
|
||||
|
||||
|
||||
def sweep_endpoint(base_url, image_b64, start, max_c, per_worker):
|
||||
"""自动加压找极限 QPS:并发从 start 逐级翻倍到 max_c,
|
||||
QPS 不再随并发上升(或错误率升高)即判定到极限,停止。"""
|
||||
print(f"自动加压 {base_url} (并发 {start}→{max_c} 逐级翻倍)")
|
||||
print(f"{'并发':>6} {'QPS':>9} {'时延ms':>9} {'错误率':>8}")
|
||||
print("-" * 36)
|
||||
best_qps, best_c = 0.0, 0
|
||||
prev_qps = 0.0
|
||||
c = start
|
||||
while c <= max_c:
|
||||
reqs = max(c * per_worker, 30) # 每个并发至少发 per_worker 次,样本够稳
|
||||
s = bench_endpoint(base_url, image_b64, reqs, c)
|
||||
print(f"{c:>6} {s['qps']:>9.2f} {s['avg_latency']*1000:>9.0f} {s['err_rate']*100:>7.0f}%")
|
||||
if s["qps"] > best_qps:
|
||||
best_qps, best_c = s["qps"], c
|
||||
if s["err_rate"] > 0.1:
|
||||
print(" → 错误率升高,已超过服务承载,停止。")
|
||||
break
|
||||
if prev_qps > 0 and s["qps"] < prev_qps * 1.08:
|
||||
print(" → QPS 不再随并发上升,已到极限,停止。")
|
||||
break
|
||||
prev_qps = s["qps"]
|
||||
c *= 2
|
||||
print("-" * 36)
|
||||
print(f"极限 QPS ≈ {best_qps:.2f} (最佳并发 {best_c})")
|
||||
return best_qps, best_c
|
||||
|
||||
|
||||
def gen_random_images(n, size=(1280, 200)):
|
||||
"""生成 n 张带随机文字的图片(让 OCR 真干活),返回 base64 列表。需要 Pillow。"""
|
||||
try:
|
||||
import io
|
||||
import string
|
||||
from PIL import Image, ImageDraw
|
||||
except ImportError:
|
||||
print("生成随机图需要 Pillow(pip install pillow);改用 --pool 指向真实图目录")
|
||||
raise
|
||||
chars = string.ascii_letters + string.digits + " ,.!?-"
|
||||
pool = []
|
||||
print(f"生成 {n} 张随机文字图...", flush=True)
|
||||
for i in range(n):
|
||||
bg = random.randint(200, 255)
|
||||
img = Image.new("RGB", size, (bg, bg, bg))
|
||||
d = ImageDraw.Draw(img)
|
||||
for row in range(random.randint(1, 3)):
|
||||
text = "".join(random.choice(chars) for _ in range(random.randint(10, 40)))
|
||||
d.text((20, 20 + row * 50), text, fill=(0, 0, 0))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=85)
|
||||
pool.append(base64.b64encode(buf.getvalue()).decode())
|
||||
if (i + 1) % 1000 == 0:
|
||||
print(f" 已生成 {i+1}/{n}", flush=True)
|
||||
return pool
|
||||
|
||||
|
||||
def build_image_pool(args):
|
||||
"""构建测试图池(base64 列表):
|
||||
--pool DIR 用真实图目录(最贴近实际负载) > --gen N 随机生成 > 单张样例图。"""
|
||||
if getattr(args, "pool", None):
|
||||
files = (glob.glob(os.path.join(args.pool, "**", "*.jpg"), recursive=True) +
|
||||
glob.glob(os.path.join(args.pool, "**", "*.png"), recursive=True))
|
||||
if not files:
|
||||
print(f"目录 {args.pool} 下没有图片")
|
||||
return []
|
||||
random.shuffle(files)
|
||||
cap = args.gen if args.gen else 5000
|
||||
files = files[:cap]
|
||||
print(f"从 {args.pool} 取 {len(files)} 张真实图作为图池")
|
||||
return [img2base64(f) for f in files]
|
||||
if getattr(args, "gen", 0):
|
||||
return gen_random_images(args.gen)
|
||||
sample = find_sample_image(args.image)
|
||||
if not sample:
|
||||
return []
|
||||
print(f"单张样例图: {sample}")
|
||||
return [img2base64(sample)]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("image", nargs="?", default=None)
|
||||
ap.add_argument("--url", action="append", default=None, help="OCR 端点(可多次)")
|
||||
ap.add_argument("--requests", type=int, default=60)
|
||||
ap.add_argument("--concurrency", type=int, default=10)
|
||||
ap.add_argument("--aggregate", action="store_true",
|
||||
help="聚合负载测试:请求轮询分发到所有存活端点,测合并总 QPS")
|
||||
ap.add_argument("--sweep", action="store_true",
|
||||
help="自动加压找极限 QPS(并发逐级翻倍直到 QPS 不再上升)")
|
||||
ap.add_argument("--soak", action="store_true",
|
||||
help="单端点持续满速压测(固定并发不停发,实时滚动QPS,Ctrl+C 停)")
|
||||
ap.add_argument("--interval", type=int, default=5, help="soak 模式打印间隔秒")
|
||||
ap.add_argument("--gen", type=int, default=0,
|
||||
help="随机生成 N 张带文字的图作为图池(每次请求随机抽,避免被缓存)")
|
||||
ap.add_argument("--pool", default=None,
|
||||
help="用该目录下的真实图作为图池(最贴近实际负载,如 ./split/某视频)")
|
||||
ap.add_argument("--start-concurrency", type=int, default=10, help="sweep 起始并发")
|
||||
ap.add_argument("--max-concurrency", type=int, default=320, help="sweep 最大并发")
|
||||
ap.add_argument("--per-worker", type=int, default=6, help="sweep 每档每并发发多少次请求")
|
||||
ap.add_argument("--no-write", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
endpoints = get_endpoints(args.url)
|
||||
if not endpoints:
|
||||
print("没有可用的 OCR 端点")
|
||||
return
|
||||
|
||||
# 构建图池:--pool 真实图目录 / --gen 随机生成 / 默认单张样例
|
||||
image_pool = build_image_pool(args)
|
||||
if not image_pool:
|
||||
print("没有可用图片:给一张 jpg 路径、或用 --gen N / --pool 目录。")
|
||||
return
|
||||
print(f"图池大小: {len(image_pool)} 张 | 端点数: {len(endpoints)} 并发: {args.concurrency}")
|
||||
print("=" * 64)
|
||||
|
||||
image_b64 = image_pool[0] # 非 soak 模式用单张(测纯吞吐)
|
||||
|
||||
# ---- soak 模式:单端点持续满速压测,每次随机抽图,直到 Ctrl+C ----
|
||||
if args.soak:
|
||||
soak_endpoint(endpoints[0], image_pool, args.concurrency, args.interval)
|
||||
return
|
||||
|
||||
# ---- aggregate 模式:所有端点轮询分发,测合并总 QPS ----
|
||||
if args.aggregate:
|
||||
run_aggregate(endpoints, image_b64, args.requests, args.concurrency)
|
||||
return
|
||||
|
||||
# ---- sweep 模式:自动加压找极限,不写权重文件 ----
|
||||
if args.sweep:
|
||||
for i, ep in enumerate(endpoints, 1):
|
||||
print(f"\n[{i}/{len(endpoints)}]", end=" ")
|
||||
sweep_endpoint(ep, image_b64, args.start_concurrency,
|
||||
args.max_concurrency, args.per_worker)
|
||||
return
|
||||
|
||||
results = {}
|
||||
for i, ep in enumerate(endpoints, 1):
|
||||
print(f"[{i}/{len(endpoints)}] 压测 {ep} ...", flush=True)
|
||||
stat = bench_endpoint(ep, image_b64, args.requests, args.concurrency)
|
||||
results[ep] = stat
|
||||
print(f" QPS={stat['qps']:.2f} 成功={stat['ok']} 失败={stat['err']}"
|
||||
f" 平均时延={stat['avg_latency']*1000:.0f}ms"
|
||||
f" 错误率={stat['err_rate']*100:.0f}%")
|
||||
if stat["err"] and stat.get("sample_err"):
|
||||
print(f" 失败原因(采样): {stat['sample_err']}")
|
||||
|
||||
print("=" * 64)
|
||||
print("汇总(按 QPS 降序):")
|
||||
for ep, s in sorted(results.items(), key=lambda kv: kv[1]["qps"], reverse=True):
|
||||
print(f" {ep:48s} QPS={s['qps']:6.2f} 时延={s['avg_latency']*1000:5.0f}ms"
|
||||
f" 错误率={s['err_rate']*100:.0f}%")
|
||||
|
||||
total_qps = sum(s["qps"] for s in results.values())
|
||||
print(f"\n合计可用 QPS ≈ {total_qps:.2f}")
|
||||
|
||||
# 写权重文件:QPS 即权重;错误率过高(>50%)的端点权重清零(不参与分发)
|
||||
weights = {ep: (round(s["qps"], 3) if s["err_rate"] <= 0.5 else 0.0)
|
||||
for ep, s in results.items()}
|
||||
|
||||
if args.no_write:
|
||||
print("\n--no-write:不写权重文件。")
|
||||
return
|
||||
|
||||
os.makedirs(os.path.dirname(WEIGHTS_FILE), exist_ok=True)
|
||||
with open(WEIGHTS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(weights, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n已写入权重文件: {WEIGHTS_FILE}")
|
||||
print("运行时会按此权重把 OCR 请求加权分发到各端点(重启进程或新批次生效)。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
117
tools/check_srt.py
Normal file
117
tools/check_srt.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
使用 cos-python-sdk-v5 全量扫描 0004/data/ 下的文件,
|
||||
找出已有 SRT 文件的目录,将对应的 MP4 COS 路径写入 txt 文件。
|
||||
|
||||
安装依赖:pip install cos-python-sdk-v5
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from qcloud_cos import CosConfig, CosS3Client
|
||||
from config.config_utils import config as app_config
|
||||
|
||||
SCAN_PREFIX = "0004/data/"
|
||||
OUTPUT_TXT = "./text/0004_has_srt.txt"
|
||||
DIR_RE = re.compile(r'.+/data/[^/]+/[^/]+/$')
|
||||
|
||||
|
||||
def build_client() -> tuple[CosS3Client, str]:
|
||||
cos_cfg = app_config.get_cos_config()
|
||||
secret_id = cos_cfg.get("secret_id")
|
||||
secret_key = cos_cfg.get("secret_key")
|
||||
region = cos_cfg.get("region", "ap-chongqing")
|
||||
bucket = cos_cfg.get("bucket", "asr-dig-data-1253181912")
|
||||
|
||||
if not secret_id or not secret_key or "填入" in secret_id:
|
||||
raise ValueError("请先在 config/config.json 的 tencentcloud.cos 中填入 secret_id 和 secret_key")
|
||||
|
||||
cfg = CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key)
|
||||
client = CosS3Client(cfg)
|
||||
return client, bucket
|
||||
|
||||
|
||||
def list_all_objects(client: CosS3Client, bucket: str, prefix: str) -> list[str]:
|
||||
"""分页获取 prefix 下全部 Key"""
|
||||
keys = []
|
||||
marker = ""
|
||||
page = 0
|
||||
|
||||
while True:
|
||||
page += 1
|
||||
kwargs = dict(Bucket=bucket, Prefix=prefix, MaxKeys=1000)
|
||||
if marker:
|
||||
kwargs["Marker"] = marker
|
||||
|
||||
resp = client.list_objects(**kwargs)
|
||||
contents = resp.get("Contents", [])
|
||||
keys.extend(item["Key"] for item in contents)
|
||||
|
||||
print(f"\r 第 {page} 页,已获取 {len(keys)} 个文件...", end="", flush=True)
|
||||
|
||||
if resp.get("IsTruncated") == "true":
|
||||
marker = resp.get("NextMarker", "")
|
||||
else:
|
||||
break
|
||||
|
||||
print()
|
||||
return keys
|
||||
|
||||
|
||||
def main():
|
||||
client, bucket = build_client()
|
||||
bucket_prefix = f"cos://{bucket}"
|
||||
|
||||
print(f"正在全量扫描 {bucket_prefix}/{SCAN_PREFIX} ...")
|
||||
keys = list_all_objects(client, bucket, SCAN_PREFIX)
|
||||
print(f"共找到 {len(keys)} 个文件")
|
||||
|
||||
if not keys:
|
||||
print("未找到任何文件,请检查密钥和路径是否正确")
|
||||
return
|
||||
|
||||
# 按父目录分组
|
||||
dir_files: dict[str, list[str]] = defaultdict(list)
|
||||
for key in keys:
|
||||
idx = key.rfind("/")
|
||||
dir_path = key[:idx + 1] # e.g. "0004/data/BV1xx/BV1xx/"
|
||||
filename = key[idx + 1:]
|
||||
if filename:
|
||||
dir_files[dir_path].append(filename)
|
||||
|
||||
# 找有 SRT 的目录,收集 MP4 路径
|
||||
mp4_paths = []
|
||||
has_srt_cnt = 0
|
||||
no_srt_cnt = 0
|
||||
|
||||
for dir_path, filenames in sorted(dir_files.items()):
|
||||
# 只处理 data/{id}/{id}/ 结构
|
||||
if not DIR_RE.match(dir_path):
|
||||
continue
|
||||
|
||||
has_srt = any(f.lower().endswith(".srt") for f in filenames)
|
||||
mp4_files = [f for f in filenames if f.lower().endswith(".mp4")]
|
||||
|
||||
if has_srt:
|
||||
has_srt_cnt += 1
|
||||
for mp4 in mp4_files:
|
||||
mp4_paths.append(f"{bucket_prefix}/{dir_path}{mp4}")
|
||||
else:
|
||||
no_srt_cnt += 1
|
||||
|
||||
print(f"有 SRT 的目录: {has_srt_cnt} 个 → {len(mp4_paths)} 个 MP4")
|
||||
print(f"无 SRT 的目录: {no_srt_cnt} 个")
|
||||
|
||||
if not mp4_paths:
|
||||
print("没有找到含 SRT 的目录")
|
||||
return
|
||||
|
||||
os.makedirs("./text", exist_ok=True)
|
||||
with open(OUTPUT_TXT, "w", encoding="utf-8") as f:
|
||||
for path in mp4_paths:
|
||||
f.write(path + "\n")
|
||||
|
||||
print(f"已写入: {OUTPUT_TXT} ({len(mp4_paths)} 条)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
157
tools/compare_jsonl.py
Normal file
157
tools/compare_jsonl.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
并排对比两个(或多个)机器的 数据.jsonl,定位谁慢、慢在哪个阶段。
|
||||
|
||||
用法:
|
||||
python temp/compare_jsonl.py 2号机器数据.jsonl 3号机器数据.jsonl
|
||||
python temp/compare_jsonl.py a.jsonl b.jsonl c.jsonl ...
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def parse_dt(s):
|
||||
try:
|
||||
return datetime.fromisoformat(s)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def analyze(path):
|
||||
"""读一个 jsonl,返回汇总指标。"""
|
||||
s = {
|
||||
"n": 0, "ok": 0, "err": 0, "no_sub": 0,
|
||||
"dur": 0.0, "elapsed": 0.0,
|
||||
"stage": {"split_frame": 0.0, "merge": 0.0, "getsrt": 0.0},
|
||||
"ocr": 0, "n_ocr": 0,
|
||||
"starts": [], "ends": [],
|
||||
}
|
||||
try:
|
||||
f = open(path, encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
with f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
s["n"] += 1
|
||||
st = r.get("status")
|
||||
if st == "success":
|
||||
s["ok"] += 1
|
||||
elif st == "no_subtitle":
|
||||
s["no_sub"] += 1
|
||||
else:
|
||||
s["err"] += 1
|
||||
try:
|
||||
s["dur"] += float(r.get("duration") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
el = r.get("elapsed_seconds")
|
||||
if el:
|
||||
try:
|
||||
s["elapsed"] += float(el)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
sd = r.get("stage_durations") or {}
|
||||
for k in s["stage"]:
|
||||
try:
|
||||
s["stage"][k] += float(sd.get(k) or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
ic = r.get("image_counts") or {}
|
||||
toc = ic.get("total_ocr_count")
|
||||
if toc:
|
||||
try:
|
||||
s["ocr"] += int(toc)
|
||||
s["n_ocr"] += 1
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
ds = parse_dt(r.get("process_start"))
|
||||
de = parse_dt(r.get("process_end"))
|
||||
if ds:
|
||||
s["starts"].append(ds)
|
||||
if de:
|
||||
s["ends"].append(de)
|
||||
return s
|
||||
|
||||
|
||||
def derive(s):
|
||||
"""从汇总算出对比指标。"""
|
||||
d = {}
|
||||
n = max(1, s["n"])
|
||||
d["记录数"] = s["n"]
|
||||
d["成功率%"] = round(s["ok"] / n * 100, 1)
|
||||
d["视频总时长h"] = round(s["dur"] / 3600, 1)
|
||||
# 墙钟跨度 + 实时倍率
|
||||
span = 0.0
|
||||
if s["starts"] and s["ends"]:
|
||||
span = (max(s["ends"]) - min(s["starts"])).total_seconds()
|
||||
d["墙钟跨度h"] = round(span / 3600, 2)
|
||||
d["实时倍率x"] = round(s["dur"] / span, 2) if span > 0 else 0
|
||||
d["视频/天"] = round(s["n"] / span * 86400) if span > 0 else 0
|
||||
d["有效并发"] = round(s["elapsed"] / span, 1) if span > 0 else 0
|
||||
# 平均每视频各阶段秒数
|
||||
d["抽帧s/视频"] = round(s["stage"]["split_frame"] / n, 1)
|
||||
d["拼接s/视频"] = round(s["stage"]["merge"] / n, 1)
|
||||
d["getsrt s/视频"] = round(s["stage"]["getsrt"] / n, 1)
|
||||
total_stage = sum(s["stage"].values()) or 1
|
||||
d["抽帧占比%"] = round(s["stage"]["split_frame"] / total_stage * 100)
|
||||
d["拼接占比%"] = round(s["stage"]["merge"] / total_stage * 100)
|
||||
d["getsrt占比%"] = round(s["stage"]["getsrt"] / total_stage * 100)
|
||||
d["平均OCR次/视频"] = round(s["ocr"] / s["n_ocr"]) if s["n_ocr"] else 0
|
||||
d["平均耗时s/视频"] = round(s["elapsed"] / n, 1)
|
||||
return d
|
||||
|
||||
|
||||
def main():
|
||||
paths = sys.argv[1:]
|
||||
if len(paths) < 2:
|
||||
print("用法: python temp/compare_jsonl.py a.jsonl b.jsonl [c.jsonl ...]")
|
||||
return
|
||||
|
||||
cols, names = [], []
|
||||
for p in paths:
|
||||
s = analyze(p)
|
||||
if s is None:
|
||||
print(f"找不到文件: {p}")
|
||||
return
|
||||
cols.append(derive(s))
|
||||
names.append(p.replace("数据.jsonl", "").replace(".jsonl", "") or p)
|
||||
|
||||
keys = list(cols[0].keys())
|
||||
w = max(14, max(len(n) for n in names) + 2)
|
||||
print("=" * (18 + w * len(cols)))
|
||||
print(f"{'指标':<16}" + "".join(f"{n:>{w}}" for n in names))
|
||||
print("-" * (18 + w * len(cols)))
|
||||
for k in keys:
|
||||
print(f"{k:<16}" + "".join(f"{str(c[k]):>{w}}" for c in cols))
|
||||
print("=" * (18 + w * len(cols)))
|
||||
|
||||
# 自动点评(两台时)
|
||||
if len(cols) == 2:
|
||||
a, b = cols
|
||||
slow_i = 0 if a["实时倍率x"] < b["实时倍率x"] else 1
|
||||
fast_i = 1 - slow_i
|
||||
print(f"\n【分析】{names[slow_i]} 更慢(实时倍率 "
|
||||
f"{cols[slow_i]['实时倍率x']}x vs {cols[fast_i]['实时倍率x']}x)")
|
||||
# 找慢在哪个阶段
|
||||
for stage, key in [("抽帧", "抽帧s/视频"), ("拼接", "拼接s/视频"), ("getsrt(OCR)", "getsrt s/视频")]:
|
||||
sv, fv = cols[slow_i][key], cols[fast_i][key]
|
||||
if fv and sv > fv * 1.3:
|
||||
print(f" ▸ {stage} 慢: {sv}s vs {fv}s (慢 {sv/fv:.1f}x)")
|
||||
if cols[slow_i]["有效并发"] < cols[fast_i]["有效并发"] * 0.8:
|
||||
print(f" ▸ 并发低: {cols[slow_i]['有效并发']} vs {cols[fast_i]['有效并发']}"
|
||||
f"(CPU/内存/OCR 排队?)")
|
||||
if cols[slow_i]["平均OCR次/视频"] > cols[fast_i]["平均OCR次/视频"] * 1.3:
|
||||
print(f" ▸ OCR 次数多: {cols[slow_i]['平均OCR次/视频']} vs "
|
||||
f"{cols[fast_i]['平均OCR次/视频']}(视频更长/composite更多)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
99
tools/daily_stats.py
Normal file
99
tools/daily_stats.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
按天统计 数据.jsonl:每天处理了多少条视频、处理的视频总时长是多少。
|
||||
日期取 process_start(没有则回退 timestamp)的年月日。
|
||||
|
||||
用法(在项目根目录运行):
|
||||
python temp/daily_stats.py [数据.jsonl路径]
|
||||
默认: python temp/daily_stats.py 数据.jsonl
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
JSONL_PATH = sys.argv[1] if len(sys.argv) > 1 else "数据.jsonl"
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
f = open(JSONL_PATH, encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
print(f"找不到文件: {JSONL_PATH}")
|
||||
return
|
||||
|
||||
# 每天: count/success/no_subtitle/error 计数;dur=全部视频时长;dur_ok=成功视频时长
|
||||
days = defaultdict(lambda: {"count": 0, "success": 0, "no_subtitle": 0,
|
||||
"error": 0, "dur": 0.0, "dur_ok": 0.0})
|
||||
no_date = 0
|
||||
|
||||
with f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
ts = rec.get("process_start") or rec.get("timestamp") or ""
|
||||
day = ts[:10] if len(ts) >= 10 else None # YYYY-MM-DD
|
||||
if not day:
|
||||
no_date += 1
|
||||
continue
|
||||
|
||||
d = days[day]
|
||||
d["count"] += 1
|
||||
|
||||
status = rec.get("status")
|
||||
if status == "success":
|
||||
d["success"] += 1
|
||||
elif status == "no_subtitle":
|
||||
d["no_subtitle"] += 1
|
||||
else:
|
||||
d["error"] += 1
|
||||
|
||||
dur = rec.get("duration") or 0
|
||||
try:
|
||||
dur = float(dur)
|
||||
d["dur"] += dur
|
||||
if status == "success":
|
||||
d["dur_ok"] += dur # 只累计成功出字幕的视频时长
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if not days:
|
||||
print("没有可统计的数据")
|
||||
return
|
||||
|
||||
print(f"文件: {JSONL_PATH}")
|
||||
print("=" * 84)
|
||||
print(f"{'日期':<12}{'视频数':>7}{'成功':>7}{'无字幕':>7}{'错误':>7}"
|
||||
f"{'总时长(h)':>12}{'成功时长(h)':>14}")
|
||||
print("-" * 84)
|
||||
|
||||
tot = {"count": 0, "success": 0, "no_subtitle": 0, "error": 0, "dur": 0.0, "dur_ok": 0.0}
|
||||
for day in sorted(days):
|
||||
d = days[day]
|
||||
print(f"{day:<12}{d['count']:>7}{d['success']:>7}{d['no_subtitle']:>7}"
|
||||
f"{d['error']:>7}{d['dur']/3600:>12.1f}{d['dur_ok']/3600:>14.1f}")
|
||||
for k in tot:
|
||||
tot[k] += d[k]
|
||||
|
||||
print("-" * 84)
|
||||
print(f"{'合计':<12}{tot['count']:>7}{tot['success']:>7}{tot['no_subtitle']:>7}"
|
||||
f"{tot['error']:>7}{tot['dur']/3600:>12.1f}{tot['dur_ok']/3600:>14.1f}")
|
||||
if no_date:
|
||||
print(f"\n({no_date} 条无日期字段,已跳过)")
|
||||
|
||||
n_days = len(days)
|
||||
if n_days:
|
||||
print(f"\n共 {n_days} 天 | 日均处理 {tot['count']/n_days:.0f} 条 / "
|
||||
f"{tot['dur']/3600/n_days:.1f} h | 日均成功 {tot['success']/n_days:.0f} 条 / "
|
||||
f"{tot['dur_ok']/3600/n_days:.1f} h")
|
||||
print(f"成功率 {tot['success']/tot['count']*100:.1f}% | "
|
||||
f"成功时长占比 {tot['dur_ok']/tot['dur']*100:.1f}%" if tot['dur'] else "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
96
tools/download_srt.py
Normal file
96
tools/download_srt.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从 数据.jsonl 中读取成功记录,将 MP4 和 SRT 文件下载到本地 ./成功/ 目录。
|
||||
用法:python download_srt.py [数据.jsonl路径] [本地输出目录]
|
||||
默认:python download_srt.py 数据.jsonl ./成功
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from config.config_utils import config
|
||||
|
||||
JSONL_PATH = sys.argv[1] if len(sys.argv) > 1 else "数据.jsonl"
|
||||
OUTPUT_DIR = sys.argv[2] if len(sys.argv) > 2 else "./成功"
|
||||
|
||||
|
||||
def download(coscli_path, cos_config_path, cos_src, local_path):
|
||||
"""下载单个文件,返回是否成功"""
|
||||
if os.path.exists(local_path):
|
||||
return "skip"
|
||||
cmd = [coscli_path, "-c", cos_config_path, "cp", cos_src, local_path]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
return "ok" if result.returncode == 0 else result.stderr.strip()
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(JSONL_PATH):
|
||||
print(f"找不到文件: {JSONL_PATH}")
|
||||
return
|
||||
|
||||
cfg = config.get_splitframe_config()
|
||||
coscli_path = cfg.get('coscli_path', './coscli-linux-amd64')
|
||||
cos_config_path = cfg.get('cosconfig_path', '.cos.yaml')
|
||||
|
||||
# 读取所有成功记录
|
||||
records = []
|
||||
with open(JSONL_PATH, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
if rec.get('status') == 'success' and rec.get('srt_cos_path'):
|
||||
records.append(rec)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
records = records[:50]
|
||||
total = len(records)
|
||||
print(f"共找到 {total} 条成功记录,开始下载...")
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
success = skip = failed = 0
|
||||
|
||||
for i, rec in enumerate(records, 1):
|
||||
video_cos = rec.get('video_cos_path').replace(".mp4", ".mp3")
|
||||
srt_cos = rec.get('srt_cos_path')
|
||||
|
||||
video_name = os.path.splitext(os.path.basename(video_cos))[0] if video_cos else None
|
||||
|
||||
print(f"[{i}/{total}] {video_name}")
|
||||
|
||||
# 下载 MP4
|
||||
if video_cos:
|
||||
local_mp4 = os.path.join(OUTPUT_DIR, os.path.basename(video_cos))
|
||||
ret = download(coscli_path, cos_config_path, video_cos, local_mp4)
|
||||
if ret == "ok":
|
||||
success += 1
|
||||
print(f" ✓ 视频: {os.path.basename(video_cos)}")
|
||||
elif ret == "skip":
|
||||
skip += 1
|
||||
print(f" - 视频已存在,跳过")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" ✗ 视频下载失败: {ret}")
|
||||
|
||||
# 下载 SRT
|
||||
if srt_cos:
|
||||
local_srt = os.path.join(OUTPUT_DIR, os.path.basename(srt_cos))
|
||||
ret = download(coscli_path, cos_config_path, srt_cos, local_srt)
|
||||
if ret == "ok":
|
||||
success += 1
|
||||
print(f" ✓ 字幕: {os.path.basename(srt_cos)}")
|
||||
elif ret == "skip":
|
||||
skip += 1
|
||||
print(f" - 字幕已存在,跳过")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" ✗ 字幕下载失败: {ret}")
|
||||
|
||||
print(f"\n完成 — 下载: {success} 失败: {failed} 跳过(已存在): {skip}")
|
||||
print(f"文件保存在: {os.path.abspath(OUTPUT_DIR)}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
69
tools/extract_errors.py
Normal file
69
tools/extract_errors.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从 数据.jsonl 提取【最终状态是错误/无字幕】的视频 COS 路径,写到 txt(可当任务列表重跑测试)。
|
||||
按每个视频的【最后一条记录】判定最终状态(忽略中间重试)。
|
||||
|
||||
用法:
|
||||
python temp/extract_errors.py 2号机器数据.jsonl error_videos.txt
|
||||
python temp/extract_errors.py 2号机器数据.jsonl out.txt error,no_subtitle # 也要无字幕的
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
JSONL = sys.argv[1] if len(sys.argv) > 1 else "数据.jsonl"
|
||||
OUT = sys.argv[2] if len(sys.argv) > 2 else "error_videos.txt"
|
||||
STATUSES = sys.argv[3].split(",") if len(sys.argv) > 3 else ["error"]
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
f = open(JSONL, encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
print(f"找不到文件: {JSONL}")
|
||||
return
|
||||
|
||||
latest = {} # video_cos_path -> (status, error_reason)
|
||||
with f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
vid = r.get("video_cos_path")
|
||||
if not vid:
|
||||
continue
|
||||
latest[vid] = (r.get("status"), r.get("error_reason", ""))
|
||||
|
||||
# 最终状态计数
|
||||
status_count = Counter(s for s, _ in latest.values())
|
||||
# 选中的(最终状态在 STATUSES 里)
|
||||
selected = [(v, er) for v, (s, er) in latest.items() if s in STATUSES]
|
||||
|
||||
# 写 txt(每行一个 COS 路径)
|
||||
with open(OUT, "w", encoding="utf-8") as w:
|
||||
for v, _ in selected:
|
||||
w.write(v + "\n")
|
||||
|
||||
print(f"文件: {JSONL}")
|
||||
print(f"去重后视频数: {len(latest)}")
|
||||
print(f"最终状态分布: {dict(status_count)}")
|
||||
print(f"提取状态 {STATUSES}: {len(selected)} 个 → 写入 {OUT}")
|
||||
|
||||
# 错误原因 Top(帮你看主要错在哪)
|
||||
reasons = Counter()
|
||||
for _, er in selected:
|
||||
if er:
|
||||
key = er.split(":")[0][:60] if ":" in er else er[:60]
|
||||
reasons[key] += 1
|
||||
if reasons:
|
||||
print("\n错误原因 Top:")
|
||||
for k, c in reasons.most_common(10):
|
||||
print(f" {c:>5} {k}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
316
tools/fleet_monitor.py
Normal file
316
tools/fleet_monitor.py
Normal file
@@ -0,0 +1,316 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""全队健康度监控: 汇总所有机器最新一小时的处理摘要, 跑异常检测, 产出报告。
|
||||
配合 run_worker.py 里的 job_cos.run_hourly_digest_loop() 后台线程(每台机器每小时自动上传
|
||||
一份小摘要到 COS)使用 —— 本脚本负责把这些小摘要拉回来汇总判断。
|
||||
|
||||
用法(项目根目录运行, 建议 crontab 每小时跑一次,比如 5分/10分 触发一次都行,脚本自己会
|
||||
算准要看哪个整点):
|
||||
python tools/fleet_monitor.py # 单小时报告 + 异常检测(原有行为)
|
||||
python tools/fleet_monitor.py --history 24 # 全队历史趋势(最近24个小时, 不跑异常检测)
|
||||
python tools/fleet_monitor.py --history # 不带数字 = 默认 24 小时
|
||||
|
||||
设计文档: docs/superpowers/specs/2026-07-08-fleet-monitoring-design.md
|
||||
不接外部通知渠道(企业微信/钉钉/飞书webhook 是后续可插拔扩展点, 本次只落地本地+COS报告文件)。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import unicodedata
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from tool import job_cos # noqa: E402
|
||||
|
||||
# ---- 异常检测阈值(先写死成常量, 要调直接改这里) ----
|
||||
OFFLINE_HOURS = 2 # 机器掉线: 最新摘要距目标小时 >= 这么多小时
|
||||
SUCCESS_RATE_MIN = 0.5 # 单小时成功率骤降阈值
|
||||
SUCCESS_RATE_MIN_SAMPLE = 10 # 样本太少(成功+错误 < 这个数)不判断成功率, 避免噪音
|
||||
MACHINE_DROP_RATIO = 0.3 # 单机吞吐骤降: 本小时产出 < 自己24h均值的这个比例
|
||||
FLEET_DROP_RATIO = 0.5 # 全队吞吐骤降: 全队本小时产出 < 全队24h均值的这个比例
|
||||
BASELINE_HOURS = 24 # 基线取多少小时的历史均值(不含目标小时本身)
|
||||
KNOWN_MACHINES_LOOKBACK = 48 # "这台机器算不算存在"往回看多少小时
|
||||
|
||||
REPORT_DIR = "./monitor_reports"
|
||||
|
||||
DEFAULT_HISTORY_HOURS = 24 # --history 不带数字时默认看多少小时
|
||||
|
||||
|
||||
def _dw(s):
|
||||
"""字符串显示宽度(中文/全角算2), 用于历史趋势表格对齐。"""
|
||||
return sum(2 if unicodedata.east_asian_width(c) in ("W", "F") else 1 for c in str(s))
|
||||
|
||||
|
||||
def _padl(s, w):
|
||||
"""按显示宽度左对齐(文字在前, 空格补在后面) —— 跟 hourly_stats.py 的同名函数保持一致约定。"""
|
||||
s = str(s)
|
||||
return s + " " * max(0, w - _dw(s))
|
||||
|
||||
|
||||
def _padr(s, w):
|
||||
"""按显示宽度右对齐(空格补在前面, 文字贴右边)。"""
|
||||
return " " * max(0, w - _dw(s)) + str(s)
|
||||
|
||||
|
||||
def target_hour(now=None) -> datetime:
|
||||
"""确定要汇总的目标整点小时: 最新一个已经过完的整点; 如果当前分钟数<5(刚跨过整点没多久,
|
||||
部分机器可能还没上传完), 再往回退一个小时留缓冲。"""
|
||||
now = now or datetime.now()
|
||||
completed = now.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1)
|
||||
if now.minute < 5:
|
||||
completed -= timedelta(hours=1)
|
||||
return completed
|
||||
|
||||
|
||||
def fetch_range_digests(end_hour_dt: datetime, hours_back: int) -> dict:
|
||||
"""拉取 [end_hour_dt - hours_back, end_hour_dt] 闭区间(按小时)所有机器的摘要,
|
||||
返回 {hour_key(YYYYMMDDHH): {machine_id: digest_dict} | None}。
|
||||
区分两种"没数据": 该小时【列COS目录本身失败】(比如COS暂时不可达)记为 None——这是需要
|
||||
main() 判断是否整体报错退出的信号; 该小时【列成功但目录是空的】(单纯没人上报)记为 {}——
|
||||
这是正常情况, 不代表故障。单台机器的摘要【下载】失败只跳过该机器、打印警告, 不影响这个
|
||||
小时其它机器的数据, 也不算列目录失败。"""
|
||||
result = {}
|
||||
for i in range(hours_back + 1):
|
||||
hdt = end_hour_dt - timedelta(hours=i)
|
||||
hkey = hdt.strftime("%Y%m%d%H")
|
||||
try:
|
||||
files = job_cos.list_hourly_digest_files_for_hour(hdt)
|
||||
except Exception as e:
|
||||
print(f"[WARN] 列出 {hkey} 的摘要文件失败: {e}")
|
||||
result[hkey] = None
|
||||
continue
|
||||
machines = {}
|
||||
for machine_id, key in files:
|
||||
try:
|
||||
machines[machine_id] = job_cos.download_hourly_digest(key)
|
||||
except Exception as e:
|
||||
print(f"[WARN] 下载 {machine_id} 在 {hkey} 的摘要失败, 跳过: {e}")
|
||||
result[hkey] = machines
|
||||
return result
|
||||
|
||||
|
||||
def compute_baseline(range_digests: dict, target_hdt: datetime) -> dict:
|
||||
"""算每台机器过去 BASELINE_HOURS 小时(不含目标小时本身)的 success_hours 均值。
|
||||
只用真实拉到的小时数据平均(缺的小时/列目录失败的小时都不计入, 不当0算, 避免拖低均值),
|
||||
没有任何数据的机器不出现在结果里。"""
|
||||
sums = {}
|
||||
counts = {}
|
||||
for i in range(1, BASELINE_HOURS + 1):
|
||||
hkey = (target_hdt - timedelta(hours=i)).strftime("%Y%m%d%H")
|
||||
for machine_id, digest in (range_digests.get(hkey) or {}).items():
|
||||
sums[machine_id] = sums.get(machine_id, 0.0) + digest.get("success_hours", 0.0)
|
||||
counts[machine_id] = counts.get(machine_id, 0) + 1
|
||||
return {m: sums[m] / counts[m] for m in sums}
|
||||
|
||||
|
||||
def known_machines_and_latest_hour(range_digests: dict) -> dict:
|
||||
"""扫全部拉到的小时, 返回 {machine_id: 该机器出现过的最新 hour_key}。列目录失败(None)的
|
||||
小时直接跳过, 不当"没有任何机器"处理(避免把偶发的单小时COS故障误判成全队一起掉线)。"""
|
||||
latest = {}
|
||||
for hkey, machines in range_digests.items():
|
||||
if machines is None:
|
||||
continue
|
||||
for machine_id in machines:
|
||||
if machine_id not in latest or hkey > latest[machine_id]:
|
||||
latest[machine_id] = hkey
|
||||
return latest
|
||||
|
||||
|
||||
def aggregate_history(range_digests: dict, hours_back: int, end_hdt: datetime) -> list:
|
||||
"""把 fetch_range_digests() 拉到的多小时摘要,按小时汇总成全队合计一行,按时间正序(从早到晚)
|
||||
返回 [(hour_key, machine_count, count, success, no_subtitle, error, success_hours, ok), ...]。
|
||||
ok=False 表示这个小时列COS目录本身失败(range_digests[hkey] is None), 其余字段填0/0.0;
|
||||
ok=True 且 machine_count=0 是"列目录成功但没人上报"的正常空窗口(比如还没到那个整点),
|
||||
跟 ok=False(拉取本身失败)是两码事,不能混在一起看成同一种"没数据"。"""
|
||||
rows = []
|
||||
for i in range(hours_back, -1, -1):
|
||||
hdt = end_hdt - timedelta(hours=i)
|
||||
hkey = hdt.strftime("%Y%m%d%H")
|
||||
machines = range_digests.get(hkey)
|
||||
if machines is None:
|
||||
rows.append((hkey, 0, 0, 0, 0, 0, 0.0, False))
|
||||
continue
|
||||
count = sum(d.get("count", 0) for d in machines.values())
|
||||
success = sum(d.get("success", 0) for d in machines.values())
|
||||
no_sub = sum(d.get("no_subtitle", 0) for d in machines.values())
|
||||
error = sum(d.get("error", 0) for d in machines.values())
|
||||
succ_hours = sum(d.get("success_hours", 0.0) for d in machines.values())
|
||||
rows.append((hkey, len(machines), count, success, no_sub, error, succ_hours, True))
|
||||
return rows
|
||||
|
||||
|
||||
def print_history(hours_back: int):
|
||||
"""打印全队历史多个小时的汇总趋势(拉COS各机器已上传的小时摘要, 不依赖本机 数据.jsonl,
|
||||
只要各机器在跑带小时摘要上传功能的新版 run_worker.py 就能看到)。跟 tools/hourly_stats.py
|
||||
的单机版本是同一个"按process_start所在小时分桶计数"的逻辑, 只是数据源从本机 数据.jsonl
|
||||
换成了COS上各机器上传的小时摘要, 且是全队加总, 不是单机。"""
|
||||
thdt = target_hour()
|
||||
print(f"目标小时: {thdt.strftime('%Y-%m-%d %H:00')} (往前拉 {hours_back} 小时, 共 {hours_back+1} 个小时)")
|
||||
range_digests = fetch_range_digests(thdt, hours_back)
|
||||
rows = aggregate_history(range_digests, hours_back, thdt)
|
||||
|
||||
# 每列宽度都比内容本身多留至少2个显示宽度的间隔, 避免"小时"这种定长字符串(如
|
||||
# "2026-07-09 13:00"共16字符)紧贴着下一列数字、看起来像连在一起。
|
||||
W = {"小时": 18, "机器数": 8, "视频数": 8, "成功": 6, "无字幕": 8, "错误": 6, "产出小时数": 12}
|
||||
header = (_padl("小时", W["小时"]) + _padr("机器数", W["机器数"]) + _padr("视频数", W["视频数"]) +
|
||||
_padr("成功", W["成功"]) + _padr("无字幕", W["无字幕"]) + _padr("错误", W["错误"]) +
|
||||
_padr("产出小时数", W["产出小时数"]))
|
||||
width = sum(W.values())
|
||||
print("=" * width)
|
||||
print(header)
|
||||
print("-" * width)
|
||||
|
||||
tot = {"count": 0, "success": 0, "no_subtitle": 0, "error": 0, "succ_hours": 0.0}
|
||||
fail_hours = []
|
||||
for hkey, mcount, count, success, no_sub, error, succ_hours, ok in rows:
|
||||
disp_hour = f"{hkey[:4]}-{hkey[4:6]}-{hkey[6:8]} {hkey[8:10]}:00"
|
||||
if not ok:
|
||||
print(_padl(disp_hour, W["小时"]) + _padr("(拉取失败)", width - W["小时"]))
|
||||
fail_hours.append(hkey)
|
||||
continue
|
||||
print(_padl(disp_hour, W["小时"]) + _padr(mcount, W["机器数"]) + _padr(count, W["视频数"]) +
|
||||
_padr(success, W["成功"]) + _padr(no_sub, W["无字幕"]) + _padr(error, W["错误"]) +
|
||||
_padr(f"{succ_hours:.1f}", W["产出小时数"]))
|
||||
tot["count"] += count
|
||||
tot["success"] += success
|
||||
tot["no_subtitle"] += no_sub
|
||||
tot["error"] += error
|
||||
tot["succ_hours"] += succ_hours
|
||||
|
||||
print("-" * width)
|
||||
nh = len(rows) - len(fail_hours)
|
||||
if nh > 0:
|
||||
print(_padl("合计/时均", W["小时"]) + _padr(tot["count"], W["视频数"] + W["机器数"]) +
|
||||
_padr(tot["success"], W["成功"]) + _padr(tot["no_subtitle"], W["无字幕"]) +
|
||||
_padr(tot["error"], W["错误"]) + _padr(f"{tot['succ_hours']:.1f}", W["产出小时数"]))
|
||||
print(f"\n共 {nh} 个有效小时 | 时均处理 {tot['count']/nh:.0f} 条 | "
|
||||
f"时均产出 {tot['succ_hours']/nh:.1f} 小时视频内容")
|
||||
if fail_hours:
|
||||
print(f"\n({len(fail_hours)} 个小时COS拉取失败, 已跳过: {', '.join(fail_hours)})")
|
||||
|
||||
|
||||
def detect_anomalies(target_hdt: datetime, target_digests: dict,
|
||||
baseline: dict, latest_hour: dict) -> list:
|
||||
"""跑4条异常检测规则, 返回 [(level, message), ...],level ∈ {"严重","警告"}。"""
|
||||
anomalies = []
|
||||
|
||||
# ① 机器掉线: 最新一次上报距目标小时 >= OFFLINE_HOURS
|
||||
for machine_id, last_hkey in latest_hour.items():
|
||||
last_hdt = datetime.strptime(last_hkey, "%Y%m%d%H")
|
||||
gap_hours = (target_hdt - last_hdt).total_seconds() / 3600
|
||||
if gap_hours >= OFFLINE_HOURS:
|
||||
anomalies.append(("严重", f"机器 {machine_id} 已连续{gap_hours:.0f}小时无上报数据,可能停机"))
|
||||
|
||||
# ② 单小时成功率骤降
|
||||
for machine_id, digest in target_digests.items():
|
||||
succ, err = digest.get("success", 0), digest.get("error", 0)
|
||||
sample = succ + err
|
||||
if sample >= SUCCESS_RATE_MIN_SAMPLE:
|
||||
rate = succ / sample
|
||||
if rate < SUCCESS_RATE_MIN:
|
||||
anomalies.append(("警告", f"机器 {machine_id} 本小时成功率仅{rate*100:.0f}%({succ}/{sample})"))
|
||||
|
||||
# ③ 单机吞吐骤降
|
||||
for machine_id, digest in target_digests.items():
|
||||
base = baseline.get(machine_id)
|
||||
if base and base > 0:
|
||||
cur = digest.get("success_hours", 0.0)
|
||||
if cur < base * MACHINE_DROP_RATIO:
|
||||
anomalies.append(("警告", f"机器 {machine_id} 本小时产出{cur:.1f}小时,远低于24h均值{base:.1f}小时"))
|
||||
|
||||
# ④ 全队吞吐骤降
|
||||
fleet_cur = sum(d.get("success_hours", 0.0) for d in target_digests.values())
|
||||
fleet_base = sum(baseline.values())
|
||||
if fleet_base > 0 and fleet_cur < fleet_base * FLEET_DROP_RATIO:
|
||||
anomalies.append(("严重", f"全队本小时总产出{fleet_cur:.1f}小时,远低于24h均值{fleet_base:.1f}小时,可能是全局性问题(比如OCR端点故障)"))
|
||||
|
||||
return anomalies
|
||||
|
||||
|
||||
def render_report(target_hdt: datetime, target_digests: dict,
|
||||
baseline: dict, anomalies: list) -> str:
|
||||
"""生成 Markdown 报告文本。"""
|
||||
lines = [f"# 全队监控报告 {target_hdt.strftime('%Y-%m-%d %H:00')}", ""]
|
||||
lines.append("## 异常告警")
|
||||
if anomalies:
|
||||
for level, msg in anomalies:
|
||||
icon = "🔴" if level == "严重" else "🟡"
|
||||
lines.append(f"- {icon} [{level}] {msg}")
|
||||
else:
|
||||
lines.append("✅ 本小时一切正常")
|
||||
lines.append("")
|
||||
lines.append("## 本小时明细")
|
||||
lines.append("| 机器 | 视频数 | 成功 | 无字幕 | 错误 | 产出小时数 |")
|
||||
lines.append("|---|---|---|---|---|---|")
|
||||
for machine_id in sorted(target_digests):
|
||||
d = target_digests[machine_id]
|
||||
lines.append(f"| {machine_id} | {d.get('count',0)} | {d.get('success',0)} | "
|
||||
f"{d.get('no_subtitle',0)} | {d.get('error',0)} | {d.get('success_hours',0):.1f} |")
|
||||
lines.append("")
|
||||
lines.append("## 过去24小时全队趋势")
|
||||
fleet_cur = sum(d.get("success_hours", 0.0) for d in target_digests.values())
|
||||
fleet_base = sum(baseline.values())
|
||||
lines.append(f"全队本小时产出小时数总和:{fleet_cur:.1f},24小时均值:{fleet_base:.1f}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _parse_history_arg(argv):
|
||||
"""解析 --history [N] —— 带上就返回要看多少小时(不带数字用默认值), 没带这个参数返回 None
|
||||
(跑原来的单小时报告模式)。"""
|
||||
if "--history" not in argv:
|
||||
return None
|
||||
i = argv.index("--history")
|
||||
if i + 1 < len(argv):
|
||||
try:
|
||||
return int(argv[i + 1])
|
||||
except ValueError:
|
||||
pass
|
||||
return DEFAULT_HISTORY_HOURS
|
||||
|
||||
|
||||
def main():
|
||||
hours_back = _parse_history_arg(sys.argv[1:])
|
||||
if hours_back is not None:
|
||||
print_history(hours_back)
|
||||
return
|
||||
|
||||
thdt = target_hour()
|
||||
print(f"目标小时: {thdt.strftime('%Y-%m-%d %H:00')}")
|
||||
|
||||
range_digests = fetch_range_digests(thdt, KNOWN_MACHINES_LOOKBACK)
|
||||
target_hkey = thdt.strftime("%Y%m%d%H")
|
||||
target_slot = range_digests.get(target_hkey)
|
||||
if target_slot is None:
|
||||
# 目标小时本身列目录都失败了(COS大概率整体不可达) -> 按设计文档"错误处理"要求直接报错退出,
|
||||
# 不生成一份看起来正常、实际数据全空的假报告掩盖问题。
|
||||
print(f"[FAIL] 目标小时 {target_hkey} 的摘要目录列取失败(COS可能不可达), 中止, 不生成报告")
|
||||
sys.exit(1)
|
||||
target_digests = target_slot
|
||||
if not target_digests:
|
||||
print(f"[WARN] 目标小时 {target_hkey} 没有拉到任何机器的摘要, 可能还没到点(正常空目录, 不是故障)")
|
||||
|
||||
baseline = compute_baseline(range_digests, thdt)
|
||||
latest_hour = known_machines_and_latest_hour(range_digests)
|
||||
anomalies = detect_anomalies(thdt, target_digests, baseline, latest_hour)
|
||||
|
||||
report = render_report(thdt, target_digests, baseline, anomalies)
|
||||
|
||||
os.makedirs(REPORT_DIR, exist_ok=True)
|
||||
local_path = os.path.join(REPORT_DIR, f"{thdt.strftime('%Y%m%d_%H')}.md")
|
||||
with open(local_path, "w", encoding="utf-8") as f:
|
||||
f.write(report)
|
||||
print(f"[OK] 报告已写入 {local_path}")
|
||||
|
||||
try:
|
||||
dst = job_cos.upload_monitor_report(local_path, thdt)
|
||||
if dst:
|
||||
print(f"[OK] 报告已上传 -> {dst}")
|
||||
except Exception as e:
|
||||
print(f"[WARN] 报告上传COS失败(本地文件已保留): {e}")
|
||||
|
||||
if anomalies:
|
||||
print(f"\n共检测到 {len(anomalies)} 条异常,详见报告。")
|
||||
else:
|
||||
print("\n本小时一切正常。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
47
tools/gen_test_srt.py
Normal file
47
tools/gen_test_srt.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""用真实OCR数据生成一份【已过滤水印】的测试SRT。跳过读图(monkeypatch)。
|
||||
用法: python temp/gen_test_srt.py <composite目录> <输出srt>
|
||||
"""
|
||||
import sys, os, json
|
||||
sys.path.insert(0, os.path.abspath("."))
|
||||
import tool.getsrt as G
|
||||
from tool.log.log_manager import UnifiedLogger
|
||||
|
||||
G.find_best_ocr_match = lambda *a, **k: (None, 0) # 跳过首尾音频匹配的读图
|
||||
|
||||
D = sys.argv[1] if len(sys.argv) > 1 else "test_srt/BV1BamdYLExJ"
|
||||
OUT = sys.argv[2] if len(sys.argv) > 2 else "test_srt/BV1BamdYLExJ.filtered.srt"
|
||||
META = os.path.join(D, "metadata.json")
|
||||
logger = UnifiedLogger(); logger.reset(); logger.init_logging("rerun_log.json"); logger.start_program("rerun")
|
||||
|
||||
pairs = []
|
||||
lines = open(os.path.join(D, "ocr_result.jsonl"), encoding="utf-8").readlines()
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
if i + 1 < len(lines) and lines[i].startswith("pic_path: "):
|
||||
fn = os.path.basename(lines[i].strip()[len("pic_path: "):])
|
||||
try:
|
||||
pairs.append([os.path.join(D, fn), json.loads(lines[i + 1])]); i += 2; continue
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
i += 1
|
||||
|
||||
# 坐标水印过滤(就是接进 getsrt 的那个)
|
||||
G._filter_watermark_by_position(pairs, logger)
|
||||
|
||||
best = G.analyze_subtitle_blocks(pairs, META, logger)
|
||||
h = G.analyze_h(pairs, META)
|
||||
results = []
|
||||
for idx in range(len(pairs)):
|
||||
try:
|
||||
text, conf, flag, sim = G.extract_highest_subtitle(idx, pairs, best, META, h, logger)
|
||||
except Exception:
|
||||
text = ""
|
||||
if text and not G.check_subtitle_quality_enhanced(text) and conf <= 90:
|
||||
text = ""
|
||||
results.append((idx, text or ""))
|
||||
# 静态文字过滤(频率, 兜底)
|
||||
results = G._filter_static_text(results, logger)
|
||||
G.generate_srt_from_results(results, OUT, interval_seconds=0.33)
|
||||
n = sum(1 for l in open(OUT, encoding="utf-8") if "-->" in l)
|
||||
print("生成: %s 共 %d 条字幕" % (OUT, n))
|
||||
174
tools/hourly_stats.py
Normal file
174
tools/hourly_stats.py
Normal file
@@ -0,0 +1,174 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
按【小时】统计 数据.jsonl:每小时处理多少条视频、视频总时长、成功情况、处理倍速。
|
||||
小时取 process_start(没有则回退 timestamp)的 YYYY-MM-DD HH。
|
||||
|
||||
"处理倍速" = 该小时内处理掉的【视频内容总时长】 / 1 墙钟小时。
|
||||
例: 一个墙钟小时里处理了 40 小时的视频内容 → 倍速 40x → 等效有效并发≈40。
|
||||
这个数最能反映真实吞吐/并发利用率(对照 max-workers 看是否吃满)。
|
||||
|
||||
用法(项目根目录运行):
|
||||
python temp/hourly_stats.py [数据.jsonl路径] [--last N]
|
||||
--last N : 只看最近 N 个有数据的小时(默认全部)
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import unicodedata
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def _parse(ts):
|
||||
try:
|
||||
return datetime.fromisoformat(ts)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _dw(s):
|
||||
"""字符串显示宽度(中文/全角算2)。"""
|
||||
return sum(2 if unicodedata.east_asian_width(c) in ("W", "F") else 1 for c in str(s))
|
||||
|
||||
|
||||
def _padr(s, w):
|
||||
"""按显示宽度右对齐。"""
|
||||
s = str(s)
|
||||
return " " * max(0, w - _dw(s)) + s
|
||||
|
||||
|
||||
def _padl(s, w):
|
||||
"""按显示宽度左对齐。"""
|
||||
s = str(s)
|
||||
return s + " " * max(0, w - _dw(s))
|
||||
|
||||
|
||||
# 列宽(显示宽度)
|
||||
COLW = {"小时": 16, "视频数": 8, "成功": 6, "无字幕": 8, "错误": 6,
|
||||
"总时长h": 10, "成功h": 9, "处理倍速": 10, "有效并发": 10}
|
||||
|
||||
|
||||
def _row(hour, count, success, no_sub, error, dur_h, ok_h, speed, eff):
|
||||
return (_padl(hour, COLW["小时"]) + _padr(count, COLW["视频数"]) +
|
||||
_padr(success, COLW["成功"]) + _padr(no_sub, COLW["无字幕"]) +
|
||||
_padr(error, COLW["错误"]) + _padr(dur_h, COLW["总时长h"]) +
|
||||
_padr(ok_h, COLW["成功h"]) + _padr(speed, COLW["处理倍速"]) +
|
||||
_padr(eff, COLW["有效并发"]))
|
||||
|
||||
args = [a for a in sys.argv[1:]]
|
||||
last_n = None
|
||||
if "--last" in args:
|
||||
i = args.index("--last")
|
||||
try:
|
||||
last_n = int(args[i + 1])
|
||||
del args[i:i + 2]
|
||||
except (IndexError, ValueError):
|
||||
last_n = None
|
||||
JSONL_PATH = args[0] if args else "数据.jsonl"
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
f = open(JSONL_PATH, encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
print(f"找不到文件: {JSONL_PATH}")
|
||||
return
|
||||
|
||||
hours = defaultdict(lambda: {"count": 0, "success": 0, "no_subtitle": 0,
|
||||
"error": 0, "dur": 0.0, "dur_ok": 0.0, "elapsed": 0.0})
|
||||
busy = defaultdict(float) # 每个墙钟小时内, 所有视频实际占用的总秒数(精确并发用)
|
||||
no_date = 0
|
||||
with f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
ts = rec.get("process_start") or rec.get("timestamp") or ""
|
||||
hour = ts[:13] if len(ts) >= 13 else None # YYYY-MM-DD HH(按开始时间归桶)
|
||||
if not hour:
|
||||
no_date += 1
|
||||
continue
|
||||
|
||||
h = hours[hour]
|
||||
h["count"] += 1
|
||||
status = rec.get("status")
|
||||
if status == "success":
|
||||
h["success"] += 1
|
||||
elif status == "no_subtitle":
|
||||
h["no_subtitle"] += 1
|
||||
else:
|
||||
h["error"] += 1
|
||||
|
||||
try:
|
||||
dur = float(rec.get("duration") or 0)
|
||||
h["dur"] += dur
|
||||
if status == "success":
|
||||
h["dur_ok"] += dur
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
h["elapsed"] += float(rec.get("elapsed_seconds") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# 精确并发: 把 [process_start, process_end] 的时长按实际跨越的每个小时切开累加,
|
||||
# 避免"长视频整段算进开始那个小时"导致失真。
|
||||
st = _parse(rec.get("process_start"))
|
||||
en = _parse(rec.get("process_end"))
|
||||
if st and en and en > st:
|
||||
cur = st
|
||||
while cur < en:
|
||||
hk = cur.strftime("%Y-%m-%dT%H")
|
||||
nxt = cur.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
|
||||
seg_end = min(en, nxt)
|
||||
busy[hk] += (seg_end - cur).total_seconds()
|
||||
cur = seg_end
|
||||
|
||||
if not hours:
|
||||
print("没有可统计的数据")
|
||||
return
|
||||
|
||||
keys = sorted(hours)
|
||||
if last_n:
|
||||
keys = keys[-last_n:]
|
||||
|
||||
width = sum(COLW.values())
|
||||
print(f"文件: {JSONL_PATH}")
|
||||
print("=" * width)
|
||||
print(_row("小时", "视频数", "成功", "无字幕", "错误", "总时长h", "成功h", "处理倍速", "有效并发"))
|
||||
print("-" * width)
|
||||
|
||||
tot = {"count": 0, "success": 0, "no_subtitle": 0, "error": 0,
|
||||
"dur": 0.0, "dur_ok": 0.0, "elapsed": 0.0}
|
||||
busy_sum = 0.0
|
||||
for hour in keys:
|
||||
h = hours[hour]
|
||||
speed = h["dur"] / 3600 # 处理倍速: 视频内容小时数 / 1墙钟小时
|
||||
eff = busy[hour] / 3600 # 有效并发: 该小时所有视频实际占用秒数 / 3600(=平均并发)
|
||||
busy_sum += busy[hour]
|
||||
print(_row(hour, h["count"], h["success"], h["no_subtitle"], h["error"],
|
||||
f"{h['dur']/3600:.1f}", f"{h['dur_ok']/3600:.1f}",
|
||||
f"{speed:.1f}x", f"{eff:.1f}"))
|
||||
for k in tot:
|
||||
tot[k] += h[k]
|
||||
|
||||
print("-" * width)
|
||||
nh = len(keys)
|
||||
print(_row("合计/均值", tot["count"], tot["success"], tot["no_subtitle"], tot["error"],
|
||||
f"{tot['dur']/3600:.1f}", f"{tot['dur_ok']/3600:.1f}",
|
||||
f"{tot['dur']/3600/nh:.1f}x", f"{busy_sum/3600/nh:.1f}"))
|
||||
if no_date:
|
||||
print(f"\n({no_date} 条无时间字段,已跳过)")
|
||||
print(f"\n共 {nh} 个小时 | 时均处理 {tot['count']/nh:.0f} 条 | "
|
||||
f"时均成功 {tot['success']/nh:.0f} 条")
|
||||
if tot["count"]:
|
||||
print(f"成功率 {tot['success']/tot['count']*100:.1f}%")
|
||||
print("提示: '有效并发'明显小于你设的 max-workers → 有串行瓶颈(GIL/锁/等待),不是并发不够。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
142
tools/patch_config.py
Normal file
142
tools/patch_config.py
Normal file
@@ -0,0 +1,142 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""在【项目根目录】运行: 把本次更新需要的 config.json 配置键就地改好。
|
||||
只动下面列出的键, 绝不碰你的密钥/COS路径/其它已调过的值。幂等, 可重复运行。
|
||||
|
||||
用法: python3 tools/patch_config.py
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
P = "config/config.json"
|
||||
if not os.path.exists(P):
|
||||
print("✗ 找不到 config/config.json, 请 cd 到项目根目录再运行")
|
||||
sys.exit(1)
|
||||
|
||||
d = json.load(open(P, encoding="utf-8"))
|
||||
sf = d.setdefault("splitframe", {})
|
||||
sub = d.setdefault("subtitle", {})
|
||||
ocr = d.setdefault("tencentcloud", {}).setdefault("ocr", {})
|
||||
dp = d.setdefault("dispatch", {})
|
||||
|
||||
changes = []
|
||||
|
||||
# -1) COS 任务分发(常驻 worker 认领任务 + 每日 数据.jsonl 备份)。缺失才加, 不动已有值。
|
||||
# jobs_root=取任务地址; daily_backup_path=数据.jsonl 备份位置; machine_id 留空=用主机名/环境变量。
|
||||
_bkt = (d.get("tencentcloud", {}).get("cos", {}) or {}).get("bucket", "asr-dig-data-1253181912")
|
||||
_dispatch_defaults = [
|
||||
("enabled", True),
|
||||
("source", "cos"), # cos=从COS认领 / local=只读本地 ./text2(启动脚本据此选模式)
|
||||
("run_long_video", False), # true=本机专跑超长队列(long_list_backup_path)
|
||||
("auto_fallback_long_video", True), # false=主队列空闲时也不兼跑长视频队列,只等主队列
|
||||
("global_dedup_on_startup", True), # false=启动时不拉全队去重清单,只按本机 数据.jsonl 去重
|
||||
("machine_id", ""),
|
||||
("jobs_root", "cos://%s/_jobs" % _bkt),
|
||||
("shard_size", 2000),
|
||||
("keep_alive", True),
|
||||
("idle_poll_seconds", 60),
|
||||
("claim_shuffle", True),
|
||||
("claim_match", ""),
|
||||
("heartbeat_seconds", 300),
|
||||
("daily_backup_path", "cos://%s/_jobs/daily" % _bkt),
|
||||
("long_list_backup_path", "cos://%s/_jobs/long_video" % _bkt),
|
||||
("monitor_root", "cos://%s/_jobs/monitor" % _bkt),
|
||||
("long_list_clear_after_upload", True),
|
||||
("long_list_min_batch", 1000), # 领取长视频队列时凑够这么多条任务才开始处理一批
|
||||
("daily_backup_hour", 10),
|
||||
("stop_flag_key", "_jobs/STOP"),
|
||||
]
|
||||
for k, v in _dispatch_defaults:
|
||||
if k not in dp:
|
||||
dp[k] = v
|
||||
changes.append("dispatch.%s -> %s (新增, COS任务分发)" % (k, v))
|
||||
|
||||
# 0) OCR 重试封顶 + 单次超时 (防"端点全超时时一张图卡~1小时"拖死worker)
|
||||
if "max_attempts" not in ocr:
|
||||
ocr["max_attempts"] = 4
|
||||
changes.append("ocr.max_attempts -> 4 (新增, 重试封顶)")
|
||||
# 单次OCR超时: 调到30s(大图负载下最慢~12s, 12s砍太狠; 有max_attempts封顶不会卡死)
|
||||
if ocr.get("timeout", 0) < 30:
|
||||
_old = ocr.get("timeout")
|
||||
ocr["timeout"] = 30
|
||||
changes.append("ocr.timeout %s -> 30 (大图留足余量)" % _old)
|
||||
if "dispatch_log_every" not in ocr:
|
||||
ocr["dispatch_log_every"] = 1000
|
||||
changes.append("ocr.dispatch_log_every -> 1000 (新增, 每1000次派发落盘计数, 0=关)")
|
||||
|
||||
# 0b) 处理完删源视频(本来就在COS上, 本地是冗余副本)。防 ./video/ 堆积撑爆磁盘。
|
||||
if sf.get("delete_video") is not True:
|
||||
sf["delete_video"] = True
|
||||
changes.append("delete_video -> True (处理完删源视频, 防磁盘爆)")
|
||||
|
||||
# 1) 抽帧 ffmpeg 线程数: 统一收敛到 "auto"(按 CPU核数÷并发进程数 自动摊分, GPU/CPU两条
|
||||
# 抽帧路径现在都吃这个值了, 比固定数字更适合核数不一的机队)。已经是 "auto" 的跳过;
|
||||
# 只升级明显偏低的字面值(<5)或完全没配的情况, 已手动调更高的字面值保留不动。
|
||||
old = sf.get("ffmpeg_threads", 2)
|
||||
_old_is_auto = isinstance(old, str) and old.strip().lower() == "auto"
|
||||
if not _old_is_auto and (not isinstance(old, (int, float)) or old < 5):
|
||||
sf["ffmpeg_threads"] = "auto"
|
||||
changes.append(f"ffmpeg_threads {old!r} -> auto")
|
||||
|
||||
# 2) 超长视频补跑列表 (缺失才加)
|
||||
if "long_video_list" not in sub:
|
||||
sub["long_video_list"] = "./超长待重跑.txt"
|
||||
changes.append("long_video_list -> ./超长待重跑.txt (新增)")
|
||||
|
||||
# 2b) 降低 OCR 拼接帧数 (大图OCR>12s会超时; 只往下调, 已更小的保留)
|
||||
_mt = sub.get("max_tiles_per_composite", 40)
|
||||
if _mt > 24:
|
||||
sub["max_tiles_per_composite"] = 24
|
||||
changes.append("max_tiles_per_composite %d -> 24 (拼接图减小, 防OCR超时)" % _mt)
|
||||
|
||||
# 2b2) 降全局OCR并发, 削掉过载突发尾延迟(端点平均快但突发会排队爆30s); 只往下调
|
||||
_mg = sub.get("max_global_ocr", 80)
|
||||
if _mg > 50:
|
||||
sub["max_global_ocr"] = 50
|
||||
changes.append("max_global_ocr %d -> 50 (降负载削尾延迟)" % _mg)
|
||||
|
||||
# 2b3) OCR图片像素上限: 超大合成图(区域过高)缩小再OCR, 防单张>30s超时
|
||||
if "ocr_max_image_side" not in sub:
|
||||
sub["ocr_max_image_side"] = 4000
|
||||
changes.append("ocr_max_image_side -> 4000 (新增, 超大图缩小防超时)")
|
||||
|
||||
# 2c) 不丢帧: OCR多轮重试, 仍失败则整段视频判失败去重跑(不产出带洞SRT)
|
||||
for k, v in [("ocr_no_drop", True), ("ocr_max_passes", 5),
|
||||
("ocr_retry_sleep", 2), ("ocr_fail_list", "./OCR失败待重跑.txt")]:
|
||||
if k not in sub:
|
||||
sub[k] = v
|
||||
changes.append("%s -> %s (新增, 不丢帧)" % (k, v))
|
||||
|
||||
# 2d) 低置信占比过滤: 幻灯片/编程视频OCR出垃圾(低置信50%+)且吃3-5倍OCR, 整段判非字幕跳过
|
||||
for k, v in [("filter_lowconf", True), ("lowconf_thr", 80),
|
||||
("lowconf_filter_frac", 0.6), ("lowconf_min_det", 200)]:
|
||||
if k not in sub:
|
||||
sub[k] = v
|
||||
changes.append("%s -> %s (新增, 过滤幻灯片/代码类非字幕视频)" % (k, v))
|
||||
|
||||
# 2e) 状态归类清单: 这些原因是"无可用字幕的正常过滤", 应记 no_subtitle 而非 error。
|
||||
# 旧逻辑把字幕太少/冗余/置信度/非字幕等错记成 error(线上 error 中~79%其实是它们)。缺失才加。
|
||||
if "no_subtitle_reasons" not in sub:
|
||||
sub["no_subtitle_reasons"] = [
|
||||
"字幕太少", "冗余文字过多", "冗余文字太多", "无字幕区域",
|
||||
"非字幕内容", "置信度太低", "未检测", "特殊字体",
|
||||
"视频过短", "视频过长",
|
||||
]
|
||||
changes.append("no_subtitle_reasons -> [10项] (新增, 过滤型结论归 no_subtitle 不计 error)")
|
||||
|
||||
# 3) 抽帧自动选空闲GPU (避开被OCR服务占用的卡, 缺失才加)
|
||||
if "gpu_auto_select" not in sf:
|
||||
sf["gpu_auto_select"] = True
|
||||
changes.append("gpu_auto_select -> True (新增, 抽帧自动避开被占用的GPU)")
|
||||
if "gpu_busy_mem_mb" not in sf:
|
||||
sf["gpu_busy_mem_mb"] = 4096
|
||||
changes.append("gpu_busy_mem_mb -> 4096 (新增, 显存占用超此判为忙)")
|
||||
sf.setdefault("gpu_skip_indices", []) # 手动跳过列表(默认空, 交给自动探测)
|
||||
|
||||
if changes:
|
||||
json.dump(d, open(P, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("✓ config 已更新:")
|
||||
for c in changes:
|
||||
print(" -", c)
|
||||
else:
|
||||
print("✓ config 无需改动(已是最新)")
|
||||
41
tools/release_machine.py
Normal file
41
tools/release_machine.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""人工救援: 当某台机器【永久报废】(不会再重启续做)时, 把它名下所有未完成分片的锁删掉,
|
||||
任务退回 todo 让别的机器重新认领。
|
||||
|
||||
⚠️ 只在确认机器真报废后用。机器只是重启的话, 它自己会续做, 不要跑这个。
|
||||
|
||||
用法:
|
||||
python tools/release_machine.py <机器ID> # 实际释放
|
||||
python tools/release_machine.py <机器ID> --dry-run # 只列出, 不删
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from tool import job_cos # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python tools/release_machine.py <机器ID> [--dry-run]")
|
||||
sys.exit(1)
|
||||
target = sys.argv[1]
|
||||
dry = "--dry-run" in sys.argv[2:]
|
||||
|
||||
shards = job_cos.my_open_claims(target)
|
||||
if not shards:
|
||||
print(f"机器 {target} 名下没有未完成分片。")
|
||||
return
|
||||
print(f"机器 {target} 名下未完成分片 {len(shards)} 个:")
|
||||
for s in shards:
|
||||
print(f" - {s}")
|
||||
if dry:
|
||||
print("(--dry-run, 未删除)")
|
||||
return
|
||||
for s in shards:
|
||||
job_cos._delete(job_cos._key("claimed", s)) # 只删锁; todo 仍在 -> 别人会重领
|
||||
print(f"✓ 已释放 {len(shards)} 个分片的锁, 它们会被其他机器重新认领。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
99
tools/rerun_srt.py
Normal file
99
tools/rerun_srt.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从已有 OCR 数据重新生成 SRT,不重新调用 OCR API。
|
||||
用法: python rerun_srt.py
|
||||
"""
|
||||
import sys, os, json
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from difflib import SequenceMatcher
|
||||
from tool.log.log_manager import UnifiedLogger
|
||||
from tool.getsrt import (
|
||||
analyze_subtitle_blocks, analyze_h,
|
||||
extract_highest_subtitle, check_subtitle_quality_enhanced,
|
||||
evaluate_text_quality_rules, generate_srt_from_results
|
||||
)
|
||||
|
||||
COMPOSITE_DIR = sys.argv[1] if len(sys.argv) > 1 else "./composite/BV1aL4y1p7Bi_p11"
|
||||
JSONL_PATH = os.path.join(COMPOSITE_DIR, "ocr_result.jsonl")
|
||||
META_PATH = os.path.join(COMPOSITE_DIR, "metadata.json")
|
||||
OUTPUT_SRT = os.path.join(COMPOSITE_DIR, "BV1aL4y1p7Bi_p11.new.srt")
|
||||
INTERVAL = 0.33
|
||||
|
||||
logger = UnifiedLogger()
|
||||
logger.reset()
|
||||
logger.init_logging("rerun_log.json")
|
||||
logger.start_program("rerun")
|
||||
|
||||
# 读取已有 OCR 数据,将旧路径修正为当前实际路径
|
||||
all_pic_ocr_pair = []
|
||||
with open(JSONL_PATH, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
if i + 1 < len(lines) and lines[i].startswith("pic_path: "):
|
||||
raw_path = lines[i].strip()[len("pic_path: "):]
|
||||
filename = os.path.basename(raw_path)
|
||||
actual_path = os.path.join(COMPOSITE_DIR, filename)
|
||||
try:
|
||||
ocr_json = json.loads(lines[i + 1])
|
||||
all_pic_ocr_pair.append([actual_path, ocr_json])
|
||||
i += 2
|
||||
continue
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
i += 1
|
||||
|
||||
print(f"读取 {len(all_pic_ocr_pair)} 帧的OCR数据")
|
||||
|
||||
# 分析字幕网格位置
|
||||
best_grids = analyze_subtitle_blocks(all_pic_ocr_pair, META_PATH, logger)
|
||||
h = analyze_h(all_pic_ocr_pair, META_PATH)
|
||||
|
||||
if not best_grids.get("composite"):
|
||||
print("ERROR: 未找到合适的字幕区域")
|
||||
sys.exit(1)
|
||||
|
||||
# 逐帧提取字幕
|
||||
results = []
|
||||
DEBUG_KEYWORD = "曹小月从角落里拿出雨伞"
|
||||
for idx in range(len(all_pic_ocr_pair)):
|
||||
print(f"\r处理 {idx + 1}/{len(all_pic_ocr_pair)}", end="", flush=True)
|
||||
text, conf, flag, sim = extract_highest_subtitle(
|
||||
idx, all_pic_ocr_pair, best_grids, META_PATH, h, logger
|
||||
)
|
||||
if text and not check_subtitle_quality_enhanced(text) and conf <= 90:
|
||||
text = ""
|
||||
# 打印包含关键词的帧的原始OCR和提取结果
|
||||
if DEBUG_KEYWORD in (text or ""):
|
||||
pic_path, ocr_json = all_pic_ocr_pair[idx]
|
||||
print(f"\n[DEBUG 帧{idx}] 提取: {repr(text)}")
|
||||
for td in ocr_json['TextDetections']:
|
||||
print(f" box: {repr(td['DetectedText'])} conf={td['Confidence']} watermark={td.get('is_watermark','?')}")
|
||||
results.append((idx, text or ""))
|
||||
|
||||
non_empty = sum(1 for _, t in results if t)
|
||||
print(f"\n共 {non_empty}/{len(results)} 帧有字幕")
|
||||
|
||||
# 生成 SRT
|
||||
generate_srt_from_results(results, OUTPUT_SRT, interval_seconds=INTERVAL)
|
||||
print(f"SRT 已输出: {OUTPUT_SRT}")
|
||||
|
||||
# 展示问题字幕附近的结果
|
||||
with open(OUTPUT_SRT, 'r', encoding='utf-8') as f:
|
||||
srt_content = f.readlines()
|
||||
|
||||
print("\n=== 修复验证('曹小月从角落里拿出雨伞' 附近) ===")
|
||||
found = False
|
||||
for j, line in enumerate(srt_content):
|
||||
if "曹小月从角落里拿出雨伞" in line:
|
||||
start = max(0, j - 3)
|
||||
end = min(len(srt_content), j + 10)
|
||||
print("".join(srt_content[start:end]))
|
||||
print("---")
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
print("未在输出 SRT 中找到目标字幕,请检查提取结果。")
|
||||
89
tools/seed_jobs.py
Normal file
89
tools/seed_jobs.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把一份/多份视频清单切成分片, 上传到 COS 的 todo/, 供常驻 worker 认领。
|
||||
|
||||
用法:
|
||||
python tools/seed_jobs.py 清单1.txt [清单2.txt ...]
|
||||
python tools/seed_jobs.py ./text2 # 目录: 取其中所有 *.txt
|
||||
python tools/seed_jobs.py ./text2 --skip-processed # 灌之前先过滤掉全局已处理过的视频
|
||||
分片大小取 config.dispatch.shard_size(默认2000)。分片名: shard_<起始序号>。
|
||||
幂等性: 同名分片已存在(todo 里有)则跳过, 不覆盖。
|
||||
|
||||
--skip-processed: 聚合 30 台机器各自最新一天的 数据.jsonl 快照(dispatch.daily_backup_path),
|
||||
合并成全局已处理视频集合再过滤清单 —— 避免同一视频被不同机器重复处理(本机 数据.jsonl 只能
|
||||
挡住"这台机器"重复跑, 挡不住跨机器重复)。有最长约1天(daily_backup_hour)的数据延迟。
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from config.config_utils import config # noqa: E402
|
||||
from tool import job_cos # noqa: E402
|
||||
|
||||
|
||||
def _collect_lines(paths):
|
||||
lines = []
|
||||
for p in paths:
|
||||
files = glob.glob(os.path.join(p, "*.txt")) if os.path.isdir(p) else [p]
|
||||
for fp in files:
|
||||
with open(fp, encoding="utf-8") as f:
|
||||
lines += [ln.strip() for ln in f if ln.strip()]
|
||||
# 去重并保序
|
||||
seen, out = set(), []
|
||||
for ln in lines:
|
||||
if ln not in seen:
|
||||
seen.add(ln)
|
||||
out.append(ln)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="切片灌任务到 COS todo/")
|
||||
ap.add_argument("paths", nargs="+", help="清单.txt 或目录(取其中所有 *.txt)")
|
||||
ap.add_argument("--skip-processed", action="store_true",
|
||||
help="灌之前先过滤掉全局(所有机器)已处理过的视频,见文件头说明")
|
||||
ap.add_argument("--all-dates", action="store_true",
|
||||
help="配合 --skip-processed: 不只取每台机器最新一天,把所有历史日期的已处理清单都拉下来合并判断"
|
||||
"(机群已停机、想更保险地核对时用;正常灌任务不需要,只会多下载很多文件)")
|
||||
args = ap.parse_args()
|
||||
|
||||
shard_size = int((config.get_dispatch_config() or {}).get("shard_size", 2000))
|
||||
lines = _collect_lines(args.paths)
|
||||
if not lines:
|
||||
print("✗ 没有读到任何视频路径")
|
||||
sys.exit(1)
|
||||
|
||||
if args.skip_processed:
|
||||
if args.all_dates:
|
||||
print("正在聚合所有机器【全部历史日期】的已处理清单(daily_backup_path)...")
|
||||
else:
|
||||
print("正在聚合各机器最新一天的已处理清单(daily_backup_path)...")
|
||||
processed = job_cos.global_processed_set(verbose=True, all_dates=args.all_dates)
|
||||
before = len(lines)
|
||||
lines = [ln for ln in lines if ln not in processed]
|
||||
print(f"全局已处理 {len(processed)} 条 -> 过滤掉 {before - len(lines)} 条,剩余 {len(lines)} 条待灌。")
|
||||
if not lines:
|
||||
print("✓ 全部已处理过,无需灌任务。")
|
||||
return
|
||||
|
||||
existing = set(job_cos.list_todo())
|
||||
n_shards = (len(lines) + shard_size - 1) // shard_size
|
||||
print(f"共 {len(lines)} 条 -> {n_shards} 个分片(每片≤{shard_size})。已存在 {len(existing)} 个分片。")
|
||||
|
||||
added = skipped = 0
|
||||
for i in range(0, len(lines), shard_size):
|
||||
shard = f"shard_{i:08d}"
|
||||
if shard in existing:
|
||||
skipped += 1
|
||||
continue
|
||||
body = "\n".join(lines[i:i + shard_size]).encode("utf-8")
|
||||
job_cos._put(job_cos._key("todo", shard), body) # 普通写(任务清单, 不需禁止覆盖)
|
||||
added += 1
|
||||
if added % 50 == 0:
|
||||
print(f" 已上传 {added} 个分片...")
|
||||
print(f"✓ 完成: 新增 {added} 个分片, 跳过已存在 {skipped} 个。worker 会自动认领。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
30
tools/split_txt.py
Normal file
30
tools/split_txt.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
将 txt 文件按指定行数拆分为多个子文件。
|
||||
用法:python split_txt.py [输入文件] [每份行数]
|
||||
默认:python split_txt.py text/0004_has_srt.txt 100
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
def split_txt(input_path: str, chunk_size: int = 100):
|
||||
with open(input_path, 'r', encoding='utf-8') as f:
|
||||
lines = [line.strip() for line in f if line.strip()]
|
||||
|
||||
total = len(lines)
|
||||
num_files = (total + chunk_size - 1) // chunk_size
|
||||
out_dir = os.path.dirname(input_path)
|
||||
base_name = os.path.splitext(os.path.basename(input_path))[0]
|
||||
|
||||
for i in range(num_files):
|
||||
chunk = lines[i * chunk_size:(i + 1) * chunk_size]
|
||||
out_path = os.path.join(out_dir, f"{base_name}_{i + 1:04d}.txt")
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(chunk) + '\n')
|
||||
print(f" {out_path} ({len(chunk)} 条)")
|
||||
|
||||
print(f"\n共 {total} 条,拆分为 {num_files} 个文件(每份 {chunk_size} 条)")
|
||||
|
||||
if __name__ == '__main__':
|
||||
input_file = sys.argv[1] if len(sys.argv) > 1 else 'text/0004_has_srt.txt'
|
||||
chunk_size = int(sys.argv[2]) if len(sys.argv) > 2 else 100
|
||||
split_txt(input_file, chunk_size)
|
||||
73
tools/stats_jsonl.py
Normal file
73
tools/stats_jsonl.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
统计 数据.jsonl:成功条数、总条数、成功数据总秒数、全部数据总秒数。
|
||||
用法: python stats_jsonl.py [数据.jsonl路径]
|
||||
默认: python stats_jsonl.py 数据.jsonl
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
JSONL_PATH = sys.argv[1] if len(sys.argv) > 1 else "数据.jsonl"
|
||||
|
||||
|
||||
def fmt(seconds):
|
||||
"""秒 → H时M分S秒,便于阅读"""
|
||||
seconds = int(round(seconds))
|
||||
h, rem = divmod(seconds, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
return f"{h}时{m}分{s}秒"
|
||||
|
||||
|
||||
def main():
|
||||
total = 0
|
||||
success = 0
|
||||
total_sec = 0.0
|
||||
success_sec = 0.0
|
||||
status_counter = Counter()
|
||||
|
||||
try:
|
||||
f = open(JSONL_PATH, encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
print(f"找不到文件: {JSONL_PATH}")
|
||||
return
|
||||
|
||||
with f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
total += 1
|
||||
status = rec.get("status")
|
||||
status_counter[status] += 1
|
||||
|
||||
dur = rec.get("duration") or 0
|
||||
try:
|
||||
dur = float(dur)
|
||||
except (TypeError, ValueError):
|
||||
dur = 0.0
|
||||
total_sec += dur
|
||||
|
||||
if status == "success":
|
||||
success += 1
|
||||
success_sec += dur
|
||||
|
||||
rate = (success / total * 100) if total else 0.0
|
||||
|
||||
print(f"文件: {JSONL_PATH}")
|
||||
print("-" * 40)
|
||||
print(f"总条数: {total}")
|
||||
print(f"成功条数: {success} ({rate:.1f}%)")
|
||||
print(f"各状态明细: {dict(status_counter)}")
|
||||
print("-" * 40)
|
||||
print(f"成功数据秒数: {success_sec:.2f} 秒 ({fmt(success_sec)})")
|
||||
print(f"全部数据秒数: {total_sec:.2f} 秒 ({fmt(total_sec)})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
117
tools/test_ocr.py
Normal file
117
tools/test_ocr.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
向 OCR 服务发一次请求做 OCR,验证端点可用、看识别结果。
|
||||
|
||||
用法:
|
||||
python test_ocr.py <图片路径>
|
||||
python test_ocr.py <图片路径> --url http://其它IP:18081
|
||||
python test_ocr.py <图片路径> --raw # 额外打印原始 JSON 响应
|
||||
|
||||
不给图片时,自动在 ./composite、./split 下找一张 .jpg 试。
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
DEFAULT_URL = "http://asr.ocr.general.smart.l20.production.polaris:18081"
|
||||
OCR_PATH = "/youtu/ocrapi/hpgeneralocr_v3"
|
||||
|
||||
|
||||
def find_sample_image(given):
|
||||
if given:
|
||||
return given if os.path.exists(given) else None
|
||||
for root in ("./composite", "./split"):
|
||||
hits = glob.glob(os.path.join(root, "**", "*.jpg"), recursive=True)
|
||||
if hits:
|
||||
return hits[0]
|
||||
return None
|
||||
|
||||
|
||||
def img2base64(img_path):
|
||||
with open(img_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode()
|
||||
|
||||
|
||||
def ocr_request(base_url, image_b64, timeout=30):
|
||||
"""向端点发一次 OCR 请求,返回 (原始响应文本, 耗时秒)。"""
|
||||
url = base_url.rstrip("/") + OCR_PATH
|
||||
app_id = f"{int(time.time() * 1000)}{random.randint(100000, 999999)}"
|
||||
payload = {
|
||||
"app_id": app_id,
|
||||
"image": image_b64,
|
||||
"ocr_template": "OCR",
|
||||
"options": {"pdf_page_index": 0, "detect_text": True},
|
||||
}
|
||||
t0 = time.time()
|
||||
resp = requests.post(url, headers={"Content-Type": "application/json"},
|
||||
json=payload, timeout=timeout)
|
||||
dt = time.time() - t0
|
||||
resp.raise_for_status()
|
||||
return resp.text, dt
|
||||
|
||||
|
||||
def extract_texts(raw):
|
||||
"""从 Youtu OCR 响应里抽出每行识别文字 (content, conf)。"""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
if data.get("error_code", 0) != 0:
|
||||
return f"OCR 返回错误: error_code={data.get('error_code')}, msg={data.get('error_msg')}"
|
||||
lines = []
|
||||
for rec in data.get("recognize_list", []):
|
||||
for item in rec.get("item_content", {}).get("item_list", []):
|
||||
lines.append((item.get("content", ""), item.get("conf", 0)))
|
||||
return lines
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("image", nargs="?", default=None, help="图片路径")
|
||||
ap.add_argument("--url", default=DEFAULT_URL, help="OCR 端点 (默认 Polaris 地址)")
|
||||
ap.add_argument("--raw", action="store_true", help="打印原始 JSON 响应")
|
||||
args = ap.parse_args()
|
||||
|
||||
img = find_sample_image(args.image)
|
||||
if not img:
|
||||
print("找不到图片,请把一张 jpg 路径作为参数传入。")
|
||||
return
|
||||
|
||||
print(f"端点: {args.url}{OCR_PATH}")
|
||||
print(f"图片: {img}")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
raw, dt = ocr_request(args.url, img2base64(img))
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"✗ 请求失败: {e}")
|
||||
return
|
||||
|
||||
print(f"✓ 请求成功,耗时 {dt * 1000:.0f}ms")
|
||||
|
||||
if args.raw:
|
||||
print("\n原始响应:")
|
||||
print(raw)
|
||||
print("-" * 60)
|
||||
|
||||
result = extract_texts(raw)
|
||||
if result is None:
|
||||
print("响应不是合法 JSON")
|
||||
elif isinstance(result, str):
|
||||
print(result)
|
||||
elif not result:
|
||||
print("(未识别到文字)")
|
||||
else:
|
||||
print(f"\n识别到 {len(result)} 行文字:")
|
||||
for content, conf in result:
|
||||
print(f" [{conf:.2f}] {content}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
159
tools/throughput_jsonl.py
Normal file
159
tools/throughput_jsonl.py
Normal file
@@ -0,0 +1,159 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
分析 数据.jsonl 的处理吞吐:单台机器一天能处理多少小时的视频数据。
|
||||
|
||||
两种口径:
|
||||
A) 墙钟吞吐(真实):用 process_start/process_end 求整批的实际墙钟跨度,
|
||||
总视频时长 ÷ 墙钟跨度 = 机器真实吞吐(含当时的并发/排队效果)。
|
||||
B) 单视频平均(理想并发):按每个视频 duration/elapsed 的实时倍率,
|
||||
再乘并发数估算(假设无 OCR/CPU 排队,即"OCR 速度够"的上限)。
|
||||
|
||||
用法: python throughput_jsonl.py [数据.jsonl路径] [并发数]
|
||||
默认: python throughput_jsonl.py 数据.jsonl 1
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
JSONL_PATH = sys.argv[1] if len(sys.argv) > 1 else "数据.jsonl"
|
||||
WORKERS = int(sys.argv[2]) if len(sys.argv) > 2 else 1
|
||||
OCR_QPS_PER_MACHINE = float(sys.argv[3]) if len(sys.argv) > 3 else 14.0 # 单台 OCR 的 QPS
|
||||
|
||||
|
||||
def parse_dt(s):
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(s)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def fmt_h(hours):
|
||||
return f"{hours:.1f} 小时"
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
f = open(JSONL_PATH, encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
print(f"找不到文件: {JSONL_PATH}")
|
||||
return
|
||||
|
||||
n = 0
|
||||
total_dur = 0.0 # 全部视频时长(秒)
|
||||
total_elapsed = 0.0 # 全部单视频处理耗时(秒),含并发
|
||||
starts, ends = [], []
|
||||
per_video_ratio = [] # 每视频 duration/elapsed
|
||||
stage_sum = {} # 各阶段耗时累计
|
||||
with_timing = 0
|
||||
total_ocr = 0 # 全部 OCR 请求次数累计
|
||||
with_ocr = 0
|
||||
|
||||
with f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
n += 1
|
||||
dur = rec.get("duration") or 0
|
||||
try:
|
||||
dur = float(dur)
|
||||
except (TypeError, ValueError):
|
||||
dur = 0.0
|
||||
total_dur += dur
|
||||
|
||||
el = rec.get("elapsed_seconds")
|
||||
if el:
|
||||
total_elapsed += float(el)
|
||||
if dur > 0:
|
||||
per_video_ratio.append(dur / float(el))
|
||||
|
||||
st = parse_dt(rec.get("process_start"))
|
||||
en = parse_dt(rec.get("process_end"))
|
||||
if st:
|
||||
starts.append(st)
|
||||
if en:
|
||||
ends.append(en)
|
||||
|
||||
sd = rec.get("stage_durations") or {}
|
||||
if sd:
|
||||
with_timing += 1
|
||||
for k, v in sd.items():
|
||||
try:
|
||||
stage_sum[k] = stage_sum.get(k, 0.0) + float(v)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
ic = rec.get("image_counts") or {}
|
||||
toc = ic.get("total_ocr_count")
|
||||
if toc:
|
||||
try:
|
||||
total_ocr += int(toc)
|
||||
with_ocr += 1
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
print(f"文件: {JSONL_PATH}")
|
||||
print("=" * 52)
|
||||
print(f"记录数: {n}")
|
||||
print(f"视频总时长: {total_dur/3600:.1f} 小时 ({total_dur:.0f} 秒)")
|
||||
print(f"单视频耗时累加: {total_elapsed/3600:.1f} 小时 ({total_elapsed:.0f} 秒)")
|
||||
if with_ocr:
|
||||
print(f"OCR 请求总数: {total_ocr} (平均 {total_ocr/with_ocr:.0f} 次/视频, {with_ocr} 条有统计)")
|
||||
|
||||
# ---- 口径A:真实墙钟吞吐 ----
|
||||
print("\n[A] 真实墙钟吞吐(按 process_start/end 的整批实际跨度)")
|
||||
if starts and ends:
|
||||
span = (max(ends) - min(starts)).total_seconds()
|
||||
if span > 0:
|
||||
tp = total_dur / span # 视频秒/墙钟秒
|
||||
print(f" 整批墙钟跨度: {span/3600:.2f} 小时")
|
||||
print(f" 实时倍率: {tp:.2f}x (每 1 秒墙钟处理 {tp:.2f} 秒视频)")
|
||||
print(f" → 一天可处理: {fmt_h(tp*24)} 的视频")
|
||||
vids_per_day = n / span * 86400 if span else 0
|
||||
print(f" → 一天可处理: {vids_per_day:.0f} 个视频")
|
||||
|
||||
# ---- OCR 需求:这台机器实际产生多少 OCR/秒、能喂几台 OCR ----
|
||||
if total_ocr:
|
||||
ocr_per_sec = total_ocr / span
|
||||
machines_fed = ocr_per_sec / OCR_QPS_PER_MACHINE
|
||||
print(f"\n [OCR 配比](单台 OCR 按 {OCR_QPS_PER_MACHINE:.0f} QPS 计)")
|
||||
print(f" 本机 OCR 速率: {ocr_per_sec:.2f} OCR/秒")
|
||||
print(f" → 本机能喂满: {machines_fed:.2f} 台 OCR")
|
||||
if machines_fed > 0:
|
||||
print(f" → 一台 OCR 可喂: {1/machines_fed:.1f} 台这样的处理机")
|
||||
else:
|
||||
print(" 墙钟跨度为0,无法计算")
|
||||
else:
|
||||
print(" 缺少 process_start/process_end 字段,跳过")
|
||||
|
||||
# ---- 口径B:按单视频平均实时倍率 × 并发 ----
|
||||
print(f"\n[B] 理想吞吐(单视频平均倍率 × 并发{WORKERS},假设无排队=OCR够快)")
|
||||
if per_video_ratio:
|
||||
avg_ratio = sum(per_video_ratio) / len(per_video_ratio)
|
||||
# 也给一个总量口径:总视频时长/总耗时(更稳,受长短视频加权)
|
||||
weighted = total_dur / total_elapsed if total_elapsed else 0
|
||||
print(f" 单视频平均倍率: {avg_ratio:.2f}x")
|
||||
print(f" 加权倍率(总/总):{weighted:.2f}x")
|
||||
print(f" → 单并发一天: {fmt_h(weighted*24)} 的视频")
|
||||
print(f" → {WORKERS}并发一天: {fmt_h(weighted*24*WORKERS)} 的视频(理想线性,实际受CPU限制)")
|
||||
else:
|
||||
print(" 缺少 elapsed_seconds 字段,跳过")
|
||||
|
||||
# ---- 各阶段占比 ----
|
||||
if stage_sum:
|
||||
print(f"\n[阶段耗时占比]({with_timing} 条有计时)")
|
||||
main_t = stage_sum.get("main", sum(v for k, v in stage_sum.items() if k != "main"))
|
||||
for k in ["split_frame", "merge", "getsrt"]:
|
||||
if k in stage_sum:
|
||||
pct = stage_sum[k] / main_t * 100 if main_t else 0
|
||||
print(f" {k:12s}: {stage_sum[k]/3600:6.1f} 小时 ({pct:.0f}%)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
91
tools/upload_composites.py
Normal file
91
tools/upload_composites.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
扫描本地 ./composite/ 下的视频目录,逐个上传到其在 COS 上对应视频目录的 composite/ 子目录,
|
||||
上传成功后删除本地。视频的 COS 路径从 数据.jsonl 的 video_cos_path 反查。
|
||||
|
||||
用法: python upload_composites.py [composite根目录] [数据.jsonl路径]
|
||||
默认: python upload_composites.py ./composite 数据.jsonl
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from config.config_utils import config
|
||||
|
||||
COMPOSITE_ROOT = sys.argv[1] if len(sys.argv) > 1 else "./composite"
|
||||
JSONL_PATH = sys.argv[2] if len(sys.argv) > 2 else "数据.jsonl"
|
||||
|
||||
|
||||
def build_cosdir_map(jsonl_path):
|
||||
"""从 数据.jsonl 建立 video_name -> cos_dir 映射。"""
|
||||
mapping = {}
|
||||
if not os.path.exists(jsonl_path):
|
||||
print(f"警告: 找不到 {jsonl_path},无法反查 COS 路径")
|
||||
return mapping
|
||||
with open(jsonl_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
vid = rec.get("video_cos_path")
|
||||
if not vid:
|
||||
continue
|
||||
cos_dir = vid[:vid.rfind("/") + 1] # 含末尾 /
|
||||
name = os.path.splitext(os.path.basename(vid))[0]
|
||||
mapping[name] = cos_dir
|
||||
return mapping
|
||||
|
||||
|
||||
def upload_dir(local_dir, cos_dest_dir, coscli_path, cos_config_path):
|
||||
if not cos_dest_dir.endswith("/"):
|
||||
cos_dest_dir += "/"
|
||||
src = local_dir if local_dir.endswith(os.sep) else local_dir + os.sep
|
||||
cmd = [coscli_path, "-c", cos_config_path, "cp", "-r", src, cos_dest_dir]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
return (cos_dest_dir if result.returncode == 0 else None), result.stderr
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isdir(COMPOSITE_ROOT):
|
||||
print(f"目录不存在: {COMPOSITE_ROOT}")
|
||||
return
|
||||
|
||||
cfg = config.get_splitframe_config()
|
||||
coscli_path = cfg.get("coscli_path", "./coscli-linux-amd64")
|
||||
cos_config_path = cfg.get("cosconfig_path", ".cos.yaml")
|
||||
|
||||
cosdir_map = build_cosdir_map(JSONL_PATH)
|
||||
|
||||
dirs = [d for d in os.listdir(COMPOSITE_ROOT)
|
||||
if os.path.isdir(os.path.join(COMPOSITE_ROOT, d))]
|
||||
print(f"发现 {len(dirs)} 个本地 composite 目录,开始上传...")
|
||||
|
||||
ok = fail = skip = 0
|
||||
for i, name in enumerate(dirs, 1):
|
||||
local_dir = os.path.join(COMPOSITE_ROOT, name)
|
||||
cos_dir = cosdir_map.get(name)
|
||||
if not cos_dir:
|
||||
skip += 1
|
||||
print(f"[{i}/{len(dirs)}] {name} ✗ 在 数据.jsonl 中找不到 COS 路径,跳过")
|
||||
continue
|
||||
cos_dest = cos_dir + "composite/"
|
||||
uploaded, err = upload_dir(local_dir, cos_dest, coscli_path, cos_config_path)
|
||||
if uploaded:
|
||||
shutil.rmtree(local_dir, ignore_errors=True)
|
||||
ok += 1
|
||||
print(f"[{i}/{len(dirs)}] {name} ✓ → {cos_dest}(已删本地)")
|
||||
else:
|
||||
fail += 1
|
||||
print(f"[{i}/{len(dirs)}] {name} ✗ 上传失败,保留本地: {err.strip()[:120]}")
|
||||
|
||||
print(f"\n完成 — 成功: {ok} 失败: {fail} 跳过(无COS路径): {skip}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
35
tools/upload_daily.py
Normal file
35
tools/upload_daily.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""手动/定时把本地 数据.jsonl 备份到 COS(文件名带机器号+日期)。
|
||||
|
||||
常驻 worker(run_worker.py)内部已有每天10点自动备份线程; 本脚本用于:
|
||||
- 不跑常驻 worker、想用系统 crontab 定时备份的场景;
|
||||
- 想立刻手动备份一次。
|
||||
|
||||
crontab 例(每天10点):
|
||||
0 10 * * * cd /项目根 && python3 tools/upload_daily.py >> log/upload_daily.log 2>&1
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from tool import job_cos # noqa: E402
|
||||
|
||||
JSONL = "数据.jsonl"
|
||||
|
||||
|
||||
def main():
|
||||
mid = job_cos.get_machine_id()
|
||||
dst = job_cos.upload_daily_backup(JSONL, mid)
|
||||
if dst:
|
||||
print(f"✓ 数据.jsonl 已上传 -> {dst}")
|
||||
else:
|
||||
print(f"✗ 本地无 {JSONL}, 未上传")
|
||||
ll = job_cos.upload_longlist_backup(mid)
|
||||
if ll:
|
||||
print(f"✓ 超长待重跑列表已上传 -> {ll}")
|
||||
else:
|
||||
print("✗ 本地无超长待重跑列表, 未上传")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
207
tools/upload_data_standalone.py
Normal file
207
tools/upload_data_standalone.py
Normal file
@@ -0,0 +1,207 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""独立备份脚本: 把本地 数据.jsonl + 超长待重跑列表 上传到 COS。
|
||||
|
||||
不 import 项目里任何其他 .py(不依赖 tool/、main.py 等), 只用标准库 + 第三方 qcloud_cos SDK,
|
||||
直接读 config/config.json —— 哪怕代码刚更新完、其他模块有问题, 这个脚本也能独立跑, 适合放在
|
||||
重新部署/更新代码前后单独执行一次, 确保数据先落到 COS。
|
||||
|
||||
用法(项目根目录下运行):
|
||||
python tools/upload_data_standalone.py
|
||||
|
||||
行为跟 tools/upload_daily.py + tool/job_cos.py 里的 upload_daily_backup/upload_longlist_backup
|
||||
一致(上传目标、命名、覆盖安全性都对齐):
|
||||
数据.jsonl -> dispatch.daily_backup_path/<日期>/数据_<机器号>.jsonl (全量快照,覆盖安全)
|
||||
已处理清单(轻量) -> dispatch.daily_backup_path/<日期>/已处理_<机器号>.txt
|
||||
(从jsonl提取的纯 video_cos_path 列表, 一行一个; 全局去重 global_processed_set()
|
||||
只拉这个小文件, 不用为了去重下载/解析整份带状态耗时字段的jsonl)
|
||||
超长待重跑列表 -> dispatch.long_list_backup_path/todo/<原文件名>_<机器号>_<日期>_<时分秒>.txt
|
||||
(增量式:传完清空本地已传部分; key精确到秒,同一天多次调用不会互相覆盖)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
from qcloud_cos import CosConfig, CosS3Client
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CONFIG_PATH = os.path.join(ROOT, "config", "config.json")
|
||||
JSONL_PATH = os.path.join(ROOT, "数据.jsonl")
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def parse_cos_url(url: str, default_bucket: str):
|
||||
"""cos://bucket/a/b -> (bucket, 'a/b')。无 cos:// 前缀时按传入的默认桶补全。"""
|
||||
url = url.strip()
|
||||
if url.startswith("cos://"):
|
||||
rest = url[len("cos://"):]
|
||||
bucket, _, key = rest.partition("/")
|
||||
return bucket, key.strip("/")
|
||||
return default_bucket, url.strip("/")
|
||||
|
||||
|
||||
def detect_ip() -> str:
|
||||
"""取本机对外默认路由的网卡IP(UDP connect触发内核选路由,不实际发包,天然跳过docker网桥/回环);
|
||||
取不到退化到 `hostname -I` 第一个。跟 tool/job_cos.py 的 _detect_ip() 逻辑一致。"""
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
finally:
|
||||
s.close()
|
||||
if ip and not ip.startswith("127."):
|
||||
return ip
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
r = subprocess.run(["hostname", "-I"], capture_output=True, text=True, timeout=5)
|
||||
ips = (r.stdout or "").split()
|
||||
return ips[0] if ips else ""
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
|
||||
|
||||
def get_machine_id(dcfg: dict) -> str:
|
||||
"""优先级: config.dispatch.machine_id > 环境变量 MACHINE_ID > 本机固定IP > 主机名。
|
||||
跟 tool/job_cos.py 的 get_machine_id() 保持一致, 这样跟常驻 worker 认领的是同一个身份。"""
|
||||
mid = (dcfg.get("machine_id") or "").strip()
|
||||
if mid:
|
||||
return mid
|
||||
mid = (os.environ.get("MACHINE_ID") or "").strip()
|
||||
if mid:
|
||||
return mid
|
||||
ip = detect_ip()
|
||||
if ip:
|
||||
return ip
|
||||
return socket.gethostname()
|
||||
|
||||
|
||||
# 数据.jsonl 是全量快照, 产能高的机器上百 MB/上 GB。put_object 单请求默认超时 30 秒, 大到
|
||||
# 传不完就一直 "The write operation timed out"。超过这个大小改用 coscli 分块上传(几 GB 也行),
|
||||
# coscli 失败自动回退 put_object。跟 tool/job_cos.py 的 _upload_dated_file 是同一套策略。
|
||||
_COSCLI_UPLOAD_THRESHOLD = 50 * 1024 * 1024 # 50MB
|
||||
|
||||
|
||||
def _coscli_put(sf_cfg: dict, local_path: str, cos_url: str) -> bool:
|
||||
"""用 coscli 上传大文件。成功 True 失败 False。sf_cfg = config 的 splitframe 段。"""
|
||||
try:
|
||||
coscli = sf_cfg.get("coscli_path", "./coscli-linux-amd64")
|
||||
coscfg = sf_cfg.get("cosconfig_path", ".cos.yaml")
|
||||
r = subprocess.run([coscli, "-c", coscfg, "cp", local_path, cos_url],
|
||||
capture_output=True, text=True, timeout=1800)
|
||||
if r.returncode == 0:
|
||||
return True
|
||||
print(f" coscli 上传失败(回退 put_object): {(r.stderr or '').strip()[:200]}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" coscli 上传异常(回退 put_object): {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def upload_jsonl(client, default_bucket: str, dcfg: dict, sf_cfg: dict, machine_id: str):
|
||||
daily_root = dcfg.get("daily_backup_path")
|
||||
if not daily_root:
|
||||
print("✗ 未配置 dispatch.daily_backup_path, 跳过 数据.jsonl")
|
||||
return
|
||||
if not os.path.exists(JSONL_PATH):
|
||||
print(f"✗ 本地无 {JSONL_PATH}, 跳过")
|
||||
return
|
||||
bkt, base = parse_cos_url(daily_root, default_bucket)
|
||||
date = datetime.now().strftime("%Y%m%d")
|
||||
key = f"{base}/{date}/数据_{machine_id}.jsonl"
|
||||
cos_url = f"cos://{bkt}/{key}"
|
||||
with open(JSONL_PATH, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
size = len(data)
|
||||
uploaded = False
|
||||
if size > _COSCLI_UPLOAD_THRESHOLD:
|
||||
print(f" 数据.jsonl 有 {size / 1024 / 1024:.0f}MB, 用 coscli 分块上传")
|
||||
uploaded = _coscli_put(sf_cfg, JSONL_PATH, cos_url)
|
||||
if not uploaded:
|
||||
client.put_object(Bucket=bkt, Body=data, Key=key)
|
||||
print(f"✓ 数据.jsonl 已上传 -> {cos_url}")
|
||||
|
||||
# 顺带生成一份轻量"已处理清单"(只有 video_cos_path, 一行一个), 供 global_processed_set()
|
||||
# 只拉这个小文件去重用, 不用为了去重下载/解析整份带状态/耗时字段的 jsonl。
|
||||
paths = []
|
||||
for line in data.decode("utf-8", errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
vid = json.loads(line).get("video_cos_path")
|
||||
if vid:
|
||||
paths.append(vid)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if paths:
|
||||
list_key = f"{base}/{date}/已处理_{machine_id}.txt"
|
||||
client.put_object(Bucket=bkt, Body=("\n".join(paths) + "\n").encode("utf-8"), Key=list_key)
|
||||
print(f"✓ 已处理清单已上传 -> cos://{bkt}/{list_key} ({len(paths)} 条)")
|
||||
|
||||
|
||||
def upload_long_list(client, default_bucket: str, dcfg: dict, sub_cfg: dict, machine_id: str):
|
||||
if dcfg.get("run_long_video"):
|
||||
print("本机 run_long_video=true(专职长视频机), 不回传超长列表, 跳过")
|
||||
return
|
||||
long_root = dcfg.get("long_list_backup_path")
|
||||
if not long_root:
|
||||
print("✗ 未配置 dispatch.long_list_backup_path, 跳过超长待重跑列表")
|
||||
return
|
||||
local_long = sub_cfg.get("long_video_list", "./超长待重跑.txt")
|
||||
if not os.path.isabs(local_long):
|
||||
local_long = os.path.join(ROOT, local_long)
|
||||
if not os.path.exists(local_long):
|
||||
print(f"✗ 本地无 {local_long}, 跳过")
|
||||
return
|
||||
with open(local_long, "r", encoding="utf-8") as f:
|
||||
snapshot = f.readlines()
|
||||
if not any(ln.strip() for ln in snapshot):
|
||||
print("✗ 超长待重跑列表为空, 跳过")
|
||||
return
|
||||
|
||||
bkt, base = parse_cos_url(long_root, default_bucket)
|
||||
stem = os.path.splitext(os.path.basename(local_long))[0]
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
key = f"{base}/todo/{stem}_{machine_id}_{ts}.txt"
|
||||
client.put_object(Bucket=bkt, Body="".join(snapshot).encode("utf-8"), Key=key)
|
||||
print(f"✓ 超长待重跑列表已上传 -> cos://{bkt}/{key}")
|
||||
|
||||
if dcfg.get("long_list_clear_after_upload", True):
|
||||
try:
|
||||
with open(local_long, "r", encoding="utf-8") as f:
|
||||
cur = f.readlines()
|
||||
with open(local_long, "w", encoding="utf-8") as f:
|
||||
f.writelines(cur[len(snapshot):])
|
||||
print(f" 本地已清空已传的 {len(snapshot)} 行, 保留上传期间新增的 {len(cur) - len(snapshot)} 行")
|
||||
except OSError as e:
|
||||
print(f" ⚠ 清空本地文件失败(不影响已上传的部分): {e}")
|
||||
|
||||
|
||||
def main():
|
||||
cfg = load_config()
|
||||
cos_cfg = cfg.get("tencentcloud", {}).get("cos", {})
|
||||
dcfg = cfg.get("dispatch", {}) or {}
|
||||
sub_cfg = cfg.get("subtitle", {}) or {}
|
||||
sf_cfg = cfg.get("splitframe", {}) or {} # coscli 路径/配置在这段
|
||||
|
||||
client = CosS3Client(CosConfig(
|
||||
Region=cos_cfg["region"], SecretId=cos_cfg["secret_id"], SecretKey=cos_cfg["secret_key"],
|
||||
))
|
||||
default_bucket = cos_cfg["bucket"]
|
||||
machine_id = get_machine_id(dcfg)
|
||||
print(f"机器ID: {machine_id}")
|
||||
|
||||
upload_jsonl(client, default_bucket, dcfg, sf_cfg, machine_id)
|
||||
upload_long_list(client, default_bucket, dcfg, sub_cfg, machine_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
150
tools/video_bench.py
Normal file
150
tools/video_bench.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
下载一个视频 → 抽帧 → 用这些【真实帧】对 OCR 端点做压测。
|
||||
比随机生成的图更贴近线上负载(真实字幕内容,OCR 干的活和实际一致)。
|
||||
|
||||
用法(项目根目录运行):
|
||||
python temp/video_bench.py <视频...> --url http://IP:18081 [选项]
|
||||
|
||||
<视频...> 一个或多个,cos://bucket/path/x.mp4 或本地 ./video/x.mp4
|
||||
多个视频时各抽一部分帧合并成图池(max-frames 在视频间均分)
|
||||
也可用 --list 文件 从文件读视频列表(每行一个)
|
||||
|
||||
选项:
|
||||
--url URL OCR 端点(可多次,soak 只用第一个)
|
||||
--concurrency N 并发(默认 100;单台拐点约 80)
|
||||
--mode soak|sweep soak=持续满速(默认);sweep=自动加压找极限
|
||||
--fps F 抽帧频率(默认 1,压测用不需要 3fps,省时省内存)
|
||||
--max-frames N 最多用多少帧做图池(默认 2000)
|
||||
--interval N soak 打印间隔秒(默认 5)
|
||||
--ffmpeg PATH ffmpeg 路径(默认 ./bin/ffmpeg)
|
||||
--coscli PATH coscli 路径(默认 ./coscli-linux-amd64)
|
||||
--cos-config PATH coscli 配置(默认 .cos.yaml)
|
||||
--keep 跑完不删临时视频/帧
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# 复用同目录 bench 脚本的压测逻辑
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import bench_ocr_endpoints as bench # noqa: E402
|
||||
|
||||
|
||||
def download_video(src, coscli, cos_config, dst):
|
||||
"""cos:// 路径用 coscli 下载到 dst;本地路径直接返回。"""
|
||||
if not src.startswith("cos://"):
|
||||
if not os.path.exists(src):
|
||||
print(f"本地视频不存在: {src}")
|
||||
return None
|
||||
return src
|
||||
print(f"下载视频: {src}")
|
||||
r = subprocess.run([coscli, "-c", cos_config, "cp", src, dst],
|
||||
capture_output=True, text=True)
|
||||
if r.returncode != 0 or not os.path.exists(dst):
|
||||
print(f"下载失败: {r.stderr.strip()[:300]}")
|
||||
return None
|
||||
print(f" → {dst} ({os.path.getsize(dst)/1024/1024:.0f} MB)")
|
||||
return dst
|
||||
|
||||
|
||||
def extract_frames(ffmpeg, video, frame_dir, fps):
|
||||
"""用 ffmpeg 抽帧到 frame_dir,返回帧文件列表。"""
|
||||
os.makedirs(frame_dir, exist_ok=True)
|
||||
print(f"抽帧 (fps={fps}) ...")
|
||||
cmd = [ffmpeg, "-i", video, "-vf", f"fps={fps}", "-q:v", "2",
|
||||
os.path.join(frame_dir, "f_%06d.jpg"), "-loglevel", "error", "-y"]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
print(f"抽帧失败: {r.stderr.strip()[:400]}")
|
||||
return []
|
||||
frames = sorted(glob.glob(os.path.join(frame_dir, "*.jpg")))
|
||||
print(f" 抽出 {len(frames)} 帧")
|
||||
return frames
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("videos", nargs="*", default=[], help="一个或多个视频(cos://... 或本地路径)")
|
||||
ap.add_argument("--list", default=None, help="从文件读视频列表(每行一个,可代替直接传)")
|
||||
ap.add_argument("--url", action="append", default=None, help="OCR 端点(可多次)")
|
||||
ap.add_argument("--concurrency", type=int, default=100)
|
||||
ap.add_argument("--mode", choices=["soak", "sweep"], default="soak")
|
||||
ap.add_argument("--fps", type=float, default=1.0)
|
||||
ap.add_argument("--max-frames", type=int, default=2000)
|
||||
ap.add_argument("--interval", type=int, default=5)
|
||||
ap.add_argument("--ffmpeg", default="./bin/ffmpeg")
|
||||
ap.add_argument("--coscli", default="./coscli-linux-amd64")
|
||||
ap.add_argument("--cos-config", default=".cos.yaml")
|
||||
ap.add_argument("--keep", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
endpoints = bench.get_endpoints(args.url)
|
||||
if not endpoints:
|
||||
print("没有 OCR 端点,用 --url 指定")
|
||||
return
|
||||
|
||||
# 收集视频列表:命令行参数 + --list 文件
|
||||
videos = list(args.videos)
|
||||
if args.list and os.path.exists(args.list):
|
||||
with open(args.list, encoding="utf-8") as f:
|
||||
videos += [ln.strip() for ln in f if ln.strip()]
|
||||
if not videos:
|
||||
print("没有视频,传一个或多个视频路径,或用 --list 文件")
|
||||
return
|
||||
# 每个视频均分帧额度,避免一个长视频占满图池
|
||||
per_video_cap = max(1, args.max_frames // len(videos))
|
||||
|
||||
work = tempfile.mkdtemp(prefix="video_bench_")
|
||||
try:
|
||||
all_frames = []
|
||||
for i, src in enumerate(videos):
|
||||
print(f"\n[{i+1}/{len(videos)}] {src}")
|
||||
vlocal = os.path.join(work, f"video_{i}.mp4")
|
||||
fdir = os.path.join(work, f"frames_{i}")
|
||||
v = download_video(src, args.coscli, args.cos_config, vlocal)
|
||||
if not v:
|
||||
continue
|
||||
fr = extract_frames(args.ffmpeg, v, fdir, args.fps)
|
||||
if len(fr) > per_video_cap:
|
||||
fr = fr[:per_video_cap]
|
||||
print(f" 取前 {per_video_cap} 帧")
|
||||
all_frames += fr
|
||||
# 处理完即删该视频,省空间
|
||||
if v == vlocal:
|
||||
try:
|
||||
os.remove(vlocal)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not all_frames:
|
||||
print("没抽到任何帧")
|
||||
return
|
||||
|
||||
# 帧 → base64 图池
|
||||
print(f"\n加载 {len(all_frames)} 帧为图池 ...")
|
||||
pool = [bench.img2base64(f) for f in all_frames]
|
||||
print("=" * 60)
|
||||
print(f"图池: {len(pool)} 张真实帧(来自 {len(videos)} 个视频) | "
|
||||
f"端点: {endpoints[0]} | 并发: {args.concurrency}")
|
||||
print("=" * 60)
|
||||
|
||||
# 4) 压测
|
||||
if args.mode == "sweep":
|
||||
bench.sweep_endpoint(endpoints[0], pool[0], 10, 320, 6)
|
||||
else:
|
||||
bench.soak_endpoint(endpoints[0], pool, args.concurrency, args.interval)
|
||||
|
||||
finally:
|
||||
if args.keep:
|
||||
print(f"\n临时文件保留在: {work}")
|
||||
else:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
128
tools/回溯过滤.py
Normal file
128
tools/回溯过滤.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
回溯过滤: 对【已跑出来】的成功视频, 用低置信占比信号找出"非字幕内容"(幻灯片/代码/满屏文字),
|
||||
和新加的实时过滤同一套标准。默认只列出(dry-run); --delete 才删 COS 上的垃圾 SRT。
|
||||
|
||||
用法:
|
||||
python3 tools/回溯过滤.py # 扫全部成功视频, 只列出
|
||||
python3 tools/回溯过滤.py --recent 200 # 只扫最近200个(快速试)
|
||||
python3 tools/回溯过滤.py --frac 0.35 --thr 80 # 阈值(同config默认)
|
||||
python3 tools/回溯过滤.py --delete # ★删掉判定为非字幕的 COS SRT★(不可逆!)
|
||||
产物: 非字幕清单.txt (video_cos_path, 低置信占比, srt路径)
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from qcloud_cos import CosConfig, CosS3Client
|
||||
from config.config_utils import config
|
||||
|
||||
cos = config.get_cos_config()
|
||||
B = cos["bucket"]
|
||||
_client = CosS3Client(CosConfig(Region=cos["region"], SecretId=cos["secret_id"],
|
||||
SecretKey=cos["secret_key"]))
|
||||
|
||||
|
||||
def key_of(cos_url):
|
||||
return cos_url.split("://", 1)[1].split("/", 1)[1]
|
||||
|
||||
|
||||
def lowconf_ratio(composite_cos_path, thr):
|
||||
"""下载该视频的 ocr_result.jsonl, 返回 (低置信占比, 检测数); 失败返回 (None, 0)。"""
|
||||
key = key_of(composite_cos_path).rstrip("/") + "/ocr_result.jsonl"
|
||||
try:
|
||||
body = _client.get_object(Bucket=B, Key=key)["Body"].get_raw_stream().read()
|
||||
confs = []
|
||||
for line in body.decode("utf-8", "ignore").splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
d = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
for det in d.get("TextDetections", []):
|
||||
if det.get("DetectedText"):
|
||||
confs.append(det.get("Confidence", 0))
|
||||
if not confs:
|
||||
return None, 0
|
||||
return sum(1 for c in confs if c < thr) / len(confs), len(confs)
|
||||
except Exception:
|
||||
return None, 0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--jsonl", default="数据.jsonl")
|
||||
ap.add_argument("--frac", type=float, default=0.6)
|
||||
ap.add_argument("--thr", type=int, default=80)
|
||||
ap.add_argument("--min-det", type=int, default=200)
|
||||
ap.add_argument("--recent", type=int, default=0, help="只扫最近N个(0=全部)")
|
||||
ap.add_argument("--workers", type=int, default=16)
|
||||
ap.add_argument("--delete", action="store_true", help="删掉非字幕视频的 COS SRT(不可逆!)")
|
||||
ap.add_argument("--out", default="非字幕清单.txt")
|
||||
args = ap.parse_args()
|
||||
|
||||
latest = {}
|
||||
for line in open(args.jsonl, encoding="utf-8"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
v = r.get("video_cos_path")
|
||||
if v:
|
||||
latest[v] = r
|
||||
vids = [r for r in latest.values()
|
||||
if r.get("status") == "success" and r.get("composite_cos_path")]
|
||||
if args.recent:
|
||||
vids = vids[-args.recent:]
|
||||
print(f"待扫描成功视频: {len(vids)} | 阈值: 低置信<{args.thr} 占比>{args.frac*100:.0f}% | "
|
||||
f"{'★删除模式★' if args.delete else 'dry-run(只列出)'}")
|
||||
|
||||
bad, done, errored = [], 0, 0
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as ex:
|
||||
fut = {ex.submit(lowconf_ratio, r["composite_cos_path"], args.thr): r for r in vids}
|
||||
for f in as_completed(fut):
|
||||
r = fut[f]
|
||||
ratio, ndet = f.result()
|
||||
done += 1
|
||||
if ratio is None:
|
||||
errored += 1
|
||||
elif ndet >= args.min_det and ratio > args.frac:
|
||||
bad.append((r["video_cos_path"], ratio, ndet, r.get("srt_cos_path")))
|
||||
if done % 100 == 0:
|
||||
print(f" ...{done}/{len(vids)} 已扫, 命中非字幕 {len(bad)}")
|
||||
|
||||
bad.sort(key=lambda x: -x[1])
|
||||
with open(args.out, "w", encoding="utf-8") as f:
|
||||
for v, ratio, ndet, srt in bad:
|
||||
f.write(f"{v}\t低置信{ratio*100:.0f}%\t{ndet}检测\t{srt}\n")
|
||||
print(f"\n扫描完成: {done} 个 | 非字幕(应过滤) {len(bad)} 个 | 读取失败 {errored} 个")
|
||||
print(f"清单 → {args.out}")
|
||||
print("命中样例(低置信占比最高的几个):")
|
||||
for v, ratio, ndet, srt in bad[:8]:
|
||||
print(f" {ratio*100:3.0f}% {ndet:5d}检测 {os.path.basename(v)}")
|
||||
|
||||
if args.delete and bad:
|
||||
print(f"\n★删除模式: 删除 {len(bad)} 个非字幕视频的 COS SRT...")
|
||||
dn = 0
|
||||
for v, ratio, ndet, srt in bad:
|
||||
if srt:
|
||||
try:
|
||||
_client.delete_object(Bucket=B, Key=key_of(srt))
|
||||
dn += 1
|
||||
except Exception as e:
|
||||
print(" 删除失败", srt, e)
|
||||
print(f"已删除 {dn} 个 COS SRT。(清单已存 {args.out}, 需要可重跑这些视频)")
|
||||
elif bad:
|
||||
print(f"\n这是 dry-run。确认清单后, 加 --delete 删除这些垃圾SRT。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
74
tools/导出成功xlsx.py
Normal file
74
tools/导出成功xlsx.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
把 数据/ 下所有 jsonl 的【成功】记录去重汇总成一个 xlsx。
|
||||
列: 视频地址 | 字幕地址 | 时长(秒) | 时长(分:秒)
|
||||
|
||||
用法: python3 tools/导出成功xlsx.py [--dir 数据] [--out 成功字幕汇总.xlsx]
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
from openpyxl import Workbook
|
||||
|
||||
|
||||
def fmt_dur(sec):
|
||||
try:
|
||||
s = int(round(float(sec)))
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
if s <= 0:
|
||||
return ""
|
||||
h, rem = divmod(s, 3600)
|
||||
m, ss = divmod(rem, 60)
|
||||
return f"{h}:{m:02d}:{ss:02d}" if h else f"{m}:{ss:02d}"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dir", default="数据")
|
||||
ap.add_argument("--out", default="成功字幕汇总.xlsx")
|
||||
args = ap.parse_args()
|
||||
|
||||
files = glob.glob(os.path.join(args.dir, "*.jsonl"))
|
||||
print(f"读取 {len(files)} 个 jsonl ...")
|
||||
latest = {}
|
||||
for fp in files:
|
||||
for line in open(fp, encoding="utf-8"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
v = r.get("video_cos_path")
|
||||
if v:
|
||||
latest[v] = r
|
||||
|
||||
rows = [r for r in latest.values() if r.get("status") == "success"]
|
||||
# 按视频地址排序, 输出稳定
|
||||
rows.sort(key=lambda r: r.get("video_cos_path", ""))
|
||||
print(f"去重视频 {len(latest)} | 成功 {len(rows)} → 写 xlsx")
|
||||
|
||||
wb = Workbook(write_only=True) # 流式写, 省内存(几十万行也扛得住)
|
||||
ws = wb.create_sheet("成功字幕")
|
||||
ws.append(["视频地址", "字幕地址", "时长(秒)", "时长(分:秒)"])
|
||||
n = 0
|
||||
for r in rows:
|
||||
dur = r.get("duration")
|
||||
try:
|
||||
dsec = round(float(dur), 1) if dur is not None else ""
|
||||
except (TypeError, ValueError):
|
||||
dsec = ""
|
||||
ws.append([r.get("video_cos_path", ""),
|
||||
r.get("srt_cos_path", ""),
|
||||
dsec,
|
||||
fmt_dur(dur)])
|
||||
n += 1
|
||||
wb.save(args.out)
|
||||
print(f"完成: {n} 行 → {args.out} ({os.path.getsize(args.out)//1024//1024} MB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
92
tools/拆分超长.py
Normal file
92
tools/拆分超长.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
从已有 数据.jsonl 里把【超长视频】的 COS 路径提取到一个 txt,供以后补跑。
|
||||
|
||||
用途:本次改动之前,被"视频过长"过滤掉的视频没有单独留列表,源 txt 又已删除。
|
||||
但 数据.jsonl 每条都存了 duration,可据此把它们捞回来。
|
||||
|
||||
判定为"待补跑的超长视频":
|
||||
- 该视频最新一条记录 reason == "视频过长"(新版记录有此字段);或
|
||||
- duration > 阈值 且 status != success(旧记录没有 reason,靠时长兜底)
|
||||
成功处理过的(status=success)不导出。
|
||||
|
||||
用法:
|
||||
python tools/拆分超长.py # 读 ./数据.jsonl,阈值取 config 的 max_duration_seconds
|
||||
python tools/拆分超长.py 数据.jsonl --min 2400 # 指定文件和阈值(秒)
|
||||
python tools/拆分超长.py a.jsonl b.jsonl -o 超长待重跑.txt
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def _default_threshold():
|
||||
try:
|
||||
from config.config_utils import config
|
||||
return float(config.get_subtitle_config().get('max_duration_seconds', 5400) or 5400)
|
||||
except Exception:
|
||||
return 5400.0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('jsonl', nargs='*', default=['数据.jsonl'],
|
||||
help='一个或多个 数据.jsonl(默认 ./数据.jsonl)')
|
||||
ap.add_argument('--min', type=float, default=None,
|
||||
help='超长阈值(秒),默认取 config.max_duration_seconds')
|
||||
ap.add_argument('-o', '--out', default='超长待重跑.txt', help='输出 txt')
|
||||
args = ap.parse_args()
|
||||
|
||||
thr = args.min if args.min is not None else _default_threshold()
|
||||
files = args.jsonl or ['数据.jsonl']
|
||||
|
||||
# 多文件合并,按 video_cos_path 取最新一条
|
||||
latest = {}
|
||||
for path in files:
|
||||
if not os.path.exists(path):
|
||||
print(f"[跳过] 不存在: {path}")
|
||||
continue
|
||||
with open(path, encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
v = r.get('video_cos_path')
|
||||
if v:
|
||||
latest[v] = r
|
||||
|
||||
out_paths, seen = [], set()
|
||||
n_reason, n_dur = 0, 0
|
||||
for v, r in latest.items():
|
||||
if r.get('status') == 'success':
|
||||
continue
|
||||
reason = r.get('reason')
|
||||
dur = r.get('duration')
|
||||
hit = False
|
||||
if reason == '视频过长':
|
||||
hit = True; n_reason += 1
|
||||
elif dur is not None and dur > thr:
|
||||
hit = True; n_dur += 1
|
||||
if hit and v not in seen:
|
||||
seen.add(v); out_paths.append(v)
|
||||
|
||||
with open(args.out, 'w', encoding='utf-8') as f:
|
||||
for p in out_paths:
|
||||
f.write(p + '\n')
|
||||
|
||||
print(f"阈值 {thr:.0f}s | 扫描 {len(latest)} 个唯一视频")
|
||||
print(f"导出 {len(out_paths)} 个超长视频 → {args.out} (reason命中 {n_reason}, 时长兜底 {n_dur})")
|
||||
print("\n补跑提示: 放进 text2/ 并【换一个 数据.jsonl】(否则会被当已处理跳过):")
|
||||
print(f" mv {args.out} text2/long_rerun.txt")
|
||||
print(" # 建议在 GPU 机器上跑(长视频GPU硬解快); 放大 config.max_duration_seconds 让它们不再被过滤")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
93
tools/查合成图尺寸.py
Normal file
93
tools/查合成图尺寸.py
Normal file
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
统计合成图的【像素尺寸】, 看有多少张超大(最长边>阈值) → 判断是不是OCR超时的根因。
|
||||
纯标准库, 不依赖 PIL / cv2 / 项目代码。只读JPEG文件头, 不解码, 很快。
|
||||
|
||||
用法:
|
||||
python3 tools/查合成图尺寸.py # 默认扫 ./split/**/composite/, 阈值4000
|
||||
python3 tools/查合成图尺寸.py --dir ./split --max 4000
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
|
||||
|
||||
def jpeg_size(path):
|
||||
"""只读 JPEG 文件头拿 (宽,高), 不解码。失败返回 None。"""
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
if f.read(2) != b'\xff\xd8':
|
||||
return None
|
||||
while True:
|
||||
b = f.read(1)
|
||||
while b and b != b'\xff':
|
||||
b = f.read(1)
|
||||
marker = f.read(1)
|
||||
while marker == b'\xff':
|
||||
marker = f.read(1)
|
||||
if not marker:
|
||||
return None
|
||||
m = marker[0]
|
||||
if 0xC0 <= m <= 0xCF and m not in (0xC4, 0xC8, 0xCC):
|
||||
f.read(3)
|
||||
h = int.from_bytes(f.read(2), 'big')
|
||||
w = int.from_bytes(f.read(2), 'big')
|
||||
return w, h
|
||||
seg = int.from_bytes(f.read(2), 'big')
|
||||
if seg < 2:
|
||||
return None
|
||||
f.seek(seg - 2, 1)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--dir', default='./split')
|
||||
ap.add_argument('--max', type=int, default=4000, help='超大阈值(最长边像素)')
|
||||
args = ap.parse_args()
|
||||
|
||||
pats = [os.path.join(args.dir, '**', 'composite', '*.jpg'),
|
||||
os.path.join(args.dir, '**', '*.jpg')]
|
||||
files = []
|
||||
for p in pats:
|
||||
files = glob.glob(p, recursive=True)
|
||||
if files:
|
||||
break
|
||||
files = [f for f in files if 'composite' in f]
|
||||
if not files:
|
||||
print(f"在 {args.dir} 下没找到 composite/*.jpg")
|
||||
return
|
||||
|
||||
tot = over = mx = 0
|
||||
sizes = []
|
||||
for f in files:
|
||||
sz = jpeg_size(f)
|
||||
if sz:
|
||||
tot += 1
|
||||
m = max(sz)
|
||||
mx = max(mx, m)
|
||||
sizes.append((os.path.getsize(f), sz, f))
|
||||
if m > args.max:
|
||||
over += 1
|
||||
|
||||
print("=" * 56)
|
||||
print(f"合成图 {tot} 张 | 最长边>{args.max} 的 {over} 张 "
|
||||
f"({over/tot*100:.0f}%) | 最大最长边 {mx}px")
|
||||
print("=" * 56)
|
||||
print("最大的 8 张:")
|
||||
for fsize, (w, h), f in sorted(sizes, reverse=True)[:8]:
|
||||
flag = ' <== 超大' if max(w, h) > args.max else ''
|
||||
print(f" {fsize//1024:5d}KB {w}x{h} 最长边={max(w,h)}{flag}")
|
||||
print()
|
||||
if over > tot * 0.3:
|
||||
print(f"⚠ {over}/{tot} 张超大 → 实锤: 超大合成图就是OCR超时根因。")
|
||||
print(f" 部署 ocr_max_image_side={args.max} 后, 这些缩到 {args.max}px, OCR 从30s降到几秒。")
|
||||
elif over == 0:
|
||||
print("✓ 没有超大图(尺寸都正常) → 超时不是图大, 是端点问题(共享/波动)。")
|
||||
else:
|
||||
print(f"部分超大({over}张), 部署尺寸上限能缓解这部分。")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
147
tools/查端点存活.py
Normal file
147
tools/查端点存活.py
Normal file
@@ -0,0 +1,147 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
逐个探测 config 里的 OCR 端点, 看有多少台【真的在服务】。
|
||||
用来确认"120台只活了20台"这类问题——死端点越多, 活端点被压得越狠 → 超时。
|
||||
|
||||
用法:
|
||||
python3 tools/查端点存活.py # 自动找一张真实帧图做OCR探测
|
||||
python3 tools/查端点存活.py --tcp # 只测TCP端口连通(更快, 不发OCR)
|
||||
python3 tools/查端点存活.py --timeout 5
|
||||
python3 tools/查端点存活.py --list-dead # 额外列出死端点IP
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import glob
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import requests
|
||||
from config.config_utils import config
|
||||
|
||||
OCR_PATH = "/youtu/ocrapi/hpgeneralocr_v3"
|
||||
DEFAULT_PORT = 18081
|
||||
|
||||
|
||||
def norm(e):
|
||||
e = str(e).strip().rstrip("/")
|
||||
if not e:
|
||||
return None
|
||||
if not e.startswith("http"):
|
||||
e = "http://" + e
|
||||
host = e.split("://", 1)[1]
|
||||
if ":" not in host:
|
||||
e = e + ":" + str(DEFAULT_PORT)
|
||||
return e
|
||||
|
||||
|
||||
def host_port(ep):
|
||||
h = ep.split("://", 1)[1]
|
||||
host, _, port = h.partition(":")
|
||||
return host, int(port or DEFAULT_PORT)
|
||||
|
||||
|
||||
def find_image_b64():
|
||||
"""找一张真实帧图转 base64; 找不到返回 None(改用TCP探测)。"""
|
||||
for pat in ("./split/**/*.jpg", "./composite/**/*.jpg", "./split/*/*.jpg"):
|
||||
for p in glob.glob(pat, recursive=True):
|
||||
try:
|
||||
with open(p, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode()
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def probe_ocr(ep, img_b64, timeout):
|
||||
url = ep + OCR_PATH
|
||||
payload = {"app_id": str(int(time.time() * 1000)), "image": img_b64,
|
||||
"ocr_template": "OCR", "options": {"pdf_page_index": 0, "detect_text": True}}
|
||||
t0 = time.time()
|
||||
try:
|
||||
r = requests.post(url, json=payload, timeout=timeout,
|
||||
headers={"Content-Type": "application/json"})
|
||||
dt = time.time() - t0
|
||||
return (ep, "alive", dt, "") if r.status_code == 200 else (ep, "http_err", dt, f"HTTP {r.status_code}")
|
||||
except requests.exceptions.ConnectTimeout:
|
||||
return (ep, "dead", time.time() - t0, "连接超时")
|
||||
except requests.exceptions.ReadTimeout:
|
||||
return (ep, "slow", time.time() - t0, "读超时(连得上不回)")
|
||||
except requests.exceptions.ConnectionError:
|
||||
return (ep, "dead", time.time() - t0, "拒绝/不可达")
|
||||
except Exception as e:
|
||||
return (ep, "dead", time.time() - t0, str(e)[:40])
|
||||
|
||||
|
||||
def probe_tcp(ep, timeout):
|
||||
host, port = host_port(ep)
|
||||
t0 = time.time()
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return (ep, "alive", time.time() - t0, "TCP通")
|
||||
except socket.timeout:
|
||||
return (ep, "dead", time.time() - t0, "TCP超时")
|
||||
except Exception as e:
|
||||
return (ep, "dead", time.time() - t0, str(e)[:40])
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--timeout", type=float, default=5.0)
|
||||
ap.add_argument("--tcp", action="store_true", help="只测TCP端口连通(不发OCR)")
|
||||
ap.add_argument("--list-dead", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
eps = config.get_tencent_ocr_config().get("endpoints") or []
|
||||
eps = [u for u in (norm(e) for e in eps) if u]
|
||||
|
||||
img = None
|
||||
if not args.tcp:
|
||||
img = find_image_b64()
|
||||
if img is None:
|
||||
print("没找到帧图, 改用 TCP 连通探测(--tcp)。")
|
||||
mode = "TCP连通" if (args.tcp or img is None) else "真实OCR请求"
|
||||
print(f"探测 {len(eps)} 个端点 (方式: {mode}, 超时 {args.timeout}s)...")
|
||||
|
||||
def work(e):
|
||||
if args.tcp or img is None:
|
||||
return probe_tcp(e, args.timeout)
|
||||
return probe_ocr(e, img, args.timeout)
|
||||
|
||||
res = []
|
||||
with ThreadPoolExecutor(max_workers=64) as ex:
|
||||
for f in as_completed([ex.submit(work, e) for e in eps]):
|
||||
res.append(f.result())
|
||||
|
||||
c = Counter(r[1] for r in res)
|
||||
alive = [r for r in res if r[1] in ("alive", "http_err")]
|
||||
lats = sorted(r[2] for r in alive)
|
||||
total = len(eps)
|
||||
good = c["alive"] + c["http_err"]
|
||||
print("=" * 52)
|
||||
print(f" 可用(alive/http_err): {good}")
|
||||
print(f" 慢(读超时): {c['slow']}")
|
||||
print(f" 死(连不上/拒绝): {c['dead']}")
|
||||
if lats:
|
||||
print(f" 可用端点延迟: 中位 {lats[len(lats)//2]*1000:.0f}ms 最慢 {lats[-1]*1000:.0f}ms")
|
||||
print("=" * 52)
|
||||
print(f"结论: {total} 个端点里 {good} 个可用 ({good/total*100:.0f}%)")
|
||||
if good < total * 0.8:
|
||||
print(f"⚠️ {total-good} 个端点不可用! 负载全压在 {good} 个上 → 这极可能就是OCR超时根因。")
|
||||
print(f" 把死端点服务拉起 / 或从config删掉死的, OCR容量最高可提升 {total/max(good,1):.1f} 倍。")
|
||||
else:
|
||||
print("端点基本都活着 → OCR超时不是'端点少', 是'整体容量/CPU抢占'问题。")
|
||||
|
||||
if args.list_dead:
|
||||
print("\n死/慢端点:")
|
||||
for ep, st, dt, msg in sorted(res, key=lambda x: x[1]):
|
||||
if st in ("dead", "slow"):
|
||||
print(f" {st:6s} {ep:32s} {msg}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
111
tools/检查srt质量.py
Normal file
111
tools/检查srt质量.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
扫描 SRT 文件, 报告字幕质量指标 + 抽样, 帮你快速判断OCR字幕好不好。
|
||||
纯标准库。
|
||||
|
||||
用法:
|
||||
python3 tools/检查srt质量.py ./srt # 扫目录下所有 .srt
|
||||
python3 tools/检查srt质量.py a.srt b.srt # 扫指定文件
|
||||
python3 tools/检查srt质量.py ./srt --sample 8 # 多抽几条看
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
|
||||
CJK = re.compile(r'[一-鿿]')
|
||||
|
||||
|
||||
def parse_srt(path):
|
||||
"""返回 [(idx, start, end, text), ...]"""
|
||||
try:
|
||||
raw = open(path, encoding='utf-8', errors='ignore').read()
|
||||
except OSError:
|
||||
return []
|
||||
blocks = re.split(r'\n\s*\n', raw.strip())
|
||||
out = []
|
||||
for b in blocks:
|
||||
lines = [x for x in b.splitlines() if x.strip()]
|
||||
if len(lines) < 2:
|
||||
continue
|
||||
m = re.search(r'(\d+):(\d+):(\d+)[,.](\d+)\s*-->\s*(\d+):(\d+):(\d+)[,.](\d+)', b)
|
||||
if not m:
|
||||
continue
|
||||
g = list(map(int, m.groups()))
|
||||
st = g[0]*3600 + g[1]*60 + g[2] + g[3]/1000.0
|
||||
en = g[4]*3600 + g[5]*60 + g[6] + g[7]/1000.0
|
||||
ti = next((i for i, l in enumerate(lines) if '-->' in l), 0)
|
||||
text = ' '.join(lines[ti+1:]).strip()
|
||||
out.append((st, en, text))
|
||||
return out
|
||||
|
||||
|
||||
def has_self_repeat(s, minlen=4):
|
||||
"""检测整条里前半段==后半段这种自我重复(如 AABB → AB 重复)。"""
|
||||
n = len(s)
|
||||
if n < minlen * 2:
|
||||
return False
|
||||
h = n // 2
|
||||
return s[:h] == s[h:2*h] and len(s[:h]) >= minlen
|
||||
|
||||
|
||||
def scan(paths, sample):
|
||||
files = []
|
||||
for p in paths:
|
||||
if os.path.isdir(p):
|
||||
files += glob.glob(os.path.join(p, '**', '*.srt'), recursive=True)
|
||||
elif p.endswith('.srt'):
|
||||
files.append(p)
|
||||
if not files:
|
||||
print("没找到 .srt")
|
||||
return
|
||||
|
||||
tot_e = empty = dup = selfrep = tooshort = lowcjk = badtime = 0
|
||||
chars = 0
|
||||
samples = []
|
||||
for f in files:
|
||||
subs = parse_srt(f)
|
||||
prev = None
|
||||
for st, en, text in subs:
|
||||
tot_e += 1
|
||||
chars += len(text)
|
||||
if not text:
|
||||
empty += 1
|
||||
if prev is not None and text and text == prev:
|
||||
dup += 1
|
||||
if has_self_repeat(text):
|
||||
selfrep += 1
|
||||
if text and len(text) <= 1:
|
||||
tooshort += 1
|
||||
if text and len(CJK.findall(text)) < len(text) * 0.3 and len(text) > 4:
|
||||
lowcjk += 1 # 中文占比过低 → 可能乱码/纯符号
|
||||
if en <= st or (en - st) > 30:
|
||||
badtime += 1
|
||||
prev = text
|
||||
if subs and len(samples) < sample:
|
||||
mid = len(subs) // 2
|
||||
for s in subs[mid:mid+2]:
|
||||
samples.append((os.path.basename(f), s))
|
||||
|
||||
print("=" * 58)
|
||||
print(f"SRT 文件 {len(files)} 个 | 字幕条目 {tot_e} 条 | 平均 {chars/max(tot_e,1):.1f} 字/条")
|
||||
print("-" * 58)
|
||||
def pct(x): return f"{x} ({x/max(tot_e,1)*100:.1f}%)"
|
||||
print(f" 空条目: {pct(empty)} <- 应≈0")
|
||||
print(f" 连续重复(同上条): {pct(dup)} <- 高=去重没做好")
|
||||
print(f" 自我重复(AABB): {pct(selfrep)} <- 高=自重复bug")
|
||||
print(f" 单字条目: {pct(tooshort)}")
|
||||
print(f" 低中文占比(疑乱码):{pct(lowcjk)} <- 高=OCR乱/纯符号")
|
||||
print(f" 时间轴异常: {pct(badtime)} <- 重叠/0时长/超30s")
|
||||
print("=" * 58)
|
||||
print("抽样字幕:")
|
||||
for fn, (st, en, text) in samples[:sample]:
|
||||
print(f" [{st:6.1f}-{en:6.1f}] {text[:50]} ({fn})")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('paths', nargs='+')
|
||||
ap.add_argument('--sample', type=int, default=6)
|
||||
a = ap.parse_args()
|
||||
scan(a.paths, a.sample)
|
||||
92
tools/看OCR分布.py
Normal file
92
tools/看OCR分布.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
聚合 ./ocr_dispatch/<pid>.json (各 worker 进程写的派发计数), 看 119 个 OCR 端点各分到多少,
|
||||
直接验证 P2C 负载均衡是否均匀。
|
||||
|
||||
用法:
|
||||
python3 tools/看OCR分布.py # 默认只统计最近5分钟内更新过的进程(活进程)
|
||||
python3 tools/看OCR分布.py --all # 包含所有(含已退出的旧进程文件)
|
||||
python3 tools/看OCR分布.py --fresh 120 # 只算最近120秒更新的
|
||||
python3 tools/看OCR分布.py --top 20 # 只看派发最多/最少各20个
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import glob
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dir", default="./ocr_dispatch", help="计数文件目录")
|
||||
ap.add_argument("--fresh", type=int, default=300, help="只统计最近N秒更新过的进程文件(默认300)")
|
||||
ap.add_argument("--all", action="store_true", help="统计所有文件(忽略新鲜度)")
|
||||
ap.add_argument("--top", type=int, default=0, help="只显示派发最多/最少各N个(0=全部)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isdir(args.dir):
|
||||
print(f"没有目录 {args.dir} —— 还没产生派发计数。")
|
||||
print("确认 config.tencentcloud.ocr.dispatch_log_every > 0, 且程序已跑一会儿(每1000次派发才落盘)。")
|
||||
raise SystemExit(0)
|
||||
|
||||
now = time.time()
|
||||
files = glob.glob(os.path.join(args.dir, "*.json"))
|
||||
counts = {} # endpoint -> 总派发数
|
||||
lat_sum = {} # endpoint -> 延迟加权和(按该进程total加权)
|
||||
lat_w = {}
|
||||
nproc = 0
|
||||
grand = 0
|
||||
for fp in files:
|
||||
try:
|
||||
if not args.all and (now - os.path.getmtime(fp)) > args.fresh:
|
||||
continue
|
||||
d = json.load(open(fp, encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
nproc += 1
|
||||
for ep, c in (d.get("counts") or {}).items():
|
||||
counts[ep] = counts.get(ep, 0) + c
|
||||
grand += c
|
||||
for ep, l in (d.get("lat_ewma") or {}).items():
|
||||
tot = d.get("total", 0) or 0
|
||||
lat_sum[ep] = lat_sum.get(ep, 0.0) + l * tot
|
||||
lat_w[ep] = lat_w.get(ep, 0) + tot
|
||||
|
||||
if not counts:
|
||||
print(f"没有可统计的计数(活进程文件0个)。用 --all 看历史, 或确认程序在跑。")
|
||||
raise SystemExit(0)
|
||||
|
||||
n = len(counts)
|
||||
avg = grand / n if n else 0
|
||||
rows = []
|
||||
for ep, c in counts.items():
|
||||
lat = (lat_sum.get(ep, 0) / lat_w[ep]) if lat_w.get(ep) else 0.0
|
||||
rows.append((c, ep, lat))
|
||||
rows.sort(reverse=True)
|
||||
|
||||
mx = rows[0][0] if rows else 0
|
||||
mn = rows[-1][0] if rows else 0
|
||||
import statistics
|
||||
cs = [r[0] for r in rows]
|
||||
std = statistics.pstdev(cs) if len(cs) > 1 else 0
|
||||
|
||||
print("=" * 60)
|
||||
print(f"统计进程数: {nproc} 端点数: {n} 总派发: {grand}")
|
||||
print(f"每端点平均: {avg:.0f} 最多: {mx} 最少: {mn} max/min: {(mx/mn) if mn else float('inf'):.2f}")
|
||||
print(f"标准差: {std:.0f} 变异系数(越小越均匀): {(std/avg*100) if avg else 0:.1f}%")
|
||||
print("=" * 60)
|
||||
|
||||
show = rows
|
||||
note = ""
|
||||
if args.top and len(rows) > 2 * args.top:
|
||||
show = rows[:args.top] + [(-1, "...", -1)] + rows[-args.top:]
|
||||
note = f"(只显示最多/最少各 {args.top} 个)"
|
||||
print(f"端点派发明细 {note}:")
|
||||
print(f" {'端点':<24}{'派发数':>8}{'占比':>8}{'延迟ms':>8}")
|
||||
for c, ep, lat in show:
|
||||
if c < 0:
|
||||
print(" ...")
|
||||
continue
|
||||
print(f" {ep:<24}{c:>8}{c/grand*100:>7.1f}%{lat*1000:>7.0f}")
|
||||
|
||||
print()
|
||||
print("判读: 变异系数 <15% 算很均匀; max/min 接近1最好。")
|
||||
print(" 某端点'派发少+延迟高'=它慢被P2C自动避开(正常); '派发少+延迟正常'=可能刚冷却过。")
|
||||
57
常用命令.txt
Normal file
57
常用命令.txt
Normal file
@@ -0,0 +1,57 @@
|
||||
==================== 常用命令(在项目根目录执行)====================
|
||||
|
||||
【1. 从 COS 下载 tar-gz 目录的所有文件】
|
||||
./coscli-linux-amd64 -c .cos.yaml cp -r cos://asr-dig-data-1253181912/0004/tar-gz/ ./tar-gz/
|
||||
|
||||
# 提高并发(文件多时更快)
|
||||
./coscli-linux-amd64 -c .cos.yaml cp -r cos://asr-dig-data-1253181912/0004/tar-gz/ ./tar-gz/ --routines 16
|
||||
|
||||
|
||||
【2. 上传到 COS 的 tar-gz 目录】
|
||||
# 上传单个文件(保留原名)
|
||||
./coscli-linux-amd64 -c .cos.yaml cp ./文件名.tar.gz cos://asr-dig-data-1253181912/0004/tar-gz/
|
||||
|
||||
# 上传整个目录(加 -r)
|
||||
./coscli-linux-amd64 -c .cos.yaml cp -r ./本地目录/ cos://asr-dig-data-1253181912/0004/tar-gz/
|
||||
|
||||
|
||||
【3. 查看 tar-gz 目录里有哪些文件】
|
||||
./coscli-linux-amd64 -c .cos.yaml ls cos://asr-dig-data-1253181912/0004/tar-gz/
|
||||
|
||||
|
||||
==================== 项目启动 / 停止 ====================
|
||||
|
||||
【启动(进程池, 后台, 写日志到 app.log)】
|
||||
nohup python main.py --source-list ./text2 --max-workers 56 --executor process > app.log 2>&1 &
|
||||
|
||||
# 96核机器内存充裕可调大: --max-workers 64
|
||||
|
||||
【实时看日志】
|
||||
tail -f app.log
|
||||
|
||||
【停止(连 ffmpeg 子进程一起杀)】
|
||||
bash scripts/stop.sh -y
|
||||
|
||||
# 或手动:
|
||||
pkill -9 -f main.py ; pkill -9 -f multiprocessing ; pkill -9 ffmpeg
|
||||
|
||||
|
||||
==================== 监控 / 统计 ====================
|
||||
|
||||
【后台采集 CPU/IO/负载/网络】
|
||||
nohup bash scripts/monitor.sh 5 monitor.log &
|
||||
|
||||
【看每小时吞吐 + 有效并发(对照 max-workers 看吃满没)】
|
||||
python tools/hourly_stats.py 数据.jsonl --last 6
|
||||
|
||||
【看每天处理量】
|
||||
python tools/daily_stats.py 数据.jsonl
|
||||
|
||||
|
||||
==================== 备注 ====================
|
||||
- Linux 首次用 coscli 先给执行权限: chmod +x coscli-linux-amd64
|
||||
- 下载/上传目录必须带 -r
|
||||
- 目标路径以 / 结尾=保留原名; 带文件名=改成该名
|
||||
- 启动靠 数据.jsonl 断点续传, 重启会跳过已处理的视频
|
||||
|
||||
|
||||
Reference in New Issue
Block a user