ACM Transactions on Graphics 45(6) — Proc. SIGGRAPH Asia 2026
Seokjun Choi*, Yunseong Moon*, Kaizhang Kang, Hoon-Gyu Chung, Jin-Nyeong Kim, Giljoo Nam, Seung-Hwan Baek
*Equal contribution
An LCD displays a linearly polarized RGB binary pattern; an RGB polarization camera with a quarter-wave plate in front of the lens records the scene in a single exposure. That one capture is decomposed into nine measurements — three spectral channels × three polarization states (unpolarized, linearly polarized, circularly polarized) — and a feed-forward transformer turns them into per-pixel normal, albedo, roughness and metallicity.
This repository holds the training and inference code. The project page lives on
the project_page branch.
git clone https://github.com/MichaelCSJ/PDIR.git
cd PDIR
conda create -n pdir python=3.10 -y && conda activate pdir
pip install -r requirements.txtThe code was developed against PyTorch 2.4 / CUDA 12.4 on NVIDIA A6000 GPUs.
Pretrained weights and five sample captures live on Hugging Face, because the checkpoint is 329 MB:
python download_assets.pyThat fills two gitignored directories:
checkpoints/pdir_best.ckpt
sample_data/{cat,bowl,case,foil,owl}/
mask.png
quad{0,1,2,3}_main_hdr_{s0,s1,s2}.npy
The sample directories hold exactly what the network reads: the four per-quadrant Stokes captures and the object mask. Then:
python inference.py --ckpt checkpoints/pdir_best.ckpt \
--dataset real --data-root sample_data --out-dir results \
--exposure-norm p95_0.95
python render_relight.py --maps-dir results --out-dir relight--exposure-norm p95_0.95 matters: it is what pdir_best was trained with, and
inference has to match (see Inference).
Two loaders are provided. Both hand the network raw per-quadrant Stokes components; the pattern weighting and the unpolarized/LP/CP decomposition happen inside the model, so the same checkpoint reads synthetic and real captures.
Synthetic (--dataset synth), one directory per scene:
<synth-root>/
0000/
obs_original.npz diffuse_{1_0,0_0,1_1,0_1}, specular_{...}, cop_{...} each [H, W, 3]
gt_maps.npz basecolor [H,W,3], roughness [H,W,1], metallic [H,W,1],
normal [H,W,3], mask [H,W]
0001/
...
Real captures (--dataset real), one directory per scene. Any directory
holding these files counts as a scene, so readable names work as well as the
capture ids:
<real-root>/
cat/
quad{0,1,2,3}_main_hdr_s0.npy [H, W, 3] total intensity, >= 0
quad{0,1,2,3}_main_hdr_s1.npy [H, W, 3] linear polarization, signed
quad{0,1,2,3}_main_hdr_s2.npy [H, W, 3] circular polarization, signed
mask.png uint8, 0/255
bowl/
...
Without split files, every --val-stride-th scene goes to validation. Pass
--synth-split-train / --synth-split-val (and the --real- equivalents) to
control the split explicitly; each line is one scene directory name.
Supervised training on synthetic scenes:
python train.py --dataset synth \
--synth-root /path/to/synth_scenes \
--session pdir_synth --devices 4Joint synthetic + real co-training, which is what the paper uses. The real branch contributes a render loss only — it needs no ground-truth maps:
python train.py --dataset joint \
--synth-root /path/to/synth_scenes \
--real-root /path/to/real_captures \
--session pdir_joint --devices 4Training writes to outputs/<timestamp>_<session>/: checkpoints/ (top-3 by
validation loss, plus last.ckpt) and logs/ (CSV scalars).
Things worth knowing before a long run:
--image-sizemust be exactly twice--canonical-resolution. Defaults are 384 / 192.- Unless you pass
--no-pretrained, training initialises from the LINO-PBR weights, downloaded once intocheckpoints/. Use--init-ckptto start from your own checkpoint instead. --pattern-color-strengthdefaults to 1.0, which is what the releasedpdir_bestwas trained with. 0.80 is the value calibrated against the real captures; whichever you train with, pass the same one at inference time.- The real loss ramps in as a staircase: zero for the first
--real-warmup-stair-stepssteps, then one step up per block until it reaches full weight after--real-warmup-stairsblocks. While the weight is zero the real forward pass is skipped entirely, which keeps early training stable. - Joint mode runs under manual optimization, so
--accumulate-grad-batchesmust stay at 1. - Multi-GPU uses DDP with bf16 mixed precision.
python train.py --help lists everything else.
python inference.py --ckpt outputs/<run>/checkpoints/last.ckpt \
--dataset real --data-root /path/to/real_captures \
--out-dir resultsFor every scene this writes <out-dir>/<scene>/:
| File | Contents |
|---|---|
normal.png, albedo.png, roughness.png, metallic.png |
8-bit previews |
maps.npz |
float32 arrays: normal (HW3, in [-1,1]), albedo (HW3), roughness (HW1), metallic (HW1), mask (HW) |
Use --dataset synth to run the same checkpoint over synthetic scenes. Images
are processed in one tile by default; --patch-size splits them instead, and
must divide --image-size.
Two flags must match the values the checkpoint was trained with, or the encoder sees a different input distribution than it learned on:
| Flag | Meaning |
|---|---|
--exposure-norm |
per-scene HDR exposure normalisation for real captures |
--pattern-color-strength |
RGB cross-talk calibration; 1.0 disables it |
The estimated maps are directly renderable. render_relight.py orbits a point
light around a scene and renders one frame per step through the same principled
BRDF the training render loss uses:
python render_relight.py --maps-dir results --out-dir relightThat writes relight/<scene>/frame####.png and relight/<scene>.mp4 for every
scene under --maps-dir. Useful options:
| Flag | Default | Meaning |
|---|---|---|
--scenes |
all | render only the named scenes |
--n-frames / --fps |
60 / 20 | orbit steps and video frame rate |
--radius / --center |
0.4 / 0 -0.29 0 |
orbit geometry, in the display calibration's units |
--brightness |
50 | exposure applied to the HDR render before clipping |
--no-video |
off | write frames only |
--brightness is a single global exposure, so scenes with dark albedo or high
metallicity come out dimmer than others; raise it per scene if a render looks
too dark. H.264 video needs imageio-ffmpeg (see requirements.txt); without
it the script falls back to OpenCV's MPEG-4 encoder, which most browsers cannot
play.
download_assets.py fetches the weights and sample captures
upload_assets.py publishes them (maintainers only)
train.py training entrypoint
inference.py inference entrypoint
render_relight.py rotating-light relighting of the estimated maps
calibration/ calibrated LCD emitter positions used by the renderer
src/io/ synthetic and real dataset loaders
src/lightning/module.py LightningModule: losses, joint co-training, optimizer
src/model/net_train_module.py the network: pattern simulation, encoder, heads, losses
src/model/module/utils.py image encoder, GLC aggregation, regressor
src/model/aggregator/ DINOv2 backbone and the alternating-attention aggregator
src/model/renderer/ principled BRDF used by the render loss
@article{choi2026snapshot,
title = {Snapshot Polarimetric Display Inverse Rendering},
author = {Choi, Seokjun and Moon, Yunseong and Kang, Kaizhang and Chung, Hoon-Gyu
and Kim, Jin-Nyeong and Nam, Giljoo and Baek, Seung-Hwan},
journal = {ACM Transactions on Graphics},
volume = {45},
number = {6},
articleno = {201},
year = {2026},
doi = {10.1145/3842531}
}The transformer backbone builds on DINOv2 and LINO-UniPS.