Headless Mode
Free · open source

pycube-lut

Reads Adobe .cube 3D LUTs, HALD CLUTs and 1D tone curves and applies them to images held as NumPy arrays

In real use since

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 haveload_cube gives you
Adobe .cube with LUT_3D_SIZEthe 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.

← All software