Headless Mode
Free · open source

pycube-lut

Applies .cube LUTs, HALD CLUTs and 1D tone curves to NumPy images

Published

For grading a still image that is already in memory, without spawning an encoder to do it.

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).


Where it fits

pycube-lut is one of the four open tools waypost, the studio’s creator console, fronts for video and photo capture-to-publish — it applies the grade. It needs no console to run.

Documentation

pycube-lut’s documentation lives in its repository, next to the code it describes — the README is the manual.

← All software