히스토그램(사진 보정)

Histogram (photo editing)

사진의 밝기 값이 어둠부터 밝음까지 몇 픽셀씩 분포하는지 막대로 보여주는 그래프. 캔버스 픽셀을 직접 읽어 그때그때 계산합니다.

다른 이름: 톤 분포Tone histogramHistogram
···
html
<div class="wrap">
  <div class="stage">
    <div class="pane"><canvas id="pc" width="300" height="180"></canvas></div>
    <div class="pane hist"><canvas id="hc" width="180" height="140"></canvas></div>
  </div>
  <div class="hud"><span>노출을 흔들며 히스토그램을 실시간 재계산</span><b id="ev">+0.0 EV</b></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}
.hist{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)}
.hud b{color:var(--fg)}
js
var W = 300, H = 180;
var pc = document.getElementById('pc'), px = pc.getContext('2d');
var hc = document.getElementById('hc'), hx = hc.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 base = px.getImageData(0, 0, W, H);
var BINS = 48;
var t = 0;
function frame() {
  t += 0.02;
  var ev = Math.sin(t) * 0.9;
  var factor = Math.pow(2, ev);
  var out = px.createImageData(W, H);
  var bd = base.data, od = out.data;
  var bins = new Array(BINS).fill(0);
  for (var i = 0; i < bd.length; i += 4) {
    var r = Math.min(255, Math.max(0, bd[i] * factor));
    var g2 = Math.min(255, Math.max(0, bd[i + 1] * factor));
    var b = Math.min(255, Math.max(0, bd[i + 2] * factor));
    od[i] = r; od[i + 1] = g2; od[i + 2] = b; od[i + 3] = 255;
    var lum = 0.2126 * r + 0.7152 * g2 + 0.0722 * b;
    var bin = Math.min(BINS - 1, Math.floor(lum / 256 * BINS));
    bins[bin]++;
  }
  px.putImageData(out, 0, 0);
  var maxB = 1;
  for (var k = 0; k < BINS; k++) if (bins[k] > maxB) maxB = bins[k];
  hx.clearRect(0, 0, hc.width, hc.height);
  hx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--accent') || '#5b5bf7';
  var bw = hc.width / BINS;
  for (var k2 = 0; k2 < BINS; k2++) {
    var bh = (bins[k2] / maxB) * (hc.height - 4);
    hx.fillRect(k2 * bw, hc.height - bh, Math.max(1, bw - 1), bh);
  }
  document.getElementById('ev').textContent = (ev >= 0 ? '+' : '') + ev.toFixed(1) + ' EV';
  requestAnimationFrame(frame);
}
frame();

히스토그램은 이미지가 아니라 이미지의 통계입니다. 가로축은 밝기(0=검정~255=흰색), 세로축은 그 밝기를 가진 픽셀 개수입니다. 원리는 단순합니다 — 모든 픽셀을 돌면서 휘도(대략 0.2126R+0.7152G+0.0722B)를 계산하고 256개(또는 더 성긴) 구간 중 하나에 1씩 더합니다. 이 데모는 노출을 흔들 때마다 실제로 getImageData 로 픽셀을 다시 읽어 막대를 새로 그립니다 — 미리 그려둔 그림이 아닙니다.

사진작가는 촬영 현장에서 라이브뷰 히스토그램으로 노출을 판단합니다. 눈은 화면 밝기·주변 조도에 쉽게 속지만 히스토그램은 숫자라서 속지 않습니다. 막대가 왼쪽 끝(0)이나 오른쪽 끝(255)에 붙어 잘려 있으면 그 부분은 디테일이 완전히 사라진 것 — 클리핑이라 부릅니다.

흔한 오해는 "히스토그램이 가운데 산 모양이어야 좋다"는 것입니다. 실제로는 장면마다 정상 모양이 다릅니다 — 눈 덮인 풍경은 오른쪽으로 몰리는 게 맞고 야경은 왼쪽으로 몰리는 게 맞습니다. 중요한 건 모양이 아니라 양쪽 끝이 잘리지 않았는지입니다.

언제 쓰나

촬영 중 노출을 판단하거나, 보정 전후 톤 분포가 어떻게 바뀌었는지 확인할 때. 막대가 끝에서 잘렸으면 클리핑을 의심하세요.