NPPE-3: Low-Light Denoising + 4x Super Resolution
A NAFNet-style restoration network that takes a low-resolution, noisy, low-light image and produces a 4x larger, denoised image in a single forward pass.
Trained from scratch for the DLP 26T2 NPPE-3 competition, where submissions are scored by PSNR on grayscale pixels of the restored images.
Task
| Input | Output | |
|---|---|---|
| Resolution | 160 x 256 | 640 x 1024 |
| Condition | noisy, low light | denoised, 4x upscaled |
Denoising and upscaling are handled jointly by one network rather than as two stages.
Architecture
- 16 NAF blocks at width 64, operating at the low resolution
- each block: LayerNorm β 1x1 conv β 3x3 depthwise conv β SimpleGate β simplified channel attention β 1x1 conv, followed by a small gated feed-forward part
- two PixelShuffle stages (2x each) for the 4x upsampling
- a bilinear upscale of the input is added to the output, so the network only learns the correction rather than the whole image
- 0.83 M parameters, no GAN and no perceptual loss, since both trade PSNR for sharpness
Data
1105 paired training images, 267 held out for validation, 60 test images. Inputs average a brightness of 43/255, and their targets 43.5/255, so the task is dominated by denoising and detail recovery rather than global brightening.
Training
Single Tesla T4, mixed precision, roughly 3.6 hours total.
Stage 1 β restoration (30,000 iterations, 155 min)
- Charbonnier (smooth L1) loss on RGB
- random 64x64 input crops (256x256 targets), random flips and 90Β° rotations
- AdamW, lr 2e-4 with a cosine schedule to 1e-7, batch 16
- EMA of the weights (decay 0.999); validation is scored with the averaged weights
Stage 2 β metric-aligned fine-tuning (6,000 iterations, 62 min)
- loss switched to grayscale MSE + 0.1 Γ Charbonnier, because the competition scores PSNR on grayscale rather than RGB
- larger 128x128 crops for more context per step
- AdamW, lr 5e-5 with a cosine schedule, batch 8
- the checkpoint is only overwritten when validation PSNR actually improves
Results
Validation grayscale PSNR, measured on full images the way the competition scores them.
| Checkpoint | Val gray PSNR (20 images) |
|---|---|
| After stage 1 | 39.147 dB |
| After stage 2 | 39.213 dB |
Final model on a larger 30-image validation subset:
| Inference | Val gray PSNR |
|---|---|
| Single pass | 38.991 dB |
| 8x self-ensemble (flips + rotations) | 39.038 dB |
The self-ensemble was the better of the two on identical images, so it was used for the test predictions. Both fine-tuning and the self-ensemble are small gains (+0.07 dB and +0.05 dB respectively) β the model had largely converged after stage 1.
Inference runs as a single pass over the whole 160x256 input, with no tiling. The image is small enough to fit in one forward pass, which also avoids blending seams between tiles.
Files
| File | Description |
|---|---|
best_model.pth |
trained weights (state dict, 3.4 MB) |
model.py |
model definition and a load_model helper |
Usage
import numpy as np
import torch
from PIL import Image
from model import load_model
model = load_model("best_model.pth", device="cpu")
img = np.array(Image.open("low_light_input.png").convert("RGB"))
x = torch.from_numpy(img).permute(2, 0, 1).float().div(255).unsqueeze(0)
with torch.no_grad():
out = model(x).clamp(0, 1)
out = (out[0].permute(1, 2, 0).numpy() * 255).round().astype(np.uint8)
Image.fromarray(out).save("restored.png")
Weights can also be pulled directly:
from huggingface_hub import hf_hub_download
path = hf_hub_download("vikas-06978/nppe3-lowlight-sr", "best_model.pth")
Limitations
- trained only on this competition's data, so it expects the same degradation: low light plus sensor-like noise at a 4x downscale
- fixed 4x factor β the PixelShuffle head cannot produce other scales
- optimised purely for PSNR, so outputs are faithful but smoother than a GAN-based model would produce; fine textures are reconstructed conservatively
License
Apache 2.0.