Physics-based color mixing for 3D printer filaments using Kubelka-Munk theory.
Replaces naive RGB interpolation β which produces muddy, desaturated blends β with the same spectral approach used for real paint/pigment mixing. Based on the Mixbox paper by SochorovΓ‘ & JamriΕ‘ka (2021).
The result: mixing blue + yellow gives you green, not gray.
Production Ready. This project implements the Kubelka-Munk mixing pipeline with two production-ready learned models:
- PolyMixer (Polynomial Neural Network): Mean Delta-E 2.07 at 0.001ms per mix β fastest option
- GPMixer (Gaussian Process Regression): Mean Delta-E 1.79 at 0.018ms per mix β most accurate non-LUT
Both models vastly outperform the baseline K-M physics engine (dE 11.77, ~4.8ms) while maintaining physically plausible color behavior (Blue+Yellow=Green). The physics engine remains available for research and custom spectral tuning.
Multi-material 3D printers (e.g. 4-in-1-out hotends with CMYW filaments) mix colors physically β just like paint. But slicers still use RGB linear interpolation to compute mixing ratios, which doesn't model how pigments actually absorb and scatter light.
FilamentMixer fixes this by:
- Decomposing colors into spectral pigment concentrations (K-M theory)
- Mixing in spectral space where the physics actually works
- Converting back to RGB for display / G-code generation
pip install filament-mixerOr from source:
git clone https://github.com/justinh-rahb/filament-mixer.git
cd filament-mixer
pip install -e ".[dev]"from filament_mixer import PolyMixer, GPMixer, FilamentMixer, CMYW_PALETTE
# Option 1: Polynomial Mixer (Fastest - 0.001ms per mix, dE 2.07)
poly = PolyMixer.from_cache("models")
green = poly.lerp(0, 33, 133, 252, 211, 0, 0.5)
print(f"Poly Result: RGB{green}")
# Option 2: Gaussian Process Mixer (Most Accurate - 0.018ms per mix, dE 1.79)
gp = GPMixer.from_cache("models")
green = gp.lerp(0, 33, 133, 252, 211, 0, 0.5)
print(f"GP Result: RGB{green}") # Vibrant green: (47, 139, 49)
# Option 3: Physics-based Mixer (Great for custom pigments)
mixer = FilamentMixer(CMYW_PALETTE)
green_fm = mixer.lerp(0, 33, 133, 252, 211, 0, 0.5)
# Get filament percentages (requires physics mixer)
ratios = mixer.get_filament_ratios(255, 128, 0) # Orange
for name, r in zip(["Cyan", "Magenta", "Yellow", "White"], ratios):
print(f" {name}: {r * 100:.1f}%")Recommended: Use GPMixer for best accuracy and speed:
from filament_mixer import GPMixer
mixer = GPMixer() # Loads pre-trained model
# Drop-in replacement for: result = (1-t)*color1 + t*color2
result = mixer.lerp(*color1, *color2, t)PolyMixer or GPMixer for best accuracy and speed:
from filament_mixer import PolyMixer # or GPMixer
mixer = PolyMixer.from_cache("models") # Fastest (0.001ms)
# OR
# mixer = GPMixer.from_cache("models") # Most accurate (0.018ms)
mixer = FilamentMixer(CMYW_PALETTE)
ratios = mixer.get_filament_ratios(128, 200, 80)
# Generate M163/M164 G-code for multi-extruder setups
for i, ratio in enumerate(ratios):
print(f"M163 S{i} P{ratio:.6f}")
print("M164 S0")| Approach | Speed | Accuracy (dE vs Mixbox) | Use Case |
|---|---|---|---|
| Naive RGB Lerp | Instant | ~35.0 (Varies) | Legacy slicers |
| PolyMixer (This) | 0.001ms | 2.07 π | Production / Fastest (C++ port available) |
| GPMixer (This) | 0.018ms | 1.79 π | Production / Most Accurate |
| FastLUT 256Β³ (This) | 0.02ms | 11.77 | Pre-cached mixing |
| K-M Physics (This) | ~4.8ms | 11.77 | Research / Spectral tuning |
| Mixbox (Reference) | 0.01ms | 0.00 | Digital painting (Commercial license) |
PolyMixer uses polynomial regression trained on Mixbox samples for ultra-fast mixing.
GPMixer uses Gaussian Process regression for near-perfect accuracy at production speeds.
Main class for physics-based mixing. Initialize with a list of 4 Pigment objects (or use a built-in palette).
| Method | Description |
|---|---|
lerp(r1, g1, b1, r2, g2, b2, t) |
Blend two colors with pigment-based mixing |
mix_n_colors(colors, weights) |
Mix N colors with arbitrary weights |
get_filament_ratios(r, g, b) |
Get filament percentages for a target RGB color |
rgb_to_latent(r, g, b) |
Encode RGB to 7-D latent space |
latent_to_rgb(latent) |
Decode latent space back to RGB |
Recommended for high-performance pairwise mixing. Loads a pre-trained polynomial regression.
| Method | Description |
|---|---|
from_cache(path) |
Load the model from a directory (default: models) |
lerp(r1, g1, b1, r2, g2, b2, t) |
Blend two colors using polynomial regression |
Pigment(name, K, S)β Spectral absorption (K) and scattering (S) coefficientsKubelkaMunk(k1, k2)β Low-level spectral mixing engineRGBUnmixer(pigments)β Inverse solver (RGB β concentrations)
Recommended for production. Gaussian Process-based mixer trained on Mixbox ground truth.
| Method | Description |
|---|---|
lerp(r1, g1, b1, r2, g2, b2, t) |
Mix two colors (fastest, most accurate) |
from_cache(cache_dir) |
Load model from directory (default: "models") |
Training your own model:
python scripts/train_gp_model.py # Trains on 2,000 Mixbox samples (~7s)# Visual comparison of RGB vs pigment mixing
python examples/visual_demo.py
# G-code generation demo
python examples/slicer_demo.pyA header-only C++ port of PolyMixer is available in cpp/. Drop-in replacement for mixbox_lerp β no dependencies, no model files, just #include and go.
#include "filament_mixer.h"
auto rgb = filament_mixer::lerp(0, 33, 133, 252, 211, 0, 0.5f);
// rgb = {47, 141, 56} (green, not gray)See cpp/README.md for full API docs, build instructions, and accuracy benchmarks.
src/filament_mixer/
βββ __init__.py # Public API exports
βββ api.py # FilamentMixer class (physics-based)
βββ km_core.py # Kubelka-Munk physics engine
βββ pigments.py # Filament spectral definitions & palettes
βββ unmixer.py # RGB β pigment concentration solver
βββ lut.py # Lookup table generator for fast caching
βββ poly_mixer.py # PolyMixer (polynomial regression)
βββ gp_mixer.py # GPMixer (Gaussian Process regression)
cpp/
βββ filament_mixer.h # Header-only C++ port of PolyMixer (coefficients inlined)
βββ example.cpp # Test/verification program
βββ CMakeLists.txt # Build configuration
scripts/
βββ train_poly_model.py # Train PolyMixer on Mixbox ground truth
βββ train_gp_model.py # Train GPMixer on Mixbox ground truth
βββ export_poly_coefficients.py # Export model to C++ header
models/
βββ poly_model.pkl # Pre-trained PolyMixer model (11KB)
βββ gp_model.pkl # Pre-trained GPMixer model (31MB)
docs/
βββ BREADCRUMB.md # Development history and decisions
βββ CLAUDE.md # AI assistant guidelines
βββ EXPERIMENTS.md # Experiment notes and results
βββ LUT_GENERATION.md # LUT generation documentation
benchmarks/
βββ compare.py # Text benchmark comparing all mixers
βββ visual_compare.py # Generate comparison images
A Gaussian Process Regressor trained on 2,000 Mixbox samples learns the direct (RGBβ, RGBβ, t) β RGB_mix mapping. This bypasses the complex physics and inverse problems entirely, achieving dE 1.79 accuracy at 0.018ms per mix.
Why it works: Mixbox is the ground truth for pigment mixing. By training directly on its outputs, GPMixer learns the perceptually-optimal color blending behavior without needing to understand the underlying spectral physics.
-
Spectral Mixing (K-M Theory): Each filament is defined by its absorption (K) and scattering (S) spectra across 38 wavelengths (380β750nm). Colors are mixed by linearly combining K and S values β this is physically correct for pigment mixtures.
-
Reflectance β Color: The mixed K/S spectra are converted to a reflectance curve, then integrated against the CIE 1931 observer functions and D65 illuminant to produce XYZ tristimulus values, which are finally transformed to sRGB.
-
Unmixing (Inverse Problem): Given a target RGB color, constrained optimization (SLSQP) finds the pigment concentrations that best reproduce it β this is how
get_filament_ratios()works. -
Latent Space: Colors are represented as 7-D vectors
[c1, c2, c3, c4, rR, rG, rB]β four pigment concentrations plus an RGB residual for out-of-gamut correction. Interpolation in this space produces natural-looking blends.
Plain Kubelka-Munk with hand-crafted spectra still has known issues (the "grey problem"). Our Gaussian-peak spectra are better than binary step functions (which is why open-km gets grey and we don't), but worse than real measured/optimized pigment data. Specifically:
- Approximate spectra. Our pigment K/S curves are Gaussian approximations, not measured from real filaments. Certain color combinations will be less accurate than others.
- Runtime optimization.
unmix()solves a constrained optimization (SLSQP) on every call β slow compared to Mixbox's LUT lookup. Thelut.pymodule can precompute tables, but they're based on the same approximate spectra. - No end-to-end training. Mixbox trains its encoder/decoder and LUT jointly to minimize perceptual error across the full gamut. We don't do that.
- Limited gamut. Colors far outside the palette's reproducible gamut rely on the RGB residual, which is a linear correction β it works but it's not perceptually optimal.
Mixbox is CC BY-NC 4.0 (non-commercial only, commercial license required). This project is MIT-licensed and purpose-built for 3D printer filament mixing, where the palette (CMYW/CMYK) and mixing physics (plastic filament, not paint) are different from Mixbox's art-pigment focus. FilamentMixer is an independent approximation of Mixbox behavior and does not include Mixbox source code, binaries, or data files.
For higher-quality results, the path forward would be:
- Measure real K/S curves from physical filament samples
- Train the encoder/decoder against measured data
- Precompute a LUT from the trained model
MIT