Compare commits
33
Commits
packaging-macos
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1eb5bb28e | ||
|
|
c65ec20119 | ||
|
|
fabde9cf83 | ||
|
|
8b583a4b0e | ||
|
|
5cdc727207 | ||
|
|
9094b3c888 | ||
|
|
b772ab3def | ||
|
|
f21f844f0f | ||
|
|
0b2a3ec6e3 | ||
|
|
8dc1276a7c | ||
|
|
b4b9837dde | ||
|
|
b81f6afd0f | ||
|
|
da119826f5 | ||
|
|
ea1784a10f | ||
|
|
27b4a2c1bf | ||
|
|
4d2fee2b1b | ||
|
|
50c6cf92ba | ||
|
|
073a2a4fba | ||
|
|
76f5d1c857 | ||
|
|
d5468ec958 | ||
|
|
a56a861d57 | ||
|
|
abd5d0ce47 | ||
|
|
e512a842e4 | ||
|
|
273f03ee9f | ||
|
|
b2dfbce962 | ||
|
|
693d394f55 | ||
|
|
5b780e3186 | ||
|
|
577b64adad | ||
|
|
c63f6b52da | ||
|
|
b6644f3374 | ||
|
|
c189f8368e | ||
|
|
3f3b061a53 | ||
|
|
bdf333c510 |
@@ -0,0 +1,13 @@
|
||||
# VSR Web 配置示例 — 复制为 .env 后修改
|
||||
# 参考 df-autotest-platform 的 .env 结构
|
||||
|
||||
# 管理员密码(留空则不需要登录)
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# 存储目录(默认 ./web_cache)
|
||||
# VSR_STORAGE=./web_cache
|
||||
|
||||
# 服务监听端口(默认 8001)
|
||||
# PORT=8001
|
||||
|
||||
# Platform Worker 连接信息在「系统设置」页面配置,无需在此设置
|
||||
@@ -375,3 +375,5 @@ test*_no_sub*.mp4
|
||||
/backend/tools/train/release_model/
|
||||
inference.onnx
|
||||
/config/config.json
|
||||
web_cache/
|
||||
.env
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<div align="center">
|
||||
<img src="design/icon_1024.PNG" alt="VSR Logo" width="128" height="128">
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://trendshift.io/repositories/9120" target="_blank"><img src="https://trendshift.io/api/badge/repositories/9120" alt="YaoFANGUK%2Fvideo-subtitle-remover | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</div>
|
||||
|
||||
## 项目简介
|
||||
|
||||
@@ -15,6 +18,7 @@ Video-subtitle-remover (VSR) 是一款基于AI技术,将视频中的硬字幕
|
||||
主要实现了以下功能:
|
||||
- **无损分辨率**将视频中的硬字幕去除,生成去除字幕后的文件
|
||||
- 通过超强AI算法模型,对去除字幕文本的区域进行填充(非相邻像素填充与马赛克去除)
|
||||
- 提取原视频字幕,可配合:[video-subtitle-extractor (VSE)](https://github.com/YaoFANGUK/video-subtitle-extractor)
|
||||
- 支持自定义字幕位置,仅去除定义位置中的字幕(传入位置)
|
||||
- 支持全视频自动去除所有文本(不传入位置)
|
||||
- 支持多选图片批量去除水印文本
|
||||
@@ -260,6 +264,10 @@ LAMA_SUPER_FAST = False # 保证效果
|
||||
|
||||
解决方案:升级7-zip解压程序到最新版本
|
||||
|
||||
4. Mac版本运行报错:Error "bad CPU type in executable"
|
||||
|
||||
解决方案:打开控制台输入`softwareupdate --install-rosetta` 安装rosetta
|
||||
|
||||
|
||||
## 赞助
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<div align="center">
|
||||
<img src="design/icon_1024.PNG" alt="VSR Logo" width="128" height="128">
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://trendshift.io/repositories/9120" target="_blank"><img src="https://trendshift.io/api/badge/repositories/9120" alt="YaoFANGUK%2Fvideo-subtitle-remover | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</div>
|
||||
|
||||
## Project Introduction
|
||||
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
"""
|
||||
智能水印检测:通过帧差分找静态区域,用SAM2精确分割。
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
SAM2_PATH = "/opt/sam2"
|
||||
SAM2_CKPT = "/opt/sam2/checkpoints/sam2.1_hiera_tiny.pt"
|
||||
SAM2_CFG = "configs/sam2.1/sam2.1_hiera_t.yaml"
|
||||
|
||||
|
||||
def detect_static_regions(video_path: str, sample_frames: int = 30, threshold: float = 20.0,
|
||||
max_scan_seconds: float = None):
|
||||
"""
|
||||
饱和度签名 + 跨帧对比法检测水印/Logo。
|
||||
采样策略:全程每秒一帧,分析时缩放到 1/4 分辨率提速,坐标还原回原始尺寸。
|
||||
"""
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||
orig_frame_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
orig_frame_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
duration_s = total / fps
|
||||
|
||||
# 全程每秒采一帧
|
||||
step = max(1, int(fps))
|
||||
positions = list(range(0, total, step))
|
||||
|
||||
# 分析用 1/4 分辨率加速
|
||||
SW, SH = max(1, orig_frame_w // 4), max(1, orig_frame_h // 4)
|
||||
|
||||
bgr_frames = [] # 小尺寸帧,用于分析
|
||||
gray_frames = []
|
||||
actual_pos_s = [] # 每帧对应的时间戳(秒)
|
||||
|
||||
for pos in positions:
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, pos)
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
continue
|
||||
small = cv2.resize(frame, (SW, SH), interpolation=cv2.INTER_AREA)
|
||||
bgr_frames.append(small)
|
||||
gray_frames.append(cv2.cvtColor(small, cv2.COLOR_BGR2GRAY).astype(np.float32))
|
||||
actual_pos_s.append(pos / fps)
|
||||
|
||||
cap.release()
|
||||
|
||||
print(f"[SAM2-detect] 采样 {len(bgr_frames)} 帧(全程每秒一帧,共{duration_s:.1f}s,分析尺寸{SW}x{SH})", flush=True)
|
||||
|
||||
if len(bgr_frames) < 4:
|
||||
print(f"[SAM2-detect] 帧数不足4帧,返回空", flush=True)
|
||||
return []
|
||||
|
||||
# ── 阶段1:高饱和度识别含logo帧 ─────────────────────────────────────
|
||||
top_h = SH // 5
|
||||
logo_sat_thresh = 200
|
||||
logo_min_pixels = top_h * SW * 0.003
|
||||
|
||||
is_logo = []
|
||||
for i, bgr in enumerate(bgr_frames):
|
||||
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
||||
high_sat = int(np.sum(hsv[:top_h, :, 1] > logo_sat_thresh))
|
||||
has_logo = high_sat >= logo_min_pixels
|
||||
is_logo.append(has_logo)
|
||||
mark = '✓' if has_logo else '✗'
|
||||
print(f"[SAM2-detect] {mark} {actual_pos_s[i]:.1f}s: 高饱和={high_sat}px", flush=True)
|
||||
|
||||
logo_bgr_frames = [bgr_frames[i] for i, v in enumerate(is_logo) if v]
|
||||
nlogo_bgr_frames = [bgr_frames[i] for i, v in enumerate(is_logo) if not v]
|
||||
logo_gray_frames = [gray_frames[i] for i, v in enumerate(is_logo) if v]
|
||||
|
||||
print(f"[SAM2-detect] logo含帧: {len(logo_bgr_frames)}/{len(bgr_frames)}", flush=True)
|
||||
|
||||
# 分析时使用小尺寸坐标空间
|
||||
w, h = SW, SH
|
||||
|
||||
# ── 路径A:彩色水印(高饱和度)检测 ──────────────────────────────────
|
||||
sat_mask = np.zeros((h, w), dtype=np.uint8)
|
||||
if len(logo_gray_frames) >= 2 and nlogo_bgr_frames:
|
||||
sat_acc = np.zeros((h, w), dtype=np.float32)
|
||||
for bgr in logo_bgr_frames:
|
||||
sat_acc += cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)[:, :, 1].astype(np.float32) / 255.0
|
||||
sat_mean_logo = sat_acc / len(logo_bgr_frames)
|
||||
|
||||
sat_acc_nl = np.zeros((h, w), dtype=np.float32)
|
||||
for bgr in nlogo_bgr_frames[:6]:
|
||||
sat_acc_nl += cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)[:, :, 1].astype(np.float32) / 255.0
|
||||
sat_mean_nlogo = sat_acc_nl / min(len(nlogo_bgr_frames), 6)
|
||||
sat_diff = sat_mean_logo - sat_mean_nlogo
|
||||
|
||||
sat_thresh = min(0.6, np.percentile(sat_mean_logo, 92))
|
||||
diff_thresh = max(0.25, sat_diff.mean() + sat_diff.std())
|
||||
sat_mask = ((sat_mean_logo >= sat_thresh) & (sat_diff >= diff_thresh)).astype(np.uint8) * 255
|
||||
print(f"[SAM2-detect] 彩色路径: sat_thresh={sat_thresh:.3f} diff_thresh={diff_thresh:.3f} 候选={np.sum(sat_mask>0)}", flush=True)
|
||||
|
||||
# ── 路径B:白色/灰色水印(亮度差分)检测 ────────────────────────────────
|
||||
# "sjhs.pw" 类白色文字水印:饱和度≈0,但亮度比背景高,且只出现在少数帧
|
||||
# 策略:把前 N 秒的帧与后续帧做亮度差分,找只出现在片头的高亮区域
|
||||
bright_mask = np.zeros((h, w), dtype=np.uint8)
|
||||
early_cutoff = min(len(gray_frames), max(5, int(10 * fps / step))) # 前10秒
|
||||
if early_cutoff >= 2 and len(gray_frames) > early_cutoff:
|
||||
early_stack = np.stack(gray_frames[:early_cutoff], axis=0)
|
||||
rest_stack = np.stack(gray_frames[early_cutoff:], axis=0)
|
||||
early_mean = early_stack.mean(axis=0)
|
||||
rest_mean = rest_stack.mean(axis=0)
|
||||
# 片头比后续亮得多 → 水印像素(值越大越亮)
|
||||
bright_diff = early_mean - rest_mean
|
||||
# 阈值:差值超过 20 灰度级的区域
|
||||
bright_mask_raw = (bright_diff > 20).astype(np.uint8) * 255
|
||||
# 只保留四角区域(水印通常在角落),避免误报视频内容
|
||||
corner_mask = np.zeros((h, w), dtype=np.uint8)
|
||||
ch, cw = h // 4, w // 4 # 角落占画面 25%
|
||||
corner_mask[:ch, :cw] = 1 # 左上
|
||||
corner_mask[:ch, -cw:] = 1 # 右上
|
||||
corner_mask[-ch:, :cw] = 1 # 左下
|
||||
corner_mask[-ch:, -cw:] = 1 # 右下
|
||||
bright_mask = cv2.bitwise_and(bright_mask_raw, corner_mask * 255)
|
||||
print(f"[SAM2-detect] 亮度差分路径: bright_diff_max={bright_diff.max():.1f} 角落候选={np.sum(bright_mask>0)}", flush=True)
|
||||
|
||||
# ── 合并两条路径 ──────────────────────────────────────────────────────
|
||||
static_mask = cv2.bitwise_or(sat_mask, bright_mask)
|
||||
if np.sum(static_mask) == 0:
|
||||
# 两条路径都没找到,回退到全局时序std法
|
||||
print(f"[SAM2-detect] 两条路径均无结果,回退到时序std法", flush=True)
|
||||
stack = np.stack(gray_frames, axis=0)
|
||||
std_map = np.std(stack, axis=0)
|
||||
static_mask = (std_map < 8.0).astype(np.uint8) * 255
|
||||
print(f"[SAM2-detect] 合并后候选像素数={np.sum(static_mask>0)}", flush=True)
|
||||
|
||||
# 仅排除纯黑区域
|
||||
mean_gray = np.mean(np.stack(gray_frames, axis=0), axis=0)
|
||||
content_mask = (mean_gray > 8).astype(np.uint8) * 255
|
||||
static_mask = cv2.bitwise_and(static_mask, content_mask)
|
||||
print(f"[SAM2-detect] 过滤纯黑后候选像素数={np.sum(static_mask > 0)}", flush=True)
|
||||
|
||||
# 形态学处理:去噪 + 膨胀
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
|
||||
static_mask = cv2.morphologyEx(static_mask, cv2.MORPH_OPEN, kernel)
|
||||
static_mask = cv2.dilate(static_mask, kernel, iterations=2)
|
||||
|
||||
# 找轮廓
|
||||
contours, _ = cv2.findContours(static_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
regions = []
|
||||
min_area = (w * h) * 0.0005 # 至少占画面 0.05%
|
||||
max_area = (w * h) * 0.25 # 最多占画面 25%
|
||||
|
||||
print(f"[SAM2-detect] 找到 {len(contours)} 个轮廓,min_area={min_area:.0f} max_area={max_area:.0f}", flush=True)
|
||||
for cnt in contours:
|
||||
area = cv2.contourArea(cnt)
|
||||
x, y, bw, bh = cv2.boundingRect(cnt)
|
||||
ratio = bw / bh if bh > 0 else 0
|
||||
if min_area < area < max_area:
|
||||
if 0.1 < ratio < 20:
|
||||
regions.append((x, y, x + bw, y + bh))
|
||||
print(f"[SAM2-detect] ✓ 区域 ({x},{y})-({x+bw},{y+bh}) 面积={area:.0f} 比例={ratio:.2f}", flush=True)
|
||||
else:
|
||||
print(f"[SAM2-detect] ✗ 比例过滤: ({x},{y})-({x+bw},{y+bh}) 面积={area:.0f} 比例={ratio:.2f}", flush=True)
|
||||
else:
|
||||
print(f"[SAM2-detect] ✗ 面积过滤: ({x},{y})-({x+bw},{y+bh}) 面积={area:.0f}", flush=True)
|
||||
|
||||
# 若检测到多个位于帧顶部的区域,合并成一个大框覆盖整个logo
|
||||
# logo通常有高饱和的亮色区域(检测到)和低饱和的暗色区域(未检测到)
|
||||
# 合并+适当扩展可确保完整覆盖
|
||||
if len(regions) >= 2:
|
||||
top_regions = [r for r in regions if r[1] < h * 0.3] # 只合并帧顶30%内的区域
|
||||
if len(top_regions) >= 2:
|
||||
x1 = max(0, min(r[0] for r in top_regions) - 30)
|
||||
y1 = max(0, min(r[1] for r in top_regions) - 15)
|
||||
x2 = min(w, max(r[2] for r in top_regions) + 30)
|
||||
y2 = min(h, max(r[3] for r in top_regions) + 20)
|
||||
other = [r for r in regions if r[1] >= h * 0.3]
|
||||
merged = [(x1, y1, x2, y2)] + other
|
||||
print(f"[SAM2-detect] 合并顶部 {len(top_regions)} 个区域为 ({x1},{y1})-({x2},{y2})", flush=True)
|
||||
regions = merged
|
||||
|
||||
# ── 坐标从分析尺寸(SW×SH)还原到原始分辨率 ──────────────────────────
|
||||
scale_x = orig_frame_w / SW
|
||||
scale_y = orig_frame_h / SH
|
||||
regions = [(int(x1*scale_x), int(y1*scale_y), int(x2*scale_x), int(y2*scale_y))
|
||||
for x1, y1, x2, y2 in regions]
|
||||
|
||||
print(f"[SAM2-detect] detect_static_regions 返回 {len(regions)} 个区域(已还原到{orig_frame_w}x{orig_frame_h})", flush=True)
|
||||
return regions
|
||||
|
||||
|
||||
def refine_with_sam2(image: np.ndarray, boxes: list, gpu_id: int = 0):
|
||||
"""
|
||||
用 SAM2 对检测到的区域精确分割,返回合并后的 mask。
|
||||
image: BGR numpy array
|
||||
boxes: [(x1,y1,x2,y2), ...]
|
||||
返回 binary mask (H, W) uint8
|
||||
"""
|
||||
if not boxes:
|
||||
return None
|
||||
|
||||
try:
|
||||
if SAM2_PATH not in sys.path:
|
||||
sys.path.insert(0, SAM2_PATH)
|
||||
|
||||
import torch
|
||||
from sam2.build_sam import build_sam2
|
||||
from sam2.sam2_image_predictor import SAM2ImagePredictor
|
||||
|
||||
device = f"cuda:{gpu_id}" if torch.cuda.is_available() else "cpu"
|
||||
model = build_sam2(SAM2_CFG, SAM2_CKPT, device=device)
|
||||
predictor = SAM2ImagePredictor(model)
|
||||
|
||||
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||
predictor.set_image(rgb)
|
||||
|
||||
h, w = image.shape[:2]
|
||||
combined_mask = np.zeros((h, w), dtype=np.uint8)
|
||||
|
||||
for (x1, y1, x2, y2) in boxes:
|
||||
box = np.array([x1, y1, x2, y2])
|
||||
masks, scores, _ = predictor.predict(
|
||||
box=box[None],
|
||||
multimask_output=False,
|
||||
)
|
||||
if masks is not None and len(masks) > 0:
|
||||
m = masks[0].astype(np.uint8) * 255
|
||||
combined_mask = cv2.bitwise_or(combined_mask, m)
|
||||
|
||||
return combined_mask
|
||||
|
||||
except Exception as e:
|
||||
print(f"[SAM2] 分割失败: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def _frame_text_candidates(gray, tophat_kernel, conn_kernel, th_val=22):
|
||||
"""单帧:white top-hat 提取细笔画亮文字 → 候选文字框 (xmin,xmax,ymin,ymax)。
|
||||
top-hat 只响应比周围亮、且比核(15px)细的结构 → 文字笔画命中,大块皮肤/丝袜不命中。
|
||||
再用「连通组件数」区分文字(多个字母,>=3)和实心亮物(卡扣/内衣,1-2块)。"""
|
||||
tophat = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, tophat_kernel)
|
||||
th = cv2.threshold(tophat, th_val, 255, cv2.THRESH_BINARY)[1]
|
||||
closed = cv2.morphologyEx(th, cv2.MORPH_CLOSE, conn_kernel, iterations=2)
|
||||
cnts, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
boxes = []
|
||||
for c in cnts:
|
||||
x, y, bw, bh = cv2.boundingRect(c)
|
||||
area = bw * bh
|
||||
ar = bw / max(bh, 1)
|
||||
# 文字特征:宽>高、有一定宽度、不至于太小
|
||||
if not (area > 350 and 1.3 < ar < 15 and bw > 35 and bh > 6):
|
||||
continue
|
||||
# 框内细笔画(未闭合的原始 top-hat)的独立组件数:文字=多字母(>=3),实心物=1-2
|
||||
roi = th[y:y+bh, x:x+bw]
|
||||
n, _, stats, _ = cv2.connectedComponentsWithStats(roi, connectivity=8)
|
||||
comp = sum(1 for i in range(1, n) if stats[i, cv2.CC_STAT_AREA] >= 8)
|
||||
if comp < 3:
|
||||
continue
|
||||
boxes.append((x, x + bw, y, y + bh)) # (xmin,xmax,ymin,ymax)
|
||||
return boxes
|
||||
|
||||
|
||||
def detect_color_logo(video_path: str, sample_n: int = 40):
|
||||
"""检测彩色不透明 logo 水印(如「黑料网」橙色横幅)。
|
||||
判据:高饱和(>150) AND 时序低方差(静止) → 排除会动的皮肤、柔和的背景画。
|
||||
返回 [(xmin,xmax,ymin,ymax), ...] 静止框(应用到所有帧)。"""
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
positions = np.linspace(0, total - 1, min(sample_n, total)).astype(int)
|
||||
gray_stack, sat_acc, cnt = [], np.zeros((h, w), np.float32), 0
|
||||
for p in positions:
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, int(p))
|
||||
ret, f = cap.read()
|
||||
if not ret:
|
||||
continue
|
||||
gray_stack.append(cv2.cvtColor(f, cv2.COLOR_BGR2GRAY).astype(np.float32))
|
||||
sat_acc += cv2.cvtColor(f, cv2.COLOR_BGR2HSV)[:, :, 1].astype(np.float32)
|
||||
cnt += 1
|
||||
cap.release()
|
||||
if cnt < 4:
|
||||
return []
|
||||
std = np.std(np.stack(gray_stack), axis=0)
|
||||
sat = sat_acc / cnt
|
||||
mask = ((sat > 150) & (std < 12)).astype(np.uint8) * 255
|
||||
k = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15))
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k, iterations=3)
|
||||
mask = cv2.dilate(mask, k, iterations=1)
|
||||
cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
raw = []
|
||||
for c in cnts:
|
||||
x, y, bw, bh = cv2.boundingRect(c)
|
||||
if bw * bh > 2000:
|
||||
raw.append((x, y, x + bw, y + bh))
|
||||
if not raw:
|
||||
return []
|
||||
# 合并相互靠近的区域(同一 logo 的不同彩色部件)
|
||||
merged = []
|
||||
used = [False] * len(raw)
|
||||
for i in range(len(raw)):
|
||||
if used[i]:
|
||||
continue
|
||||
x1, y1, x2, y2 = raw[i]
|
||||
used[i] = True
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for j in range(len(raw)):
|
||||
if used[j]:
|
||||
continue
|
||||
bx1, by1, bx2, by2 = raw[j]
|
||||
# 间距 < 80px 视为同一 logo
|
||||
if (bx1 < x2 + 80 and bx2 > x1 - 80 and by1 < y2 + 80 and by2 > y1 - 80):
|
||||
x1, y1 = min(x1, bx1), min(y1, by1)
|
||||
x2, y2 = max(x2, bx2), max(y2, by2)
|
||||
used[j] = True
|
||||
changed = True
|
||||
# pad 扩展确保覆盖整个 logo(含低饱和的白字/黑字部分)
|
||||
pad = 18
|
||||
x1, y1 = max(0, x1 - pad), max(0, y1 - pad)
|
||||
x2, y2 = min(w, x2 + pad), min(h, y2 + pad)
|
||||
# 角落吸附:logo 通常贴边,若框已靠近某边(<160px)则延伸到该边,
|
||||
# 确保覆盖低饱和的边缘文字(如「黑料网」的黑字)
|
||||
if x1 < 160:
|
||||
x1 = 0
|
||||
if x2 > w - 160:
|
||||
x2 = w
|
||||
if y1 < 160:
|
||||
y1 = 0
|
||||
if y2 > h - 160:
|
||||
y2 = h
|
||||
merged.append((x1, x2, y1, y2))
|
||||
print(f"[ColorLogo] 检测到 {len(merged)} 个彩色 logo: {merged}", flush=True)
|
||||
return merged
|
||||
|
||||
|
||||
def detect_watermark_per_frame(video_path: str, sample_step: int = None, pad: int = 6):
|
||||
"""逐帧检测半透明/移动文字水印,返回 {frame_no: [(xmin,xmax,ymin,ymax), ...]}。
|
||||
|
||||
适用于 OCR 检测不到的淡色水印(白字、半透明)以及垂直滚动水印。
|
||||
采样:每秒一帧(cap.set 跳帧,长视频也快)。
|
||||
判据:基于轨迹自身(绝对出现秒数 + 出现时段内密度),不依赖视频总长,
|
||||
因此「只在部分时段出现的滚动水印」「贯穿全程偶有漏检的固定水印」都能被保留。
|
||||
"""
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
|
||||
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
|
||||
tophat_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
|
||||
conn_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (25, 9))
|
||||
|
||||
# 阶段1:每秒一帧采样(cap.set 跳帧;frame_no 用 1-based 与 inpaint 管线一致)
|
||||
step = max(1, int(round(fps))) # 每秒对应的帧步长
|
||||
sample_frames = list(range(0, total, step))
|
||||
frame_boxes = {} # sample_idx -> [box]
|
||||
sidx_to_fno = {} # sample_idx -> 1-based 帧号
|
||||
for si, pos in enumerate(sample_frames):
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, pos)
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
continue
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
frame_boxes[si] = _frame_text_candidates(gray, tophat_kernel, conn_kernel)
|
||||
sidx_to_fno[si] = pos + 1
|
||||
cap.release()
|
||||
|
||||
n_sampled = len(frame_boxes)
|
||||
if n_sampled == 0:
|
||||
return {}
|
||||
|
||||
# 阶段2:轨迹连接(按「采样序号」相邻,间断容忍 3 个采样点=3秒)
|
||||
sampled_sidx = sorted(frame_boxes.keys())
|
||||
|
||||
def cx_of(b): return (b[0] + b[1]) / 2
|
||||
def cy_of(b): return (b[2] + b[3]) / 2
|
||||
def wid_of(b): return b[1] - b[0]
|
||||
|
||||
GAP_TOL = 5 # 允许漏检 5 个采样点(5秒,滚动水印经过浅背景会暂时漏检)
|
||||
tracks = [] # {"boxes": {sidx:box}, "last_si":, "last_box":}
|
||||
for si in sampled_sidx:
|
||||
for b in frame_boxes[si]:
|
||||
best, best_d = None, 1e9
|
||||
for tr in tracks:
|
||||
if si - tr["last_si"] > GAP_TOL:
|
||||
continue
|
||||
lb = tr["last_box"]
|
||||
gap = si - tr["last_si"]
|
||||
dx = abs(cx_of(b) - cx_of(lb))
|
||||
dw = abs(wid_of(b) - wid_of(lb))
|
||||
dy = abs(cy_of(b) - cy_of(lb))
|
||||
# 同一水印:X几乎不变、宽度接近;Y 可滚动(按间隔放宽,每秒最多移动~60px)
|
||||
if dx < 45 and dw < 28 and dy < 60 * max(1, gap):
|
||||
d = dx * 2 + dw * 2 + dy * 0.2
|
||||
if d < best_d:
|
||||
best, best_d = tr, d
|
||||
if best is None:
|
||||
tracks.append({"boxes": {si: b}, "last_si": si, "last_box": b})
|
||||
else:
|
||||
best["boxes"][si] = b
|
||||
best["last_si"] = si
|
||||
best["last_box"] = b
|
||||
|
||||
# 阶段3:筛选有效轨迹 —— 不依赖视频总长
|
||||
# ① 至少出现 MIN_SECONDS 秒(绝对阈值,滤掉一闪而过的噪点)
|
||||
# ② 在其出现时段 [first,last] 内密度高(持续存在,不是零散巧合)
|
||||
# ③ 宽度稳定(文字水印宽度恒定,人体亮区会抖)
|
||||
MIN_SECONDS = 4
|
||||
valid = [] # 有效轨迹的 boxes dict 列表
|
||||
for tr in tracks:
|
||||
boxes = tr["boxes"]
|
||||
n_pts = len(boxes)
|
||||
if n_pts < MIN_SECONDS:
|
||||
continue
|
||||
sids = sorted(boxes.keys())
|
||||
span = sids[-1] - sids[0] + 1
|
||||
density = n_pts / span
|
||||
if density < 0.5:
|
||||
continue
|
||||
widths = [wid_of(b) for b in boxes.values()]
|
||||
wmean = sum(widths) / len(widths)
|
||||
wstd = (sum((x - wmean) ** 2 for x in widths) / len(widths)) ** 0.5
|
||||
if wmean > 0 and wstd / wmean > 0.4:
|
||||
continue
|
||||
valid.append(boxes)
|
||||
|
||||
print(f"[WM-perframe] 每秒一帧采样{n_sampled}帧 轨迹{len(tracks)}条 有效{len(valid)}条", flush=True)
|
||||
|
||||
# 阶段4:每条有效轨迹在其 [first,last] 时段内逐采样点线性插值,
|
||||
# 补全中间漏检的采样点(滚动水印经过浅背景漏检时也能被覆盖),
|
||||
# 再把每个采样点的框展开到对应的 1 秒帧区间,y 方向外扩覆盖滚动移动。
|
||||
ROLL_PAD = 16
|
||||
full = {}
|
||||
|
||||
def lerp_box(ba, bb, t):
|
||||
return tuple(int(round(ba[i] + (bb[i] - ba[i]) * t)) for i in range(4))
|
||||
|
||||
for boxes in valid:
|
||||
sids = sorted(boxes.keys())
|
||||
first, last = sids[0], sids[-1]
|
||||
for si in range(first, last + 1):
|
||||
if si in boxes:
|
||||
b = boxes[si]
|
||||
else:
|
||||
# 线性插值:前后最近的已知采样点
|
||||
prev = max(s for s in sids if s < si)
|
||||
nxt = min(s for s in sids if s > si)
|
||||
b = lerp_box(boxes[prev], boxes[nxt], (si - prev) / (nxt - prev))
|
||||
x1, x2, y1, y2 = b
|
||||
bx = (max(0, x1 - pad), min(w, x2 + pad),
|
||||
max(0, y1 - ROLL_PAD), min(h, y2 + ROLL_PAD))
|
||||
# 该采样点覆盖 [本帧, 下一采样帧) 的所有原始帧
|
||||
f_start = si * step + 1
|
||||
f_end = (si + 1) * step + 1
|
||||
for f in range(f_start, min(f_end, total + 1)):
|
||||
full.setdefault(f, [])
|
||||
full[f].append(bx)
|
||||
|
||||
# 阶段5:彩色 logo(高饱和+静止)→ 静止框,注入所有帧
|
||||
color_logos = detect_color_logo(video_path)
|
||||
if color_logos:
|
||||
for f in range(1, total + 1):
|
||||
full.setdefault(f, [])
|
||||
full[f] = full[f] + list(color_logos)
|
||||
|
||||
total_boxes = sum(len(v) for v in full.values())
|
||||
print(f"[WM-perframe] 输出 {len(full)} 帧 共 {total_boxes} 个水印框(文字+彩色logo)", flush=True)
|
||||
return full
|
||||
|
||||
|
||||
def smart_detect_watermark(video_path: str, sample_frames: int = 30, gpu_id: int = 0):
|
||||
"""
|
||||
完整流程:帧差分(前30秒每秒一帧)→ SAM2 精确分割。
|
||||
限制扫描前30秒:H.264压缩导致长视频里即使静态logo的像素std也会随时间累积超过阈值。
|
||||
返回 [(x1, y1, x2, y2), ...] 像素坐标列表。
|
||||
"""
|
||||
print(f"[SAM2] smart_detect_watermark 开始: {video_path}", flush=True)
|
||||
# 1. 静态区域检测:跨时间段帧对比法(全视频)
|
||||
boxes = detect_static_regions(video_path, sample_frames)
|
||||
print(f"[SAM2] detect_static_regions 返回 {len(boxes)} 个候选区域: {boxes}", flush=True)
|
||||
if not boxes:
|
||||
return []
|
||||
|
||||
# 2. 读取代表帧用于 SAM2
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, total // 2)
|
||||
ret, frame = cap.read()
|
||||
cap.release()
|
||||
if not ret:
|
||||
return boxes # SAM2 失败时直接返回粗检测结果
|
||||
|
||||
# 3. SAM2 精确分割
|
||||
mask = refine_with_sam2(frame, boxes, gpu_id=gpu_id)
|
||||
if mask is None:
|
||||
return boxes
|
||||
|
||||
# 4. 从 mask 提取最终 bounding boxes
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
refined = []
|
||||
for cnt in contours:
|
||||
if cv2.contourArea(cnt) > 100:
|
||||
x, y, bw, bh = cv2.boundingRect(cnt)
|
||||
refined.append((x, y, x + bw, y + bh))
|
||||
|
||||
result = refined if refined else boxes
|
||||
print(f"[SAM2] smart_detect_watermark 最终返回 {len(result)} 个区域: {result}", flush=True)
|
||||
return result
|
||||
@@ -21,9 +21,10 @@ class SubtitleDetect:
|
||||
# 采样间隔,根据视频帧率在 _init_sample_step 中自适应设置
|
||||
SAMPLE_STEP = 3
|
||||
|
||||
def __init__(self, video_path, sub_areas=[]):
|
||||
def __init__(self, video_path, sub_areas=[], gpu_id=0):
|
||||
self.video_path = video_path
|
||||
self.sub_areas = sub_areas
|
||||
self.gpu_id = gpu_id
|
||||
self._init_sample_step()
|
||||
|
||||
def _init_sample_step(self):
|
||||
@@ -46,10 +47,12 @@ class SubtitleDetect:
|
||||
hardware_accelerator = HardwareAccelerator.instance()
|
||||
onnx_providers = hardware_accelerator.onnx_providers
|
||||
model_config = ModelConfig()
|
||||
import torch as _torch
|
||||
_device = f"gpu:{self.gpu_id}" if _torch.cuda.is_available() else "cpu"
|
||||
return TextDetection(
|
||||
model_name=model_config.DET_MODEL_NAME,
|
||||
model_dir=model_config.DET_MODEL_DIR,
|
||||
device="cpu",
|
||||
device=_device,
|
||||
enable_hpi=len(onnx_providers) > 0,
|
||||
)
|
||||
|
||||
@@ -81,34 +84,97 @@ class SubtitleDetect:
|
||||
break
|
||||
return temp_list
|
||||
|
||||
def find_subtitle_frame_no(self, sub_remover=None):
|
||||
video_cap = cv2.VideoCapture(get_readable_path(self.video_path))
|
||||
frame_count = video_cap.get(cv2.CAP_PROP_FRAME_COUNT)
|
||||
tbar = tqdm(total=int(frame_count), unit='frame', position=0, file=sys.__stdout__, desc='Subtitle Finding')
|
||||
current_frame_no = 0
|
||||
# 阶段1:采样检测,仅对每隔 sample_step 帧执行 OCR
|
||||
sampled_results = {} # frame_no -> temp_list
|
||||
if sub_remover:
|
||||
sub_remover.append_output(tr['Main']['ProcessingStartFindingSubtitles'])
|
||||
while video_cap.isOpened():
|
||||
ret, frame = video_cap.read()
|
||||
# 如果读取视频帧失败(视频读到最后一帧)
|
||||
def _scan_range(self, start_frame, end_frame, ab_sections, results, lock,
|
||||
progress_counter, frame_count, sub_remover, gpu_id=None):
|
||||
"""单线程扫描指定帧范围。
|
||||
每个线程在自己内部初始化 OCR 模型(CUDA 上下文归该线程所有),避免跨线程 CUDA 问题。
|
||||
gpu_id: >=0 用 GPU,None 用 CPU。
|
||||
"""
|
||||
# 在本线程内创建并初始化 OCR 模型,CUDA 上下文属于本线程
|
||||
if gpu_id is not None and gpu_id >= 0:
|
||||
import torch as _t
|
||||
if _t.cuda.is_available():
|
||||
_local_det = SubtitleDetect(self.video_path, self.sub_areas, gpu_id=gpu_id)
|
||||
_ = _local_det.text_detector # 在本线程建立 CUDA 上下文
|
||||
else:
|
||||
_local_det = SubtitleDetect(self.video_path, self.sub_areas)
|
||||
else:
|
||||
_local_det = SubtitleDetect(self.video_path, self.sub_areas)
|
||||
|
||||
cap = cv2.VideoCapture(get_readable_path(self.video_path))
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
||||
frame_no = start_frame
|
||||
while frame_no < end_frame:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
# 读取视频帧成功
|
||||
current_frame_no += 1
|
||||
if not is_frame_number_in_ab_sections(current_frame_no - 1, sub_remover.ab_sections):
|
||||
tbar.update(1)
|
||||
frame_no += 1
|
||||
if not is_frame_number_in_ab_sections(frame_no - 1, ab_sections):
|
||||
continue
|
||||
# 仅对采样帧执行 OCR 推理
|
||||
if (current_frame_no - 1) % self.SAMPLE_STEP == 0 or self.SAMPLE_STEP <= 1:
|
||||
temp_list = self.detect_subtitle(frame)
|
||||
if len(temp_list) > 0:
|
||||
sampled_results[current_frame_no] = temp_list
|
||||
tbar.update(1)
|
||||
if sub_remover:
|
||||
sub_remover.progress_total = (100 * float(current_frame_no) / float(frame_count)) // 2
|
||||
if (frame_no - 1) % self.SAMPLE_STEP == 0 or self.SAMPLE_STEP <= 1:
|
||||
temp_list = _local_det.detect_subtitle(frame)
|
||||
if temp_list:
|
||||
with lock:
|
||||
results[frame_no] = temp_list
|
||||
with lock:
|
||||
progress_counter[0] += 1
|
||||
if sub_remover:
|
||||
sub_remover.progress_total = (100 * progress_counter[0] / frame_count) // 2
|
||||
sub_remover.notify_progress_listeners()
|
||||
cap.release()
|
||||
|
||||
def find_subtitle_frame_no(self, sub_remover=None, num_workers=4):
|
||||
import threading
|
||||
video_cap = cv2.VideoCapture(get_readable_path(self.video_path))
|
||||
frame_count = int(video_cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
video_cap.release()
|
||||
tbar = tqdm(total=frame_count, unit='frame', position=0, file=sys.__stdout__, desc='Subtitle Finding')
|
||||
# 阶段1:并行采样检测
|
||||
sampled_results = {}
|
||||
if sub_remover:
|
||||
sub_remover.append_output(tr['Main']['ProcessingStartFindingSubtitles'])
|
||||
ab_sections = sub_remover.ab_sections if sub_remover else []
|
||||
lock = threading.Lock()
|
||||
import torch as _torch
|
||||
cuda_count = _torch.cuda.device_count() if _torch.cuda.is_available() else 0
|
||||
# 每个线程在自己内部初始化模型,CUDA 上下文属于该线程,避免跨线程推理失败
|
||||
# GPU0 用于 OCR,GPU1 留给 STTN/LaMa(若可用);无 GPU 则 CPU
|
||||
_ocr_gpu_id = 0 if cuda_count >= 1 else None
|
||||
print(f"[OCR] {'GPU' + str(_ocr_gpu_id) if _ocr_gpu_id is not None else 'CPU'} 推理,{num_workers} 线程并行(每线程独立模型)", flush=True)
|
||||
progress_counter = [0]
|
||||
chunk = max(1, frame_count // num_workers)
|
||||
ranges = [(i * chunk, min((i + 1) * chunk, frame_count)) for i in range(num_workers)]
|
||||
threads = []
|
||||
for i, (s, e) in enumerate(ranges):
|
||||
t = threading.Thread(
|
||||
target=self._scan_range,
|
||||
args=(s, e, ab_sections, sampled_results, lock, progress_counter, frame_count, sub_remover, _ocr_gpu_id),
|
||||
daemon=True,
|
||||
)
|
||||
threads.append(t)
|
||||
t.start()
|
||||
# tqdm 进度跟踪(单独线程更新)
|
||||
import time as _time
|
||||
last = 0
|
||||
while any(t.is_alive() for t in threads):
|
||||
with lock:
|
||||
cur = progress_counter[0]
|
||||
tbar.update(cur - last)
|
||||
last = cur
|
||||
_time.sleep(0.2)
|
||||
for t in threads:
|
||||
t.join()
|
||||
tbar.update(frame_count - last)
|
||||
tbar.close()
|
||||
# 线程退出后其局部 OCR 模型自动被 GC,触发 GPU 显存释放
|
||||
import gc as _gc
|
||||
_gc.collect()
|
||||
try:
|
||||
import torch as _t2
|
||||
if _t2.cuda.is_available():
|
||||
_t2.cuda.empty_cache()
|
||||
except Exception:
|
||||
pass
|
||||
# 阶段2:插值填充 — 两个采样帧之间都有字幕时,中间帧也标记为有字幕
|
||||
subtitle_frame_no_box_dict = {}
|
||||
detected_nos = sorted(sampled_results.keys())
|
||||
|
||||
+63
-43
@@ -25,7 +25,13 @@ class FramePrefetcher:
|
||||
def _read_loop(self):
|
||||
while not self._stopped:
|
||||
ret, frame = self.cap.read()
|
||||
self._buffer.put((ret, frame))
|
||||
# 用带超时的 put,避免队列满时永久阻塞
|
||||
while not self._stopped:
|
||||
try:
|
||||
self._buffer.put((ret, frame), timeout=0.5)
|
||||
break
|
||||
except queue.Full:
|
||||
pass
|
||||
if not ret:
|
||||
break
|
||||
|
||||
@@ -39,11 +45,12 @@ class FramePrefetcher:
|
||||
def stop(self):
|
||||
"""停止预读取,不释放底层 video_cap。"""
|
||||
self._stopped = True
|
||||
try:
|
||||
while not self._buffer.empty():
|
||||
# 排空队列,解除后台线程可能阻塞的 put()
|
||||
while True:
|
||||
try:
|
||||
self._buffer.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
except queue.Empty:
|
||||
break
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def release(self):
|
||||
@@ -53,52 +60,65 @@ class FramePrefetcher:
|
||||
|
||||
class FFmpegVideoWriter:
|
||||
"""
|
||||
通过 FFmpeg 管道写入帧,使用 libx264 编码。
|
||||
接口兼容 cv2.VideoWriter(write/release)。
|
||||
独立 I/O 线程写帧到 FFmpeg stdin,直接输出 H.264。
|
||||
主线程只往队列里放帧,永不阻塞在 I/O 上。
|
||||
"""
|
||||
|
||||
def __init__(self, output_path, fps, size):
|
||||
w, h = size
|
||||
cmd = [
|
||||
FFmpegCLI.instance().ffmpeg_path,
|
||||
'-y',
|
||||
'-f', 'rawvideo',
|
||||
'-vcodec', 'rawvideo',
|
||||
'-s', f'{w}x{h}',
|
||||
'-pix_fmt', 'bgr24',
|
||||
'-r', str(fps),
|
||||
'-i', '-',
|
||||
'-c:v', 'libx264',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-crf', '18',
|
||||
'-preset', 'fast',
|
||||
'-loglevel', 'error',
|
||||
output_path
|
||||
]
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
self._queue = queue.Queue(maxsize=60)
|
||||
self._error = None
|
||||
# 优先用系统 FFmpeg + NVENC(GPU 编码),回退到 libx264
|
||||
import shutil as _shutil
|
||||
_sys_ffmpeg = '/usr/bin/ffmpeg'
|
||||
_nvenc_ok = (
|
||||
_shutil.which(_sys_ffmpeg) is not None and
|
||||
subprocess.run(
|
||||
[_sys_ffmpeg, '-hide_banner', '-encoders'],
|
||||
capture_output=True, text=True
|
||||
).stdout.find('h264_nvenc') >= 0
|
||||
)
|
||||
ffmpeg_bin = _sys_ffmpeg if _nvenc_ok else FFmpegCLI.instance().ffmpeg_path
|
||||
codec_args = (
|
||||
['-vf', 'format=yuv420p', '-vcodec', 'h264_nvenc', '-cq', '18', '-preset', 'p4']
|
||||
if _nvenc_ok else
|
||||
['-vcodec', 'libx264', '-crf', '18', '-preset', 'ultrafast']
|
||||
)
|
||||
cmd = [
|
||||
ffmpeg_bin, '-y',
|
||||
'-f', 'rawvideo', '-vcodec', 'rawvideo',
|
||||
'-s', f'{w}x{h}', '-pix_fmt', 'bgr24',
|
||||
'-r', str(fps), '-i', 'pipe:0',
|
||||
*codec_args,
|
||||
'-loglevel', 'error', output_path,
|
||||
]
|
||||
self._proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
||||
self._thread = threading.Thread(target=self._io_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _io_loop(self):
|
||||
try:
|
||||
while True:
|
||||
frame = self._queue.get()
|
||||
if frame is None: # sentinel
|
||||
break
|
||||
self._proc.stdin.write(frame.tobytes())
|
||||
except Exception as e:
|
||||
self._error = e
|
||||
finally:
|
||||
try:
|
||||
self._proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def write(self, frame):
|
||||
"""写入一帧(numpy BGR 数组)。"""
|
||||
if frame.dtype != np.uint8:
|
||||
frame = np.clip(frame, 0, 255).astype(np.uint8)
|
||||
try:
|
||||
self._process.stdin.write(frame.tobytes())
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
self._queue.put(frame)
|
||||
|
||||
def release(self):
|
||||
"""关闭管道并等待编码完成。"""
|
||||
try:
|
||||
self._process.stdin.close()
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
try:
|
||||
self._process.wait(timeout=600)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._process.terminate()
|
||||
self._process.wait(timeout=5)
|
||||
self._queue.put(None) # 通知 I/O 线程退出
|
||||
self._thread.join() # 等待所有帧写完
|
||||
self._proc.wait() # 等待 FFmpeg 完成编码
|
||||
if self._error:
|
||||
raise RuntimeError(f"FFmpegVideoWriter error: {self._error}")
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Minimal qfluentwidgets stub for headless/server use — no Qt required.
|
||||
Provides just enough for backend/config.py to import and function correctly.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
class _Signal:
|
||||
def connect(self, fn): pass
|
||||
def disconnect(self, fn=None): pass
|
||||
def emit(self, *args): pass
|
||||
|
||||
|
||||
class ConfigValidator:
|
||||
def validate(self, v): return True
|
||||
def correct(self, v): return v
|
||||
|
||||
|
||||
class BoolValidator(ConfigValidator):
|
||||
def validate(self, v): return isinstance(v, bool)
|
||||
def correct(self, v): return bool(v)
|
||||
|
||||
|
||||
class OptionsValidator(ConfigValidator):
|
||||
def __init__(self, options):
|
||||
try:
|
||||
self._opts = [e for e in options]
|
||||
except TypeError:
|
||||
self._opts = []
|
||||
|
||||
def validate(self, v): return v in self._opts
|
||||
def correct(self, v): return v if v in self._opts else (self._opts[0] if self._opts else v)
|
||||
|
||||
|
||||
class RangeValidator(ConfigValidator):
|
||||
def __init__(self, min_val, max_val):
|
||||
self.min_val = min_val
|
||||
self.max_val = max_val
|
||||
|
||||
def validate(self, v): return self.min_val <= v <= self.max_val
|
||||
def correct(self, v): return max(self.min_val, min(self.max_val, v))
|
||||
|
||||
|
||||
class EnumSerializer:
|
||||
def __init__(self, enum_cls):
|
||||
self.enum_cls = enum_cls
|
||||
|
||||
def serialize(self, v):
|
||||
return v.value if hasattr(v, 'value') else str(v)
|
||||
|
||||
def deserialize(self, s):
|
||||
for e in self.enum_cls:
|
||||
if e.value == s:
|
||||
return e
|
||||
return s
|
||||
|
||||
|
||||
class ConfigItem:
|
||||
def __init__(self, section, key, default, validator=None, restart=False):
|
||||
self.section = section
|
||||
self.key = key
|
||||
self.value = default
|
||||
self.validator = validator or ConfigValidator()
|
||||
self.restart = restart
|
||||
self.valueChanged = _Signal()
|
||||
|
||||
|
||||
class OptionsConfigItem(ConfigItem):
|
||||
def __init__(self, section, key, default, validator=None, serializer=None, restart=False):
|
||||
super().__init__(section, key, default, validator, restart)
|
||||
self.serializer = serializer
|
||||
|
||||
|
||||
class RangeConfigItem(ConfigItem):
|
||||
def __init__(self, section, key, default, validator=None):
|
||||
super().__init__(section, key, default, validator)
|
||||
|
||||
|
||||
class QConfig:
|
||||
appRestartSig = _Signal()
|
||||
|
||||
def set(self, item, value):
|
||||
item.value = value
|
||||
try:
|
||||
item.valueChanged.emit(value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class _QConfigLoader:
|
||||
def load(self, filepath, config_obj):
|
||||
if not os.path.exists(filepath):
|
||||
return
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
cls = type(config_obj)
|
||||
for attr_name in dir(cls):
|
||||
if attr_name.startswith('_'):
|
||||
continue
|
||||
item = getattr(cls, attr_name, None)
|
||||
if not isinstance(item, ConfigItem):
|
||||
continue
|
||||
section_data = data.get(item.section, {})
|
||||
if item.key not in section_data:
|
||||
continue
|
||||
raw = section_data[item.key]
|
||||
if isinstance(item, OptionsConfigItem) and item.serializer:
|
||||
try:
|
||||
raw = item.serializer.deserialize(raw)
|
||||
except Exception:
|
||||
pass
|
||||
if item.validator.validate(raw):
|
||||
item.value = raw
|
||||
|
||||
|
||||
qconfig = _QConfigLoader()
|
||||
|
||||
# ── additional symbols referenced by gui code (safe no-ops) ──────────────
|
||||
class FluentIcon:
|
||||
HOME = None; SETTING = None; FOLDER = None; PLAY = None
|
||||
|
||||
class Theme:
|
||||
DARK = 'dark'; LIGHT = 'light'
|
||||
|
||||
class NavigationItemPosition:
|
||||
BOTTOM = 'bottom'
|
||||
|
||||
def setTheme(*a, **k): pass
|
||||
def setThemeColor(*a, **k): pass
|
||||
def isDarkTheme(): return True
|
||||
|
||||
# Fluent widget stubs — only imported by GUI code, never used by backend
|
||||
class _Widget:
|
||||
def __init__(self, *a, **k): pass
|
||||
|
||||
CardWidget = _Widget
|
||||
PushButton = _Widget
|
||||
Slider = _Widget
|
||||
ProgressBar = _Widget
|
||||
PlainTextEdit = _Widget
|
||||
TextEdit = _Widget
|
||||
SubtitleLabel = _Widget
|
||||
HollowHandleStyle = _Widget
|
||||
InfoBar = _Widget
|
||||
SettingCardGroup = _Widget
|
||||
ComboBoxSettingCard = _Widget
|
||||
SwitchSettingCard = _Widget
|
||||
FluentWindow = _Widget
|
||||
NavigationWidget = _Widget
|
||||
+42
-29
@@ -51,6 +51,7 @@ class HomeInterface(QWidget):
|
||||
self._worker_thread = None
|
||||
self.running_process = None
|
||||
self._saved_inpaint_mode = None # 保存图片锁定前的 inpaint 模式
|
||||
self._video_cap_lock = threading.Lock() # 保护 video_cap 的线程锁
|
||||
|
||||
# 当前正在处理的任务索引
|
||||
self.current_processing_task_index = -1
|
||||
@@ -164,13 +165,17 @@ class HomeInterface(QWidget):
|
||||
|
||||
|
||||
def slider_changed(self, value):
|
||||
if self.video_cap is not None and self.video_cap.isOpened():
|
||||
frame_no = self.video_slider.value()
|
||||
self.video_cap.set(cv2.CAP_PROP_POS_FRAMES, frame_no)
|
||||
ret, frame = self.video_cap.read()
|
||||
if ret:
|
||||
# 更新预览图像
|
||||
self.update_preview(frame)
|
||||
frame = None
|
||||
with self._video_cap_lock:
|
||||
if self.video_cap is not None and self.video_cap.isOpened():
|
||||
frame_no = self.video_slider.value()
|
||||
self.video_cap.set(cv2.CAP_PROP_POS_FRAMES, frame_no)
|
||||
ret, frame = self.video_cap.read()
|
||||
if not ret:
|
||||
frame = None
|
||||
if frame is not None:
|
||||
# 更新预览图像
|
||||
self.update_preview(frame)
|
||||
|
||||
def ab_sections_changed(self, ab_sections):
|
||||
get_current_task_index = self.task_list_component.get_current_task_index()
|
||||
@@ -347,9 +352,10 @@ class HomeInterface(QWidget):
|
||||
# 选中当前任务
|
||||
self.select_task_signal.emit(self.current_processing_task_index)
|
||||
|
||||
if self.video_cap:
|
||||
self.video_cap.release()
|
||||
self.video_cap = None
|
||||
with self._video_cap_lock:
|
||||
if self.video_cap:
|
||||
self.video_cap.release()
|
||||
self.video_cap = None
|
||||
|
||||
self.task_status_signal.emit(self.current_processing_task_index, TaskStatus.PROCESSING)
|
||||
options = {}
|
||||
@@ -385,9 +391,10 @@ class HomeInterface(QWidget):
|
||||
self.task_status_signal.emit(self.current_processing_task_index, TaskStatus.FAILED)
|
||||
break
|
||||
finally:
|
||||
if self.video_cap:
|
||||
self.video_cap.release()
|
||||
self.video_cap = None
|
||||
with self._video_cap_lock:
|
||||
if self.video_cap:
|
||||
self.video_cap.release()
|
||||
self.video_cap = None
|
||||
time.sleep(1)
|
||||
finally:
|
||||
self.toggle_buttons_signal.emit(True)
|
||||
@@ -559,22 +566,27 @@ class HomeInterface(QWidget):
|
||||
|
||||
def load_video(self, video_path):
|
||||
self.video_path = video_path
|
||||
if self.video_cap:
|
||||
self.video_cap.release()
|
||||
self.video_cap = None
|
||||
with self._video_cap_lock:
|
||||
if self.video_cap:
|
||||
self.video_cap.release()
|
||||
self.video_cap = None
|
||||
# 如果是图片文件,直接走图片加载路径
|
||||
if is_image_file(video_path):
|
||||
return self.load_as_picture(video_path)
|
||||
self.video_cap = cv2.VideoCapture(get_readable_path(self.video_path))
|
||||
if not self.video_cap.isOpened():
|
||||
return self.load_as_picture(video_path)
|
||||
ret, frame = self.video_cap.read()
|
||||
if not ret:
|
||||
return self.load_as_picture(video_path)
|
||||
self.frame_count = int(self.video_cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
self.frame_height = int(self.video_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
self.frame_width = int(self.video_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
self.fps = self.video_cap.get(cv2.CAP_PROP_FPS)
|
||||
with self._video_cap_lock:
|
||||
self.video_cap = cv2.VideoCapture(get_readable_path(self.video_path))
|
||||
if not self.video_cap.isOpened():
|
||||
self.video_cap = None
|
||||
return self.load_as_picture(video_path)
|
||||
ret, frame = self.video_cap.read()
|
||||
if not ret:
|
||||
self.video_cap.release()
|
||||
self.video_cap = None
|
||||
return self.load_as_picture(video_path)
|
||||
self.frame_count = int(self.video_cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
self.frame_height = int(self.video_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
self.frame_width = int(self.video_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
self.fps = self.video_cap.get(cv2.CAP_PROP_FPS)
|
||||
|
||||
self.update_preview(frame)
|
||||
self.video_slider.setMaximum(self.frame_count)
|
||||
@@ -666,9 +678,10 @@ class HomeInterface(QWidget):
|
||||
self.video_display_component.ab_sections_changed.disconnect(self.ab_sections_changed)
|
||||
self.video_display_component.selections_changed.disconnect(self.selections_changed)
|
||||
# 释放视频资源
|
||||
if self.video_cap:
|
||||
self.video_cap.release()
|
||||
self.video_cap = None
|
||||
with self._video_cap_lock:
|
||||
if self.video_cap:
|
||||
self.video_cap.release()
|
||||
self.video_cap = None
|
||||
except Exception as e:
|
||||
print(f"Error during close window:", e)
|
||||
super().closeEvent(event)
|
||||
|
||||
+1534
File diff suppressed because it is too large
Load Diff
+1348
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user