← Photoshot · Guides & development↗Open editor
Photoshot / Development03

Building a Free Photoshop Alternative, Part 3: Wiring Levels and Histograms into the Code

Levels needed more than a black-point, white-point and gamma formula. We used GIMP’s documentation to check behavior, then wrote and connected the calculation, handles and histogram with AI. Three files in Photoshot’s GitHub repository show how those parts fit together.

Sources checked · Revised October 16, 2026

Read the article ↓
Before · AI-generated example portrait
Before · AI-generated example portrait
Actual Photoshot editor · October 13, 2026
Actual Photoshot editor · October 13, 2026
BUILD LOG / 01

GIMP informed behavior; Photoshot supplied the code

GIMP’s Levels documentation helped distinguish input endpoints, output range, gamma and individual channels. It was a behavioral reference; the record does not establish a port of GIMP’s C implementation.

We added the calculation to adjustments.ts, handle coordinates to levels-controls.ts and histogram analysis to tone-analysis.ts. Those three Photoshot files are the GitHub trail for this entry.

BUILD LOG / 02

Put levels into the existing LUT path

Read the expression as input normalization, inverse gamma, then output-range mapping. The loop applies the shared rgb settings and then the individual channel settings.

This calculation lives inside the existing 256-entry table builder. The new function could therefore use the adjustment layer’s established masking, opacity and persistence flow.

Code excerpt · src/adjustments.ts · v0.16.0 · L521–528 ↗
if (t === 'levels')
  for (const c of ['rgb', channel])
    x =
      (v[c + 'OutBlack'] +
        (v[c + 'OutWhite'] - v[c + 'OutBlack']) *
          clamp((x * 255 - v[c + 'Black']) / (v[c + 'White'] - v[c + 'Black'])) **
            (1 / v[c + 'Gamma'])) /
      255;
BUILD LOG / 03

The middle handle must match the math

The middle handle cannot simply sit at the geometric center. It represents the input that becomes 50% after gamma correction. gammaPosition maps gamma to a position; gammaAtPosition uses logarithms for the inverse mapping.

Keeping the pair together allows typed values and dragged handles to describe the same adjustment. The integration required both directions, not merely a formula that changed pixels.

Code excerpt · src/levels-controls.ts · v0.16.0 · L2–13 ↗
// 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));
}
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;
}
BUILD LOG / 04

The histogram needs an alpha rule

A histogram is not decoration. Counting transparent pixels as black would misrepresent the image. tone-analysis.ts uses alpha as a weight, skips fully transparent pixels, and accumulates luminance and RGB distributions separately.

This analysis code was written with AI and connected to the document being adjusted. That connection turns a pixel operation into an editing tool a person can interpret.

Code excerpt · src/tone-analysis.ts · v0.16.0 · L22–28 ↗
const weight = data[i + 3] / 255;
if (!weight) continue;
bins[Math.round(0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2])] += weight;
channelBins.r[data[i]] += weight;
channelBins.g[data[i + 1]] += weight;
channelBins.b[data[i + 2]] += weight;
count += weight;
BUILD LOG / 05

The editor and comparison record

The portrait uses RGB input 12 / 1.08 / 245 and output 0 / 255. The source portrait is AI-generated; the editor view and output are real.

The historical Photoshop chart used different input settings: 18 / 1.12 / 235, with output 0 / 255. Its mean channel difference was about 0.229, maximum 1. Handle-to-value consistency was checked separately.

Before · AI-generated example portrait
Before · AI-generated example portrait
After · Actual Photoshot output
After · Actual Photoshot output
BUILD LOG / 06

Three files, one coherent tool

Levels combines behavior learned from documentation with calculation, interaction and analysis code integrated into Photoshot. The public files make those responsibilities inspectable.

The value of AI-assisted code was the correctness of those connections. Part 4 follows the LUT idea examined in glfx.js and the interpolation code written separately for Photoshot.

Implementation and references

All guides & development logs ↗↗Try the editor