Rework watermark detection + remove STTN modes
Docker Build and Push / check-secrets (push) Successful in 5s
Docker Build and Push / build-and-push (directml, latest) (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 (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
Build Windows CUDA 12.8 / build (push) Has been cancelled
Build Windows DirectML / build (push) Has been cancelled
Build Windows CUDA 11.8 / build (push) Has been cancelled
Build Windows CPU / build (push) Has been cancelled
Build Windows CUDA 12.6 / build (push) Has been cancelled

Detection (smart_detect.py) — two complementary paths, both feed per-frame masks:
1. Faint/semi-transparent & scrolling text (e.g. "sjhs.pw"):
   - per-frame white top-hat extracts thin bright strokes (OCR can't see these)
   - connected-component count (>=3) keeps text, rejects solid bright objects
   - track-linking across frames keeps stable-width persistent tracks,
     drops erratic body/skin highlights
2. Colorful opaque logos (e.g. "黑料网 heiliao.com"):
   - high-saturation (>150) AND low temporal-variance → static logo,
     rejects moving skin and muted background art
   - corner-snapping extends box to frame edge for full logo coverage

Key fixes:
- per-frame dict uses 1-based frame numbers to match the inpaint pipeline
  (was 0-based → masks never applied → nothing removed)
- persistent watermarks require LaMa spatial inpaint; STTN temporal inpaint
  has the watermark in its own reference frames so it can't remove them

Remove STTN modes entirely:
- INPAINT_MODE_MAP drops sttn-auto/sttn-det; unknown modes fall back to LaMa
- frontend algorithm selector: LaMa (default) / ProPainter / OpenCV
- platform-task & TaskCreate default inpaint_mode = lama

Validated: both "sjhs.pw" (scrolling text) and "黑料网" (color logo)
fully removed on preview with LaMa, no content damage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
liuchen
2026-06-27 17:59:41 +08:00
co-authored by Claude Opus 4.8
parent fabde9cf83
commit c65ec20119
3 changed files with 258 additions and 57 deletions
+215
View File
@@ -231,6 +231,221 @@ def refine_with_sam2(image: np.ndarray, boxes: list, gpu_id: int = 0):
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 = 2, pad: int = 6):
"""逐帧检测半透明/移动文字水印,返回 {frame_no: [(xmin,xmax,ymin,ymax), ...]}。
适用于 OCR 检测不到的淡色水印(白字、半透明)以及垂直滚动水印。
原理:
1. 每帧 white top-hat 提取亮文字笔画 → 候选框
2. 粗网格投票:水印(固定或缓慢移动)会让相邻网格被大量帧命中,
瞬时亮点(皮肤反光等)命中少 → 过滤掉
3. 每帧只保留落在「水印网格」内的候选框,并补全相邻帧(插值)
"""
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:逐帧候选(1-based 帧号,与 find_subtitle_frame_no/inpaint 管线一致)
frame_boxes = {}
fno = 0
while True:
ret, frame = cap.read()
if not ret:
break
fno += 1
if (fno - 1) % sample_step == 0:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
frame_boxes[fno] = _frame_text_candidates(gray, tophat_kernel, conn_kernel)
cap.release()
n_sampled = len(frame_boxes)
if n_sampled == 0:
return {}
# 阶段2:轨迹连接
# 真水印(固定/垂直滚动)跨帧形成连续轨迹:宽度稳定、X几乎不变、Y平滑变化。
# 人体亮区(皮肤/内衣)断续出现、形状多变 → 轨迹短或宽度抖动 → 被过滤。
sampled_nos = 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]
tracks = [] # 每条: {"boxes": {fno:box}, "last_f":, "last_box":}
for f in sampled_nos:
for b in frame_boxes[f]:
best, best_d = None, 1e9
for tr in tracks:
lb = tr["last_box"]
# 已结束的轨迹(间隔太久)跳过
if f - tr["last_f"] > sample_step * 6:
continue
dx = abs(cx_of(b) - cx_of(lb))
dy = abs(cy_of(b) - cy_of(lb))
dw = abs(wid_of(b) - wid_of(lb))
# 同一水印:X几乎不变(<40)、宽度接近(<25)、Y可移动(滚动)
if dx < 40 and dw < 25 and dy < 120:
d = dx * 2 + dw * 2 + dy * 0.3
if d < best_d:
best, best_d = tr, d
if best is None:
tracks.append({"boxes": {f: b}, "last_f": f, "last_box": b})
else:
best["boxes"][f] = b
best["last_f"] = f
best["last_box"] = b
# 阶段3:筛选有效轨迹
# 出现帧数 >= 35% 且 宽度稳定(变异系数<0.35)
min_frames = max(3, int(n_sampled * 0.35))
per_frame = {}
valid_tracks = 0
for tr in tracks:
boxes = tr["boxes"]
if len(boxes) < min_frames:
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.35:
continue # 宽度抖动大 → 不是文字水印
valid_tracks += 1
for f, b in boxes.items():
x1, x2, y1, y2 = b
per_frame.setdefault(f, []).append(
(max(0, x1 - pad), min(w, x2 + pad), max(0, y1 - pad), min(h, y2 + pad)))
print(f"[WM-perframe] 采样{n_sampled}帧 轨迹{len(tracks)}条 有效{valid_tracks}条(>={min_frames}帧)", flush=True)
# 阶段4:插值填充文字水印框(用最近的已检测帧)
full = {}
pf_nos = sorted(per_frame.keys())
if pf_nos:
for f, nf in zip(pf_nos, pf_nos[1:]):
full[f] = list(per_frame[f])
if nf - f <= sample_step * 3:
for fill in range(f + 1, nf):
full[fill] = list(per_frame[f])
full[pf_nos[-1]] = list(per_frame[pf_nos[-1]])
# 阶段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 精确分割。
+6 -12
View File
@@ -688,16 +688,10 @@
<div class="field">
<label>去除算法</label>
<div class="mode-grid" id="modeGrid">
<label class="mode-card active" onclick="selMode('sttn-det',this)">
<input type="radio" name="mode" value="sttn-det" checked>
<div class="mode-name">STTN</div>
<div class="mode-desc">带字幕检测</div>
<span class="mode-tag tag-fast">快</span>
</label>
<label class="mode-card" onclick="selMode('lama',this)">
<input type="radio" name="mode" value="lama">
<label class="mode-card active" onclick="selMode('lama',this)">
<input type="radio" name="mode" value="lama" checked>
<div class="mode-name">LaMa</div>
<div class="mode-desc">适合动画</div>
<div class="mode-desc">空间修复,适合水印</div>
<span class="mode-tag tag-medium">中</span>
</label>
<label class="mode-card" onclick="selMode('propainter',this)">
@@ -804,7 +798,7 @@ const S = {
ws: null,
wsRetry: 1000,
regions: [],
mode: 'sttn-det',
mode: 'lama',
};
// ── Init ───────────────────────────────────────────────────────
@@ -926,7 +920,7 @@ function renderTasks() {
return;
}
const modeLabel = { 'sttn-auto':'STTN 自动','sttn-det':'STTN','lama':'LaMa','propainter':'ProPainter','opencv':'OpenCV' };
const modeLabel = { 'lama':'LaMa','propainter':'ProPainter','opencv':'OpenCV' };
body.innerHTML = tasks.map(t => {
const { cls, label } = taskStatus(t);
const name = urlName(t.url);
@@ -1012,7 +1006,7 @@ function openDetail(id) {
if (!t) return;
const fmtTime = ts => ts ? new Date(ts*1000).toLocaleString('zh-CN', {year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '-';
const modeLabel = { 'sttn-auto':'STTN 自动','sttn-det':'STTN','lama':'LaMa','propainter':'ProPainter','opencv':'OpenCV' };
const modeLabel = { 'lama':'LaMa','propainter':'ProPainter','opencv':'OpenCV' };
// 全量渲染一次,含静态内容 + 占位 div(id 固定供后续局部更新)
const body = document.getElementById('detailBody');
+37 -45
View File
@@ -226,13 +226,14 @@ def _settings_set_many(data: dict[str, str]) -> None:
# ── inpaint mode map ──────────────────────────────────────────────────────────
# STTN 时序 inpaint 无法去除持久水印(参考帧里也有水印),已移除。
# 未知/不支持的模式统一回退到 LaMa(空间 inpaint,对持久水印有效)。
INPAINT_MODE_MAP = {
"sttn-auto": lambda: InpaintMode.STTN_AUTO,
"sttn-det": lambda: InpaintMode.STTN_DET,
"lama": lambda: InpaintMode.LAMA,
"propainter": lambda: InpaintMode.PROPAINTER,
"opencv": lambda: InpaintMode.OPENCV,
}
DEFAULT_INPAINT = "lama"
# ── task state ────────────────────────────────────────────────────────────────
@@ -444,8 +445,10 @@ def _process(task_id: str, video_path: str, inpaint_mode_str: str, subtitle_area
update_task(task_id, status="error", error="VSR backend not available")
return
modeLabels = {'sttn-auto': 'STTN 自动', 'sttn-det': 'STTN 检测',
'lama': 'LaMa', 'propainter': 'ProPainter', 'opencv': 'OpenCV'}
# 未知/已废弃模式(含旧的 sttn-*)统一回退 LaMa
if inpaint_mode_str not in INPAINT_MODE_MAP:
inpaint_mode_str = DEFAULT_INPAINT
modeLabels = {'lama': 'LaMa', 'propainter': 'ProPainter', 'opencv': 'OpenCV'}
push_log(task_id, f"启动 — 算法:{modeLabels.get(inpaint_mode_str, inpaint_mode_str)}")
update_task(task_id, status="processing", progress=0, started_at=time.time())
@@ -453,7 +456,8 @@ def _process(task_id: str, video_path: str, inpaint_mode_str: str, subtitle_area
video_path = _ensure_h264(task_id, video_path)
class _WebRemover(SubtitleRemover):
_static_boxes = []
_static_boxes = [] # 静态框(手动指定时用),注入所有帧
_per_frame_boxes = None # {frame_no: [(xmin,xmax,ymin,ymax)]} 逐帧框
def append_output(self_, *args):
msg = " ".join(str(a) for a in args)
@@ -464,21 +468,25 @@ def _process(task_id: str, video_path: str, inpaint_mode_str: str, subtitle_area
pass
def run(self_):
if self_._static_boxes:
if self_._per_frame_boxes or self_._static_boxes:
from backend.tools.subtitle_detect import SubtitleDetect as _SD
_orig = _SD.find_subtitle_frame_no
_boxes = list(self_._static_boxes)
_static = list(self_._static_boxes or [])
_perframe = self_._per_frame_boxes or {}
def _patched(sd_self, sub_remover=None):
result = _orig(sd_self, sub_remover=sub_remover)
# 逐帧框模式:直接用检测到的逐帧字典,不跑 OCR(淡水印 OCR 检测不到)
cap = cv2.VideoCapture(sd_self.video_path)
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
cap.release()
result = {}
for frame_no in range(1, total + 1):
if frame_no not in result:
result[frame_no] = []
result[frame_no] = list(result[frame_no]) + _boxes
push_log(task_id, f"SAM2 静态区域已注入全部 {total} 帧")
boxes = list(_perframe.get(frame_no, []))
if _static:
boxes = boxes + _static
if boxes:
result[frame_no] = boxes
push_log(task_id, f"水印逐帧框:{len(result)}/{total} 帧有水印框")
return result
_SD.find_subtitle_frame_no = _patched
try:
@@ -516,40 +524,24 @@ def _process(task_id: str, video_path: str, inpaint_mode_str: str, subtitle_area
if preview_mode:
push_log(task_id, f"预览片段:{sr.frame_count} 帧 / {sr.fps:.1f}fps")
mode_factory = INPAINT_MODE_MAP.get(inpaint_mode_str, INPAINT_MODE_MAP["sttn-auto"])
mode_factory = INPAINT_MODE_MAP.get(inpaint_mode_str, INPAINT_MODE_MAP[DEFAULT_INPAINT])
config.set(config.inpaintMode, mode_factory())
if not subtitle_areas:
import concurrent.futures
def _run_ocr():
from backend.tools.subtitle_detect import SubtitleDetect
detector = SubtitleDetect(video_path, [])
cap = cv2.VideoCapture(video_path)
ret, frame = cap.read()
cap.release()
return detector.detect_subtitle(frame) if ret else []
def _run_sam2():
if detect_mode != "smart":
return []
from backend.tools.smart_detect import smart_detect_watermark
return smart_detect_watermark(video_path, sample_frames=30)
push_log(task_id, "并发检测中(OCR + SAM2)..." if detect_mode == "smart" else "文字检测(OCR)中...")
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as ex:
f_ocr = ex.submit(_run_ocr)
f_sam2 = ex.submit(_run_sam2)
ocr_result = f_ocr.result()
sam2_result = f_sam2.result()
if ocr_result:
push_log(task_id, f"OCR 检测到 {len(ocr_result)} 个文字区域")
if sam2_result:
push_log(task_id, f"SAM2 检测到 {len(sam2_result)} 个区域")
sr._static_boxes = [(x1, x2, y1, y2) for x1, y1, x2, y2 in sam2_result]
elif detect_mode == "smart":
push_log(task_id, "SAM2 未检测到静态水印区域")
if detect_mode == "smart":
# 水印模式:逐帧 top-hat 检测半透明/移动文字水印
push_log(task_id, "水印检测中(逐帧 top-hat)...")
from backend.tools.smart_detect import detect_watermark_per_frame
per_frame = detect_watermark_per_frame(video_path, sample_step=2)
if per_frame:
n_boxes = sum(len(v) for v in per_frame.values())
sr._per_frame_boxes = per_frame
push_log(task_id, f"水印检测:{len(per_frame)} 帧共 {n_boxes} 个水印框")
else:
push_log(task_id, "未检测到水印,回退到全帧 OCR")
else:
# 文字模式:标准 OCR 逐帧检测(走原生 find_subtitle_frame_no)
push_log(task_id, "文字检测(OCR)将逐帧进行")
if subtitle_areas:
if detect_mode == "smart":
@@ -750,7 +742,7 @@ class VSRTaskWorker:
"url": video_url,
"is_cache_file": False,
"cache_path": str(cache_path),
"inpaint_mode": ptask.get("inpaint_mode", "sttn-auto"),
"inpaint_mode": ptask.get("inpaint_mode", "lama"),
"subtitle_areas": ptask.get("subtitle_areas", []),
"preview_mode": ptask.get("preview_mode", False),
"detect_mode": ptask.get("detect_mode", "text"),
@@ -1059,7 +1051,7 @@ async def health():
class TaskCreate(BaseModel):
url: str
inpaint_mode: str = "sttn-auto"
inpaint_mode: str = "lama"
subtitle_areas: list = []
preview_mode: bool = False
detect_mode: str = "text"