deep-analog / models.py
EtonMu's picture
Deploy Deep Analog web app
8f376e0 verified
Raw
History Blame Contribute Delete
10.1 kB
"""
Deep Analog — Model Definitions (deployment build)
Architecture matches training exactly. Backbone weights come from the
checkpoint, so no ImageNet download is needed at startup.
"""
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision.models as tv_models
# =========================================================================
# Trilinear Interpolation
# =========================================================================
class TrilinearInterpolation(nn.Module):
def forward(self, lut, x):
B, C, H, W = x.shape
x = torch.clamp(x, 0, 1)
output = []
for c in range(C):
lut_c = lut[:, c:c+1, :, :, :]
grid = x[:, :, :, :].permute(0, 2, 3, 1)
grid_norm = grid * 2 - 1
grid_reshaped = grid_norm.unsqueeze(1)
sampled = F.grid_sample(lut_c, grid_reshaped, mode='bilinear',
padding_mode='border', align_corners=False)
sampled = sampled.squeeze(1).squeeze(1)
output.append(sampled)
return torch.stack(output, dim=1)
# =========================================================================
# StyleLUTNet — Conditional 3D LUT prediction (open-set color transfer)
# =========================================================================
class StyleLUTNet(nn.Module):
def __init__(self, lut_dim=33, lut_dim_low=17):
super().__init__()
self.lut_dim = lut_dim
self.lut_dim_low = lut_dim_low
resnet18 = tv_models.resnet18(weights=None) # checkpoint supplies weights
self.encoder = nn.Sequential(*list(resnet18.children())[:-1])
self.feat_dim = 512
n_values = lut_dim_low ** 3 * 3
self.decoder = nn.Sequential(
nn.Linear(self.feat_dim, 1024), nn.LayerNorm(1024), nn.GELU(), nn.Dropout(0.1),
nn.Linear(1024, 2048), nn.LayerNorm(2048), nn.GELU(), nn.Dropout(0.1),
nn.Linear(2048, 4096), nn.LayerNorm(4096), nn.GELU(),
nn.Linear(4096, n_values),
)
self.register_buffer('residual_scale', torch.tensor(1.0))
self.register_buffer('identity_lut', self._make_identity_lut(lut_dim_low))
self.trilinear = TrilinearInterpolation()
@staticmethod
def _make_identity_lut(dim):
coords = torch.linspace(0, 1, dim)
lut = torch.zeros(3, dim, dim, dim)
lut[0] = coords.view(dim, 1, 1).expand(dim, dim, dim)
lut[1] = coords.view(1, dim, 1).expand(dim, dim, dim)
lut[2] = coords.view(1, 1, dim).expand(dim, dim, dim)
return lut
def predict_lut(self, reference_224):
B = reference_224.shape[0]
feat = self.encoder(reference_224).flatten(1)
residual = self.decoder(feat)
d = self.lut_dim_low
residual = residual.view(B, 3, d, d, d)
lut_low = self.identity_lut.unsqueeze(0) + self.residual_scale * residual
if self.lut_dim != self.lut_dim_low:
lut = F.interpolate(
lut_low.view(B * 3, 1, d, d, d),
size=(self.lut_dim, self.lut_dim, self.lut_dim),
mode='trilinear', align_corners=True,
).view(B, 3, self.lut_dim, self.lut_dim, self.lut_dim)
else:
lut = lut_low
return lut, feat
def forward(self, x_full, reference_224):
lut, feat = self.predict_lut(reference_224)
output = self.trilinear(lut, x_full)
return output, lut, feat
# =========================================================================
# Film Physics Renderers (grain + halation) and tone matching
# =========================================================================
def _gaussian_blur_2d(x, sigma):
if sigma < 0.3:
return x
ks = int(6 * sigma + 1) | 1
ks = min(ks, 31)
coords = torch.arange(ks, dtype=torch.float32, device=x.device) - ks // 2
k = torch.exp(-0.5 * (coords / sigma) ** 2)
k = k / k.sum()
B, C, H, W = x.shape
pad = ks // 2
kh = k.view(1, 1, 1, ks).expand(C, -1, -1, -1)
x = F.conv2d(F.pad(x, [pad, pad, 0, 0], 'reflect'), kh, groups=C)
kv = k.view(1, 1, ks, 1).expand(C, -1, -1, -1)
x = F.conv2d(F.pad(x, [0, 0, pad, pad], 'reflect'), kv, groups=C)
return x
def _channel_blur(x1, radius):
sigma = radius / 2.0
if sigma < 0.5:
return x1
ks = int(6 * sigma + 1) | 1
ks = min(ks, 181)
coords = torch.arange(ks, dtype=torch.float32, device=x1.device) - ks // 2
k = torch.exp(-0.5 * (coords / sigma) ** 2)
k = k / k.sum()
pad = ks // 2
kh = k.view(1, 1, 1, ks)
x1 = F.conv2d(F.pad(x1, [pad, pad, 0, 0], 'reflect'), kh)
kv = k.view(1, 1, ks, 1)
x1 = F.conv2d(F.pad(x1, [0, 0, pad, pad], 'reflect'), kv)
return x1
def match_tone_curve(source, reference, strength=0.8, return_transfer=False):
"""Per-channel histogram matching with smooth transfer function."""
B, C, H, W = source.shape
dev = source.device
result = torch.zeros_like(source)
n_bins = 256
transfer_curves = []
for c in range(C):
src_ch = source[0, c].flatten()
ref_ch = reference[0, c].flatten()
bins = torch.linspace(0, 1, n_bins + 1, device=dev)
bin_centers = (bins[:-1] + bins[1:]) / 2
src_cdf = torch.zeros(n_bins, device=dev)
ref_cdf = torch.zeros(n_bins, device=dev)
for i in range(n_bins):
src_cdf[i] = (src_ch <= bins[i + 1]).float().mean()
ref_cdf[i] = (ref_ch <= bins[i + 1]).float().mean()
transfer = torch.zeros(n_bins, device=dev)
for i in range(n_bins):
target_cdf = src_cdf[i]
idx = torch.searchsorted(ref_cdf, target_cdf.unsqueeze(0)).squeeze()
idx = idx.clamp(0, n_bins - 1)
transfer[i] = bin_centers[idx]
kernel_size = 9
sigma = 2.0
coords = torch.arange(kernel_size, device=dev).float() - kernel_size // 2
kernel = torch.exp(-0.5 * (coords / sigma) ** 2)
kernel = kernel / kernel.sum()
transfer_padded = F.pad(transfer.unsqueeze(0).unsqueeze(0),
[kernel_size // 2, kernel_size // 2], mode='reflect')
transfer_smooth = F.conv1d(transfer_padded, kernel.view(1, 1, -1)).squeeze()
transfer_curves.append(transfer_smooth)
src_flat = src_ch.clamp(0, 1)
idx_float = src_flat * (n_bins - 1)
idx_low = idx_float.long().clamp(0, n_bins - 2)
idx_high = (idx_low + 1).clamp(max=n_bins - 1)
frac = idx_float - idx_low.float()
mapped = transfer_smooth[idx_low] * (1 - frac) + transfer_smooth[idx_high] * frac
result[0, c] = mapped.view(H, W)
lum_w = torch.tensor([0.2126, 0.7152, 0.0722], device=dev).view(1, 3, 1, 1)
src_lum = (source * lum_w).sum(dim=1, keepdim=True)
highlight_protect = ((src_lum - 0.55) / (0.90 - 0.55)).clamp(0, 1)
effective_strength = strength * (1.0 - highlight_protect * 0.7)
out = source * (1 - effective_strength) + result * effective_strength
dither = (torch.rand_like(out) + torch.rand_like(out) - 1.0) * (0.5 / 256.0)
out = out + dither * strength
out = torch.clamp(out, 0, 1)
if return_transfer:
return out, torch.stack(transfer_curves, dim=0)
return out
def render_grain(img, sigma_val, grain_size_val, lum_params, grain_mult=1.0):
B, C, H, W = img.shape
dev = img.device
lum_w = torch.tensor([0.2126, 0.7152, 0.0722], device=dev).view(1, 3, 1, 1)
L = (img * lum_w).sum(dim=1, keepdim=True)
a = lum_params[:, 0:1, None, None]
b = lum_params[:, 1:2, None, None]
c = lum_params[:, 2:3, None, None]
lum_mask = torch.sigmoid(a * L**2 + b * L + c)
res_factor = math.sqrt(H * W) / 480.0
raw_gs = grain_size_val if isinstance(grain_size_val, float) else grain_size_val.mean().item()
fine_sigma = 0.0
mid_sigma = max(0.3, raw_gs * 0.12) * res_factor
coarse_sigma = min(max(0.5, raw_gs * 0.25) * res_factor, 2.0 * res_factor)
scales = [fine_sigma, mid_sigma, coarse_sigma]
weights = [0.50, 0.33, 0.17]
shared = torch.randn(B, 1, H, W, device=dev)
noise = torch.cat([
shared * 0.5 + torch.randn(B, 1, H, W, device=dev) * 0.5,
shared * 0.5 + torch.randn(B, 1, H, W, device=dev) * 0.5,
shared * 0.5 + torch.randn(B, 1, H, W, device=dev) * 0.5,
], dim=1)
grain = torch.zeros(B, 3, H, W, device=dev)
for s, w in zip(scales, weights):
octave = _gaussian_blur_2d(noise, s) if s >= 0.3 else noise
std = octave.std(dim=[2, 3], keepdim=True).clamp(min=1e-6)
grain = grain + w * (octave / std)
intensity = sigma_val * grain_mult
if isinstance(intensity, torch.Tensor):
intensity = intensity.view(-1, 1, 1, 1) if intensity.dim() >= 1 else intensity
grain = intensity * lum_mask * grain
return torch.clamp(img + grain, 0, 1), grain
def render_halation(img, threshold_val, radius_val, intensity_val, color_bias):
B, C, H, W = img.shape
lum_w = torch.tensor([0.2126, 0.7152, 0.0722], device=img.device).view(1, 3, 1, 1)
L = (img * lum_w).sum(dim=1, keepdim=True)
if isinstance(threshold_val, torch.Tensor):
mask = torch.sigmoid(20.0 * (L - threshold_val.view(B, 1, 1, 1)))
else:
mask = torch.sigmoid(20.0 * (L - threshold_val))
highlights = img * mask
res_factor = math.sqrt(H * W) / 480.0
base_r = (radius_val.mean().item() if isinstance(radius_val, torch.Tensor) else radius_val) * res_factor
sr = _channel_blur(highlights[:, 0:1], base_r * 1.4)
sg = _channel_blur(highlights[:, 1:2], base_r * 1.0)
sb = _channel_blur(highlights[:, 2:3], base_r * 0.7)
scattered = torch.cat([sr, sg, sb], dim=1)
cw = torch.sigmoid(color_bias).view(B, 3, 1, 1)
if isinstance(intensity_val, torch.Tensor):
iv = intensity_val.view(B, 1, 1, 1)
else:
iv = intensity_val
hmap = iv * cw * scattered
return torch.clamp(img + hmap, 0, 1), hmap