언샵 마스크

Unsharp mask

이미지를 일부러 흐리게 만든 버전을 원본에서 빼서 만든 경계선을 다시 더해 또렷하게 보이게 만드는 샤프닝 기법.

다른 이름: USM샤프닝
···
html
<div class="wrap">
  <div class="stage"><canvas id="c" width="300" height="180"></canvas></div>
  <div class="hud"><span>왼쪽 원본 / 오른쪽 샤프닝</span><b id="amt">0%</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}
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);
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;
}
var blurred = boxBlur(orig.data, W, H, 2);
var t = 0;
function frame() {
  t += 0.02;
  var amt = (Math.sin(t) + 1) / 2 * 1.8;
  var out = ctx.createImageData(W, H);
  var id = orig.data, od = out.data;
  for (var i = 0; i < id.length; i += 4) {
    od[i] = Math.min(255, Math.max(0, id[i] + (id[i] - blurred[i]) * amt));
    od[i + 1] = Math.min(255, Math.max(0, id[i + 1] + (id[i + 1] - blurred[i + 1]) * amt));
    od[i + 2] = Math.min(255, Math.max(0, id[i + 2] + (id[i + 2] - blurred[i + 2]) * amt));
    od[i + 3] = 255;
  }
  var frac = (Math.sin(t * 0.55) + 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('amt').textContent = Math.round(amt / 1.8 * 100) + '%';
  requestAnimationFrame(frame);
}
frame();

이름이 역설적입니다 — 날카롭게 만드는데 "언샵(흐림)" 마스크라니. 순서를 보면 이해됩니다. 1) 원본을 흐리게 만든 사본을 만든다. 2) 원본에서 그 흐린 사본을 뺀다 — 경계선 부근에서만 값이 크게 남는 "디테일 레이어"가 된다. 3) 그 디테일을 원본에 다시 더한다. 결과적으로 경계 바로 옆은 더 밝게, 반대쪽은 더 어둡게 밀려 대비가 강조되고 눈에는 더 선명해 보입니다 — 실제로 새 정보가 생긴 게 아니라 기존 경계의 대비를 과장한 것입니다.

카메라·렌즈의 광학적 한계, 베이어 필터의 디모자이킹, 리사이즈 과정에서 이미지는 항상 약간 물러집니다. 언샵 마스크는 이 손실을 보정해 원래 있던 디테일을 눈에 다시 보이게 만드는 마지막 단계로, 보통 웹·인쇄용으로 최종 크기로 리사이즈한 다음에 적용합니다.

양을 과하게 올리면 경계마다 밝고 어두운 테두리(헤일로)가 또렷이 보이고, 특히 하늘과 나뭇가지가 만나는 부분처럼 대비가 강한 경계에서 부자연스러운 윤곽선이 생깁니다. 피부처럼 원래 부드러워야 할 면에 걸면 모공과 잔주름까지 과장돼 거칠어 보입니다.

언제 쓰나

리사이즈·서적 최종 단계에서 마지막으로 적용하세요. 하늘과 물체가 만나는 강한 경계에서는 양을 낮추거나 마스크로 제외하세요.