Loads any Adobe .cube 3D LUT, HALD CLUT PNG, or 1D tone curve and applies it to a NumPy image in
one call — pure NumPy, no ffmpeg process, no OpenColorIO, no GPU.
Why a 3D lookup table
A global knob — saturation, an S-curve, a black point — moves every hue in an image the same way.
A real film stock’s look is hue-selective: it does one thing to skin and a different thing to
foliage, which needs a full three-dimensional lookup. Video tools reach .cube files through
ffmpeg’s lut3d filter; for a single still image already in memory, spawning an encoder just to
grade it is the wrong tool. pycube-lut is the still-image equivalent — a small NumPy module that
parses the table and trilinearly interpolates the pixels through it.
One call, three input shapes
| You have | load_cube gives you |
|---|---|
Adobe .cube with LUT_3D_SIZE | the table as-is |
| HALD CLUT PNG (a square grid image) | the same table, read from pixels |
.cube with LUT_1D_SIZE (three tone curves) | an equivalent separable 3D cube |
apply_cube(image, cube, strength) takes it from there. strength is opacity in [0, 1], and
the output dtype always matches the input — a 16-bit master stays 16-bit out, so a look laid over
it does not throw away its headroom.
What it does to real pixels
The LUT below is a small .cube generated for this page, not a product, and it is short enough to
state in full: LUT_3D_SIZE 17, and for every entry, take Rec.709 luma
(0.2126·R + 0.7152·G + 0.0722·B on normalized values), then shift red by +0.15·(luma − 0.5),
shift blue by −0.15·(luma − 0.5), and leave green exactly as it came in. That warms anything
above mid-luma and cools anything below — the kind of split a single global knob cannot make.
Rebuild those 4,913 rows in the R-fastest order the Cube specification requires and the numbers
below reproduce. They are applied to four literal RGB swatches rather than to a photograph, so
nothing here is a customer image, and they are a real run, pasted verbatim:
1cube: 17 (17, 17, 17, 3) | dtype in/out: uint8 uint8
2shadow [24 28 34] -> [ 9 28 49] @0.5 [17 28 41]
3midtone [110 110 110] -> [107 110 113] @0.5 [109 110 111]
4skin [196 150 120] -> [201 150 115] @0.5 [198 150 118]
5highlight [235 232 228] -> [250 232 212] @0.5 [243 232 220]
One table pushes the shadow blue and the highlight orange, moves the near-neutral midtone by three
points, and leaves green untouched at every level, because this particular LUT only moves red and
blue. The @0.5 column is the same call with strength=0.5: every value lands halfway between the
input and the full-strength result. uint8 went in and uint8 came back.
Honest limits
Trilinear interpolation only — Resolve, Photoshop and ffmpeg’s own lut3d default to tetrahedral,
so expect small deviations on steep LUTs. 8-bit and 16-bit integer images only, no float and no
color management: a LUT authored for log footage needs log input, and nothing here checks that
for you.
Requirements
Python 3.11+. NumPy only for .cube and 1D-curve files; reading a HALD CLUT PNG additionally
needs Pillow (the package’s hald extra pulls it in).
Documentation
pycube-lut’s documentation lives in its repository, next to the code it describes — the README is the manual.