LUT format

What LUT lattice size actually costs

Sixty LUTs are published here, across five lattice sizes from 16×16×16 to 65×65×65. The FAQ states the position behind those files — the creative looks are 17³, the technical conversions 33³, "because a conversion has to be accurate rather than merely pleasing". This measures that difference: how many code values it is worth, for which cameras, and where in the picture.

The experiment

The site's colour maths ships as a library, public/js/color-transforms.js: toRec709() decodes the camera's log curve, applies its gamut matrix, runs an extended Reinhard shoulder (WHITE = 8) and then the Rec.709 OETF. It is analytic: evaluate it anywhere and you have the exact answer. A LUT is that function sampled onto a lattice, with interpolation in between.

So: evaluate toRec709() at every node of a 17³, 33³ and 65³ lattice (4,913, 35,937 and 274,625 points), sample each baked lattice with sample() from middleware/lutprofile.js, and compare against the analytic answer at 912,673 points, 97 steps per axis, each offset half a step. Exactly one of those points, the centre of the cube, coincides with a lattice node. Error is in 8-bit code values: the largest of the three channel differences, times 255.

The result

Cameramean 17³mean 33³mean 65³max 17³max 33³max 65³
Sony S-Log37.072.751.02159.1141.5120.1
Canon C-Log34.821.830.66121.585.865.4
Panasonic V-Log6.252.661.02162.6146.1118.6
ARRI LogC36.972.701.07153.6148.1135.1
RED Log3G109.023.951.55183.2166.9141.4
Apple Log3.361.200.40118.288.870.4
Blackmagic Film Gen59.804.051.91205.0184.3171.0
DJI D-Log M6.182.450.91142.0122.690.4

The mean columns behave: each doubling buys a factor of 2.1 to 3.0. The max columns do not. A 65³ S-Log3 conversion — 274,625 points, 7.41 MB of text — is still 120 code values wrong somewhere in the cube, and a hypothetical 129³ only takes that to 86.9. Blackmagic Film Gen5 runs 205.0, 184.3, 171.0, 166.6.

Why the worst case will not go away

For S-Log3 the worst 65³ error is at R = 0.871, G = 0.985, B = 0.912. The analytic transform takes that to near-pure cyan: red 2.3 code values, green and blue both pinned at 255. The lattice returns red 122.4. The enclosing nodes say why: at red node 55/64 the baked red output is 0.0, at 56/64 it is 170.6. One lattice step apart, black to two thirds.

That is the gamut matrix. The colour is outside Rec.709, so the matrix drives red negative and toRec709 clamps it to zero — at node 55/64 the matrixed linear red is −1.86. That zero crossing is a curved surface, and it takes no notice of where the nodes are. The chain is steepest right where it comes off the clamp: at a matrixed linear red of 0.001 the output climbs at 1,145 code values per unit of linear red, so node 56/64, at matrixed linear red 0.804, is already at 170.6. Trilinear draws a straight line across that, at any resolution.

Repeat the measurement over only the part of the cube where nothing clamps — not in the truth, not at any enclosing node of the coarsest lattice — and the mean falls by 4.20–4.41× from 17³ to 33³, and 3.90–4.00× again to 65³, on all eight cameras. The maximum falls too: S-Log3 goes 40.8, 24.3, 10.1, 2.3. Four times per doubling is second-order convergence, what trilinear gives on a smooth function. The whole-cube maximum does not converge because the function has a crease, and no lattice the format allows can sit on it.

Resolution fixes curvature. It does not fix clamping.

The near-neutral part of the cube

Over the whole-cube sweep for S-Log3, 94.5% of test points hit a clamp somewhere in the chain — a decode gone negative, a matrixed channel below zero, a shoulder output above 1 — and only 5.7% come through with all three channels strictly between 0 and 1, which is what the maximum column above is measuring. Restrict the sweep to a log-space chroma of 0.05 or less — the three log values spanning no more than 0.05, which is 50,593 points at 193 steps per axis — and the numbers change character.

Latticemean error, near-neutralerror at 18% greyworst error on the neutral ramp
17³1.77–8.041.52–4.153.60–20.87
33³0.40–1.720.26–1.351.39–4.63
65³0.11–0.350.02–0.400.87–2.53

Ranges are across the eight cameras. The first two columns converge cleanly; the third does not. The ramp is exact at every node the lattice puts on it — measured error is exactly zero at all N diagonal nodes, every camera, every size — and drifts in between, but its worst point is not in the middle. At 33³ and 65³, seven of the eight cameras put it within a third of a node of where the ramp clips, and that point is computable: shoulder() reaches exactly 1 at scene-linear 8.0 and the OETF maps 1 to 1, so a neutral clips at encode(8.0) — 0.826 for S-Log3, 0.950 for Apple Log, 0.711 for Blackmagic Film Gen5. The eighth, Canon C-Log3, puts its worst point half a node above where its decode crosses zero, at 0.125. Both are clamps, so both behave like the out-of-gamut crease: more nodes barely help.

A look and a conversion are not the same file

A 17-point lattice decimates exactly to a 9-point one: keep nodes 0, 2, 4 … 16. Do that to every 17³ .cube in this repo's uploads directory — 214 files, of which 212 parse as complete lattices and 183 are byte-distinct — and compare each 9³ result against its 17³ original.

  • Real looks, 183 distinct files: median mean error 1.69 code values, median worst case 14.1.
  • The eight baked conversions, same decimation: mean 6.34–14.22, worst case 93.8–160.6.

3.7 to 8.4 times worse on the mean, 6.7 to 11.4 on the worst case, from the identical operation. The mechanism is curvature, and it is measurable: take the largest second difference along each file's neutral diagonal and the two populations separate. 181 of the 183 distinct files sit below 0.0440 — median 0.0155, which is 3.9 code values — while all eight baked conversions sit between 0.0440 and 0.1363 (11.2 to 34.8 code values).

