CorridorKey-Realtime-EdgeRefiner / corridorkey_refiner.py
mantelmoritz's picture
CorridorKey Real-Time Edge Refiner v10w and v9b
b0c7fe5 verified
Raw History Blame Contribute Delete
6.93 kB
"""CorridorKey Real-Time Edge Refiner: standalone reference implementation.
Licensed under the Corridor Key Licence (CC BY-NC-SA 4.0 + additional terms), see LICENSE.
Built for use with CorridorKey 2.0 by Corridor Digital LLC.
The refiner restores native-resolution edge detail (hair strands, motion blur, soft edges) on top of a
CorridorKey 2.0 matte that was computed at a reduced resolution. Inputs per native pixel:
I native RGB frame (sRGB, [0, 1])
B smooth background estimate (sRGB), from pixels CorridorKey calls background
F CorridorKey foreground transferred onto the native frame (I + upsampled (F_low - I_low))
a_up CorridorKey alpha, bilinear upsampled
a_gf alpha of a colour guided filter (He et al.) driven by CorridorKey's low-res alpha
It predicts residuals for alpha (on top of a_gf) and foreground (on top of F). The result is only used
inside an uncertainty band around CorridorKey's edges; elsewhere the guided-filter result is kept.
`refine()` below processes a whole frame at once (simple, a few ms on a modern GPU at 1080p). The real-time
engine evaluates exactly the same maths on 32x32 band tiles only.
"""
from __future__ import annotations
import json
import torch
import torch.nn as nn
import torch.nn.functional as F
IN_CHANNELS = 11
class EdgeRefiner(nn.Module):
def __init__(self, width: int = 24, dilations=(1, 2, 4, 1)):
super().__init__()
self.dilations = tuple(int(d) for d in dilations)
self.inp = nn.Conv2d(IN_CHANNELS, width, 3, padding=1)
self.blocks = nn.ModuleList(nn.Conv2d(width, width, 3, padding=d, dilation=d) for d in self.dilations)
self.out = nn.Conv2d(width, 4, 1)
def receptive_radius(self) -> int:
return 1 + sum(self.dilations)
def forward(self, x: torch.Tensor):
"""x: (B, 11, H, W) = [I(3), B(3), F(3), a_up(1), a_gf(1)]. Returns (alpha, fg_srgb) in [0, 1]."""
h = F.gelu(self.inp(x))
for blk in self.blocks:
h = h + F.gelu(blk(h))
d = self.out(h)
alpha = (x[:, 10:11] + d[:, 0:1]).clamp(0.0, 1.0)
fg = (x[:, 6:9] + d[:, 1:4]).clamp(0.0, 1.0)
return alpha, fg
def load_refiner(path: str, device="cuda", dtype=torch.float16) -> EdgeRefiner:
"""Load a .safetensors checkpoint of this repository."""
from safetensors import safe_open
from safetensors.torch import load_file
with safe_open(path, framework="pt") as f:
cfg = json.loads(f.metadata()["config"])
net = EdgeRefiner(width=cfg["width"], dilations=tuple(cfg["dilations"]))
net.load_state_dict(load_file(path))
return net.to(device=device, dtype=dtype).eval()
# ---------------------------------------------------------------------------------------------------------
# Input construction (identical to the real-time engine)
# ---------------------------------------------------------------------------------------------------------
def srgb_to_linear(x):
return torch.where(x <= 0.04045, x / 12.92, ((x + 0.055) / 1.055) ** 2.4)
def linear_to_srgb(x):
x = x.clamp(min=0.0)
return torch.where(x <= 0.0031308, x * 12.92, 1.055 * x.clamp(min=0.0031308) ** (1 / 2.4) - 0.055)
def guided_coef(rgb_low, alpha_low, radius: int = 2, eps: float = 5e-4):
"""Colour guided filter (He et al.) at low resolution: smoothed (a_r, a_g, a_b, b) coefficients."""
k = 2 * radius + 1
box = lambda z: F.avg_pool2d(z, k, stride=1, padding=radius, count_include_pad=False)
I, p = rgb_low, alpha_low
_, _, h, w = p.shape
mean_I, mean_p = box(I), box(p)
cov_Ip = box(I * p) - mean_I * mean_p
mean_II = box((I.unsqueeze(1) * I.unsqueeze(2)).reshape(1, 9, h, w)).reshape(1, 3, 3, h, w)
s = mean_II - mean_I.unsqueeze(1) * mean_I.unsqueeze(2)
s = s + eps * torch.eye(3, device=s.device, dtype=s.dtype).view(1, 3, 3, 1, 1)
a = torch.linalg.solve(s.permute(0, 3, 4, 1, 2), cov_Ip.permute(0, 2, 3, 1).unsqueeze(-1))
a = a.squeeze(-1).permute(0, 3, 1, 2)
b = mean_p - (a * mean_I).sum(1, keepdim=True)
return torch.cat([box(a), box(b)], 1)
def push_pull(num, den):
"""Hole-filling interpolation: weighted average num/den, holes (den ~ 0) filled from coarser levels."""
levels = [(num, den)]
while min(levels[-1][0].shape[-2:]) > 4:
n, d = levels[-1]
levels.append((F.avg_pool2d(n, 2, ceil_mode=True), F.avg_pool2d(d, 2, ceil_mode=True)))
n, d = levels[-1]
est = (n.sum((-2, -1), keepdim=True) / d.sum((-2, -1), keepdim=True).clamp(min=1e-6)).expand_as(n)
for n, d in reversed(levels):
est = F.interpolate(est, size=n.shape[-2:], mode="bilinear", align_corners=False)
d = d.clamp(0.0, 1.0)
est = (n + (1.0 - d) * est) / (d + (1.0 - d)).clamp(min=1e-6)
return est
def background_low(rgb_low, alpha_low):
w = (1.0 - alpha_low).clamp(0.0, 1.0) ** 4
return linear_to_srgb(push_pull(srgb_to_linear(rgb_low.clamp(0.0, 1.0)) * w, w).clamp(0.0, 1.0))
def refiner_band(alpha_low, gain: float = 10.0, lo: float = 0.004):
"""Where the refiner may change the guided result: uncertain alpha, dilated, weighted by nearby
foreground (gain > 0 keeps it out of noisy/dark screen areas) and feathered."""
unc = ((alpha_low > lo) & (alpha_low < 0.996)).float()
unc = F.max_pool2d(unc, 5, stride=1, padding=2)
if gain > 0:
near_fg = (F.max_pool2d(alpha_low, 5, stride=1, padding=2) * gain).clamp(0.0, 1.0)
unc = F.avg_pool2d(unc * near_fg, 3, stride=1, padding=1, count_include_pad=False)
return unc
@torch.inference_mode()
def refine(net: EdgeRefiner, rgb, rgb_low, alpha_low, fg_low, band_gain: float = 10.0):
"""
rgb: (1, 3, H, W) native frame, sRGB in [0, 1]
rgb_low: (1, 3, h, w) the frame as CorridorKey saw it (downscaled), sRGB
alpha_low: (1, 1, h', w') CorridorKey alpha (any low resolution, e.g. 256x256 from a 512x512 pass)
fg_low: (1, 3, h', w') CorridorKey straight foreground, sRGB
Returns (alpha, fg) at native resolution, float32. Apply despill / alpha contrast afterwards as needed.
"""
rgb, alpha_low, fg_low = rgb.float(), alpha_low.float(), fg_low.float()
rgb_low = F.interpolate(rgb_low.float(), size=alpha_low.shape[-2:], mode="bilinear", align_corners=False)
H, W = rgb.shape[-2:]
up = lambda z: F.interpolate(z, size=(H, W), mode="bilinear", align_corners=False)
coef = up(guided_coef(rgb_low, alpha_low))
a_gf = ((coef[:, :3] * rgb).sum(1, keepdim=True) + coef[:, 3:4]).clamp(0.0, 1.0)
fg_in = (rgb + up(fg_low - rgb_low)).clamp(0.0, 1.0)
x = torch.cat([rgb, up(background_low(rgb_low, alpha_low)), fg_in, up(alpha_low), a_gf], 1)
p = next(net.parameters())
a_r, f_r = net(x.to(p.dtype))
band = up(refiner_band(alpha_low, band_gain))
alpha = band * a_r.float() + (1.0 - band) * a_gf
fg = band * f_r.float() + (1.0 - band) * fg_in
return alpha, fg