> ## Documentation Index
> Fetch the complete documentation index at: /docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Apply LUTs in code

> Grade photos and video with slay's .cube LUTs from ffmpeg, Python, Node.js, the browser and Swift.

A 3D LUT maps every input colour to an output colour. To apply one, you look up each pixel's RGB value in the table and blend the nearest entries. Every sample on this page does exactly that. They all produce the same result: within 1 level out of 255 of ffmpeg's `lut3d` filter.

Get the files first from [Download the LUTs](/docs/luts/download.md). The samples below use `slay-01-amber.cube`, and any of the five works the same way.

## How a .cube file is laid out

A slay `.cube` file is plain text: a short header, then one RGB row per table entry.

```text slay-01-amber.cube theme={null}
TITLE "slay Amber"
# Generated by slay — https://www.slaycamera.com
# License: CC BY 4.0 — free to use with attribution (slaycamera.com)
LUT_3D_SIZE 33
DOMAIN_MIN 0.0 0.0 0.0
DOMAIN_MAX 1.0 1.0 1.0
0.000000 0.000000 0.000000
0.024086 0.000000 0.000000
...
```

* `LUT_3D_SIZE 33` means the table has 33 steps per channel, so 33³ = 35,937 rows.
* Values run from 0 to 1. Scale 8-bit pixels by dividing by 255.
* **Red changes fastest**, then green, then blue. Row `r + g·33 + b·33²` holds the output for grid point `(r, g, b)`.
* Lines starting with `#` are comments, and the `TITLE` and `DOMAIN_*` lines are metadata. The parsers below skip them.

> [!NOTE]
>
> slay LUTs expect ordinary sRGB pixels, which is what JPEG, PNG and HEIC photos and most video already are. Don't convert to linear light before applying them.

## ffmpeg

ffmpeg's [`lut3d`](https://ffmpeg.org/ffmpeg-filters.html#lut3d-1) filter reads `.cube` files directly. It's the quickest way to grade images, video and whole folders.

```bash Image theme={null}
ffmpeg -i photo.jpg -vf lut3d=slay-01-amber.cube photo-amber.jpg
```

```bash Video theme={null}
ffmpeg -i clip.mp4 -vf "lut3d=slay-01-amber.cube:interp=tetrahedral" -c:a copy clip-amber.mp4
```

```bash Folder theme={null}
mkdir -p graded
for f in in/*.{jpg,png}; do
  ffmpeg -loglevel error -i "$f" -vf lut3d=slay-01-amber.cube "graded/$(basename "$f")"
done
```

```bash Strength (60%) theme={null}
ffmpeg -i photo.jpg -filter_complex \
  "[0:v]format=rgb24,split[orig][grade];[grade]lut3d=slay-01-amber.cube[graded];[orig][graded]blend=all_expr='A*(1-0.6)+B*0.6'" \
  photo-amber-60.jpg
```

`interp=tetrahedral` is slightly smoother than the default trilinear and costs almost nothing. `-c:a copy` keeps the audio as it is. For the strength variant, change both `0.6` values to set how much of the look to mix in.

## Python

**Pillow**

Pillow has a built-in 3D LUT filter, so you only need to read the file into it.

```bash theme={null}
pip install pillow
```

```python grade.py theme={null}
from PIL import Image, ImageFilter


def load_cube(path):
    """Read a 3D .cube file into a Pillow filter."""
    size, table = None, []
    with open(path) as f:
        for line in f:
            parts = line.split()
            if not parts or parts[0].startswith("#"):
                continue
            if parts[0] == "LUT_3D_SIZE":
                size = int(parts[1])
            elif parts[0][0].isdigit() or parts[0][0] in "-.":
                table.extend(float(v) for v in parts)
    # .cube rows run red-fastest, the same order Pillow expects.
    return ImageFilter.Color3DLUT(size, table)


amber = load_cube("slay-01-amber.cube")
photo = Image.open("photo.jpg").convert("RGB")
photo.filter(amber).save("photo-amber.jpg", quality=95)
```

Load each LUT once and reuse the filter for every image. To apply the look at partial strength, blend the original and graded images:

```python theme={null}
graded = photo.filter(amber)
Image.blend(photo, graded, 0.6).save("photo-amber-60.jpg", quality=95)
```

**NumPy**

Use this version when your pixels are already arrays, such as video frames from OpenCV or tensors in an ML pipeline. It works on any array of shape `(..., 3)` with values from 0 to 1.

```bash theme={null}
pip install numpy pillow
```

