Levels

레벨(레벨 보정)

Remaps the tonal range using three points — black point, white point and gamma — to stretch contrast into the empty space at either end of the histogram.

Also known as: Levels adjustment레벨 조정
···
html
<div class="wrap">
  <div class="stage"><canvas id="c" width="300" height="180"></canvas></div>
  <div class="hud"><span>검정점 <b id="bl">30</b> · 흰점 <b id="wh">225</b></span><span>왼쪽 원본 / 오른쪽 보정</span></div>
</div>
css
.wrap{position:absolute;inset:0;padding:clamp(8px,3vmin,20px);display:flex;flex-direction:column;gap:6px}
.stage{position:relative;flex:1;min-height:0}
canvas{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;border-radius:10px;border:1px solid var(--line);background:#0a0a0a}
.hud{display:flex;justify-content:space-between;gap:8px;font:700 clamp(9px,2.2vmin,11px)/1 ui-monospace,monospace;color:var(--muted)}
.hud b{color:var(--fg)}
js
var W = 300, H = 180;
var c = document.getElementById('c'), ctx = c.getContext('2d');

function paintPhoto(cx, w, h) {
  var g = cx.createLinearGradient(0, 0, 0, h * 0.62);
  g.addColorStop(0, '#5b86c9'); g.addColorStop(0.55, '#bcd6ea'); g.addColorStop(1, '#f5e0ad');
  cx.fillStyle = g; cx.fillRect(0, 0, w, h);
  var sx = w * 0.74, sy = h * 0.22, sr = h * 0.11;
  var sg = cx.createRadialGradient(sx, sy, 0, sx, sy, sr * 3.2);
  sg.addColorStop(0, 'rgba(255,247,214,0.9)'); sg.addColorStop(1, 'rgba(255,247,214,0)');
  cx.fillStyle = sg; cx.fillRect(0, 0, w, h * 0.62);
  cx.fillStyle = '#fff8df'; cx.beginPath(); cx.arc(sx, sy, sr, 0, 7); cx.fill();
  cx.fillStyle = '#6f9152';
  cx.beginPath(); cx.moveTo(0, h * 0.64);
  for (var x = 0; x <= w; x += 14) cx.lineTo(x, h * 0.64 - Math.sin(x * 0.014) * h * 0.05 - Math.sin(x * 0.032 + 1.2) * h * 0.02);
  cx.lineTo(w, h); cx.lineTo(0, h); cx.closePath(); cx.fill();
  cx.fillStyle = '#405f38';
  cx.beginPath(); cx.moveTo(0, h * 0.79);
  for (var x2 = 0; x2 <= w; x2 += 14) cx.lineTo(x2, h * 0.79 - Math.sin(x2 * 0.022 + 0.6) * h * 0.035);
  cx.lineTo(w, h); cx.lineTo(0, h); cx.closePath(); cx.fill();
  var tx = w * 0.2, ty = h * 0.8;
  cx.strokeStyle = '#3a2a1c'; cx.lineWidth = Math.max(1.5, w * 0.008);
  cx.beginPath(); cx.moveTo(tx, ty); cx.lineTo(tx - w * 0.01, ty - h * 0.13); cx.stroke();
  cx.fillStyle = '#2e4a28';
  for (var i = 0; i < 5; i++) { cx.beginPath(); cx.arc(tx + (i - 2) * w * 0.013, ty - h * 0.15 - (i % 2) * h * 0.018, w * 0.028, 0, 7); cx.fill(); }
  cx.fillStyle = '#1e1e1c';
  cx.beginPath(); cx.arc(w * 0.56, h * 0.745, h * 0.018, 0, 7); cx.fill();
  cx.fillRect(w * 0.555, h * 0.76, w * 0.01, h * 0.06);
  var img = cx.getImageData(0, 0, w, h), d = img.data;
  for (var p = 0; p < d.length; p += 4) { var n = (Math.random() - 0.5) * 10; d[p] += n; d[p + 1] += n; d[p + 2] += n; }
  cx.putImageData(img, 0, 0);
}
paintPhoto(ctx, W, H);
var orig = ctx.getImageData(0, 0, W, H);
var t = 0;
function frame() {
  t += 0.015;
  var black = 30 + Math.sin(t) * 28;
  var white = 225 - Math.sin(t) * 28;
  var lut = new Uint8ClampedArray(256);
  for (var v = 0; v < 256; v++) {
    var f = (v - black) / (white - black);
    f = f < 0 ? 0 : f > 1 ? 1 : f;
    lut[v] = Math.round(f * 255);
  }
  var out = ctx.createImageData(W, H);
  var od = out.data, id = orig.data;
  for (var i = 0; i < id.length; i += 4) { od[i] = lut[id[i]]; od[i + 1] = lut[id[i + 1]]; od[i + 2] = lut[id[i + 2]]; od[i + 3] = 255; }
  var frac = (Math.sin(t * 0.6) + 1) / 2 * 0.7 + 0.15;
  ctx.putImageData(out, 0, 0);
  var sw = Math.round(W * frac);
  if (sw > 0) ctx.putImageData(orig, 0, 0, 0, 0, sw, H);
  ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(sw, 0); ctx.lineTo(sw, H); ctx.stroke();
  document.getElementById('bl').textContent = Math.round(black);
  document.getElementById('wh').textContent = Math.round(white);
  requestAnimationFrame(frame);
}
frame();

Levels is a lookup table that maps one input value to one output value. Anything darker than the black point gets pushed to 0, anything brighter than the white point gets pushed to 255, and everything between is redistributed. This demo rebuilds a 256-entry LUT every frame from the current black and white points and reapplies it to every original pixel.

It's the go-to fix for photos whose histogram doesn't reach either edge — the camera simply never recorded anything that dark or that bright. Nudging the black point up rescues a flat, foggy shot; pulling the white point down restores contrast to a dull, underexposed one.

The most common mistake is dragging the points inward when the histogram is already touching both edges. That clips detail that genuinely existed into pure black or white. If you only want to shift overall brightness, leave black and white where the histogram actually ends and move gamma instead.

When to use

Use it to restore contrast when the histogram doesn't reach the edges. If it already does, don't pull the points in further — that clips real detail.