LUT Lab Developer Reference
Maintain the LUT workbench: the .cube reader and writer and the one axis order they share, trilinear interpolation, the clipping predicate that replaced an out-of-gamut flag, the looks and what each one actually is, exports and URL state — and the two features that were withdrawn rather than fixed.
Overview
The bench is a static page plus four plain scripts: a numeric core that touches no document, a register of what every figure rests on, a page half that draws the pair, and a views half for the Method, Looks, Data, Export and Reference tabs. The interpolator underneath was correct and is unchanged. What sat around it was not: an overlay that flagged pure red before any LUT ran, a look naming two standards it never applied, a tone-map operating on the wrong quantity, and four wide-gamut standards listed under Reference with zero occurrences in the engine.
1. Code map
+| File | Holds | Reaches the DOM |
|---|---|---|
js/tool/lt/lt-core.js |
Every calculation. Installs as AALTCore. |
No — deliberately, so the suite runs the shipped file. |
js/tool/lt/lt-sources.js |
The 17-entry register, as AALTSources. |
No. |
js/tool/lut-lab.js |
State, the two canvases, the readouts, exports, tab switching. | Yes. AALTPage. |
js/tool/lut-lab-views.js |
Method, Looks, Data, Export, Reference, as AALTViews. |
Yes. |
css/lut-lab-shell.css |
The workbench shell plus this route’s blocks under §V. | — |
Load order matters and none of these is a module: lt-core.js
must be present before the page half, which throws a clear error rather than
failing silently if it is not. The route is in the PAGES list
of scripts/extract-anz-shell.mjs, so a class used only here
still survives the cut into css/anz-shell.css.
The shell sheet is a checked port of
css/dye-chemistry-shell.css, itself ported back through
material lighting and psychophysics to the illuminants sheet. Lines 1–1581
are that shared chrome with the dy- prefix renamed; everything
under §V is this route’s. Where a selector looks over-specified it
is answering a rule in css/color-science-lab.css, and the
comment beside it says which.
2. What was found, and how
+Seven findings, each measured against the shipped code rather than reasoned about. The first, checked again against the old exporter, turned out not to be a defect.
The .cube axis order now has one definition
The reader indexed r + g*size + b*size*size, which is the
specification, and the old writer looped b outermost and
r innermost, which writes the same order, so its tables read back
correctly here and elsewhere. An earlier version of this page said the writer
exchanged the red and blue axes; it did not — the loop that runs red
outermost draws the scatter plot, not the file. The order is still the most
common place a .cube tool disagrees with itself, so reader and writer now call
one cubeIndex() and cannot drift apart, and the Method tab prints
the eight rows of a 2³ table in the order the file writes them.
The out-of-gamut overlay flagged pure red
The predicate was after[c] <= 0 || after[c] >= 255 for
any channel, tested on the output alone. All six saturated primaries, black
and white satisfy it before any LUT runs. On the neutral wedge scene it
reports 80% of the picture. What a colourist is asking is what the transform
crushed, which needs the input as well:
clippedPixel(before, after) is true only where a channel was
driven to an endpoint it did not already sit on. Both predicates
remain in the engine and both run on every pixel, so the Lab tab reports the
difference as a number rather than describing it.
“Cine Log → Rec.709” named two standards and applied neither
The function is a 0.45 power with a lift and a gain. No log encoding is
named anywhere in it, no 709 matrix is applied, and the sRGB primaries this
tool already works in are the 709 primaries — so there was
never a conversion to perform. Same arithmetic, honest name:
log-lift, “Log-ish lift”, labelled a stylisation.
The Reinhard tone-map ran on encoded values
L / (1 + L) is defined on linear radiance. It was being handed
an sRGB code value and then given a 1/2.2 gamma on top of a
value that already carried an sRGB curve — encoding twice. Mid grey
came out at code 183 where it should be 141, and a scene whose shadows
started at code 13 started at 86. The chain is now decode, scale, tone-map,
encode. Both placements are in the engine so the Method tab can print the
two columns side by side.
“ACEScct → ACEScg” decoded a scene-referred encoding into 8 bits
ACEScg is linear, scene-referred and wide-gamut; the specification runs to 65504. The look decoded ACEScct correctly and then clamped the result to [0,1] and wrote it into an 8-bit channel. 114 of the 256 input codes — everything from code 142 up — came out pure white, so 44.5% of the input range collapsed onto a single output. Input code 255 decodes to 222.86. The decode is kept in the engine (it is four lines and it is right) so the Looks tab can show exactly this; the look is withdrawn.
The old constants also carried one slip: the log segment divided by
0.057023 where the specification’s slope is
1/17.52 = 0.0570776, putting mid grey 0.165% low and the top of
the range 0.5% high. acescctToLinearAsShipped() reproduces it.
The 32-bit float export checkbox had two identical branches
It branched on use32 and both sides pushed
(out.r / 255).toFixed(6) — character for character the
same line. It could not have worked either way: out.r is an
integer from 0 to 255, so the value written is k/255 whichever
branch runs. Offering a float switch on an 8-bit pipeline promises a
precision the tool has nowhere to get. Withdrawn; the export header now
states the 256 levels instead.
Four standards were listed and none was present
BT.2100 PQ, BT.2020, the ACES primaries and a “wide gamut” path
appeared under Standards. Searched in the old engine by name: zero
occurrences of 2084, of PQ, of 2020,
of AP0 or AP1. The only matrix in the file is the
sRGB one. BT.709 appears once, inside the label string
'Cine Log to Rec.709' — so of four claimed standards,
three appear nowhere and the fourth appears only as a word in a menu. A LUT
tool needs none of them, and the Reference tab now says so.
And one more, found while porting
The ΔE₀₀ mean-hue term folded the two >180° cases
into a single “add 360”. CIE 142-2001 splits them: add 360 when
the two hues sum below 360°, subtract when above. The two answers differ
by exactly 360°, which the T weighting cannot tell apart
because it reads the hue through cosines — but RT reads
it through a Gaussian centred on 275°, which is not periodic. This is
the fourth file in the repository found with the same slip.
3. The .cube format
+
parseCube(text, fileName) reads TITLE,
LUT_1D_SIZE, LUT_3D_SIZE, DOMAIN_MIN
and DOMAIN_MAX, and returns a record rather than throwing:
| Field | Meaning |
|---|---|
ok | false with error set if the file cannot be used at all. |
kind | "1D" or "3D". |
size, rows, table | The grid, and one [r,g,b] per row. |
domainMin, domainMax | As declared, not as assumed. |
warnings | Everything the file says that does not match what it contains. |
reachable | The fraction of the declared domain an 8-bit input can address. |
A .cube that disagrees with itself is the ordinary case, not the exception,
so the warnings list is a permanent part of the rail rather than an error
state. It reports a missing size keyword (and what was guessed), a body
shorter or longer than the header promises, and a domain this tool cannot
reach — a LUT declaring DOMAIN_MAX 4 4 4 is a valid file
that an 8-bit input can only address a quarter of, and
reachableFraction() says so instead of letting the tool silently
use a corner of it.
serialiseCube(opts) writes the 3D form and
serialiseCube1D(opts) the 1D form. The loop is
b outermost, then g, then r —
red innermost, so red varies fastest, matching cubeIndex().
4. Interpolation and the domain
+
trilinear(table, size, r01, g01, b01) is seven linear
interpolations per channel: eight corners collapse to four along red, to two
along green, to one along blue. The top index is clamped, so a sample sitting
exactly on the last node reads that node instead of running off the end of
the table. Note the signature takes the table, not the parsed
record — identityCube(size) returns the record, so pass
identityCube(n).table.
linear1D() is the same idea on one axis, and each output
channel reads its own column: a 1D LUT is three curves, not one applied
three times. Every 1D fixture in the suite used to be a neutral ramp, in
which the three columns are equal, so a defect here was invisible; there is
now a three-curve fixture.
applierFor(lut) honours the declared domain, mapping an 8-bit
input through (v - domainMin) / (domainMax - domainMin) before
the interpolation, and returns identityApply for a LUT that did
not load — so a failed file leaves the picture alone rather than
producing something.
5. Clipping, and what it replaced
+Two predicates, both shipped, both run on every pixel:
clippedPixel(before, after)— true where a channel reached 0 or 255 and did not start there.legacyOutOfGamut(after)— the old test, kept so the page can print what it flagged.
The overlay hatches clipped pixels in two colours, because crushing a
highlight and crushing a shadow are different failures and a colourist
treats them differently. analyseImage(before, after) returns
meanDE, p95DE, maxDE,
clippedFraction and legacyFlaggedFraction over
every pixel of the pair.
When touching the predicate, note that each of its six clauses needs its own
fixture. Black, white and the saturated primaries all have at least two
channels at an endpoint, so five of the six clauses could go missing and a
suite built from those still passed. The tests now use single-channel probes
such as [0, 128, 128].
6. The looks, and the two withdrawn
+
Every entry in LOOKS carries a kind, and the
distinction is the point of the tab. A stylisation is a
curve somebody liked; there is nothing to check it against. A
transform claims to convert between two defined encodings
and can be checked against the definition. Of the original seven: four were
always honest stylisations, one was a stylisation wearing a
transform’s name, one was a real transform applied in the wrong place,
and one is withdrawn.
| id | Kind | Note |
|---|---|---|
teal-orange | stylisation | Hand-tuned split-tone. |
bleach-bypass | stylisation | Desaturates toward luminance, lifts contrast. |
film-soft | stylisation | Lifted toe, shoulder short of white, per-channel warmth. |
mono-contrast | stylisation | Rec.709 luminance weights in linear light, then an S-curve. |
log-lift | stylisation | Was “Cine Log → Rec.709”. A 0.45 power with a lift and a gain. |
reinhard | transform | Reinhard et al. (2002), applied in linear light. |
WITHDRAWN holds the two removed features with a
why and a wouldTake each, and the Looks tab renders
them from that array — so a withdrawal cannot quietly lose its reason.
A look must return three integers in 0–255. encode()
clamps, which means a look whose curve has been broken still returns valid
bytes: a test asserting only totality passes on a look that has stopped
being the look. Each stylisation therefore has an assertion pinning its
character — that film-soft lifts its black, keeps its white
below 255 and stays warm and monotone, for instance.
7. Linear light and the tone-map
+
toneMapReinhard(r, g, b, exposure) decodes sRGB to linear,
multiplies by the exposure, applies L / (1 + L) and encodes
back. The exposure defaults to 2 and is a stated argument rather than a
hidden constant — the register carries it as a stand-in, because a
one-stop lift before the operator is this tool’s choice and not
anyone’s specification.
srgbEotf and srgbOetf take and return 0–1,
not 0–255. That is the trap this repository has fallen into before.
8. The hue sweep and the shift readout
+
hueSweep({lightness, chroma, step}) walks a circle of constant
L* and C* in CIELAB — 72 samples at 5° by default. sRGB cannot
hold all of that circle, so a sample outside it is pulled in along the
radius until it fits, and the sample records clipped: true when
it had to be. The page prints how many were pulled. Reporting a hue shift
without saying which samples had to be moved first would credit the
transform with a displacement the gamut caused.
analyseShift(apply, opts) reports hueShift
(wrapped to ±180°), lightnessShift,
chromaShift, dE00 and clipped
together. Hue alone is not enough: a transform can hold the hue angle
exactly while moving everything else, and the readout it replaced reported
an HSL hue — a hexagonal restatement of sRGB, which a transform can
move while leaving the perceptual hue still.
9. The round trip, both halves of it
+“Does it round-trip?” hides two questions, and stating either one alone gets it read as the other. Both are measured, and both are on the Method tab.
Does an identity give me my picture back? Yes, at every size this page writes. Measured through the whole path — serialise, parse, apply — over the full neutral ramp and 4,000 random colours at each of nine sizes: the worst output difference is 0 code values.
Does the file describe an identity exactly? Only when the
grid lands on 8-bit steps. The writer samples each node as an 8-bit code, and
a size-n grid has levels i/(n-1), which sit on
k/255 only when (n-1) divides
255 = 3 × 5 × 17 — true of 2, 4, 6, 16, 18, 52,
86 and 256, and not of the conventional 17, 33 and 65, whose stored
node values sit up to half a code (1.96e-3) off the exact grid. Rounding back
to 8 bits removes that; an application reading the file at higher precision
does not.
Separately, a table written and re-read by this parser reproduces every sample to 4.9e-7, which is the six-decimal text rounding and nothing else.
10. UI contract
+Things that will break the page quietly if changed:
<main>must carryclass="anz-main ax-lab". The control styling is scoped to.ax-lab.- Rails are
class="panel lt-rail"and stagesclass="panel lt-stage", both insidediv.lt-band. .lt-row__headand.lt-citeare four-column grids and need exactly four direct children. Wrap two of them in a div and they share one cell and overflow it..lt-capis a flex callout expecting a<p>child. A bare paragraph makes every text node and<em>a separate flex item. Plain prose uses.lt-caption.- A run carrying a Greek letter needs
class="lt-sym"; uppercasing λ gives Λ, which means something else. - The Data tab’s count badge is written from
counts().all, never typed into the markup.
Setting a <select> value from script needs
setSelectValue(), not sel.value = v. The site
replaces every select with a custom picker that follows the native element
through a MutationObserver on its selected
attributes; assigning .value moves the property only, which no
observer can see, so the visible label would keep showing whatever was last
clicked.
11. Exports and URL state
+
Four exports: the .cube itself, the same text to the clipboard,
the hue sweep as CSV, and a JSON report that carries the image statistics,
the transform, whether the scene was drawn or supplied, and the whole
register.
The header is not decoration. A .cube carries no statement of its own
precision, so a table written from an 8-bit pipeline looks exactly like one
written from 32 — six decimals either way. headerLines()
writes the transform, its kind, the strength and exposure if they apply, the
sample order, the number of levels the pipeline actually has, and which side
of the 8-bit grid question this size falls on.
?scene=&look=&s=&e=&n=&k= restores a
setting. Every value is validated against what exists — an unknown
look id or an out-of-range size is ignored rather than applied.
12. Public API
+window.AALTPage:
| Member | Does |
|---|---|
state | The live state object. |
SCENES | The four drawn scenes. |
activeApply() | The transform currently in force, look or file. |
activeLabel() | What to call it. |
cubeText() | The file as it would be written. |
setLook(id) | Selects a look; false for an unknown id. |
refresh() | Schedules a redraw. |
window.AALTSources exposes all(),
byId(), byStatus(), reportable(id),
counts(), statusLabel(),
markOwnImage() and resetScene(). A drop zone is
the only path from synthesised to verbatim, and
resetScene() must put back what it would take — there is
a test for that, because a reset that leaves the entry claiming a real image
is how a register starts lying.
13. Tests
+
npm run test:colorimetry runs the whole colorimetry suite, including
tests/colorimetry/lt-reference.test.js and
lt-sources.test.js — 44 assertions between them.
lt-harness.js loads the shipped core and register in a
vm context, so every assertion runs the code the page runs.
Arrays built inside that realm are not reference-equal to the test’s,
so array comparisons go through the harness’s same()
rather than deepStrictEqual.
The ones that matter most when changing this file:
- the sample ordering — the regression guard for the defect this rebuild exists for, checked on a hand-written file rather than on something the writer produced;
- trilinear against an independent implementation;
- a 1D LUT built from three different curves;
- the old and new clipping predicates on probes that separate them, each clause with a case only it can answer;
- ΔE₀₀ against an independent transcription of CIE 142-2001 — the published pairs pin seven points, not the surface between them;
- the ACEScct toe as well as its log segment, and the encode inverting the decode across the whole range;
- both halves of the round trip, above.
The suite was proved by injecting 54 defects — a transposed axis
order, a writer looping the other way, a parser that accepts a short file, a
domain not reported, the red fraction dropped from the interpolation, the
old out-of-gamut predicate restored, Reinhard back on encoded values, the
ACEScct constants moved, a stylisation relabelled a transform, a stand-in
relabelled verbatim — and 52 were caught. Six survived the first run;
four were gaps in the tests and are closed. The remaining two are the
blend() early-outs at strength 0 and 1, and those were measured
rather than argued: 1,968,512 comparisons against a shortcut-free blend
across every look, zero disagreements. They are an optimisation, not a
behaviour — and their safety rests on every look returning integers in
range, which is now itself a test.