scope-camera-video / scope /encoding.py
iggy101's picture
Upload folder using huggingface_hub (part 2)
2dc3625 verified
Raw
History Blame Contribute Delete
17.2 kB
"""
SCoPE: Sightline-Coordinate Positional Encoding — Normalize-Gate-Inject
用于解决跨数据集 scale 不一致导致的数值不稳定问题。
核心机制:
1. 将 6D Plücker 坐标 (d, m) 解耦为归一化几何 (d, m̂) + 对数尺度 log‖m‖
→ 投影输入变为 7D,几何方向 scale-invariant
2. 增加 scale_gate: log_scale → sigmoid(MLP) ∈ (0,1)
→ 动态调节 PE 注入强度:近景强、远景弱
3. log_scale 同时参与 E_q / E_k 投影,不丢失绝对距离信息
4. PE 输出经 RMSNorm 归一化,与 content path 的 QKNorm 对称
→ α 直接控制 geometry/content 的相对比例
数学形式:
d_i, m̂_i, s_i = decompose(r_i) # d 不变, m̂=m/‖m‖, s=log‖m‖
pe_q_i = gate(s_i) · α_q · RMSNorm(E_q(d_i, m̂_i, s_i))
pe_k_j = gate(s_j) · α_k · RMSNorm(E_k(m̂_j, d_j, s_j)) ← flip (d,m̂)
q_i = QKNorm(W_Q x_i) + pe_q_i
k_j = QKNorm(W_K x_j) + pe_k_j
α=1.0 时 geometry 与 content 等权参与 attention。
Usage:
pe = SightlineCoordinatePE(dim=1536, plucker_init="zero", plucker_scale=1.0)
q, k = pe.apply_to_qk(q, k, plucker_6d)
# or with cam_residual:
q, k, cam_res = pe.apply_to_qk_and_output(q, k, plucker_6d, num_frames=21)
"""
import torch
from torch import nn
class _RMSNorm(nn.Module):
"""Per-token RMSNorm with learnable scale (matches WAN's QKNorm)."""
def __init__(self, dim: int, eps: float = 1e-6):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(dim))
def forward(self, x: torch.Tensor) -> torch.Tensor:
return x * torch.rsqrt(x.pow(2).mean(dim=-1, keepdim=True) + self.eps) * self.weight
class SightlineCoordinatePE(nn.Module):
"""
Sightline-coordinate positional encoding with Normalize-Gate-Inject.
Args:
dim: attention feature dimension (e.g. 3072 for 5B).
plucker_init: "zero" or "small" for E_q/E_k initialization.
plucker_init_scale: std for "small" init.
plucker_mlp_hidden: if > 0, use 7→hidden→dim MLP; if 0, use 7→dim Linear.
plucker_scale: if > 0, add learnable α_q/α_k initialized to this value.
With PE RMSNorm, α=1.0 means geometry and content contribute equally.
gate_init_bias: initial value for cam_residual gate logit (only used
when enable_cam_residual=True).
enable_cam_residual: whether to add frame-uniform gated camera residual.
scale_gate_hidden: hidden dim of the scale gate MLP. Defaults to dim // 4.
log_scale_aug_prob: probability of applying a uniform per-sample shift
to the log_scale that feeds the scale_gate MLP during training.
0.0 = disabled (backward compatible). Only `scale_gate` input is
perturbed; feat_q/feat_k (E_q/E_k inputs) keep the true log_scale.
Only active when self.training is True.
log_scale_aug_range: (lo, hi) tuple of the uniform shift range in
natural-log units. Default (-1.2, 1.6) spans roughly ÷3.3 … ×5.
"""
def __init__(
self,
dim: int,
plucker_init: str = "zero",
plucker_init_scale: float = 0.01,
plucker_mlp_hidden: int = 0,
plucker_scale: float = 0.0,
gate_init_bias: float = -2.0,
enable_cam_residual: bool = True,
scale_gate_hidden: int = 0,
log_scale_aug_prob: float = 0.0,
log_scale_aug_range: tuple = (-1.2, 1.6),
):
super().__init__()
self.dim = dim
self.use_mlp = plucker_mlp_hidden > 0
self.use_scale = plucker_scale > 0
self.enable_cam_residual = enable_cam_residual
self.log_scale_aug_prob = float(log_scale_aug_prob)
self.log_scale_aug_range = (float(log_scale_aug_range[0]), float(log_scale_aug_range[1]))
in_dim = 7 # (d(3), m̂(3), log_s(1))
# ── Q/K geometric projections ────────────────────────────────────
if self.use_mlp:
self.eq = nn.Sequential(
nn.Linear(in_dim, plucker_mlp_hidden, bias=False),
nn.GELU(),
nn.Linear(plucker_mlp_hidden, dim, bias=False),
)
self.ek = nn.Sequential(
nn.Linear(in_dim, plucker_mlp_hidden, bias=False),
nn.GELU(),
nn.Linear(plucker_mlp_hidden, dim, bias=False),
)
else:
self.eq = nn.Linear(in_dim, dim, bias=False)
self.ek = nn.Linear(in_dim, dim, bias=False)
# ── PE RMSNorm: align PE magnitude with content QKNorm ──────────
self.norm_pe_q = _RMSNorm(dim)
self.norm_pe_k = _RMSNorm(dim)
# ── Scale gate: log_scale → (0, 1) per-dim ──────────────────────
sg_hidden = scale_gate_hidden if scale_gate_hidden > 0 else max(dim // 4, 1)
self.scale_gate = nn.Sequential(
nn.Linear(1, sg_hidden),
nn.SiLU(),
nn.Linear(sg_hidden, dim),
nn.Sigmoid(),
)
# init gate bias so output ≈ 0.5 at start (log_scale=0 → neutral)
nn.init.zeros_(self.scale_gate[0].bias)
nn.init.zeros_(self.scale_gate[2].bias)
# ── Learnable per-layer scale α ──────────────────────────────────
# Shape (1,) instead of () because FSDP refuses to shard 0-dim
# parameters. Broadcasting `alpha * pe_q` is identical for both shapes.
if self.use_scale:
self.alpha_q = nn.Parameter(torch.tensor([plucker_scale]))
self.alpha_k = nn.Parameter(torch.tensor([plucker_scale]))
# Optional camera residual.────────────────────────────────────────────────────────────
if self.enable_cam_residual:
if self.use_mlp:
self.ev = nn.Sequential(
nn.Linear(in_dim, plucker_mlp_hidden, bias=False),
nn.GELU(),
nn.Linear(plucker_mlp_hidden, dim, bias=False),
)
self.gate_proj = nn.Sequential(
nn.Linear(in_dim, plucker_mlp_hidden, bias=True),
nn.GELU(),
nn.Linear(plucker_mlp_hidden, dim, bias=False),
)
else:
self.ev = nn.Linear(in_dim, dim, bias=False)
self.gate_proj = nn.Linear(in_dim, dim, bias=False)
self.gate_logit = nn.Parameter(torch.full((dim,), gate_init_bias))
self._init_weights(plucker_init, plucker_init_scale)
# ─────────────────────────────────────────────────────────────────────
# Backward-compat ckpt loading
# ─────────────────────────────────────────────────────────────────────
def _load_from_state_dict(
self, state_dict, prefix, local_metadata, strict, missing_keys, unexpected_keys, error_msgs
):
# alpha_q / alpha_k were 0-dim scalars in earlier checkpoints; FSDP
# requires shape (1,). Promote legacy entries while loading.
for name in ("alpha_q", "alpha_k"):
key = prefix + name
if key in state_dict and state_dict[key].dim() == 0:
state_dict[key] = state_dict[key].view(1)
super()._load_from_state_dict(
state_dict,
prefix,
local_metadata,
strict,
missing_keys,
unexpected_keys,
error_msgs,
)
# ─────────────────────────────────────────────────────────────────────
# Weight init
# ─────────────────────────────────────────────────────────────────────
def _init_weights(self, mode: str, scale: float):
if self.use_mlp:
# For 2-layer MLP with zero init: only zero the OUTPUT layer.
# Zeroing both layers creates dead gradients (h=GELU(0)=0 → ∂L/∂W=0).
# The first layer keeps default kaiming init so hidden activations ≠ 0.
qk_output_layers = [self.eq[2], self.ek[2]]
qk_input_layers = [self.eq[0], self.ek[0]]
else:
qk_output_layers = [self.eq, self.ek]
qk_input_layers = []
for m in qk_output_layers:
if mode == "zero":
nn.init.zeros_(m.weight)
else:
nn.init.normal_(m.weight, 0.0, scale)
for m in qk_input_layers:
if mode == "zero":
nn.init.kaiming_uniform_(m.weight, a=5**0.5)
else:
nn.init.normal_(m.weight, 0.0, scale)
if self.enable_cam_residual:
v_modules = [self.ev[0], self.ev[2]] if self.use_mlp else [self.ev]
for m in v_modules:
nn.init.normal_(m.weight, 0.0, scale)
if self.use_mlp:
nn.init.xavier_uniform_(self.gate_proj[0].weight)
nn.init.zeros_(self.gate_proj[0].bias)
nn.init.zeros_(self.gate_proj[2].weight)
else:
nn.init.zeros_(self.gate_proj.weight)
# ─────────────────────────────────────────────────────────────────────
# Plücker decomposition
# ─────────────────────────────────────────────────────────────────────
@staticmethod
def decompose_plucker(plucker_6d: torch.Tensor):
"""Decompose (d, m) → (d, m̂, log‖m‖).
Returns:
feat_q: (B, S, 7) = (d, m̂, log_s) for Q projection.
feat_k: (B, S, 7) = (m̂, d, log_s) for K projection (flip).
log_scale: (B, S, 1) for scale gate.
"""
d = plucker_6d[..., :3]
m = plucker_6d[..., 3:]
m_norm = m.norm(dim=-1, keepdim=True).clamp(min=1e-6)
m_hat = m / m_norm
log_scale = torch.log(m_norm)
feat_q = torch.cat([d, m_hat, log_scale], dim=-1)
feat_k = torch.cat([m_hat, d, log_scale], dim=-1) # flip d ↔ m̂
return feat_q, feat_k, log_scale
# ─────────────────────────────────────────────────────────────────────
# Training-time scale augmentation
# ─────────────────────────────────────────────────────────────────────
def _maybe_perturb_log_scale(self, log_scale: torch.Tensor) -> torch.Tensor:
"""Apply a per-sample uniform shift to log_scale during training.
The shift is shared across all tokens of a sample (same offset for
all frames / patches), mimicking the effect of globally rescaling
the camera translation (e.g. `poses[:, :, 3] *= k` → log_scale += log k).
Only the copy fed into `scale_gate` is perturbed; E_q / E_k still see
the true log_scale so absolute-distance information is preserved.
No-op when:
* not training, or
* `log_scale_aug_prob <= 0`, or
* the Bernoulli draw rejects this forward.
"""
if not self.training or self.log_scale_aug_prob <= 0.0:
return log_scale
# Bernoulli(prob) gate — batch-wide single draw to minimise overhead.
if torch.rand((), device=log_scale.device).item() > self.log_scale_aug_prob:
return log_scale
lo, hi = self.log_scale_aug_range
B = log_scale.shape[0]
# (B, 1, 1) broadcast over (S, 1) → per-sample scalar shift.
shift = torch.empty(B, 1, 1, device=log_scale.device, dtype=log_scale.dtype).uniform_(
lo, hi
)
return log_scale + shift
# ─────────────────────────────────────────────────────────────────────
# Core forward: Q/K only
# ─────────────────────────────────────────────────────────────────────
def apply_to_qk(
self,
q: torch.Tensor,
k: torch.Tensor,
plucker_6d: torch.Tensor,
):
"""Add scale-gated Plücker PE to Q and K.
Args:
q: (B, S, D) query after RoPE.
k: (B, S, D) key after RoPE.
plucker_6d: (B, S, 6) raw Plücker coordinates (d, m).
Returns:
q, k with Normalize-Gate-Inject PE applied.
"""
feat_q, feat_k, log_scale = self.decompose_plucker(plucker_6d)
pe_q = self.norm_pe_q(self.eq(feat_q.to(q.dtype)))
pe_k = self.norm_pe_k(self.ek(feat_k.to(k.dtype)))
# Perturb the gate's log_scale input only — feat_q/feat_k keep the
# true log_scale so E_q / E_k absolute-distance information stays intact.
log_scale_for_gate = self._maybe_perturb_log_scale(log_scale)
gate = self.scale_gate(log_scale_for_gate.to(q.dtype)) # (B, S, D)
pe_q = gate * pe_q
pe_k = gate * pe_k
if self.use_scale:
pe_q = self.alpha_q * pe_q
pe_k = self.alpha_k * pe_k
return q + pe_q, k + pe_k
# ─────────────────────────────────────────────────────────────────────
# Extended forward: Q/K + cam_residual
# ─────────────────────────────────────────────────────────────────────
def apply_to_qk_and_output(
self,
q: torch.Tensor,
k: torch.Tensor,
plucker_6d: torch.Tensor,
num_frames: int = 1,
):
"""Apply Plücker PE to Q/K, optionally compute frame-uniform cam_residual.
Args:
q, k: (B, S, dim) where S = num_frames * H * W.
plucker_6d: (B, S, 6) raw Plücker coordinates.
num_frames: latent frame count T, for frame-level averaging.
Returns:
q, k: with Normalize-Gate-Inject PE applied.
cam_residual: (B, S, dim) or None.
"""
orig_dtype = q.dtype
feat_q, feat_k, log_scale = self.decompose_plucker(plucker_6d)
feat_q = feat_q.to(orig_dtype)
feat_k = feat_k.to(orig_dtype)
pe_q = self.norm_pe_q(self.eq(feat_q))
pe_k = self.norm_pe_k(self.ek(feat_k))
# Perturb the gate's log_scale input only — feat_q/feat_k keep the
# true log_scale so E_q / E_k absolute-distance information stays intact.
log_scale_for_gate = self._maybe_perturb_log_scale(log_scale)
gate = self.scale_gate(log_scale_for_gate.to(orig_dtype))
pe_q = gate * pe_q
pe_k = gate * pe_k
if self.use_scale:
pe_q = self.alpha_q * pe_q
pe_k = self.alpha_k * pe_k
q = q + pe_q
k = k + pe_k
cam_residual = None
if self.enable_cam_residual:
B, S, C = feat_q.shape
spatial = S // num_frames
# frame-level average of normalized features
feat_frame = feat_q.reshape(B, num_frames, spatial, C).mean(dim=2, keepdim=True)
feat_frame = feat_frame.expand(B, num_frames, spatial, C).reshape(B, S, C)
cam_gate = torch.sigmoid(self.gate_logit + self.gate_proj(feat_frame))
cam_residual = cam_gate.to(orig_dtype) * self.ev(feat_frame)
return q.to(orig_dtype), k.to(orig_dtype), cam_residual