얼굴 위에 메시를 놓다 / original
DEVELOPMENT JOURNAL / 35
Photoshot
CODE × UI
무료 포토샵 개발기

얼굴 위에 메시를 놓다

슬라이더가 있다는 것과 실제 얼굴 위치를 찾아 변형한다는 것은 다릅니다. 이번 사진에서는 공개 포토샷의 Detect Faces를 실행해 얼굴 지점을 검출한 뒤 Eye Size와 Smile을 조절했습니다. 변형 메시와 적용 결과, 실행 취소·복원을 함께 확인합니다.

PHOTOSHOT DEVELOPMENT JOURNAL / 35

AI 생성 시연 이미지 · 실제 인물 촬영 및 공식 협업 이미지가 아닙니다. 편집 화면과 결과는 실제 포토샷에서 만들었습니다.

Photoshot무료 포토샵 개발기 / 35FOUNDATION

출처에서 시작하다.

붓자국 대신 변위 메시를 쌓는다

Liquify는 메시의 각 지점에 가로·세로 변위와 보호 영역을 기록합니다. 기본 메시 크기는 129이며 검증 함수는 9부터 129까지의 정사각 격자를 허용합니다. 브러시를 움직일 때 역방향 변형을 합성하고 최종 적용 때 이미지를 다시 샘플링합니다.

도구는 Forward Warp, Pucker, Bloat, Twirl Clockwise, Reconstruct, Smooth, Freeze Mask, Thaw Mask의 여덟 가지입니다. 보호 영역도 같은 메시 안에 있으므로 변형 계산에서 함께 읽어야 합니다. 단순히 위에 격자를 그려 놓는 기능과는 데이터 구조부터 다릅니다.

얼굴의 위치는 로컬 검출 모델로 찾는다

face-liquify는 MediaPipe FaceLandmarker를 로컬 제공 모델과 함께 사용합니다. IMAGE 모드에서 최대 다섯 얼굴을 찾도록 설정하며, 검출된 좌표를 메시 변형 함수로 전달합니다. 얼굴을 못 찾으면 명시적인 오류를 내고 일반 Liquify 브러시를 안내합니다.

변형 함수는 최소 468개 지점과 유효한 좌표·화면 비율·조절 범위를 검사합니다. 눈의 방향을 기준으로 얼굴의 회전 좌표계를 만들고 눈·코·입·턱 주변에 국소 변형을 계산합니다. 이 검출은 위치를 찾는 것이며 인물의 신원을 판정하는 절차가 아닙니다.

app/studio/liquify.ts · SHA-256 2ba27135fce3 · L5–13포토샷 구현 코드 ↗
export const liquifyTools={warp:'Forward Warp',pucker:'Pucker',bloat:'Bloat',twirl:'Twirl Clockwise',reconstruct:'Reconstruct',smooth:'Smooth',freeze:'Freeze Mask',thaw:'Thaw Mask'};
export type LiquifyTool=keyof typeof liquifyTools;
export type LiquifyMesh={version:1;size:number;dx:number[];dy:number[];freeze:number[]};
export type LiquifyBrush={tool:LiquifyTool;radius:number;pressure:number;density:number;aspect:number};
export const newLiquifyMesh=(size=129):LiquifyMesh=>({version:1,size,dx:Array(size*size).fill(0),dy:Array(size*size).fill(0),freeze:Array(size*size).fill(0)});
export function validateLiquifyMesh(v:unknown):asserts v is LiquifyMesh{const m=v as LiquifyMesh;if(!m||m.version!==1||!Number.isInteger(m.size)||m.size<9||m.size>129)throw Error('Invalid Liquify mesh.');for(const k of ['dx','dy','freeze'] as const)if(!Array.isArray(m[k])||m[k].length!==m.size*m.size||m[k].some(n=>!Number.isFinite(n)||(k==='freeze'?(n<0||n>1):Math.abs(n)>4)))throw Error('Invalid Liquify mesh values.');}
const clamp=(v:number,a=0,b=1)=>Math.max(a,Math.min(b,v));
function sample(a:number[],n:number,x:number,y:number){x=clamp(x)*(n-1);y=clamp(y)*(n-1);const ix=Math.floor(x),iy=Math.floor(y),fx=x-ix,fy=y-iy,jy=Math.min(n-1,iy+1),jx=Math.min(n-1,ix+1);return a[iy*n+ix]*(1-fx)*(1-fy)+a[iy*n+jx]*fx*(1-fy)+a[jy*n+ix]*(1-fx)*fy+a[jy*n+jx]*fx*fy;}
/** Inverse deformation composition; images are resampled once on application. */
Photoshot무료 포토샵 개발기 / 35IMPLEMENTATION

