Curves

커브(톤 커브)

Maps input brightness to output brightness along a freeform curve — a far more precise version of levels.

Also known as: Tone curve톤커브
···
html
<div class="wrap">
  <div class="stage">
    <div class="pane"><canvas id="pc" width="300" height="180"></canvas></div>
    <div class="pane graph"><canvas id="gc" width="160" height="160"></canvas></div>
  </div>
  <div class="hud"><span>톤 커브 LUT</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;display:flex;gap:clamp(6px,2vmin,14px)}
.pane{position:relative;flex:1.3;min-height:0}
.graph{flex:1}
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)}
js
var W = 300, H = 180;
var pc = document.getElementById('pc'), px = pc.getContext('2d');
var gc = document.getElementById('gc'), gx = gc.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(px, W, H);
var orig = px.getImageData(0, 0, W, H);
var t = 0;
function frame() {
  t += 0.012;
  var k = Math.sin(t) * 0.34;
  var lut = new Uint8ClampedArray(256);
  for (var v = 0; v < 256; v++) {
    var x = v / 255;
    var y = x - k * Math.sin(2 * Math.PI * x) / (2 * Math.PI);
    lut[v] = Math.round(Math.min(1, Math.max(0, y)) * 255);
  }
  var out = px.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; }
  px.putImageData(out, 0, 0);

  var gw = gc.width, gh = gc.height;
  gx.clearRect(0, 0, gw, gh);
  gx.strokeStyle = 'rgba(150,150,150,.4)'; gx.lineWidth = 1;
  gx.beginPath(); gx.moveTo(0, gh); gx.lineTo(gw, 0); gx.stroke();
  gx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--accent') || '#5b5bf7';
  gx.lineWidth = 2.5;
  gx.beginPath();
  for (var vv = 0; vv < 256; vv += 4) {
    var xx = vv / 255 * gw, yy = gh - (lut[vv] / 255 * gh);
    if (vv === 0) gx.moveTo(xx, yy); else gx.lineTo(xx, yy);
  }
  gx.stroke();
  requestAnimationFrame(frame);
}
frame();

A curve starts as the diagonal line (input equals output); moving a point on the graph changes brightness only in that stretch. This demo bakes a gentle S-curve — a sine term that lifts one side of the midtone and pulls down the other, leaving the midpoint fixed — into a 256-entry LUT and applies that exact LUT to every pixel, so the curve's shape and the photo's change move together.

Levels only gives you three points (black, white, gamma); curves lets you add a point anywhere and shape just that stretch. Bend a soft S to add contrast, lift only the shadows, or roll off only the highlights.

Bend the curve too sharply and neighboring brightness values collapse into the same output — banding and posterization in that range. Push per-channel curves separately and color shifts unintentionally. Small, gradual moves stay safe.

When to use

Reach for it when levels isn't precise enough, or you need to touch only one brightness range. Sharp bends cause banding.