Fix S3 streaming upload + detect white/gray watermarks
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 / check-secrets (push) Successful in 4s
Docker Build and Push / build-and-push (cpu, latest) (push) Has been skipped
Docker Build and Push / build-and-push (directml, latest) (push) Has been skipped
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
Build Windows CPU / build (push) Has been cancelled

S3 upload:
- Replace read_bytes() (loads entire file into memory) with streaming SHA256
  computation (8MB chunks) + httpx streaming PUT with Content-Length header
- Add detailed logging for each upload step to diagnose future failures

Watermark detection (smart_detect.py):
- Add Path B: brightness-diff detection for low-saturation watermarks like
  white/gray text (e.g. 'sjhs.pw'). Compares mean brightness in first 10s
  vs the rest of the video. Regions that are brighter in early frames AND
  located in frame corners (top-left, top-right, etc.) are flagged.
- Merge Path A (saturation-based) + Path B (brightness-diff) results
- Fall back to global std method only if both paths find nothing

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
liuchen
2026-06-27 17:06:18 +08:00
co-authored by Claude Haiku 4.5
parent 8b583a4b0e
commit fabde9cf83
2 changed files with 81 additions and 51 deletions
+49 -42
View File
@@ -76,51 +76,58 @@ def detect_static_regions(video_path: str, sample_frames: int = 30, threshold: f
# 分析时使用小尺寸坐标空间
w, h = SW, SH
if len(logo_gray_frames) < 2:
print(f"[SAM2-detect] logo帧不足,回退到时序std法", flush=True)
# ── 路径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
else:
# 在logo帧中统计每像素平均饱和度
sat_acc = np.zeros((h, w), dtype=np.float32)
for bgr in logo_bgr_frames:
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
sat_acc += (hsv[:, :, 1].astype(np.float32) / 255.0)
sat_mean_logo = sat_acc / len(logo_bgr_frames)
if nlogo_bgr_frames:
# ── 有无logo帧对比:准确检测logo区域 ───────────────────────────
# Logo像素: logo帧高饱和 + 无logo帧低饱和(差值大)
sat_acc_nl = np.zeros((h, w), dtype=np.float32)
for bgr in nlogo_bgr_frames[:6]:
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
sat_acc_nl += (hsv[:, :, 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())
logo_pixel_mask = ((sat_mean_logo >= sat_thresh) & (sat_diff >= diff_thresh)).astype(np.uint8) * 255
print(f"[SAM2-detect] logo帧平均饱和: max={sat_mean_logo.max():.3f}", flush=True)
print(f"[SAM2-detect] 饱和差值: max={sat_diff.max():.3f} mean={sat_diff.mean():.3f}", flush=True)
print(f"[SAM2-detect] sat_thresh={sat_thresh:.3f} diff_thresh={diff_thresh:.3f}, 候选={np.sum(logo_pixel_mask > 0)}", flush=True)
else:
# ── 无非logo帧(短视频/预览:logo全程可见)──────────────────────
# 只在帧的边缘区域(顶部30%)寻找高饱和logo
# 避免把视频中央的自然内容误报为logo
border_mask = np.zeros((h, w), dtype=np.uint8)
border_mask[:h * 3 // 10, :] = 1 # 顶部 30%
# 取最高饱和的4%像素(极鲜艳 = logo文字/图形)
sat_thresh = max(0.6, np.percentile(sat_mean_logo, 96))
logo_pixel_mask = ((sat_mean_logo >= sat_thresh).astype(np.uint8) * border_mask * 255)
print(f"[SAM2-detect] 无非logo帧,限定边缘区域搜索", flush=True)
print(f"[SAM2-detect] sat_thresh={sat_thresh:.3f}, 候选={np.sum(logo_pixel_mask > 0)}", flush=True)
static_mask = logo_pixel_mask
print(f"[SAM2-detect] 合并后候选像素数={np.sum(static_mask>0)}", flush=True)
# 仅排除纯黑区域
mean_gray = np.mean(np.stack(gray_frames, axis=0), axis=0)
+32 -9
View File
@@ -834,17 +834,26 @@ class VSRTaskWorker:
logger.info(f"[vsr_worker] 任务 #{platform_task_id} 上报结果 status={status}")
async def _upload_result_video(self, local_path: str) -> str | None:
"""处理完成后将输出视频上传到平台,返回 play_url(S3 地址)。"""
"""处理完成后将输出视频上传到平台,返回 play_url(S3 地址)。流式上传,不一次性读入内存。"""
import hashlib as _hl
try:
data = Path(local_path).read_bytes()
sha256 = _hl.sha256(data).hexdigest()
filename = Path(local_path).name
file_path = Path(local_path)
file_size = file_path.stat().st_size
filename = file_path.name
# 流式计算 SHA256,避免 read_bytes() 把几百 MB 全加载进内存
h = _hl.sha256()
with open(local_path, "rb") as f:
for chunk in iter(lambda: f.read(8 * 1024 * 1024), b""):
h.update(chunk)
sha256 = h.hexdigest()
logger.info(f"[vsr_worker] 文件 {filename} size={file_size//1048576}MB sha256={sha256[:12]}…")
sign_resp = await self._signed_post(
"/openapi/file/upload/get_upload_sign",
{
"sha256": sha256,
"size": len(data),
"size": file_size,
"filename": filename,
"content_type": "video/mp4",
"file_type": "video",
@@ -853,24 +862,36 @@ class VSRTaskWorker:
},
)
if sign_resp is None:
logger.warning("[vsr_worker] get_upload_sign 返回空,跳过上传")
return None
action = sign_resp.get("action", "")
if action == "skip":
logger.info(f"[vsr_worker] 文件已存在,直接返回 play_url")
return sign_resp.get("play_url")
presigned_url = sign_resp.get("presigned_url", "")
object_key = sign_resp.get("object_key", "")
if not presigned_url or not object_key:
logger.warning(f"[vsr_worker] upload sign 未返回 presigned_url/object_key: {sign_resp}")
return None
put_resp = await self._client.put(presigned_url, content=data,
headers={"Content-Type": "video/mp4"})
# 流式 PUT 上传,不把文件加载到内存
logger.info(f"[vsr_worker] 开始流式上传 {file_size//1048576}MB → S3…")
async with httpx.AsyncClient(timeout=7200) as upload_client:
with open(local_path, "rb") as f:
put_resp = await upload_client.put(
presigned_url,
content=f,
headers={"Content-Type": "video/mp4", "Content-Length": str(file_size)},
)
put_resp.raise_for_status()
logger.info(f"[vsr_worker] S3 上传完成,注册资产…")
reg_resp = await self._signed_post(
"/openapi/file/assets/register",
{
"sha256": sha256,
"object_key": object_key,
"size": len(data),
"size": file_size,
"filename": filename,
"content_type": "video/mp4",
"file_type": "video",
@@ -878,7 +899,9 @@ class VSRTaskWorker:
"no_transcode": True,
},
)
return reg_resp.get("play_url") if reg_resp else None
play_url = reg_resp.get("play_url") if reg_resp else None
logger.info(f"[vsr_worker] 资产注册完成 play_url={play_url}")
return play_url
except Exception as e:
logger.warning(f"[vsr_worker] 上传结果视频失败: {e}")
return None