노이즈 감소

Noise reduction

높은 ISO나 어두운 촬영에서 생긴 무작위 알갱이(노이즈)를 주변 픽셀과 섞어 눌러주되, 진짜 디테일까지 뭉개지 않도록 균형을 잡는 보정.

다른 이름: Denoise디노이즈
···
html
<div class="wrap">
  <div class="stage">
    <div class="pane"><canvas id="lc" width="300" height="180"></canvas><span class="lbl">일반 블러(경계 무시)</span></div>
    <div class="pane"><canvas id="rc" width="300" height="180"></canvas><span class="lbl">경계 보존 디노이즈</span></div>
  </div>
  <div class="hud"><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,12px)}
.pane{position:relative;flex:1;min-height:0}
.lbl{position:absolute;left:8px;bottom:8px;z-index:2;font:800 clamp(9px,2.2vmin,11px)/1 ui-monospace,monospace;color:#fff;text-shadow:0 1px 3px rgba(0,0,0,.6)}
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:center;font:700 clamp(9px,2.2vmin,11px)/1 ui-monospace,monospace;color:var(--muted)}
js
var W = 300, H = 180;
var lc = document.getElementById('lc'), lx = lc.getContext('2d');
var rc = document.getElementById('rc'), rx = rc.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(lx, W, H);
var base = lx.getImageData(0, 0, W, H);
function boxBlur(src, w, h, r) {
  var tmp = new Float32Array(src.length), out = new Float32Array(src.length);
  for (var y = 0; y < h; y++) {
    for (var x = 0; x < w; x++) {
      var sr = 0, sg = 0, sb = 0, n = 0;
      for (var k = -r; k <= r; k++) { var xx = x + k; if (xx < 0 || xx >= w) continue; var idx = (y * w + xx) * 4; sr += src[idx]; sg += src[idx + 1]; sb += src[idx + 2]; n++; }
      var o = (y * w + x) * 4; tmp[o] = sr / n; tmp[o + 1] = sg / n; tmp[o + 2] = sb / n;
    }
  }
  for (var x2 = 0; x2 < w; x2++) {
    for (var y2 = 0; y2 < h; y2++) {
      var sr2 = 0, sg2 = 0, sb2 = 0, n2 = 0;
      for (var k2 = -r; k2 <= r; k2++) { var yy = y2 + k2; if (yy < 0 || yy >= h) continue; var idx2 = (yy * w + x2) * 4; sr2 += tmp[idx2]; sg2 += tmp[idx2 + 1]; sb2 += tmp[idx2 + 2]; n2++; }
      var o2 = (y2 * w + x2) * 4; out[o2] = sr2 / n2; out[o2 + 1] = sg2 / n2; out[o2 + 2] = sb2 / n2; out[o2 + 3] = 255;
    }
  }
  return out;
}
function edgeAwareBlur(src, w, h, r, sigma) {
  var out = new Float32Array(src.length);
  for (var y = 0; y < h; y++) {
    for (var x = 0; x < w; x++) {
      var ci = (y * w + x) * 4;
      var cl = (src[ci] + src[ci + 1] + src[ci + 2]) / 3;
      var sr = 0, sg = 0, sb = 0, sw = 0;
      for (var dy = -r; dy <= r; dy++) {
        for (var dx = -r; dx <= r; dx++) {
          var xx = x + dx, yy = y + dy;
          if (xx < 0 || xx >= w || yy < 0 || yy >= h) continue;
          var idx = (yy * w + xx) * 4;
          var nl = (src[idx] + src[idx + 1] + src[idx + 2]) / 3;
          var diff = nl - cl;
          var wgt = Math.exp(-(diff * diff) / (2 * sigma * sigma));
          sr += src[idx] * wgt; sg += src[idx + 1] * wgt; sb += src[idx + 2] * wgt; sw += wgt;
        }
      }
      out[ci] = sr / sw; out[ci + 1] = sg / sw; out[ci + 2] = sb / sw; out[ci + 3] = 255;
    }
  }
  return out;
}
function addNoiseArr(src) {
  var out = new Uint8ClampedArray(src.length);
  for (var i = 0; i < src.length; i += 4) {
    var n = (Math.random() - 0.5) * 46;
    out[i] = src[i] + n; out[i + 1] = src[i + 1] + n; out[i + 2] = src[i + 2] + n; out[i + 3] = 255;
  }
  return out;
}
function recompute() {
  var noisy = addNoiseArr(base.data);
  var plain = boxBlur(noisy, W, H, 2);
  var edge = edgeAwareBlur(noisy, W, H, 2, 24);
  var lImg = lx.createImageData(W, H);
  for (var i = 0; i < plain.length; i++) lImg.data[i] = plain[i];
  var rImg = rx.createImageData(W, H);
  for (var j = 0; j < edge.length; j++) rImg.data[j] = edge[j];
  lx.putImageData(lImg, 0, 0);
  rx.putImageData(rImg, 0, 0);
}
recompute();
var frameCount = 0;
function frame() {
  frameCount++;
  if (frameCount % 50 === 0) recompute();
  requestAnimationFrame(frame);
}
frame();

노이즈는 랜덤한 밝기·색 튐이라서 이웃 픽셀과 평균을 내면 서로 상쇄돼 줄어듭니다 — 원리 자체는 블러와 같습니다. 다만 무작정 블러를 걸면 진짜 경계까지 뭉개지므로, 실제 디노이즈 알고리즘은 "비슷한 픽셀끼리만" 평균 내 경계를 지키려 합니다. 이 데모는 사진에 인위적으로 노이즈를 얹은 뒤, 단순 평균(경계 무시)과 경계를 살리는 평균(비슷한 밝기끼리만 평균) 두 방식을 나란히 적용해 차이를 보여줍니다.

어두운 곳에서 ISO를 올려 찍었거나, 노출 부족을 나중에 밝게 보정해서 그림자에 노이즈가 도드라질 때 씁니다. 색 노이즈(색이 알록달록 튀는 것)와 휘도 노이즈(밝기만 튀는 것)는 눈에 걸리는 정도가 달라서, 보통 색 노이즈는 세게, 휘도 노이즈는 약하게 눌러 디테일을 지킵니다.

세게 걸면 피부·하늘 같은 매끈한 면은 좋아 보이지만 잔디·모래·직물의 미세한 질감까지 뭉개져 사진이 플라스틱처럼 밋밋해 보입니다("왁스칠한" 느낌이라고도 합니다). 그래서 전체에 같은 세기로 걸기보다 평평한 영역엔 세게, 질감 있는 영역엔 약하게 거는 것이 이상적입니다.

언제 쓰나

고감도 촬영이나 그림자를 밝게 보정한 뒤 노이즈가 두드러질 때 씁니다. 세게 걸면 질감까지 뭉개져 왁스칠한 느낌이 나니 영역별로 세기를 나누세요.