
무료 포토샵 개발기
주변 픽셀로 고치는 일
무료 포토샵을 만드는 포토샷 개발기입니다. 이번에는 AI 인물 사진의 벽에 있는 작은 얼룩을 복제 도장으로 지웠습니다. 원본을 복사한 레이어에서 작업하고, 되돌리기와 재실행까지 실제 화면에서 확인했습니다.
AI 생성 시연용 사진. 편집 화면과 출력 이미지는 실제 포토샷에서 만들었습니다.
출처에서 시작하다.
먼저 원본과 수정할 레이어를 나눈다
복제·복구는 대상 픽셀을 바꾸는 작업입니다. 이번 시연에서는 original 레이어를 남기고 Clone wall repair라는 복사본을 만들었습니다. 원본 보존과 실행 취소는 서로 다른 안전장치입니다. 한쪽이 있다고 다른 쪽을 생략하지 않습니다.
retouch.ts는 픽셀 잠금을 검사하며 문자·조정·도형·스마트 오브젝트 같은 대상을 바로 수정하지 않습니다. 크기·강도·경로·샘플 범위도 검사합니다. 화면에 도구가 있다는 사실과 해당 레이어를 수정할 수 있다는 조건을 구분합니다.
복제 도장은 소스에서 픽셀을 가져온다
Clone Stamp는 Alt 클릭으로 지정한 소스가 필요합니다. 목적지에서 움직인 만큼 소스 쪽도 이동하고, Clone Source의 크기·회전·뒤집기 변환을 거쳐 읽을 위치를 계산합니다. Current Layer와 합성된 다른 레이어를 읽는 선택도 구분됩니다.
이번에는 깨끗한 벽을 소스로 지정하고 왼쪽 얼룩에 한 번 찍었습니다. Size 110px, Hardness 20, Strength 100, Current Layer, Aligned를 사용했고 Pressure는 껐습니다. 낮은 경도로 가장자리를 섞었으므로 픽셀 전체를 딱 잘라 복사하는 것과 다릅니다.
if(layerLocked(doc,layer,'pixels'))throw Error('Unlock the layer before retouching.');
if(layer.kind==='text'||layer.kind==='adjustment'||layer.shape||layer.smartObject||layer.gridWarp||layer.puppet)throw Error('Rasterize this layer before retouching.');
if(!['clone','heal','dodge','burn','sponge','blur','sharpen','spot','smudge','remove','mixer'].includes(settings.mode)||!Number.isFinite(settings.size)||settings.size<1||settings.size>300||!Number.isFinite(settings.hardness)||settings.hardness<0||settings.hardness>100||!Number.isFinite(settings.strength)||settings.strength<0||settings.strength>100)throw Error('Invalid retouch brush.');
if(settings.sourceTransform)validateCloneTransform(settings.sourceTransform);
if(settings.sample!==undefined&&!['current','below','all'].includes(settings.sample))throw Error('Invalid sample layers.');
if(!['shadows','midtones','highlights'].includes(settings.range)||typeof settings.saturate!=='boolean'||typeof settings.pressure!=='boolean'||settings.source&&(!Number.isFinite(settings.source.x)||!Number.isFinite(settings.source.y)))throw Error('Invalid retouch settings.');if(!points.length)return doc;if(points.length>100000||points.some(p=>!Number.isFinite(p.x)||!Number.isFinite(p.y)||p.pressure!==undefined&&(!Number.isFinite(p.pressure)||p.pressure<0||p.pressure>1)))throw Error('Invalid retouch path.');계산의
안쪽으로.
복구·패치·제거 도구는 같은 이름의 작업이 아니다
현재 Healing Brush는 소스 픽셀에 주변 5×5 영역의 대상·소스 평균 차이를 더해 색을 맞춥니다. 패치는 선택 영역과 가져올 영역을 연결하는 별도 경로입니다. 기존 패치 보고서는 소스 보존·선택 이동·색 적응·새 레이어 출력 등을 확인한 사례를 기록합니다.
Spot Healing과 Remove는 칠한 영역을 마스크로 만든 뒤 자체 contentAwareFill 경로에 전달합니다. 이는 현재 문서의 주변 정보를 쓰는 구현이며 생성형 AI가 새 장면을 만드는 지우개라는 뜻은 아닙니다. 오늘의 사진 시연은 Clone Stamp만 사용했습니다.
되돌리기와 재실행으로 실제 변경을 확인했다
2026년 10월 22일 복제 도장 적용 뒤 벽의 어두운 얼룩이 주변 질감으로 덮였습니다. Undo를 누르면 얼룩이 돌아오고 Redo를 누르면 보정 결과가 돌아왔습니다. 브러시 원과 소스 표시를 감추기 위해 Move 도구로 바꾼 뒤 결과 화면을 저장했습니다.
프로젝트도 별도로 저장했습니다. 두 레이어가 남아 있어 원본과 수정본을 비교할 수 있습니다. 사진의 작은 얼룩에서 확인한 결과이며 넓은 반복 무늬·윤곽선·머리카락 같은 복잡한 경계의 품질까지 증명하는 시연은 아닙니다.
코드를 읽다.
export function validateCloneTransform(t:CloneTransform){if(!t||![t.scaleX,t.scaleY].every(n=>Number.isFinite(n)&&n>=1&&n<=1000)||!Number.isFinite(t.angle)||Math.abs(t.angle)>360||typeof t.flipX!=='boolean'||typeof t.flipY!=='boolean')throw Error('Invalid clone source transform.');}
// Inverse of the displayed source scale/rotation: destination pixels map back to donors.
export function cloneDelta(p:Point,t:CloneTransform=defaultCloneTransform):Point{const a=t.angle*Math.PI/180,c=Math.cos(a),s=Math.sin(a);return {x:(p.x*c+p.y*s)*100/t.scaleX*(t.flipX?-1:1),y:(-p.x*s+p.y*c)*100/t.scaleY*(t.flipY?-1:1)};}
export function cloneDonor(point:Point,start:Point,source:Point,t?:CloneTransform):Point{const d=cloneDelta({x:point.x-start.x,y:point.y-start.y},t);return {x:source.x+d.x,y:source.y+d.y};} const clone=settings.mode==='clone'||settings.mode==='heal';if(clone&&!settings.source)throw Error('Alt-click to choose a source point.');
const edit=await openPixelEdit(doc,layer),image=edit.image,base=image.data.slice(),data=image.data,w=image.width,h=image.height,clip=doc.selection?selectionForLayer(doc.selection,layer):undefined,path=points.map(p=>({...localPoint(p,layer),pressure:p.pressure??1})),coverage=new Float32Array(w*h),hard=settings.hardness/100;
const merged=clone&&settings.sample&&settings.sample!=='current'?(await openPixelEdit({...doc,layers:settings.sample==='below'?doc.layers.slice(0,doc.layers.findIndex(l=>l.id===layer.id)+1):doc.layers})).image:undefined;
const donor=merged,matrix=homography(quadPoints(layer));
const read=(x:number,y:number,shift:boolean):number[]|undefined=>{const world=mapMatrix(matrix,layer.flipX?1-(x+.5)/layer.width:(x+.5)/layer.width,layer.flipY?1-(y+.5)/layer.height:(y+.5)/layer.height),point=shift?cloneDonor(world,points[0],settings.source!,settings.sourceTransform):world;if(donor){const dx=Math.floor(point.x),dy=Math.floor(point.y);if(dx<0||dy<0||dx>=donor.width||dy>=donor.height)return;const i=(dy*donor.width+dx)*4;return Array.from(donor.data.subarray(i,i+4));}const local=shift?localPoint(point,layer):{x:x+.5,y:y+.5},dx=Math.floor(local.x+1e-7),dy=Math.floor(local.y+1e-7);if(dx<0||dy<0||dx>=w||dy>=h)return;const i=(dy*w+dx)*4;return Array.from(base.subarray(i,i+4));};
const lum=(i:number)=>(base[i]*.2126+base[i+1]*.7152+base[i+2]*.0722)/255;
const stamp=(p:RetouchPoint)=>{const pressure=settings.pressure?Math.max(.05,Math.min(1,p.pressure??1)):1,r=settings.size*pressure/2;for(let y=Math.max(0,Math.floor(p.y-r));y<Math.min(h,Math.ceil(p.y+r));y++)for(let x=Math.max(0,Math.floor(p.x-r));x<Math.min(w,Math.ceil(p.x+r));x++){const d=Math.hypot(x+.5-p.x,y+.5-p.y)/r;if(d>1)continue;const n=y*w+x,a=(d<=hard?1:(1-d)/Math.max(.001,1-hard))*settings.strength/100*(clip?clip.alpha[n]/255:1);coverage[n]=Math.max(coverage[n],a);}}; else for(let y=0;y<h;y++)for(let x=0;x<w;x++){const n=y*w+x,i=n*4,a=coverage[n];if(!a)continue;const brightness=lum(i),tone=Math.max(0,Math.min(1,brightness)),range=settings.range==='shadows'?(1-tone)**2:settings.range==='highlights'?tone**2:4*tone*(1-tone);let sampled:number[]=[];
if(clone){const pixel=read(x,y,true);if(!pixel)continue;sampled=pixel;if(settings.mode==='heal'){let target=[0,0,0],from=[0,0,0],tw=0,fw=0;for(let yy=-2;yy<=2;yy++)for(let xx=-2;xx<=2;xx++){const t=read(x+xx,y+yy,false),f=read(x+xx,y+yy,true);if(t){tw+=t[3];for(let k=0;k<3;k++)target[k]+=t[k]*t[3];}if(f){fw+=f[3];for(let k=0;k<3;k++)from[k]+=f[k]*f[3];}}if(tw&&fw)for(let k=0;k<3;k++)sampled[k]+=target[k]/tw-from[k]/fw;}}
for(let k=0;k<3;k++){const v=base[i+k];let value=v;if(clone)value=sampled[k];else if(settings.mode==='dodge')value=edit.native?v*(1+range):v+(255-v)*range;else if(settings.mode==='burn')value=v*(1-range);else if(settings.mode==='sponge')value=brightness*255+(v-brightness*255)*(settings.saturate?2:0);else {let sum=0,count=0;for(let yy=-1;yy<=1;yy++)for(let xx=-1;xx<=1;xx++){const qx=Math.max(0,Math.min(w-1,x+xx)),qy=Math.max(0,Math.min(h-1,y+yy));const alpha=base[(qy*w+qx)*4+3]/255;sum+=base[(qy*w+qx)*4+k]*alpha;count+=alpha;}const mean=count?sum/count:v;value=settings.mode==='blur'?mean:v+(v-mean)*2;}const outAlpha=clone&&!layer.lockTransparency?base[i+3]*(1-a)+sampled[3]*a:0;data[i+k]=clone&&!layer.lockTransparency?(outAlpha?(v*base[i+3]*(1-a)+(edit.native?value:Math.max(0,Math.min(255,value)))*sampled[3]*a)/outAlpha:0):v*(1-a)+(edit.native?value:Math.max(0,Math.min(255,value)))*a;}
if(clone&&!layer.lockTransparency)data[i+3]=base[i+3]*(1-a)+sampled[3]*a;편집 화면에서.

믹서·닷지·번과 과거 검사의 범위를 남긴다
Mixer Brush는 색 저장소와 섞기 설정을 별도 엔진으로 전달합니다. Dodge와 Burn은 Shadows·Midtones·Highlights 가중치를 사용하며, 선택 영역과 투명도 잠금도 결과에 영향을 줍니다. 이 기능들을 모두 같은 픽셀 복사 방식으로 설명하면 정확하지 않습니다.
기존 precision-retouch 보고서에는 여러 색 공간의 16비트·작은 알파·선택 밖 보존 사례가 있지만 이번에 다시 실행한 전체 시험은 아닙니다. 인물은 성인 가상 모델의 AI 생성 이미지이며 작업 화면은 실제 캡처입니다. 27편에서는 변형과 원근을 다룹니다.
원본과 벽 얼룩 복제 보정.


AI 생성 시연용 사진. 편집 화면과 출력 이미지는 실제 포토샷에서 만들었습니다.