스타일 트랜스퍼

Style transfer

한 이미지의 형태(내용)는 유지한 채, 다른 이미지의 색감·질감·붓터치 같은 스타일만 입히는 기법.

다른 이름: Neural style transferStyle conditioning
···
html
<div class="wrap">
  <div class="top">
    <div class="mini"><canvas id="content"></canvas><span>내용</span></div>
    <div class="mini"><canvas id="swatch"></canvas><span id="sname">스타일: 수채</span></div>
  </div>
  <div class="result"><canvas id="result"></canvas><span>결과</span></div>
</div>
css
.wrap{width:min(340px,96%);height:92%;display:grid;grid-template-rows:auto 1fr;gap:8px}
.top{display:flex;gap:8px}
.mini{flex:1;display:flex;flex-direction:column;align-items:center;gap:3px}
.mini canvas{width:100%;height:64px;border-radius:8px;border:1px solid var(--line)}
.mini span,.result span{font-size:9.5px;color:var(--muted);font-weight:700}
.result{display:flex;flex-direction:column;align-items:center;gap:3px;min-height:0}
.result canvas{width:100%;flex:1;border-radius:9px;border:1px solid var(--line);min-height:0}
js
const contentCv = document.getElementById('content'), swatchCv = document.getElementById('swatch'), resultCv = document.getElementById('result');
const sname = document.getElementById('sname');
function fitAll() {
  const dpr = Math.min(devicePixelRatio || 1, 2);
  [contentCv, swatchCv, resultCv].forEach((cv) => { const r = cv.getBoundingClientRect(); cv.width = Math.max(1, r.width) * dpr; cv.height = Math.max(1, r.height) * dpr; cv.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0); });
}
addEventListener('resize', fitAll);

function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }

function ridge(w, h, seed) {
  const r = mulberry32(seed);
  const pts = [];
  for (let i = 0; i <= 8; i++) { const x = w * i / 8; const y = h * 0.45 + Math.sin(i * 1.1 + seed) * h * 0.12 - r() * h * 0.08; pts.push([x, y]); }
  return pts;
}
const pts = { c: null };
function drawContent(w, h) {
  const ctx = contentCv.getContext('2d');
  ctx.clearRect(0, 0, w, h);
  pts.c = ridge(w, h, 3);
  ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--fg') || '#15151a';
  ctx.lineWidth = 1.6;
  ctx.beginPath(); ctx.moveTo(pts.c[0][0], pts.c[0][1]); pts.c.forEach(([x, y]) => ctx.lineTo(x, y)); ctx.stroke();
}
const styles = [
  { name: '수채', colors: ['#f2b5d4', '#f7dd72', '#8fd9c4'], pattern: 'blot' },
  { name: '잉크 그라데이션', colors: ['#2b2d63', '#7d3cff', '#00c2a8'], pattern: 'stripe' },
];
function paintSwatch(w, h, style) {
  const ctx = swatchCv.getContext('2d');
  ctx.clearRect(0, 0, w, h);
  const r = mulberry32(50);
  if (style.pattern === 'blot') {
    ctx.fillStyle = style.colors[0]; ctx.fillRect(0, 0, w, h);
    for (let i = 0; i < 10; i++) { ctx.fillStyle = style.colors[1 + (i % 2)]; ctx.globalAlpha = 0.6; ctx.beginPath(); ctx.arc(r() * w, r() * h, 6 + r() * 10, 0, Math.PI * 2); ctx.fill(); }
    ctx.globalAlpha = 1;
  } else {
    ctx.fillStyle = style.colors[0]; ctx.fillRect(0, 0, w, h);
    ctx.strokeStyle = style.colors[1]; ctx.lineWidth = 3;
    for (let x = -h; x < w; x += 7) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x + h, h); ctx.stroke(); }
  }
}
function paintResult(w, h, style) {
  const ctx = resultCv.getContext('2d');
  ctx.clearRect(0, 0, w, h);
  if (!pts.c) return;
  ctx.save();
  ctx.beginPath(); ctx.moveTo(0, h); pts.c.forEach(([x, y]) => ctx.lineTo(x, y)); ctx.lineTo(w, h); ctx.closePath(); ctx.clip();
  const r = mulberry32(77);
  if (style.pattern === 'blot') {
    ctx.fillStyle = style.colors[0]; ctx.fillRect(0, 0, w, h);
    for (let i = 0; i < 40; i++) { ctx.fillStyle = style.colors[1 + (i % 2)]; ctx.globalAlpha = 0.55; ctx.beginPath(); ctx.arc(r() * w, r() * h, 6 + r() * 14, 0, Math.PI * 2); ctx.fill(); }
    ctx.globalAlpha = 1;
  } else {
    ctx.fillStyle = style.colors[0]; ctx.fillRect(0, 0, w, h);
    ctx.strokeStyle = style.colors[1]; ctx.lineWidth = 4;
    for (let x = -h; x < w; x += 9) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x + h, h); ctx.stroke(); }
  }
  ctx.restore();
  ctx.strokeStyle = style.colors[2]; ctx.lineWidth = 2;
  ctx.beginPath(); ctx.moveTo(pts.c[0][0], pts.c[0][1]); pts.c.forEach(([x, y]) => ctx.lineTo(x, y)); ctx.stroke();
}
let si = 0;
function renderAll() {
  fitAll();
  const cr = contentCv.getBoundingClientRect(), sr = swatchCv.getBoundingClientRect(), rr = resultCv.getBoundingClientRect();
  drawContent(cr.width, cr.height);
  paintSwatch(sr.width, sr.height, styles[si]);
  paintResult(rr.width, rr.height, styles[si]);
  sname.textContent = '스타일: ' + styles[si].name;
}
renderAll();
setInterval(() => { si = (si + 1) % styles.length; renderAll(); }, 2200);

고전적인 신경망 스타일 트랜스퍼는 "내용"(형태·구도, 깊은 층의 활성값으로 대표됨)과 "스타일"(색·질감, 특징 맵들 사이의 상관관계로 대표됨)을 분리해서 뽑아낸 뒤, 내용은 한 이미지에서 스타일은 다른 이미지에서 가져와 새 이미지로 합성합니다.

요즘 확산 기반 도구에서는 이 개념을 최적화 대신 조건화로 구현하는 경우가 많습니다 — 스타일 참조 이미지를 img2img처럼 넣거나, 스타일만 전담하는 어댑터를 붙이는 식입니다. 결과물의 "무엇이 그려지는가"는 내용 쪽에, "어떻게 보이는가"는 스타일 쪽에 맡기는 발상 자체는 같습니다.

내용과 스타일은 완전히 독립적이지 않아서 서로 살짝 번질 수 있습니다. 스타일을 너무 강하게 걸면 형태의 윤곽까지 스타일 쪽 질감에 잡아먹히고, 반대로 내용을 너무 강하게 지키면 스타일이 옅은 색조 정도로만 남습니다.

아래 데모는 실제 스타일 분리·재합성 대신, 고정된 내용 윤곽선 위에 스타일 스와치의 팔레트·패턴만 바꿔 칠하는 절차적 시뮬레이션입니다.

언제 쓰나

구도는 정해져 있고 색감·질감만 다르게 입히고 싶을 때 씁니다. 형태 자체를 바꾸고 싶다면 img2img 강도나 컨트롤넷 쪽이 더 맞습니다.