A faithful Python reimplementation of the FFCC algorithm (Barron & Tsai, CVPR 2017) for automatic white balance and illuminant estimation.
This implementation reproduces the results from the original Google FFCC MATLAB codebase, validated on the Gehler/Shi (Reprocessed) benchmark dataset.
- Pure Python/NumPy — no MATLAB required
- Reproduces the MATLAB reference on Gehler/Shi to within 0.03 deg mean angular error
- Ships the dataset, the tuned hyperparameters, and a pre-trained model, so the reported numbers come out of a single command
- Preconditioned L-BFGS training with cross-entropy → Von Mises loss annealing
- MATLAB-compatible featurization for exact reproduction
pip install -e .Or install dependencies directly:
pip install numpy scipy # core
pip install opencv-python # only needed by the scripts, for image loadingimport numpy as np
from ffcc import FFCCModel
# models/gehler_model.npz ships with the repo (trained on all 568 Gehler/Shi images)
model = FFCCModel()
model.load("models/gehler_model.npz")
# image: (H, W, 3) float64 linear RGB in [0, 1]
rgb_illuminant = model.predict(image)
# Apply white balance
white_balanced = image / rgb_illuminant[np.newaxis, np.newaxis, :]Or from the command line:
python scripts/demo.py --image data/GehlerShiThumb/000001.pngfrom ffcc import featurize_image, gt_rgb_to_uv, train_ffcc
# Prepare training data: list of (histogram_feature, gt_uv) tuples
train_data = []
for img, gt_rgb in your_dataset:
X = featurize_image(img) # (64, 64, 2) histogram
train_data.append((X, gt_rgb_to_uv(gt_rgb)))
# Train. The default hyperparameters are the ones tuned for Gehler/Shi;
# retune them for a different sensor (see "Hyperparameters" below).
model = train_ffcc(train_data)
model.save("my_model.npz")FFCC estimates a scene's global illuminant by:
-
Log-chroma histograms: Convert the image to UV space (
u = log(G/R),v = log(G/B)) and compute 2D histograms (64x64 bins) for two channels:- Channel 0: Original pixel chromaticities
- Channel 1: Edge chromaticities (Masked Local Absolute Deviation)
-
FFT convolution: Apply learned frequency-domain filters to the histograms, producing a log-probability map over possible illuminants.
-
Von Mises fitting: Fit a bivariate Von Mises distribution (circular Gaussian on the torus) to the posterior, extracting the mean as the illuminant estimate.
-
Training: L-BFGS in a preconditioned Fourier latent space, with an annealed loss:
- Stage 1: Cross-entropy (convex warm-up), 16 L-BFGS iterations
- Stage 2: Von Mises negative log-likelihood (non-convex, more accurate), 64 iterations
Image → UV Histograms → FFT Conv → Softmax → Von Mises → UV → RGB gains
(64×64×2) (learned) (P map) (fit) (μ)
The preconditioning is not cosmetic: it whitens the Fourier-domain total-variation prior so L-BFGS sees a well-conditioned problem. Optimizing the raw FFT coefficients instead converges to a far worse model.
| Metric | MATLAB (reference) | This repo | Delta |
|---|---|---|---|
| Mean | 1.979 | 2.005 | +0.026 |
| Median | 1.050 | 1.139 | +0.089 |
| Trimean | 1.312 | 1.344 | +0.032 |
| Best 25% | 0.300 | 0.338 | +0.038 |
| Worst 25% | 5.106 | 5.124 | +0.018 |
568 images, 3 folds, 2 annealing stages. Angular error in degrees (lower is better). The MATLAB column is the rgb_err block reported in projects/GehlerShiThumb/GehlerShiThumbHyperparams.m of the reference codebase.
The Gehler/Shi thumbnails (568 images, 1.4 MB) are included in data/GehlerShiThumb/, and the tuned hyperparameters are built into the package. Just run:
python scripts/benchmark_gehler.py --data-dir data/GehlerShiThumb
# Results saved to results/gehler_results.jsonAbout a minute of training on a laptop (~20 s per fold). Featurizing the 568 thumbnails takes under a second and is cached under cache/ afterwards.
Training is sensitive to the regularizer scale across several orders of magnitude, so the tuned values ship explicitly as ffcc.GEHLER_HYPERPARAMS and are used by default:
| Hyperparameter | Value |
|---|---|
CROSSENT_MULTIPLIER |
2-8.25 |
VONMISES_MULTIPLIER |
21.75 |
FILTER_MULTIPLIERS |
[2-26.25, 2-23.5] |
FILTER_SHIFTS |
[2-60, 2-71.75] |
BIAS_MULTIPLIER |
2-23 |
BIAS_SHIFT |
2-93 |
VON_MISES_DIAGONAL_EPS |
1 |
These are the values tuned for Gehler/Shi in the MATLAB reference (GehlerShiThumbHyperparams.m). They are dataset- and sensor-specific — expect to retune them for your own camera. To benchmark against a different MATLAB project, point the script at that project's file:
python scripts/benchmark_gehler.py --data-dir <dir> --hyperparams-m <path/to/*Hyperparams.m>models/gehler_model.npz is trained on all 568 images with the same recipe:
python scripts/train_full_model.py --data-dir data/GehlerShiThumbIt is there for the demo and for off-the-shelf use. It has seen the entire dataset (training-set error 1.267 deg mean), so it must not be used to report accuracy on Gehler/Shi — use the cross-validation script for that.
ffcc-python/
├── ffcc/ # Core package
│ ├── __init__.py # Public API
│ ├── core.py # Featurization, forward pass, Von Mises fit, metrics
│ ├── train.py # Preconditioned L-BFGS training + tuned hyperparameters
│ └── matlab_port.py # MATLAB-compatible featurization
├── scripts/
│ ├── benchmark_gehler.py # GehlerShi 3-fold CV reproduction
│ ├── train_full_model.py # Train the shipped pre-trained model
│ ├── demo.py # Single-image prediction demo
│ └── download_gehler.py # Dataset download helper
├── tests/
│ ├── test_core.py # Featurization / forward pass / metrics tests
│ └── test_train.py # Training + hyperparameter regression tests
├── data/GehlerShiThumb/ # Gehler/Shi dataset (568 images, included)
├── models/gehler_model.npz # Pre-trained model (full dataset)
├── pyproject.toml # Package metadata
├── requirements.txt # Runtime dependencies
└── README.md
| Function | Description |
|---|---|
featurize_image(image, mask=None) |
Extract 2-channel UV histogram (64×64×2) |
ffcc_forward(X, F_fft, B) |
Forward pass: histogram → (illuminant UV, posterior) |
uv_to_rgb_gains(mu_uv) |
Convert UV illuminant to unit-norm RGB |
gt_rgb_to_uv(gt_rgb) |
Convert GT RGB illuminant to UV space |
angular_error(pred, gt) |
Angular error between two RGB vectors (degrees) |
train_ffcc(data, ...) |
Train an FFCC model (preconditioned L-BFGS) |
compute_error_metrics(errors) |
Compute mean/median/trimean/best25/worst25 |
Two featurizers exist. ffcc.featurize_image is the readable float implementation; ffcc.matlab_port.featurize_image preserves the MATLAB integer arithmetic path and is what the benchmark and models/gehler_model.npz use. They are not bit-identical. Swapping them at test time costs little when training used the same one (1.818 vs 1.820 deg on fold 1), but mixing them across training and inference does cost something: the shipped model scores 1.267 deg over the 568 images with MATLAB features and 1.354 deg with the float ones. Use one featurizer consistently; if you train through ffcc.featurize_image, predict through it too (which is what FFCCModel.predict does).
model = FFCCModel(n_channels=2, init_mode='zeros')
model.predict(image) # End-to-end: image → RGB illuminant
model.forward(X) # Histogram → (mu_uv, P)
model.save("model.npz") # Save weights
model.load("model.npz") # Load weightstrain_ffcc(train_data, val_data=None, hyperparams=None,
n_anneal_stages=2, verbose=True) -> FFCCModeln_anneal_stages is the number of loss-annealing stages, not an epoch count: each stage is one L-BFGS run whose iteration budget grows log-linearly from 16 to 64. Two stages reproduce the reported results; more stages cost proportionally more time for a marginal change. val_data does not influence training — there is no early stopping; the per-stage validation error is recorded on model.val_history.
- Required: NumPy ≥ 1.21, SciPy ≥ 1.7
- Scripts: OpenCV-Python ≥ 4.5 (for image loading)
- Testing: pytest ≥ 7.0
- Fixed: the benchmark script's built-in hyperparameters did not match the tuned values that produced the reported results, so running the documented command gave 3.21 deg mean instead of 2.005. The tuned values now ship in the package (
ffcc.GEHLER_HYPERPARAMS) and are the default. - Fixed:
ffcc.train_ffccwas an unconditioned implementation that did not converge usefully (~8 deg mean on fold 1, worse than gray-world). It is replaced by the preconditioned trainer that produces the reported results (1.82 deg on the same fold), which previously lived only inside the benchmark script. - Added:
models/gehler_model.npzandscripts/train_full_model.py— the quick-start example referenced a model file that was not in the repository. - Changed:
--epochs/n_epochsrenamed to--anneal-stages/n_anneal_stages, which is what the parameter actually controls. The old name was misleading:--epochs 20and--epochs 29gave bit-identical results. - Added:
tests/test_train.py, including a regression test that pins the tuned hyperparameter values — the defect above would have been caught by it.
Shuwei Yue / 岳书威 — shuwei_yue@szpu.edu.cn
WeChat Official Account: ColorWorld花花世界
If you use this code, please cite the original FFCC paper:
@inproceedings{barron2017fast,
title={Fast Fourier Color Constancy},
author={Barron, Jonathan T and Tsai, Yun-Ta},
booktitle={IEEE Conference on Computer Vision and Pattern Recognition (CVPR)},
year={2017}
}If you find this Python reimplementation useful, a star or acknowledgment is appreciated:
@misc{yue2025ffccpython,
title={FFCC-Python: A faithful Python reimplementation of Fast Fourier Color Constancy},
author={Yue, Shuwei},
year={2025},
url={https://github.com/shuwei666/ffcc-python}
}Apache License 2.0. See LICENSE for details.
- Google FFCC MATLAB codebase — the reference implementation
- Gehler & Rosenhahn (2008), Shi & Funt (2010) — benchmark dataset