Files
video-subtitle-remover/backend/tools/smart_detect.py
T
liuchenandClaude Sonnet 4.6 a1eb5bb28e
Docker Build and Push / check-secrets (push) Successful in 11s
Docker Build and Push / build-and-push (cpu, latest) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 11.8) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 12.6) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 12.8) (push) Has been skipped
Docker Build and Push / build-and-push (directml, latest) (push) Has been skipped
Build Windows CPU / build (push) Has been cancelled
Build Windows CUDA 11.8 / build (push) Has been cancelled
Build Windows CUDA 12.6 / build (push) Has been cancelled
Build Windows CUDA 12.8 / build (push) Has been cancelled
Build Windows DirectML / build (push) Has been cancelled
Fix watermark detection + S3 upload + NVENC color + task download on S3 failure
- smart_detect.py: rewrite detect_static_regions using saturation-signature method
  (high-sat logo frames vs no-logo frames diff) to handle animated/periodic watermarks
  that break temporal-std approach; merge top-region boxes; add print debugging
- web_app.py: fix S3 upload (async chunked streaming, error visible in task log);
  s3_failed flag keeps task downloadable even when S3 upload fails; add s3_failed
  column to DB; download/stream endpoints allow s3_failed tasks
- web/index.html: show play/download buttons for s3_failed tasks; task sort by
  created_at desc; watermark mode label update; isLocalDone() helper
- video_io.py: add -vf format=yuv420p before h264_nvenc to fix green video

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-28 00:20:44 +08:00

508 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
智能水印检测:通过帧差分找静态区域,用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