계산의
안쪽으로.

새 시연 사진에서 두 조절을 적용했다

공개 편집기의 Liquify 창에서 Detect Faces를 실행하고 실제 검출 결과가 있는지 확인했습니다. Eye Size를 25, Smile을 15로 지정한 뒤 Add Face Shape로 메시 변형을 더했습니다. Show Mesh를 켠 화면을 이 글의 작업 화면으로 남겼습니다.

이 수치는 기능 경로를 확인하기 위한 예시입니다. 기본값이 더 아름답다거나 특정 얼굴로 교정해야 한다는 기준으로 사용하지 않습니다. 생성한 원본 사진과 그 원본에 실제 변형을 적용한 결과를 전후로 배치했습니다.

메시 파일과 문서의 실행 취소를 따로 확인했다

Save Mesh로 내려받은 파일의 변위가 실제로 바뀌어 있는지 확인한 뒤 Apply Liquify를 실행했습니다. 적용 후 src는 원본과 달랐고 Undo는 적용 전 src를 정확히 복원했습니다. Redo는 적용 후 src를 정확히 복원했습니다.

별도로 다시 실행한 렌더 검사는 여덟 조절, 중립값에서 원본 유지, 눈 확대의 역매핑, Freeze 보호, 입력 불변성, 잘못된 입력을 확인합니다. 이 검사는 고정된 테스트 데이터에 대한 결과이고, 이번 사진의 UI 검사는 별도 기록에 남겼습니다.

PHOTOSHOT / SOURCE NOTES35

코드를 읽다.

