Files
workbuddy_skills/oil-motion/scripts/chroma_key.py
T
admin 237a09a5b0 修:draw-ui / oil-motion 原被当子模块指针收录 ⇒ 改为正常文件入库(两份内容原先对别人是空的)
一、问题(本轮实测)
`draw-ui` 与 `oil-motion` 目录里**各自带一个内嵌 `.git`** ⇒ 上一次提交把它们记成了 **gitlink(子模块指针)**
⇒ 仓库里只存了一个不属于任何远端的 commit id,**别人克隆下来这两份是空的** ✗(`git status` 显示 ` m draw-ui` / ` m oil-motion` = 子模块内容有改动)。

二、处置(可回退)
· 把两处的 `.git` **挪走**(⛔ 不是删除)⇒ `归档/内嵌git-20261008/{draw-ui,oil-motion}.git`;
· `git rm --cached` 掉那两个 gitlink,再 `git add` 两个目录 ⇒ **按正常文件入库**(内容才真的进仓库)。

三、副作用(如实记)
挪走 `.git` 后,这两个技能**不能再原地 `git pull` 取上游更新**(要更新得重新拉一份覆盖);
如需恢复其本地仓库,把 `归档/内嵌git-20261008/` 里的 `.git` 挪回原处即可。
2026-10-08 22:29:52 +08:00

178 lines
5.9 KiB
Python

"""绿幕视频编译与 WebGL 运行时共享的 dominance-v2 色键规范。"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
import numpy as np
from PIL import Image
@dataclass(frozen=True)
class ChromaKeyParameters:
algorithm: str
mode: str
key_color: tuple[int, int, int]
similarity: float = 0.12
smoothness: float = 0.06
dominance_start: float = 0.0
dominance_end: float = 0.12
spill_start: float = -0.005
spill_end: float = 0.06
spill: float = 1.0
def manifest(self) -> dict[str, Any]:
data = asdict(self)
return {
"algorithm": data["algorithm"],
"mode": data["mode"],
"keyColor": list(data["key_color"]),
"similarity": data["similarity"],
"smoothness": data["smoothness"],
"dominanceStart": data["dominance_start"],
"dominanceEnd": data["dominance_end"],
"spillStart": data["spill_start"],
"spillEnd": data["spill_end"],
"spill": data["spill"],
}
def key_mode(key: tuple[int, int, int]) -> str:
red, green, blue = key
if green >= red + 40 and green >= blue + 40:
return "green"
if red >= green + 40 and blue >= green + 40:
return "magenta"
raise ValueError("色键不是可识别的绿色或洋红色")
def default_parameters(key: tuple[int, int, int]) -> ChromaKeyParameters:
return ChromaKeyParameters(
algorithm="dominance-v2",
mode=key_mode(key),
key_color=key,
)
def _smoothstep(edge0: float, edge1: float, value: np.ndarray) -> np.ndarray:
width = max(0.0001, edge1 - edge0)
normalized = np.clip((value - edge0) / width, 0.0, 1.0)
return normalized * normalized * (3.0 - 2.0 * normalized)
def _chroma(colors: np.ndarray) -> np.ndarray:
luminance = (
colors[..., 0] * 0.299
+ colors[..., 1] * 0.587
+ colors[..., 2] * 0.114
)
return np.stack(
(colors[..., 2] - luminance, colors[..., 0] - luminance),
axis=-1,
)
def _dominance(colors: np.ndarray, mode: str) -> np.ndarray:
if mode == "green":
return colors[..., 1] - np.maximum(colors[..., 0], colors[..., 2])
return np.minimum(colors[..., 0], colors[..., 2]) - colors[..., 1]
def apply_key(
rgb: np.ndarray,
parameters: ChromaKeyParameters,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""返回去溢色 RGB、Alpha 和原始色键通道优势,数值范围均为 0..1。"""
colors = np.asarray(rgb, dtype=np.float32)
if colors.size and float(colors.max()) > 1.0:
colors = colors / 255.0
key = np.asarray(parameters.key_color, dtype=np.float32) / 255.0
distance = np.linalg.norm(_chroma(colors) - _chroma(key), axis=-1)
distance_alpha = _smoothstep(
parameters.similarity,
parameters.similarity + parameters.smoothness,
distance,
)
dominance = _dominance(colors, parameters.mode)
dominance_mask = _smoothstep(
parameters.dominance_start,
parameters.dominance_end,
dominance,
)
alpha = np.minimum(distance_alpha, 1.0 - dominance_mask)
dominance_spill = _smoothstep(
parameters.spill_start,
parameters.spill_end,
dominance,
)
spill_mask = np.clip(
np.maximum(dominance_spill, (1.0 - alpha) * parameters.spill),
0.0,
1.0,
)
output = colors.copy()
if parameters.mode == "green":
neutral = np.maximum(colors[..., 0], colors[..., 2])
output[..., 1] = colors[..., 1] * (1.0 - spill_mask) + neutral * spill_mask
else:
neutral = colors[..., 1]
output[..., 0] = colors[..., 0] * (1.0 - spill_mask) + neutral * spill_mask
output[..., 2] = colors[..., 2] * (1.0 - spill_mask) + neutral * spill_mask
return np.clip(output, 0.0, 1.0), np.clip(alpha, 0.0, 1.0), dominance
def key_image(
source: Path,
output: Path,
parameters: ChromaKeyParameters,
) -> dict[str, int]:
rgb = np.asarray(Image.open(source).convert("RGB"), dtype=np.float32) / 255.0
color, alpha, _ = apply_key(rgb, parameters)
rgba = np.dstack(
(
np.rint(color * 255).astype(np.uint8),
np.rint(alpha * 255).astype(np.uint8),
)
)
output.parent.mkdir(parents=True, exist_ok=True)
Image.fromarray(rgba, "RGBA").save(output)
return {
"transparentPixels": int(np.count_nonzero(alpha <= 0.01)),
"partialPixels": int(np.count_nonzero((alpha > 0.01) & (alpha < 0.99))),
}
def analyze_frame(
source: Path,
parameters: ChromaKeyParameters,
) -> dict[str, float | int]:
rgb = np.asarray(Image.open(source).convert("RGB"), dtype=np.float32) / 255.0
output, alpha, dominance = apply_key(rgb, parameters)
key_like = dominance > 0.02
visible_key = key_like & (alpha > 0.01)
opaque_key = key_like & (alpha > 0.5)
border = np.zeros(alpha.shape, dtype=bool)
band = max(2, min(alpha.shape) // 80)
border[:band] = True
border[-band:] = True
border[:, :band] = True
border[:, -band:] = True
edge = (alpha > 0.02) & (alpha < 0.98)
output_dominance = _dominance(output, parameters.mode)
def percentile(values: np.ndarray, quantile: float) -> float:
return float(np.quantile(values, quantile)) if values.size else 0.0
key_count = max(1, int(np.count_nonzero(key_like)))
return {
"keyLikePixels": int(np.count_nonzero(key_like)),
"keyLikeAlphaP99": percentile(alpha[key_like], 0.99),
"visibleKeyPixelRatio": float(np.count_nonzero(visible_key) / key_count),
"opaqueKeyPixelRatio": float(np.count_nonzero(opaque_key) / key_count),
"borderAlphaP99": percentile(alpha[border], 0.99),
"edgeKeyDominanceP95": max(0.0, percentile(output_dominance[edge], 0.95)),
"meanAlpha": float(alpha.mean()),
}