Color Grade AI
Generate targeted .cube 3D LUTs for color correction.
Point it at a frame or a clip and it measures what is wrong, fits the correction
by applying candidates and re-measuring, and hands you a .cube. It states
whether the footage is log or display-referred before it does anything, because
every target here assumes display-referred Rec.709. Interactive in-browser
preview generates LUTs live and exports the chain you are looking at.
Quick Start
# Generate a correction LUT (presets defined in presets.yml)
ruby generate_lut.rb <type> <output_path> [--strength=0.0-1.0]
# Bake multiple presets into a single .cube LUT
ruby generate_chain_lut.rb <output_path> <preset@strength> ...
# Is this log or display-referred Rec.709?
python3 footage_type.py <frame.png>
# Analyse a frame, and optionally fit and emit the correction
python3 auto_grade.py <frame.png> [--emit fix.cube] [--transfer log|display]
# Fit a correction without the analysis report
python3 solve_grade.py <frame.png> --emit fix.cube
# Fit one correction for a whole clip rather than one frame
python3 sample_clip.py <clip.mov> --emit fix.cube [--frames 12]
# Match one shot to another and emit the LUT
python3 match_grade.py <reference.png> <output.png> --emit match.cube
# Colour statistics for one region (skin, wall, costume...)
ruby analyze_frame.rb <image_path> <x1,y1,x2,y2> [label]
python3 analyze_region.py <image_path> <x1,y1,x2,y2> [label] # JSON only
# Serve the interactive preview
python3 -m http.server 8080
# Open http://localhost:8080/preview.html
Auto-Grade Analysis
auto_grade.py analyzes a Rec.709 frame (post-conversion LUT) and recommends correction values for each node using classical color science:
| Node | Method | Target |
|---|---|---|
| Contrast/Exposure | Luminance histogram percentiles | Median ~0.45, dynamic range ~0.85 |
| White Balance | Shades of Gray (Minkowski p=6) + White Patch blend | RGB gains near 1.0 |
| Skin Tone | YCbCr skin-locus detection, I-line targeting | Hue ~20° (peach) |
| Saturation | Hasler-Susstrunk colorfulness metric | no target — acts only outside 25-95 |
| Black Level | Bottom 5% luminance analysis + noise floor | Black point < 0.02 |
# Full workflow
ffmpeg -y -ss 19 -i video.mp4 -vframes 1 -vf "scale=1280:-1" -update 1 frame_raw.png
ffmpeg -y -i frame_raw.png -vf "lut3d='conversion.cube':interp=tetrahedral" -update 1 frame_709.png
python3 auto_grade.py frame_709.png
Match Grade
match_grade.py fits a correction that moves one frame toward another and emits
the .cube. Same closed-loop method as solve_grade.py — apply, re-measure,
keep what closes the gap — but the target is a reference image rather than a set
of fixed ideals.
python3 match_grade.py <reference.png> <output.png> --emit match.cube
It matches channel balance, exposure, black level and saturation, and refuses to tone-match across transfer curves: matching log against Rec.709 is a missing conversion LUT, not a grading problem.
Interactive Preview (preview.html)
Browser-based node chain previewer using Preact + HTM (~4KB). Correction LUTs
are generated live from presets.json at the exact strength requested, not
fetched pre-baked — so the slider interpolates parameters, the same operation
the CLI performs, and what you see is what generate_lut.rb emits.
Features:
- Drag-and-drop any frame (raw S-Log3 or converted)
- Node 1: Conversion LUT — add your own .cube files (not included in repo)
- Nodes 2-6: Correction chain — dropdown presets + strength sliders
- Export .cube — bakes the whole visible chain to a file you can drop into Resolve, so the preview is not a separate thing you have to reproduce
- Creative presets — one-click curated looks (Studio Dance, Studio Clean, Studio Ambient, Studio Gold, Studio Film)
- S-Gamut3 → Cine compensation toggle for gamut mismatch
- Bypass/reset all nodes instantly
- URL parameters — deep-link to a specific state:
?image=frame.png&lut=path/to.cube&preset=studio_gold
The preview generates at a 17-grid (~30ms, keeps up with a slider) and exports
at 33. Conversion LUTs are user-provided (gitignored in luts/); the pre-baked
correction LUTs in correction_luts/ remain for download-only users who never
run the tools.
Config-Driven Presets
All LUT types are defined in presets.yml. Each preset is a pipeline of ordered processing steps. Adding a new LUT type means adding a YAML entry — no Ruby code changes needed.
Available step types: exposure, highlight_protect, black_crush, hue_desat, skin_correction, shadow_sat_boost, skin_highlight, skin_rolloff, global_highlight_desat, rgb_rebalance, global_sat.
Available LUT Types
Correction LUTs (apply after conversion)
| Type | What it fixes |
|---|---|
night_warm_fix |
All-in-one for underexposed warm/red scenes. ~1 stop lift + skin hue shift + black crush. |
night_purple_fix |
All-in-one for underexposed purple/magenta stage lighting. RGB rebalancing + lift + desat + skin fix. |
yellow_fix |
Warm amber/yellow cast from stage lighting. H=10-60, 55% desat. |
red_skin_fix |
Red/flushed skin from warm practicals. Hue shift to peach, skin-only targeting. |
pink_cast_fix |
Pink/magenta cast from stage lighting. Gentle RGB rebalance + magenta desat. |
overexposure_fix |
Scene-wide ~1 stop reduction with highlight rolloff. |
underexposure_fix |
Scene-wide ~1.2 stop lift with shadow recovery. |
black_crush |
Crushes milky/lifted blacks below 12% to true black. |
skin_highlight_fix |
Subtle skin-only highlight rolloff above 70% luminance. |
golden_warm |
Strong golden warm shift via RGB rebalance (R:1.20, G:1.00, B:0.80). For rich warm cinematic looks. |
cinema_dark |
Deep moody contrast with gamma 1.45, shadow lift 0.02, and knee compression (0.72-0.90). |
Node Chain Building Blocks
| Type | Node | What it does |
|---|---|---|
studio_punch |
Contrast | Subtle contrast boost for well-lit interiors |
film_contrast |
Contrast | Stronger filmic contrast with deeper shadows |
flat_lift |
Contrast | Lifts shadows for softer, more open look |
warm_shift |
Temperature | Subtle warm shift via RGB rebalance |
cool_shift |
Temperature | Subtle cool shift via RGB rebalance |
led_green_fix |
Temperature | Fixes green tint from LED/fluorescent lights |
sat_boost |
Saturation | Global +15% saturation boost |
golden_warm |
Temperature | Strong golden warm shift (R:1.20, G:1.00, B:0.80) |
sat_reduce |
Saturation | Global -15% saturation reduction |
sgamut3_to_cine |
Gamut | S-Gamut3 → Cine compensation (desat ~12%) |
cinema_dark |
Contrast | Deep moody contrast (gamma 1.45, shadow lift, knee compression) |
black_lift |
Black Level | Lifts blacks for vintage/faded look |
Creative Presets (node chain combinations)
| Preset | Look | Chain |
|---|---|---|
| Studio Clean | Natural bright | studio_punch(50%) + sat_boost(50%) |
| Studio Balanced | Warm natural | studio_punch(80%) + warm_shift(30%) + sat_boost(50%) + black_crush(15%) |
| Studio Dance | Warm cinematic | studio_punch(100%) + warm_shift(40%) + sat_boost(60%) + black_crush(25%) |
| Studio Ambient | No fill light | studio_punch(80%) + warm_shift(100%) + sat_boost(100%) + black_crush(20%) |
| Studio Gold | Warm cinematic | cinema_dark(80%) + golden_warm(75%) + sat_boost(100%) + black_crush(10%) |
| Studio Film | Moody cinematic | film_contrast(60%) + warm_shift(20%) + sat_reduce(30%) + black_crush(40%) |
Baking Chain LUTs
generate_chain_lut.rb bakes multiple presets at different strengths into a single .cube file. This lets you apply a creative preset as one node in Resolve instead of multiple.
# Bake Studio Balanced into a single LUT
ruby generate_chain_lut.rb correction_luts/studio_balanced_baked.cube \
studio_punch@0.8 warm_shift@0.3 sat_boost@0.5 black_crush@0.15
# Bake Studio Dance
ruby generate_chain_lut.rb correction_luts/studio_dance_baked.cube \
studio_punch@1.0 warm_shift@0.4 sat_boost@0.6 black_crush@0.25
Pre-baked LUTs for all creative presets are committed in correction_luts/.
You can also bake a conversion LUT + creative chain into a single .cube for one-node grading in Resolve:
# Bake conversion + creative into a single pass LUT
ruby generate_chain_lut.rb final_grade.cube \
--conversion=luts/SLog3_to_Rec709.cube \
cinema_dark@0.8 golden_warm@1.0 sat_boost@1.0 black_crush@0.1
Video Export Workflow
Apply conversion + correction LUTs and encode to H.265 in one pass using ffmpeg.
# Extract a sample frame for analysis
ffmpeg -y -ss 19 -i input.mp4 -vframes 1 -vf "scale=1280:-1" -update 1 frame_raw.png
# Apply conversion LUT to frame for preview
ffmpeg -y -i frame_raw.png -vf "lut3d='conversion.cube':interp=tetrahedral" -update 1 frame_709.png
# Full encode: conversion + correction → H.265 4K
# 50Mbps masters (for re-editing):
ffmpeg -i input.mp4 \
-vf "lut3d='conversion.cube':interp=tetrahedral,lut3d='correction.cube':interp=tetrahedral,format=nv12" \
-c:v hevc_videotoolbox -b:v 50M -spatial_aq 1 \
-c:a aac -b:a 192k -tag:v hvc1 output_master.mp4
# 15Mbps web-optimized (for Instagram/web):
ffmpeg -i input.mp4 \
-vf "lut3d='conversion.cube':interp=tetrahedral,lut3d='correction.cube':interp=tetrahedral,format=nv12" \
-c:v hevc_videotoolbox -b:v 15M -spatial_aq 1 \
-c:a aac -b:a 128k -tag:v hvc1 output_web.mp4
Notes:
hevc_videotoolboxuses Apple Silicon hardware encoding (M1/M2/M3)format=nv12converts to YUV420p for compatibility-tag:v hvc1ensures QuickTime/browser playback compatibilityspatial_aq 1enables adaptive quantization for better quality
Conversion LUTs (not in repo)
Conversion LUTs (e.g. Sony S-Log3 → Rec.709, ARRI LogC → Rec.709) are camera-specific and often commercially licensed. Store them in luts/ (gitignored).
Phantom LUTs (phantomluts.com) — ARRI Alexa709 emulation for Sony cameras:
- Location:
luts/a7s3-arri-g8/A7s3 Phntm Arri LUTs G8/65x/SLog3/Neutral A7s3_65x.cube - Variants: Neutral, IceBlue, Jamaica, Tungsten, Utopia (Standard + Legacy versions)
- Use 65x for post, 33x for in-camera/monitoring
- Designed for S-Log3 + S-Gamut3.Cine; enable Cine comp toggle for S-Gamut3 footage
Resolve settings: Colour Science = DaVinci YRGB, Timeline = Rec.709-A (Mac), 3D LUT interpolation = Tetrahedral
Node Order (DaVinci Resolve)
Professional serial node chain:
- Conversion LUT (camera log → Rec.709)
- Contrast / Exposure
- White Balance / Color Temperature
- Skin Tone Correction
- Color Saturation
- Black Levels
- (Noise Reduction — not achievable via LUT)
- (Vignette — not achievable via LUT)
- (Sharpening — not achievable via LUT)
Applying LUTs
DaVinci Resolve: Add a serial node per LUT. Right-click node → LUT → browse to .cube file.
Adobe Premiere Pro: Lumetri Color → Creative → Look dropdown → browse to .cube file.
ffmpeg (batch/preview):
ffmpeg -i input.mp4 -vf "lut3d='conversion.cube':interp=tetrahedral,lut3d='correction.cube':interp=tetrahedral" output.mp4
Log vs Display-Referred
Every LUT and every target in this repo assumes display-referred Rec.709, applied after a camera conversion LUT. Handed log footage, the analyzer sees lifted blacks and flat contrast, calls them defects, and fits a correction for something that is supposed to be there. The result looks plausible and is wrong, which is worse than failing — so the distinction is measured and stated out loud on every run.
python3 footage_type.py frame.png # just ask what it is
python3 auto_grade.py frame.png --transfer log
python3 solve_grade.py frame.png --transfer display --emit fix.cube
Detection reads the shape of the encoding, not metadata, because by the time a frame reaches the tool it is usually a PNG:
| Signal | Log looks like |
|---|---|
| Black point | lifted well above zero (S-Log3 puts black near 0.09) |
| Highlights | held back, nothing approaching 1.0 |
| Range | compressed into a narrow band |
| Purity | desaturated, pre-conversion |
| Midpoint position | median sits high within the range |
Three outcomes, not two. A heavily flattened, lifted, desaturated
display-referred grade is genuinely indistinguishable from log by shape alone,
so there is an explicit ambiguous verdict. Guessing either way is worse:
calling it log blocks a legitimate grade, calling it display produces a
confidently wrong one. Ambiguous asks you, which you can always answer and the
measurements never can.
The midpoint signal is what separates the two where anything can: rescaling a display-referred image leaves the median's relative position untouched, while a log transfer moves it.
What changes on log or ambiguous footage. Exposure and black level are held
back, because a 0.45 median and a 0.02 black point describe a graded image, not
a log container. White balance and skin hue still run — a cast is a cast, and
skin should read as skin, whatever the transfer curve. --transfer overrides
detection entirely.
Closed-Loop Grading
Rather than reporting recommendations for you to transcribe, the tool can fit a correction by measurement and hand you the LUT:
# Frame in, correction LUT out
python3 auto_grade.py frame.png --emit fix.cube
# Same thing without the analysis report
python3 solve_grade.py frame.png --emit fix.cube
# Fit against a whole clip instead of one arbitrary frame
python3 sample_clip.py clip.mov --emit fix.cube --frames 12
How the strengths are chosen. The solver applies a candidate correction,
re-measures the frame, and keeps the value that actually minimises the
remaining error. Earlier versions guessed with hand-tuned constants
(deviation * 10), which had no feedback at all.
Corrections are synthesised, not just selected. The preset library is
deliberately gentle — cool_shift is a 4% channel shift at full strength — so
no combination of library presets can neutralise a 22% tungsten cast. For white
balance, exposure and black level the solver fits the parameters directly from
the measurement. Skin correction still uses the tuned red_skin_fix preset,
because that is a shape correction rather than a magnitude.
What it measures (grade_metrics.py, reference-free — no pristine
reference needed):
| Measurement | Target | Runs on log? |
|---|---|---|
| White balance | neutral illuminant, skin and saturated props excluded | yes |
| Skin hue | 20 degrees | yes |
| Exposure | median luminance 0.45 | no |
| Black level | black point 0.02 | no |
| Saturation | no target — only acts outside a plausible band | no |
Saturation has no target on purpose. Colourfulness is a property of the
scene: a grey warehouse is legitimately drab and a fruit market legitimately
vivid. The evaluation scene measures 63.8 against auto_grade's old fixed
target of 45 — fitting to that number would "correct" a scene that was already
right. So the solver stays silent inside a wide band and only acts when
saturation has left any plausible range. It is display-only for the same reason
the tone stages are: log measures 37.6 against 63.8 for the same scene in
Rec.709, because it is desaturated by design.
Skin detection uses the YCbCr skin locus rather than an HSV box. An HSV box
that accepts skin also accepts wood, khaki and amber practicals — "warm and
mid-bright" is not a description of skin. The locus tolerance is asymmetric:
generous toward flushed and sunburnt skin, which is exactly what red_skin_fix
exists to correct, and tight toward the wood and amber direction.
Clip analysis samples across the clip, aggregates with the median so one blown frame cannot steer the grade, and warns when the clip varies too much to deserve a single LUT.
Measured on the synthetic evaluation set (eval_scenes.py, eleven known
defects), closed-loop grading removes a mean of 81% of the introduced
error, worst case 60%.
Interactive LUT Generation
preview.html generates LUTs live in the browser from presets.json rather
than fetching pre-baked files, and can export exactly what you are looking at
via Export .cube.
This also fixed a real defect: the preview used to interpolate the result of
a full-strength LUT against the original, while the CLI interpolates the
parameters. For studio_punch at 50% those diverge by up to a quarter of the
range on saturated colours — greys agreed, which is why it went unnoticed.
The browser port (pipeline.mjs) is verified against every shipped .cube
file in CI, so it cannot silently drift from the Ruby reference.
Tone Model (v2)
Tone operations — exposure, black_crush, highlight_protect, skin_rolloff,
skin_highlight — run in linear light, not on gamma-encoded HSL lightness.
The pipeline decodes with the BT.1886 display EOTF (pure 2.4 gamma), applies the curve to Rec.709 luminance, and scales R, G and B by the resulting ratio. Because all three channels are scaled together, hue and saturation are preserved by construction. Where brightening pushes a colour outside the cube, it is desaturated toward its target luminance rather than clipped per channel, which would skew the hue.
Hue and saturation steps (hue_desat, skin_correction, global_sat,
shadow_sat_boost, rgb_rebalance) still work in HSL, where perceptual
behaviour is what you want.
What changed from v1. v1 applied tone curves to HSL lightness,
L = (max + min) / 2, which is not luminance — a saturated red and a grey that
look equally bright have very different L, so they were tonemapped by different
amounts and colours drifted relative to greys. Rewriting L and converting back
also quietly shifted hue and saturation.
Greys are bit-identical between v1 and v2. Only saturated colours move, by up to ~0.37 at the most saturated grid points. 14 of the 27 shipped LUTs changed; the 13 that are pure hue/saturation work are untouched.
To reproduce v1 output exactly:
ruby generate_lut.rb black_crush out.cube --legacy
ruby generate_chain_lut.rb out.cube studio_punch@0.8 sat_boost@0.5 --legacy
ruby regenerate_luts.rb --legacy
reference_checksums_legacy.yml pins the v1 output, and the test suite asserts
--legacy still reproduces it byte-for-byte.
Color Science Notes
- Desaturating warm tones in HSL produces brown/sepia. Use hue shifting or RGB rebalancing instead.
- Skin tones occupy H=10-35, S=0.08-0.45, L=0.25-0.75. Use all three windows to isolate from light sources.
- S-Gamut3 vs S-Gamut3.Cine: S-Gamut3 is wider. LUTs calibrated for .Cine may oversaturate S-Gamut3 footage. Use
sgamut3_to_cinecompensation. - 3D LUTs allow cross-channel operations — R output can depend on G and B input.
- LUTs have no spatial awareness — no noise reduction, sharpening, or vignettes.
Requirements
To generate LUTs — Ruby 2.7+ and nothing else. generate_lut.rb,
generate_chain_lut.rb and regenerate_luts.rb have no gems to install.
To analyse frames and fit corrections — Python 3 with:
pip3 install Pillow numpy PyYAML
PyYAML is needed because the solver reads presets.yml when it uses a library
preset. Missing it fails only on the skin stage, which is an easy trap.
To sample clips — ffmpeg and ffprobe on PATH, used by sample_clip.py
for frame extraction and duration probing.
To run the full test suite — additionally Node 20+, for the browser pipeline parity suite. Not needed to use the tool.
preview.html needs a static server (python3 -m http.server) rather than
file://, because it loads presets.json and pipeline.mjs as modules.