192 lines
6.1 KiB
Python
192 lines
6.1 KiB
Python
#!/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())
|