Unsharp mask

언샵 마스크

Blurs a copy of the image on purpose, subtracts it from the original to isolate edges, then adds those edges back to make the photo look sharper.

Also known as: 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();

The name sounds backwards — sharpening via an "unsharp" mask? The steps explain it. 1) Make a blurred copy of the original. 2) Subtract that blurred copy from the original — what's left is a "detail layer" that's near zero everywhere except at edges. 3) Add that detail layer back onto the original. The result pushes one side of every edge brighter and the other darker, exaggerating contrast right at the boundary so it reads as sharper — no new information was created, existing edge contrast was just amplified.

Optics, sensor demosaicing and resizing all soften an image a little along the way. Unsharp mask is the last-step correction that makes detail that was always there visible again, and it's usually applied after resizing to final output size, right before delivery to web or print.

Push the amount too high and every edge grows a visible bright/dark halo, especially at high-contrast boundaries like a tree branch against the sky. Apply it to something that should stay soft, like skin, and it exaggerates pores and fine lines into a rough, over-processed look.

When to use

Apply it last, after resizing to final output size. Dial the amount down — or mask it off — at strong edges like sky against an object.