The separation is not perfect, and the exceptions are informative. Two catalogue files land in the conversion band: a Sony S-Log3 to Rec.709 LUT at 0.1216, which is a conversion and belongs there, and one film-emulation look at 0.0550. A second difference measures how hard a file's output bends along the diagonal, and trilinear error scales with exactly that. 17³ for looks and 33³ for conversions is a measurement, not a preference.

Even lattices, and the middle of the cube

An even lattice cannot put a node at the centre of the cube. Node positions are k/(N−1): for N = 17 the centre is node 8/16 exactly, for N = 16 it falls at 7.5/15 and is interpolated. The centre is not 18% grey — each camera's sits at encode(0.18), between 0.331 and 0.488 — but it is on the neutral axis, and it is the one point a lattice either samples exactly or does not.

Latticenode at the centreerror there
16³no3.06–23.04
17³yes0.000
32³no0.67–3.21
33³yes0.000
64³no0.16–0.73
65³yes0.000

16³ carries 16.6% fewer points than 17³ and saves 22 kB, and in exchange is 3.1 to 23.0 code values out at the centre of the neutral axis. Prefer odd.

What a size costs to ship, and why the ceiling is 65

Latticepoints.cube bytesparse, mspublished here
51213.8 kB0.14
16³4,096111 kB1.065
17³4,913133 kB1.2929
32³32,768885 kB10.3816
33³35,937970 kB11.878
65³274,6257.41 MB95.602

A row written to six decimals is exactly 27 bytes, so the byte column is arithmetic, not a measurement. Parsing is not: parseCube costs 0.26 to 0.35 microseconds per point on the machine these ran on, and the 65³ file took 74 times as long as the 17³ one for 56 times the points. Applying the lattice does not scale that way. sample() reads eight nodes and does seven blends whatever the size, with no dependence on N, and a million samples measured 36 to 53 ms across every size in the table — 65³ at 1.4× the 8³, with no clean ordering in between. That gap is memory traffic, not arithmetic. A big LUT is a bigger download and a slower load, not a slower grade.

middleware/lutfile.js refuses anything below MIN_3D_SIZE 8 or above MAX_3D_SIZE 65, and both ends are arithmetic. An 8³ file is 512 points, and the uploads that provoked the check — 2³ identity files, a 2³ channel swap sold as a warm look — are eight corners with interpolation between them. At the top, 129³ would be 2,146,689 points and 57,960,603 bytes at six decimals, past the 52,428,800-byte upload ceiling routes/luts.js computes from MAX_FILE_SIZE_MB (50 in this repo) — and on the evidence above it would not buy a converging maximum either.

What to ask for

  • A creative look: 17³ is enough. Halving one to 9³ costs a median of 1.69 code values on the mean, 14.1 at worst, across 183 distinct catalogue files.
  • A log conversion: 33³ is the floor. At 17³ a conversion puts 18% grey 1.5 to 4.1 code values off and averages 1.8 to 8.0 near neutral.
  • 65³ for 10-bit delivery of a mostly-gradient frame. A 10-bit step is 255/1023 = 0.25 in these units; no camera's 33³ near-neutral mean gets under it, and seven of the eight do at 65³.
  • 65³ is a wasted 7 MB on 8-bit delivery, on a creative look, or wherever 33³ already sits under a code value — near neutral, six of these eight cameras.
  • No size rescues a colour the matrix drives negative. That hard edge is the clamp, not the lattice, and the fix is upstream of the conversion.

How these numbers were produced

public/js/color-transforms.js runs unchanged under Node: require() it and call toRec709(camera, rgb). Baking a lattice of size N means writing toRec709(camera, [r/(N-1), g/(N-1), b/(N-1)]) into rows[r + gN + bN*N] — the red-fastest index order middleware/lutfile.js checks in isIdentity and middleware/lutprofile.js reads in sample(rows, size, r, g, b), the interpolator under test rather than a reimplementation. The whole-cube sweep is a triple loop at (i + 0.5)/97 per axis, the near-neutral sweep at (i + 0.5)/193 keeping points whose log channels span no more than 0.05, and the ramp figures a 4,000,001-step sweep along (v, v, v). The clamp mask comes from a copy of the chain that reports its clamps instead of applying them; the decimation test reads the repo's own files with parseCube and de-duplicates by MD5. Timings are medians of nine warmed-up runs on one machine; treat the absolute values as indicative. Catalogue counts came from /api/luts (two pages of 50) and /api/luts/:id/cube-meta, 23 August 2026.

As a harness check: encode(0.18) gives 0.410557 for S-Log3, and all eight cameras land 18% grey within 0.00002 of 0.37314 in Rec.709. Every odd lattice returns exactly 0.000 error at the cube centre, and every lattice returns 0.000 at each node on the neutral diagonal, which is what confirms the bake order and the sampler agree.

Sources

  • public/js/color-transforms.js — log curves, gamut matrices, shoulder, OETF, toRec709(). Every analytic figure is that file evaluated directly.
  • middleware/lutprofile.jssample(), the interpolator behind every measured figure here.
  • middleware/lutfile.jsparseCube, the index order, and the MIN_3D_SIZE 8 / MAX_3D_SIZE 65 bounds with their reasoning.
  • routes/luts.js, .env, uploads/ — the upload ceiling and the 214 files in the decimation test.
  • The live API, read 23 August 2026 — lattice size for all sixty published LUTs.
  • The live homepage FAQ — the 17³-for-looks, 33³-for-conversions position.
All guides in the Academy