주파수 분리

Frequency separation

사진을 색·톤을 담은 저주파 레이어와 질감을 담은 고주파 레이어로 나눠, 둘을 따로 손보고 다시 합치는 기법.

다른 이름: Freq sep고저주파 분리
···
html
<div class="wrap">
  <div class="stage"><canvas id="c" width="300" height="180"></canvas></div>
  <div class="hud"><b id="lbl">원본</b><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}
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, 4);
var lowData = ctx.createImageData(W, H);
var highData = ctx.createImageData(W, H);
var id0 = orig.data;
for (var i = 0; i < id0.length; i += 4) {
  lowData.data[i] = blurred[i]; lowData.data[i + 1] = blurred[i + 1]; lowData.data[i + 2] = blurred[i + 2]; lowData.data[i + 3] = 255;
  highData.data[i] = Math.min(255, Math.max(0, id0[i] - blurred[i] + 128));
  highData.data[i + 1] = Math.min(255, Math.max(0, id0[i + 1] - blurred[i + 1] + 128));
  highData.data[i + 2] = Math.min(255, Math.max(0, id0[i + 2] - blurred[i + 2] + 128));
  highData.data[i + 3] = 255;
}
var states = [orig, lowData, highData, orig];
var labels = ['원본', '저주파 (색·톤)', '고주파 (질감)', '재합성 = 저주파 + 고주파'];
var frameCount = 0;
function frame() {
  var stageIdx = Math.floor(frameCount / 66) % states.length;
  ctx.putImageData(states[stageIdx], 0, 0);
  document.getElementById('lbl').textContent = labels[stageIdx];
  frameCount++;
  requestAnimationFrame(frame);
}
frame();

저주파 레이어는 원본을 흐리게 만든 것 자체입니다 — 큰 색 얼룩과 명암만 남고 잔주름·모공 같은 질감은 사라집니다. 고주파 레이어는 원본에서 그 저주파를 뺀 차이입니다(중간 회색 128을 기준으로 표시) — 색과 큰 명암은 사라지고 미세한 질감 경계만 남습니다. 저주파와 고주파를 다시 더하면 원본과 완전히 같아집니다. 이 데모는 원본·저주파·고주파·재합성 네 상태를 순서대로 보여줍니다.

인물 리터칭에서 널리 씁니다. 피부의 붉은기·색 얼룩은 저주파 레이어에서만 고치고(질감은 안 건드림), 모공·잔주름 같은 질감은 고주파 레이어에서만 다듬어서(색은 안 건드림) 두 작업이 서로 간섭하지 않게 만듭니다. 클래리티처럼 톤 위에 얹는 근사가 아니라, 정말로 두 성분을 분리해 각각 정밀하게 조작한다는 점이 다릅니다.

저주파를 지나치게 흐리게 만들면(반경이 너무 크면) 경계 부근에 색이 새어나오는 헤일로가 생기고, 고주파를 과하게 강조하면 모공이 비현실적으로 도드라집니다. 재합성 전 고주파 레이어의 블렌드 모드를 잘못 두면(보통 "선형광" 류가 필요) 합쳐도 원본과 같아지지 않는다는 점도 흔한 실수입니다.

언제 쓰나

인물 피부처럼 색과 질감을 따로 다뤄야 할 때 씁니다. 저주파 반경이 너무 크면 색이 번지고, 고주파를 과하게 쓰면 모공이 부자연스러워집니다.