hal-net / app.py
ahadstfu's picture
fix: resolution-aware halation sigma, quantitative eval
e15728b verified
Raw
History Blame Contribute Delete
8.23 kB
import torch
import torch.nn as nn
import torch.nn.functional as F
from torchvision import transforms
import torchvision.transforms.functional as TF
import numpy as np
from PIL import Image, ExifTags
from scipy.ndimage import gaussian_filter
import gradio as gr
# ── Architecture ──────────────────────────────────────────────────────────────
class ResBlock(nn.Module):
def __init__(self, ch):
super().__init__()
self.conv1 = nn.Conv2d(ch, ch, 3, padding=1)
self.bn1 = nn.BatchNorm2d(ch)
self.conv2 = nn.Conv2d(ch, ch, 3, padding=1)
self.bn2 = nn.BatchNorm2d(ch)
def forward(self, x):
res = x
x = F.relu(self.bn1(self.conv1(x)))
x = self.bn2(self.conv2(x))
return F.relu(x + res)
class HALNet(nn.Module):
def __init__(self):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 32, 3, padding=1, stride=2), nn.ReLU(),
nn.Conv2d(32, 64, 3, padding=1, stride=2), nn.ReLU(),
nn.Conv2d(64, 128, 3, padding=1, stride=2), nn.ReLU(),
ResBlock(128), ResBlock(128),
nn.AdaptiveAvgPool2d(1), nn.Flatten()
)
self.regressor = nn.Sequential(
nn.Linear(128, 64), nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(64, 3), nn.Sigmoid()
)
def forward(self, x):
return self.regressor(self.features(x))
# ── Load model ────────────────────────────────────────────────────────────────
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
model = HALNet().to(DEVICE)
model.load_state_dict(torch.load("halnet_best.pt", map_location=DEVICE))
model.eval()
transform = transforms.Compose([
transforms.Resize((256, 256)),
transforms.ToTensor()
])
# ── Helpers ───────────────────────────────────────────────────────────────────
def fix_orientation(img):
try:
exif = img._getexif()
if exif:
for tag, val in exif.items():
if ExifTags.TAGS.get(tag) == "Orientation":
if val == 3: img = img.rotate(180, expand=True)
elif val == 6: img = img.rotate(270, expand=True)
elif val == 8: img = img.rotate(90, expand=True)
except Exception:
pass
return img
def pad_to_square(img):
w, h = img.size
max_s = max(w, h)
pad_w = (max_s - w) // 2
pad_h = (max_s - h) // 2
return TF.pad(img, (pad_w, pad_h, max_s-w-pad_w, max_s-h-pad_h), fill=0)
def apply_halation(img_array, radius_norm, intensity, warmth):
"""
Synthesize halation at the image's native resolution.
radius_norm is calibrated to a 256-px working resolution (sigma = radius * 32).
We scale sigma by the longer image side so the spread looks the same on
high-resolution inputs as it does at 256 px.
"""
img = img_array.astype(np.float32) / 255.0
R, G, B = img[:,:,0], img[:,:,1], img[:,:,2]
# Scale sigma to native resolution so the glow is not artificially tight
# on large images.
resolution_scale = max(img.shape[:2]) / 256.0
sigma = max(radius_norm * 32.0 * resolution_scale, 1.0)
luma = 0.299*R + 0.587*G + 0.114*B
mask = np.clip((luma - 0.65) / 0.35, 0, 1)
bloom = gaussian_filter(mask, sigma=sigma)
bloom /= (bloom.max() + 1e-6)
out = img.copy()
out[:,:,0] = np.clip(out[:,:,0] + bloom * intensity * (0.6 + 0.4*warmth), 0, 1)
out[:,:,1] = np.clip(out[:,:,1] + bloom * intensity * 0.12, 0, 1)
out[:,:,2] = np.clip(out[:,:,2] + bloom * intensity * max(0, 0.08 - 0.08*warmth), 0, 1)
return (out * 255).astype(np.uint8)
# ── Inference ─────────────────────────────────────────────────────────────────
def predict(input_image, manual_radius, manual_intensity, manual_warmth, use_manual):
if input_image is None:
return None, "Upload an image to get started."
img = fix_orientation(Image.fromarray(input_image).convert("RGB"))
img_sq = pad_to_square(img)
inp = transform(img_sq).unsqueeze(0).to(DEVICE)
with torch.no_grad():
p = model(inp).squeeze().cpu().numpy()
if use_manual:
radius = manual_radius
intensity = manual_intensity
warmth = manual_warmth
mode = "manual override"
else:
radius, intensity, warmth = float(p[0]), float(p[1]), float(p[2])
mode = "HAL-Net prediction"
result = apply_halation(np.array(img), radius, intensity, warmth)
feedback = f"""Mode: {mode}
HAL-Net raw output
Radius: {p[0]:.3f} ({p[0]*32:.1f}px spread @ 256px)
Intensity: {p[1]:.3f}
Warmth: {p[2]:.3f}
Applied parameters
Radius: {radius:.3f} ({radius*32:.1f}px spread @ 256px)
Intensity: {intensity:.3f}
Warmth: {warmth:.3f}
Warmth guide
0.0 - 0.3 neutral / white glow
0.3 - 0.6 warm golden
0.6 - 1.0 CineStill red-orange"""
return Image.fromarray(result), feedback
# ── UI ────────────────────────────────────────────────────────────────────────
with gr.Blocks(theme=gr.themes.Monochrome(), title="HAL-Net") as demo:
gr.Markdown("""
# HAL-Net
### Analog Film Halation Estimation and Synthesis
A CNN trained on 100 authentic CineStill 800T film scans.
Upload any image β€” HAL-Net estimates halation parameters from highlight regions
and synthesizes a warm analog film glow.
Inspired by [FGA-NN](https://arxiv.org/abs/2506.14350) (Ameur et al., 2025).
""")
with gr.Row():
with gr.Column():
image_input = gr.Image(label="Input Image", type="numpy")
gr.Markdown("### Manual Override")
gr.Markdown("Adjust sliders to override HAL-Net predictions.")
use_manual = gr.Checkbox(label="Use manual parameters", value=False)
manual_radius = gr.Slider(0.05, 1.0, value=0.3, step=0.01, label="Radius (0=tight, 1=wide spread)")
manual_intensity = gr.Slider(0.0, 1.0, value=0.4, step=0.01, label="Intensity (glow strength)")
manual_warmth = gr.Slider(0.0, 1.0, value=0.7, step=0.01, label="Warmth (0=white, 1=CineStill red)")
run_btn = gr.Button("Apply HAL-Net Halation", variant="primary")
with gr.Column():
image_output = gr.Image(label="Output", type="pil")
params_output = gr.Textbox(label="Parameter Readout", lines=14, interactive=False)
gr.Markdown("""
---
**What is halation?**
Halation is the red-orange glow around bright light sources in analog film photography.
It occurs when light passes through the emulsion, reflects off the film base, and exposes
the silver halide crystals from behind. CineStill 800T is particularly known for this
effect because its anti-halation layer was removed during processing for cinema use.
**Evaluation (15-image test split)**
| Method | Radius MAE | Intensity MAE | Warmth MAE |
|---|---|---|---|
| Mean-pred baseline | 0.012 | 0.317 | 0.328 |
| HAL-Net | 0.015 | 0.247 | 0.218 |
HAL-Net beats the baseline on the two harder parameters (intensity and warmth).
Radius is already near-zero for both methods because the pseudo-label distribution
is narrow β€” nearly every image maps to a spread around 0.09.
**Dataset**
100 handpicked CineStill 800T scans from Flickr and Lomography exhibiting authentic halation.
Train / val / test split: 70 / 15 / 15.
""")
run_btn.click(
fn=predict,
inputs=[image_input, manual_radius, manual_intensity, manual_warmth, use_manual],
outputs=[image_output, params_output]
)
demo.launch()