You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
191 lines
6.1 KiB
191 lines
6.1 KiB
#!/usr/bin/env python3
|
|
"""
|
|
绿幕抠像脚本 - 三步法流水线
|
|
从 Kling Avatar 生成的绿幕视频输出透明 MOV
|
|
|
|
使用方式:
|
|
python green_screen_keying.py --input talking_head_green.mp4 --output pip_clean.mov
|
|
|
|
三步法:
|
|
1. ffmpeg chromakey 提取 RGBA 帧
|
|
2. Python PIL 后处理(硬阈值、平滑、Despill)
|
|
3. 组装透明 MOV
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageFilter
|
|
|
|
# ─────────────────────────── 配置 ───────────────────────────
|
|
|
|
ALPHA_THRESHOLD = 120 # 硬阈值:消除半透明像素(保留更多发丝)
|
|
GAUSSIAN_BLUR_RADIUS = 1 # 轻微平滑(不要用 2,过度模糊)
|
|
DESPILL_OFFSET = 5 # 绿色抑制强度(+5 比 +10 更激进)
|
|
CHROMAKEY_SIMILARITY = 0.20 # ffmpeg chromakey similarity
|
|
CHROMAKEY_BLEND = 0.05 # ffmpeg chromakey blend(保守参数,避免人物透明)
|
|
TARGET_FPS = 24
|
|
|
|
# ─────────────────────────── 辅助函数 ───────────────────────────
|
|
|
|
|
|
def sample_green_color(frame_path: str) -> str:
|
|
"""用 PIL 采样实际背景绿色均值(不要写死颜色值)"""
|
|
img = Image.open(frame_path).convert("RGBA")
|
|
arr = np.array(img)
|
|
|
|
# 采样顶部区域(通常是纯绿幕背景)
|
|
top = arr[:100, :, :]
|
|
|
|
# 找出绿色像素(绿色分量大于红和蓝)
|
|
mask = (
|
|
(top[:, :, 1] > 100)
|
|
& (top[:, :, 1] > top[:, :, 0])
|
|
& (top[:, :, 1] > top[:, :, 2])
|
|
)
|
|
|
|
if mask.sum() == 0:
|
|
print("⚠️ 自动采样失败,使用默认绿色 #00FF00")
|
|
return "00FF00"
|
|
|
|
avg = top[mask].mean(axis=0).astype(int)
|
|
hex_color = f"{avg[0]:02X}{avg[1]:02X}{avg[2]:02X}"
|
|
print(f"📊 采样绿色均值: #{hex_color} (R={avg[0]}, G={avg[1]}, B={avg[2]})")
|
|
return hex_color
|
|
|
|
|
|
def process_frame(input_path: str, output_path: str) -> None:
|
|
"""
|
|
单帧处理:硬阈值 + 轻微平滑 + Despill
|
|
关键参数说明:
|
|
- Alpha 硬阈值 > 120 → 255:消除半透明像素,保留更多发丝
|
|
- 禁止 MinFilter:会严重损伤辫子/卷发等细节发丝
|
|
- GaussianBlur(1):轻微平滑,不过度模糊
|
|
- Despill:g = min(g, avg(r,b) + 5):激进绿色抑制
|
|
"""
|
|
img = Image.open(input_path).convert("RGBA")
|
|
arr = np.array(img, dtype=np.float32)
|
|
r, g, b, a = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2], arr[:, :, 3]
|
|
|
|
# 硬阈值:消除半透明像素
|
|
a_clean = np.where(a > ALPHA_THRESHOLD, 255.0, 0.0)
|
|
alpha_img = Image.fromarray(a_clean.astype(np.uint8), "L")
|
|
|
|
# 轻微平滑(避免锯齿,但不腐蚀)
|
|
alpha_smooth = alpha_img.filter(ImageFilter.GaussianBlur(GAUSSIAN_BLUR_RADIUS))
|
|
|
|
# 激进 Despill:钳制绿通道
|
|
avg_rb = (r + b) / 2.0
|
|
g_new = np.minimum(g, avg_rb + DESPILL_OFFSET)
|
|
|
|
# 组装 RGBA
|
|
result = np.stack(
|
|
[r, g_new, b, np.array(alpha_smooth, dtype=np.float32)], axis=-1
|
|
).astype(np.uint8)
|
|
|
|
Image.fromarray(result, "RGBA").save(output_path)
|
|
|
|
|
|
def run(args: argparse.Namespace) -> None:
|
|
input_path = Path(args.input)
|
|
output_path = Path(args.output)
|
|
temp_dir = Path("_temp_keying")
|
|
frames_dir = temp_dir / "frames"
|
|
clean_dir = temp_dir / "clean_frames"
|
|
|
|
# 清理临时目录
|
|
for d in [frames_dir, clean_dir]:
|
|
if d.exists():
|
|
shutil.rmtree(d)
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
|
|
# ── Step 1: 提取首帧用于采样绿色 ──
|
|
print("\n📸 Step 1: 提取首帧...")
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg",
|
|
"-i",
|
|
str(input_path),
|
|
"-vframes",
|
|
"1",
|
|
"-q:v",
|
|
"2",
|
|
str(temp_dir / "first_frame.png"),
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
|
|
# 采样绿色
|
|
green_hex = sample_green_color(str(temp_dir / "first_frame.png"))
|
|
|
|
# ── Step 2: ffmpeg chromakey 提取 RGBA 帧 ──
|
|
print(f"\n🎬 Step 2: ffmpeg chromakey 提取帧(green=#{green_hex})...")
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg",
|
|
"-i",
|
|
str(input_path),
|
|
"-vf",
|
|
f"chromakey=0x{green_hex}:{CHROMAKEY_SIMILARITY}:{CHROMAKEY_BLEND},"
|
|
f"format=rgba,fps={TARGET_FPS}",
|
|
"-q:v",
|
|
"2",
|
|
str(frames_dir / "frame_%04d.png"),
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
|
|
frame_files = sorted(frames_dir.glob("frame_*.png"))
|
|
total = len(frame_files)
|
|
print(f" 共提取 {total} 帧")
|
|
|
|
# ── Step 3: Python PIL 后处理 + 组装 ──
|
|
print(
|
|
f"\n🧹 Step 3: Python 后处理(阈值={ALPHA_THRESHOLD}, "
|
|
f"Blur={GAUSSIAN_BLUR_RADIUS}, Despill=+{DESPILL_OFFSET})..."
|
|
)
|
|
|
|
for i, frame_file in enumerate(frame_files, 1):
|
|
if i % 50 == 0 or i == 1 or i == total:
|
|
print(f" 处理帧 {i}/{total}...")
|
|
process_frame(str(frame_file), str(clean_dir / frame_file.name))
|
|
|
|
# ── Step 4: 组装透明 MOV ──
|
|
print("\n🎞️ Step 4: 组装透明 MOV...")
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg",
|
|
"-framerate",
|
|
str(TARGET_FPS),
|
|
"-i",
|
|
str(clean_dir / "frame_%04d.png"),
|
|
"-c:v",
|
|
"png",
|
|
"-pix_fmt",
|
|
"rgba",
|
|
str(output_path),
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
|
|
# 清理临时文件
|
|
shutil.rmtree(temp_dir)
|
|
|
|
print(f"\n✅ 完成!输出:{output_path}")
|
|
duration = total / TARGET_FPS
|
|
print(f" 时长: {duration:.2f}s | 帧数: {total} | 帧率: {TARGET_FPS}fps")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description="绿幕抠像三步法流水线")
|
|
parser.add_argument("--input", "-i", required=True, help="输入绿幕视频路径")
|
|
parser.add_argument("--output", "-o", required=True, help="输出透明 MOV 路径")
|
|
run(parser.parse_args())
|
|
|