아웃페인팅

Outpainting

원본 이미지 바깥으로 캔버스를 넓히고, 넓어진 여백을 원본과 자연스럽게 이어지도록 새로 채워 넣는 방식.

다른 이름: Generative fill outwardCanvas extension
···
html
<div class="wrap"><canvas id="cv"></canvas></div>
css
.wrap{position:relative;width:100%;height:100%}
#cv{position:absolute;inset:0;width:100%;height:100%}
js
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const big = document.createElement('canvas');
const bctx = big.getContext('2d');
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 paintFull(w, h) {
  const r = mulberry32(9);
  const g = bctx.createLinearGradient(0, 0, 0, h);
  g.addColorStop(0, 'hsl(255 45% 55%)'); g.addColorStop(1, 'hsl(230 40% 22%)');
  bctx.fillStyle = g; bctx.fillRect(0, 0, w, h);
  bctx.beginPath(); bctx.arc(w * 0.62, h * 0.2, Math.min(w, h) * 0.07, 0, Math.PI * 2); bctx.fillStyle = 'hsl(50 85% 78%)'; bctx.fill();
  for (let l = 0; l < 3; l++) {
    bctx.beginPath(); bctx.moveTo(0, h);
    for (let i = 0; i <= 10; i++) { const x = w * i / 10; const y = h * (0.55 + l * 0.12) - r() * h * 0.1; bctx.lineTo(x, y); }
    bctx.lineTo(w, h); bctx.closePath();
    bctx.fillStyle = 'hsl(255 30% ' + (16 + l * 10) + '%)'; bctx.fill();
  }
}
function rebuild() {
  const dpr = Math.min(devicePixelRatio || 1, 2);
  cv.width = innerWidth * dpr; cv.height = innerHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  big.width = innerWidth; big.height = innerHeight;
  paintFull(innerWidth, innerHeight);
}
addEventListener('resize', rebuild); rebuild();

const core = { x: 0.32, y: 0.3, w: 0.36, h: 0.4 };
function rectAt(g) {
  const cx = core.x + core.w / 2, cy = core.y + core.h / 2;
  const cw = core.w + (1 - core.w) * g, ch = core.h + (1 - core.h) * g;
  return { x: cx - cw / 2, y: cy - ch / 2, w: cw, h: ch };
}
function render(g) {
  const w = innerWidth, h = innerHeight;
  ctx.clearRect(0, 0, w, h);
  ctx.fillStyle = 'rgba(255,255,255,0.05)';
  for (let y = 0; y < h; y += 14) for (let x = 0; x < w; x += 14) ctx.fillRect(x, y, 2, 2);
  const r = rectAt(g);
  const rx = r.x * w, ry = r.y * h, rw = r.w * w, rh = r.h * h;
  ctx.save(); ctx.beginPath(); ctx.rect(rx, ry, rw, rh); ctx.clip();
  ctx.drawImage(big, 0, 0, w, h);
  ctx.restore();
  ctx.setLineDash([6, 4]); ctx.strokeStyle = 'rgba(255,255,255,0.75)'; ctx.lineWidth = 1.5;
  ctx.strokeRect(rx, ry, rw, rh); ctx.setLineDash([]);
  const cr = { x: core.x * w, y: core.y * h, w: core.w * w, h: core.h * h };
  ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.strokeRect(cr.x, cr.y, cr.w, cr.h);
}
let g = 0, dir = 1;
function loop() {
  render(g);
  g += dir * 0.012;
  if (g >= 1) { g = 1; dir = 0; setTimeout(() => { dir = -1; }, 1000); }
  if (g <= 0 && dir === -1) { g = 0; dir = 0; setTimeout(() => { dir = 1; }, 800); }
  setTimeout(loop, 30);
}
loop();

인페인팅을 뒤집은 방식이라고 볼 수 있습니다. 새로 넓어진 캔버스 영역 전체가 마스크가 되고, 원본에 가까운 가장자리 픽셀을 조건으로 삼아 그 원근·조명·질감이 이어지도록 새 내용을 채웁니다. 원본 안쪽 영역 자체는 인페인팅과 마찬가지로 건드리지 않습니다.

한 번에 조금씩 넓힌 뒤 그 결과를 다시 원본 삼아 또 넓히는 식으로 반복하면 원래 프레임 밖으로 한참 벗어난 장면까지 만들어낼 수 있습니다. 파노라마를 만들거나, 세로 사진을 가로 배경으로 확장할 때 흔히 쓰입니다.

다만 원본에서 멀어질수록 새로 생성되는 내용은 원본 앵커와의 일관성이 흐려지기 쉽습니다. 여러 타일을 이어 붙일수록 스타일이 조금씩 표류할 수 있다는 뜻이라, 전체적인 통일성이 보장되는 건 아닙니다.

아래 데모는 실제 생성 대신, 미리 한 번에 그려둔 큰 장면 이미지를 중앙의 원본 영역에서부터 점점 넓은 영역으로 드러내는 방식으로 "바깥이 채워지는" 느낌을 흉내 낸 시뮬레이션입니다 — 그래서 중앙 영역은 항상 픽셀 단위로 그대로 유지됩니다.

언제 쓰나

기존 이미지의 구도를 넓히거나 비율을 바꿔야 할 때 씁니다. 원본에서 아주 멀리까지 확장하면 스타일 일관성을 눈으로 계속 확인하는 편이 좋습니다.