app/studio/face-liquify.ts · SHA-256 a4d951919dcc · L7–11포토샷 구현 코드 ↗
export async function detectLiquifyFaces(source:HTMLCanvasElement):Promise<FacePoint[][]>{
 const base=((import.meta as ImportMeta & {env?:{BASE_URL?:string}}).env?.BASE_URL||'/photoshot/editor/')+'portrait-ai/';
 detector??=(async()=>{const vision=await import('./vendor/portrait/vision_bundle.js'),files=await vision.FilesetResolver.forVisionTasks(base+'wasm');return vision.FaceLandmarker.createFromOptions(files,{baseOptions:{modelAssetPath:base+'face_landmarker.task',delegate:'CPU'},runningMode:'IMAGE',numFaces:5,minFaceDetectionConfidence:.5,minFacePresenceConfidence:.5,outputFaceBlendshapes:false,outputFacialTransformationMatrixes:false});})().catch(e=>{detector=undefined;throw e;});
 const model=await detector;await new Promise<void>(r=>setTimeout(r,0));const faces=model.detect(source).faceLandmarks as FacePoint[][];if(!faces?.length)throw Error('No face found. Use a clear front-facing portrait or the Liquify brushes.');return faces.map(f=>f.map(({x,y})=>({x,y})));
}
app/studio/face-liquify.ts · SHA-256 a4d951919dcc · L15–21포토샷 구현 코드 ↗
export function faceLiquifyMesh(mesh:LiquifyMesh,landmarks:FacePoint[],controls:FaceControls,aspect:number):LiquifyMesh{
 validateLiquifyMesh(mesh);if(landmarks.length<468||landmarks.some(p=>!p||!Number.isFinite(p.x)||!Number.isFinite(p.y))||!Number.isFinite(aspect)||aspect<=0||Object.keys(faceControlNames).some(k=>!Number.isFinite(controls[k as keyof FaceControls])||Math.abs(controls[k as keyof FaceControls])>100))throw Error('Invalid Face-Aware Liquify settings.');
 const point=(i:number)=>({x:landmarks[i].x*aspect,y:landmarks[i].y}),average=(a:number,b:number)=>({x:(point(a).x+point(b).x)/2,y:(point(a).y+point(b).y)/2}),left=average(33,133),right=average(362,263),eyeDistance=Math.hypot(right.x-left.x,right.y-left.y),ux=(right.x-left.x)/eyeDistance,uy=(right.y-left.y)/eyeDistance,vx=-uy,vy=ux,width=Math.hypot(point(454).x-point(234).x,point(454).y-point(234).y),height=Math.hypot(point(152).x-point(10).x,point(152).y-point(10).y);if(eyeDistance<.005||width<.01||height<.01)throw Error('Face is too small to reshape.');
 const out={...mesh,dx:[...mesh.dx],dy:[...mesh.dy],freeze:[...mesh.freeze]},n=mesh.size;
 type Region={center:FacePoint;rx:number;ry:number;sx?:number;sy?:number;mx?:number;my?:number};const regions:Region[]=[];
 for(const [eye,direction] of [[left,-1],[right,1]] as const)regions.push({center:eye,rx:width*.2,ry:height*.12,sx:controls.eyeSize*.003,sy:controls.eyeSize*.003,mx:direction*controls.eyeDistance/100*width*.07});
 regions.push({center:point(1),rx:width*.19,ry:height*.2,sx:controls.noseWidth*.003});
app/studio/blur-gallery.ts · SHA-256 fadb2387fa48 · L1–9포토샷 구현 코드 ↗
import {surface,layerSurface,type StudioDoc,type Layer} from './document.ts';import {gaussianBlur} from './gaussian.ts';import {canGeneralFilter} from './general-filters.ts';import {selectionForLayer} from './photoshop-engine.ts';import {rasterizeLayer} from './rasterize.ts';
export type BlurGallery={bokeh?:number;bokehColor?:number;bokehLow?:number;bokehHigh?:number;mode:'field'|'iris'|'tilt'|'path'|'spin';path?:{x:number;y:number}[];spinAngle?:number;pins:{x:number;y:number;blur:number}[];x:number;y:number;radiusX:number;radiusY:number;feather:number;angle:number;blur:number};
export const defaultBlurGallery=():BlurGallery=>({bokeh:0,bokehColor:50,bokehLow:192,bokehHigh:255,mode:'field',path:[{x:.2,y:.5},{x:.5,y:.25},{x:.8,y:.5}],spinAngle:20,pins:[{x:.5,y:.5,blur:0},{x:.15,y:.15,blur:15}],x:.5,y:.5,radiusX:.25,radiusY:.25,feather:.25,angle:0,blur:15});
export function validateBlurGallery(v:unknown):asserts v is BlurGallery{const s=v as BlurGallery;if(!s||!['field','iris','tilt','path','spin'].includes(s.mode)||!Array.isArray(s.pins)||s.pins.length<1||s.pins.length>16||s.pins.some(p=>!p||![p.x,p.y,p.blur].every(Number.isFinite)||p.x<0||p.x>1||p.y<0||p.y>1||p.blur<0||p.blur>50))throw Error('Invalid Blur Gallery settings.');for(const [key,min,max]of [['x',0,1],['y',0,1],['radiusX',.01,1],['radiusY',.01,1],['feather',.01,1],['angle',-180,180],['blur',0,50]] as const)if(!Number.isFinite(s[key])||s[key]<min||s[key]>max)throw Error('Invalid Blur Gallery control.');for(const [key,min,max,initial]of [['bokeh',0,100,0],['bokehColor',0,100,50],['bokehLow',0,254,192],['bokehHigh',1,255,255]] as const){const v=s[key]??initial;if(!Number.isFinite(v)||v<min||v>max)throw Error('Invalid Bokeh setting.');}if((s.bokehLow??192)>=(s.bokehHigh??255))throw Error('Bokeh light range must increase.');if(s.mode==='path'&&(!Array.isArray(s.path)||s.path.length!==3||s.path.some(p=>!p||![p.x,p.y].every(Number.isFinite)||p.x<0||p.x>1||p.y<0||p.y>1)))throw Error('Path Blur requires three control points.');if(s.mode==='spin'&&(!Number.isFinite(s.spinAngle)||s.spinAngle!<0||s.spinAngle!>180))throw Error('Invalid Spin Blur angle.');}
export function blurAt(s:BlurGallery,x:number,y:number,aspect=1){if(s.mode==='field'){let sum=0,weight=0;for(const p of s.pins){const d=((x-p.x)*aspect)**2+(y-p.y)**2;if(d<1e-10)return p.blur;const q=1/(d*d);sum+=p.blur*q;weight+=q;}return sum/weight;}const a=s.angle*Math.PI/180,dx=(x-s.x)*aspect,dy=y-s.y,px=dx*Math.cos(a)+dy*Math.sin(a),py=-dx*Math.sin(a)+dy*Math.cos(a),d=s.mode==='iris'?(Math.hypot(px/(s.radiusX*aspect),py/s.radiusY)-1)*Math.min(s.radiusX*aspect,s.radiusY):Math.abs(py)-s.radiusY,t=Math.max(0,Math.min(1,d/s.feather));return s.blur*t*t*(3-2*t);}
async function renderBlurBase(source:HTMLCanvasElement,s:BlurGallery,ratio=1,cancel=()=>false,coverage?:number[]|Uint8ClampedArray,preserveAlpha=false){validateBlurGallery(s);const w=source.width,h=source.height;if(w*h>4_000_000)throw Error('Blur Gallery supports up to 4 MP per layer.');if(s.mode==='path'||s.mode==='spin')return renderMotionGallery(source,s,ratio,cancel,coverage,preserveAlpha);const check=()=>{if(cancel())throw Error('Blur Gallery cancelled. No changes applied.');},src=source.getContext('2d')!.getImageData(0,0,w,h),out=surface(w,h),g=out.getContext('2d')!,result=g.createImageData(w,h),radius=new Float32Array(w*h),sum=new Float32Array(w*h*4),max=(s.mode==='field'?Math.max(...s.pins.map(p=>p.blur)):s.blur),steps=8;for(let y=0;y<h;y++)for(let x=0;x<w;x++)radius[y*w+x]=max?blurAt(s,x/Math.max(1,w-1),y/Math.max(1,h-1),w/h)/max*steps:0;
 for(let level=0;level<=steps;level++){await new Promise<void>(r=>setTimeout(r,0));check();const c=level?gaussianBlur(source,max*ratio*level/steps):source,pixels=level?c.getContext('2d')!.getImageData(0,0,w,h).data:src.data;for(let i=0;i<w*h;i++){const t=Math.max(0,1-Math.abs(radius[i]-level)),k=i*4,a=pixels[k+3]*t;sum[k+3]+=a;for(let ch=0;ch<3;ch++)sum[k+ch]+=pixels[k+ch]*a;}if(c!==source)c.width=c.height=1;}
 for(let i=0;i<w*h;i++){const k=i*4,t=coverage?coverage[i]/255:1,a=sum[k+3]*t+src.data[k+3]*(1-t);for(let ch=0;ch<3;ch++)result.data[k+ch]=a?(sum[k+ch]*t+src.data[k+ch]*src.data[k+3]*(1-t))/a:0;result.data[k+3]=preserveAlpha?src.data[k+3]:a;}check();g.putImageData(result,0,0);return out;
}
Photoshot무료 포토샵 개발기 / 35IN PRACTICE

편집 화면에서.

얼굴 위에 메시를 놓다 / workspace
공개 포토샷에서 얼굴 검출 후 메시를 표시한 Liquify 작업창.

전용 작업창은 각자의 구조를 가진다

같은 Filter 메뉴에 있어도 모든 작업창이 Liquify 메시를 공유하지는 않습니다. 원래 편성에 포함된 Adaptive Wide Angle은 선 제약을, Blur Gallery는 핀과 흐림 영역을 다룹니다. Blur Gallery의 다섯 모드와 4MP 제한도 현재 코드를 확인했습니다. Liquify 경로는 일반 필터의 16MP 검사를 사용합니다.

이번 새 사진의 실행 화면은 Liquify이며, 광각·흐림 작업창까지 이 사진으로 새로 시연했다고 기록하지 않았습니다. 각 기능의 데이터 구조와 검사 범위를 구분해 두어 다음 수정 때 근거를 다시 찾을 수 있게 했습니다. 다음 편은 센서 원본을 보관하고 다시 현상하는 RAW 처리입니다.

Photoshot무료 포토샵 개발기 / 35BEFORE / AFTER

원본과 편집 결과.

얼굴 위에 메시를 놓다 / original
AI 생성 시연 원본. 실제 인물 촬영이나 공식 협업 사진이 아닙니다.
얼굴 위에 메시를 놓다 / result
Eye Size 25·Smile 15를 메시로 더하고 실제 적용한 출력.

AI 생성 시연 이미지 · 실제 인물 촬영 및 공식 협업 이미지가 아닙니다. 편집 화면과 결과는 실제 포토샷에서 만들었습니다.