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
- 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>
508 lines
22 KiB
Python
508 lines
22 KiB
Python
"""
|
||
智能水印检测:通过帧差分找静态区域,用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
|