```python grade_numpy.py theme={null}
import numpy as np
from PIL import Image


def load_cube(path):
    """Read a 3D .cube file into a (size, size, size, 3) array indexed [b, g, r]."""
    size, rows = None, []
    with open(path) as f:
        for line in f:
            parts = line.split()
            if not parts or parts[0].startswith("#"):
                continue
            if parts[0] == "LUT_3D_SIZE":
                size = int(parts[1])
            elif parts[0][0].isdigit() or parts[0][0] in "-.":
                rows.append([float(v) for v in parts])
    return np.asarray(rows, dtype=np.float32).reshape(size, size, size, 3)


def apply_lut(rgb, lut):
    """Trilinear lookup. rgb is a float array of shape (..., 3) in 0-1."""
    n = lut.shape[0] - 1
    x = np.clip(rgb, 0.0, 1.0) * n
    i0 = np.floor(x).astype(np.intp)
    i1 = np.minimum(i0 + 1, n)
    f = x - i0
    r0, g0, b0 = i0[..., 0], i0[..., 1], i0[..., 2]
    r1, g1, b1 = i1[..., 0], i1[..., 1], i1[..., 2]
    fr, fg, fb = f[..., 0:1], f[..., 1:2], f[..., 2:3]

    def mix(a, b, t):
        return a + (b - a) * t

    c00 = mix(lut[b0, g0, r0], lut[b0, g0, r1], fr)
    c01 = mix(lut[b0, g1, r0], lut[b0, g1, r1], fr)
    c10 = mix(lut[b1, g0, r0], lut[b1, g0, r1], fr)
    c11 = mix(lut[b1, g1, r0], lut[b1, g1, r1], fr)
    return mix(mix(c00, c01, fg), mix(c10, c11, fg), fb)


lut = load_cube("slay-01-amber.cube")
rgb = np.asarray(Image.open("photo.jpg").convert("RGB"), dtype=np.float32) / 255
graded = apply_lut(rgb, lut)
Image.fromarray(np.round(graded * 255).astype(np.uint8)).save("photo-amber.png")
```

OpenCV loads frames as BGR, so convert with `cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)` before the lookup and back afterwards.

## JavaScript

This module has no dependencies and runs in Node.js and the browser. It takes 8-bit RGB or RGBA pixels and returns graded pixels, leaving alpha as it is.

```js lut.js theme={null}
// Works in Node.js and the browser — no dependencies.

/** Parse a 3D .cube file's text into { size, table }. */
export function parseCube(text) {
  let size = 0
  const table = []
  for (const line of text.split(/\r?\n/)) {
    const parts = line.trim().split(/\s+/)
    if (parts[0] === "LUT_3D_SIZE") size = Number(parts[1])
    else if (/^[-\d.]/.test(parts[0])) table.push(...parts.map(Number))
  }
  return { size, table: Float32Array.from(table) }
}

/**
 * Grade 8-bit pixels with trilinear interpolation.
 * `pixels` is RGB or RGBA bytes; alpha is left untouched.
 * Returns a new Uint8ClampedArray.
 */
export function applyLut(pixels, channels, { size, table }) {
  const out = new Uint8ClampedArray(pixels)
  const n = size - 1
  const at = (r, g, b, c) => table[((b * size + g) * size + r) * 3 + c]
  const mix = (a, b, t) => a + (b - a) * t

  for (let i = 0; i < pixels.length; i += channels) {
    const r = (pixels[i] / 255) * n
    const g = (pixels[i + 1] / 255) * n
    const b = (pixels[i + 2] / 255) * n
    const r0 = Math.floor(r), g0 = Math.floor(g), b0 = Math.floor(b)
    const r1 = Math.min(r0 + 1, n), g1 = Math.min(g0 + 1, n), b1 = Math.min(b0 + 1, n)
    const fr = r - r0, fg = g - g0, fb = b - b0

    for (let c = 0; c < 3; c++) {
      const c0 = mix(
        mix(at(r0, g0, b0, c), at(r1, g0, b0, c), fr),
        mix(at(r0, g1, b0, c), at(r1, g1, b0, c), fr),
        fg
      )
      const c1 = mix(
        mix(at(r0, g0, b1, c), at(r1, g0, b1, c), fr),
        mix(at(r0, g1, b1, c), at(r1, g1, b1, c), fr),
        fg
      )
      out[i + c] = mix(c0, c1, fb) * 255
    }
  }
  return out
}
```

### Node.js

