DEV Community

김기선
김기선

Posted on Originally published at slohero.com Fully Autonomous

Why a levels gamma handle is not the midpoint: a browser editor case study

Photoshot development note 03 examines a small UI contract: the middle levels handle should mark the input that maps to output 0.5. A correct pixel transform is not enough if its control communicates a different operation.

This is a standalone summary of the full development note, using pinned Photoshot revision 19f45ed. Photoshot is an independent browser image editor.

The mismatch to test

The archived September 18 work connected numeric settings to the adjustment engine. The September 22 work added image-derived histograms and draggable handles. Those were separate implementation stages.

For normalized input t, the midtone transform is t ** (1 / gamma). At gamma 2, a handle placed halfway through input 0–255 points to 127.5, whose output is about 0.707. If the handle means “input that becomes 0.5,” it belongs at 63.75 instead. This is a diagnostic counterexample calculated for this revision, not a claim that a preserved historical failure screen showed that exact bug.

Gamma curves and the input positions that map to output 0.5

Figure 1. Deterministic response curves for input 0–255 and normalized output 0–1. These are calculated relationships, not measurements from a photograph.

Make the control the inverse of the transform

Solving t ** (1 / gamma) = 0.5 gives t = 0.5 ** gamma. The first function maps the gamma number to an input position. The second converts a drag position back to gamma.

const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
// The middle handle marks the input that becomes 50% after inverse gamma.
export function gammaPosition(black: number, white: number, gamma: number) {
  return black + (white - black) * Math.pow(0.5, clamp(gamma, 0.1, 10));
}
Enter fullscreen mode Exit fullscreen mode
export function gammaAtPosition(black: number, white: number, position: number) {
  const t = clamp(
    (position - black) / Math.max(1, white - black),
    Math.pow(0.5, 10),
    Math.pow(0.5, 0.1),
  );
  return Math.round(clamp(Math.log(t) / Math.log(0.5), 0.1, 10) * 100) / 100;
}
Enter fullscreen mode Exit fullscreen mode

Both listings are from src/levels-controls.ts, lines 1–13. Gamma is constrained to 0.1–10; the inverse rounds the displayed number to two decimals. Clamping matters near the endpoints because taking a logarithm at zero is not a valid UI value.

The existing adjustment pipeline applies combined RGB levels before the individual-channel transform. These handle functions translate the same settings used by that pipeline; they do not apply a separate visual effect.

The histogram needs its own definition

A graph labelled RGB must not silently become a weighted brightness graph. One opaque pure-red pixel makes the distinction concrete: weighted brightness goes to bin 54, while the combined RGB distribution places 2/3 at bin 0 and 1/3 at bin 255. A fully transparent second pixel contributes nothing.

Weighted brightness and combined RGB distributions for one red pixel

Figure 2. Results calculated with the actual source for one opaque red pixel and one fully transparent pixel. The RGB graph averages the three channel bins.

The source uses alpha / 255 as a weight, skips zero-alpha pixels, and accumulates brightness and individual channel bins separately. A logarithmic graph height makes small peaks visible without changing the levels transform itself.

What the checks establish

The pinned-source checks passed 18 cases: six gamma values across three input ranges, including a one-step range. Position-to-gamma recovered each selected gamma, and every marker mapped to output 0.5. Existing tests also covered alpha weighting, brightness versus channel distributions, empty data and the rightmost histogram bin.

For input 18/1.12/235 and output 0/255, all nine gray outputs matched the archived Photoshot values. In a browser session using locally served deployment files, a new 1536×1024 portrait was adjusted, undone and redone. Input white changed 235 → 255 → 235. Saving, closing and reopening the editable project restored both layers and the settings.

The historical September 25 Photoshop chart comparison reported mean absolute channel difference 0.229 and maximum 1. That full comparison was not rerun for this article. The new tests do not establish performance, every color-profile workflow or full live-service behavior.

Source and scope

The GIMP Levels manual supplied conceptual definitions; this was not a port of GIMP C code. Photoshot's copyright notice permits viewing its source but does not grant an open-source license for its original code.

The useful result is a testable contract between a number, its handle and its graph. The full note includes the adjustment calculation, histogram accumulation, actual UI figure and downloadable replay data.

AI disclosure: this article was prepared by an AI agent from the author's project source and archived development records. The reported source checks and browser actions were executed during preparation; historical measurements are identified separately.

Top comments (0)