Files
imagepipeline/imagepipeline/ai/hdrnet/model.py
T
Frank Schwenk a60a18a253 chore: add Ruff and apply formatting across codebase
Introduce ruff lint/format config, expand .gitignore, and reformat Python sources.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:34:14 +02:00

251 lines
7.9 KiB
Python

from __future__ import annotations
import numpy as np
import torch
import torch.nn as nn
from imagepipeline.ai.hdrnet.slice import batch_bilateral_slice
class ConvBlock(nn.Module):
def __init__(
self,
inc,
outc,
kernel_size=3,
padding=1,
stride=1,
use_bias=True,
activation=nn.ReLU,
batch_norm=False,
) -> None:
super().__init__()
self.conv = nn.Conv2d(
int(inc), int(outc), kernel_size, padding=padding, stride=stride, bias=use_bias
)
self.activation = activation() if activation else None
self.bn = nn.BatchNorm2d(outc) if batch_norm else None
if use_bias and not batch_norm:
self.conv.bias.data.fill_(0.0)
torch.nn.init.kaiming_uniform_(self.conv.weight)
def forward(self, x):
x = self.conv(x)
if self.bn is not None:
x = self.bn(x)
if self.activation is not None:
x = self.activation(x)
return x
class FC(nn.Module):
def __init__(self, inc, outc, activation=nn.ReLU, batch_norm=False) -> None:
super().__init__()
self.fc = nn.Linear(int(inc), int(outc), bias=(not batch_norm))
self.activation = activation() if activation else None
self.bn = nn.BatchNorm1d(outc) if batch_norm else None
if not batch_norm:
self.fc.bias.data.fill_(0.0)
torch.nn.init.kaiming_uniform_(self.fc.weight)
def forward(self, x):
x = self.fc(x)
if self.bn is not None:
x = self.bn(x)
if self.activation is not None:
x = self.activation(x)
return x
class Slice(nn.Module):
def forward(self, bilateral_grid, guidemap):
bilateral_grid = bilateral_grid.permute(0, 3, 4, 2, 1)
guidemap = guidemap.squeeze(1)
coeffs = batch_bilateral_slice(bilateral_grid, guidemap).permute(0, 3, 1, 2)
return coeffs
class ApplyCoeffs(nn.Module):
def forward(self, coeff, full_res_input):
r = (
torch.sum(full_res_input * coeff[:, 0:3, :, :], dim=1, keepdim=True)
+ coeff[:, 9:10, :, :]
)
g = (
torch.sum(full_res_input * coeff[:, 3:6, :, :], dim=1, keepdim=True)
+ coeff[:, 10:11, :, :]
)
b = (
torch.sum(full_res_input * coeff[:, 6:9, :, :], dim=1, keepdim=True)
+ coeff[:, 11:12, :, :]
)
return torch.cat([r, g, b], dim=1)
class GuideNN(nn.Module):
def __init__(self, params) -> None:
super().__init__()
self.conv1 = ConvBlock(
3, params["guide_complexity"], kernel_size=1, padding=0, batch_norm=True
)
self.conv2 = ConvBlock(
params["guide_complexity"], 1, kernel_size=1, padding=0, activation=nn.Sigmoid
)
def forward(self, x):
return self.conv2(self.conv1(x))
class Coeffs(nn.Module):
def __init__(self, nin=4, nout=3, params=None) -> None:
super().__init__()
self.params = params
self.nin = nin
self.nout = nout
lb = params["luma_bins"]
cm = params["channel_multiplier"]
sb = params["spatial_bin"]
bn = params["batch_norm"]
nsize = params["net_input_size"]
n_layers_splat = int(np.log2(nsize / sb))
self.splat_features = nn.ModuleList()
prev_ch = 3
for index in range(n_layers_splat):
use_bn = bn if index > 0 else False
out_ch = cm * (2**index) * lb
self.splat_features.append(ConvBlock(prev_ch, out_ch, 3, stride=2, batch_norm=use_bn))
prev_ch = out_ch
splat_ch = prev_ch
n_layers_global = int(np.log2(sb / 4))
self.global_features_conv = nn.ModuleList()
self.global_features_fc = nn.ModuleList()
for _ in range(n_layers_global):
self.global_features_conv.append(
ConvBlock(prev_ch, cm * 8 * lb, 3, stride=2, batch_norm=bn)
)
prev_ch = cm * 8 * lb
n_total = n_layers_splat + n_layers_global
prev_ch = int(prev_ch * (nsize / 2**n_total) ** 2)
self.global_features_fc.append(FC(prev_ch, 32 * cm * lb, batch_norm=bn))
self.global_features_fc.append(FC(32 * cm * lb, 16 * cm * lb, batch_norm=bn))
self.global_features_fc.append(
FC(16 * cm * lb, 8 * cm * lb, activation=None, batch_norm=bn)
)
self.local_features = nn.ModuleList(
[
ConvBlock(splat_ch, 8 * cm * lb, 3, batch_norm=bn),
ConvBlock(8 * cm * lb, 8 * cm * lb, 3, activation=None, use_bias=False),
]
)
self.conv_out = ConvBlock(8 * cm * lb, lb * nout * nin, 1, padding=0, activation=None)
self.relu = nn.ReLU()
def forward(self, lowres_input):
params = self.params
bs = lowres_input.shape[0]
lb = params["luma_bins"]
cm = params["channel_multiplier"]
x = lowres_input
for layer in self.splat_features:
x = layer(x)
splat_features = x
for layer in self.global_features_conv:
x = layer(x)
x = x.view(bs, -1)
for layer in self.global_features_fc:
x = layer(x)
global_features = x
x = splat_features
for layer in self.local_features:
x = layer(x)
fusion = self.relu(x + global_features.view(bs, 8 * cm * lb, 1, 1))
x = self.conv_out(fusion)
return torch.stack(torch.split(x, self.nin * self.nout, 1), 2)
class HDRPointwiseNN(nn.Module):
def __init__(self, params) -> None:
super().__init__()
self.coeffs = Coeffs(params=params)
self.guide = GuideNN(params=params)
self.slice = Slice()
self.apply_coeffs = ApplyCoeffs()
def forward(self, lowres, fullres):
coeffs = self.coeffs(lowres)
guide = self.guide(fullres)
slice_coeffs = self.slice(coeffs, guide)
return self.apply_coeffs(slice_coeffs, fullres)
def default_hdrnet_params(net_input_size: int = 256) -> dict:
return {
"luma_bins": 8,
"channel_multiplier": 1,
"spatial_bin": 16,
"batch_norm": True,
"net_input_size": net_input_size,
"guide_complexity": 16,
}
def load_hdrnet_checkpoint(checkpoint_path, device: torch.device):
state = torch.load(checkpoint_path, map_location=device, weights_only=False)
if "model_params" in state:
params = state["model_params"]
del state["model_params"]
else:
params = default_hdrnet_params()
model = HDRPointwiseNN(params=params)
model.load_state_dict(state)
model.to(device)
model.eval()
return model, params
def resize_rgb_array(arr: np.ndarray, size: int) -> np.ndarray:
from PIL import Image
image = Image.fromarray(arr.astype(np.uint8))
short = min(image.size)
scale = size / short
new_size = (max(1, round(image.size[0] * scale)), max(1, round(image.size[1] * scale)))
return np.asarray(image.resize(new_size, Image.Resampling.NEAREST))
def enhance_image_hdrnet(
model: HDRPointwiseNN,
image_rgb,
*,
device: torch.device,
net_input_size: int,
strength: float = 1.0,
):
from PIL import Image
if not isinstance(image_rgb, Image.Image):
image_rgb = Image.fromarray(image_rgb)
full_arr = np.asarray(image_rgb, dtype=np.float32)
low_arr = resize_rgb_array(full_arr, net_input_size)
low = torch.from_numpy(low_arr).permute(2, 0, 1).unsqueeze(0).float() / 255.0
full = torch.from_numpy(full_arr).permute(2, 0, 1).unsqueeze(0).float() / 255.0
low = low.to(device)
full = full.to(device)
with torch.no_grad():
out = model(low, full)
if strength < 1.0:
out = full * (1.0 - strength) + out * strength
out = torch.clamp(out, 0.0, 1.0)
result = (out.squeeze(0).permute(1, 2, 0).cpu().numpy() * 255.0).astype(np.uint8)
return Image.fromarray(result)