[sharp](https://sharp.pixelplumbing.com) decodes the image to raw pixels and encodes the result.

```bash theme={null}
npm install sharp
```

```js grade.mjs theme={null}
import { readFile } from "node:fs/promises"
import sharp from "sharp"
import { applyLut, parseCube } from "./lut.js"

const lut = parseCube(await readFile("slay-01-amber.cube", "utf8"))

const { data, info } = await sharp("photo.jpg")
  .removeAlpha()
  .raw()
  .toBuffer({ resolveWithObject: true })

const graded = applyLut(data, info.channels, lut)

await sharp(graded, {
  raw: { width: info.width, height: info.height, channels: info.channels },
})
  .jpeg({ quality: 95 })
  .toFile("photo-amber.jpg")
```

### In the browser

Draw the image to a canvas, grade its pixels, and put them back. The LUT can come straight from GitHub because the raw URLs allow cross-origin requests.

```js preview.js theme={null}
import { applyLut, parseCube } from "./lut.js"

const LUT_URL =
  "https://raw.githubusercontent.com/slaycamera/luts/main/luts/slay-01-amber.cube"

const lut = parseCube(await (await fetch(LUT_URL)).text())

const img = document.querySelector("#photo")
await img.decode()

const canvas = document.querySelector("#preview")
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext("2d")
ctx.drawImage(img, 0, 0)

const frame = ctx.getImageData(0, 0, canvas.width, canvas.height)
const graded = applyLut(frame.data, 4, lut)
ctx.putImageData(new ImageData(graded, canvas.width, canvas.height), 0, 0)
```

> [!WARNING]
>
> The photo itself must come from your own origin, or from a server that sends CORS headers with `crossorigin="anonymous"` set on the `<img>`. Otherwise the canvas is tainted and `getImageData` throws.

For live video or very large images, run the same lookup in a WebGL fragment shader with the LUT uploaded as a 3D texture. The per-pixel maths is the same.

## Swift (Core Image)

Core Image's `CIColorCubeWithColorSpace` filter does the lookup on the GPU. It wants the table as RGBA floats in the same red-fastest order as the `.cube` file, so the loader adds an alpha of 1 to each row.

```swift Grade.swift theme={null}
import CoreImage
import CoreImage.CIFilterBuiltins

/// Read a 3D .cube file into a Core Image color-cube filter.
func loadCube(_ url: URL) throws -> CIFilter & CIColorCubeWithColorSpace {
    var size = 0
    var rgba: [Float] = []
    for line in try String(contentsOf: url, encoding: .utf8).split(whereSeparator: \.isNewline) {
        let parts = line.split(whereSeparator: \.isWhitespace)
        guard let first = parts.first else { continue }
        if first == "LUT_3D_SIZE" {
            size = Int(parts[1])!
        } else if parts.count == 3, let r = Float(first), let g = Float(parts[1]), let b = Float(parts[2]) {
            rgba += [r, g, b, 1]  // Core Image wants RGBA, red-fastest like .cube
        }
    }
    let filter = CIFilter.colorCubeWithColorSpace()
    filter.cubeDimension = Float(size)
    filter.cubeData = rgba.withUnsafeBufferPointer { Data(buffer: $0) }
    filter.colorSpace = CGColorSpace(name: CGColorSpace.sRGB)
    return filter
}

let amber = try loadCube(URL(fileURLWithPath: "slay-01-amber.cube"))
amber.inputImage = CIImage(contentsOf: URL(fileURLWithPath: "photo.jpg"))

let context = CIContext()
try context.writeJPEGRepresentation(
    of: amber.outputImage!,
    to: URL(fileURLWithPath: "photo-amber.jpg"),
    colorSpace: CGColorSpace(name: CGColorSpace.sRGB)!
)
```

In an app, load the `.cube` from your bundle with `Bundle.main.url(forResource: "slay-01-amber", withExtension: "cube")`. Use `context.createCGImage(_:from:)` when you need a `CGImage` or `UIImage` rather than a file.

Setting `colorSpace` to sRGB makes Core Image apply the cube to sRGB values, matching the other samples. Build the filter once and reuse it. For live camera preview, set `inputImage` on each frame and render through the same `CIContext`.

## Troubleshooting

> **My output differs slightly from ffmpeg's**
>
> The LUT maths agree to within 1 level. Bigger differences usually come from JPEG decoding: ffmpeg and libjpeg-based tools turn the same JPEG into slightly different RGB values before any grading happens. Decode with the same tool, or start from a PNG, and the results match.

> **Colours look washed out or too dark**
>
> The pixels were probably in linear light, or in a wide colour space such as Display P3, when the LUT was applied. Convert to sRGB first and apply the LUT before any linearising step.

> **The parser rejects a third-party .cube file**
>
> These parsers expect 3D LUTs with a 0–1 domain, which every slay LUT uses. Files with `LUT_1D_SIZE`, or with `DOMAIN_MIN`/`DOMAIN_MAX` other than 0 and 1, need extra handling.

> **Grading is slow**
>
> Parse each LUT once and cache it. In Python, grade a whole frame with the NumPy version rather than looping over pixels. In the browser or on iOS, use the GPU: WebGL or Core Image.

Using these LUTs in public work or a product? [Credit slay](/docs/luts/download.md#license-and-credit).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.