채울 곳과 지킬 곳 / original
DEVELOPMENT JOURNAL / 28
Photoshot
CODE × UI
무료 포토샵 개발기

채울 곳과 지킬 곳

무료 포토샵을 만드는 포토샷 개발기입니다. 채우기에 쓸 주변 영역이 사라진 원인을 해결한 뒤 스티커 제거를 다시 완료했습니다. 이어 인물 보호 채널을 적용해 폭을 줄이고, 보호 설정을 끈 결과와 저장 픽셀까지 비교했습니다.

PHOTOSHOT DEVELOPMENT JOURNAL / 28

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

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

출처에서 시작하다.

내용 인식 채우기는 주변의 패치를 찾는다

content-aware.ts는 선택 영역을 바깥 경계부터 안쪽으로 채웁니다. 아직 채우지 않은 곳을 제외한 주변 색을 비교해 후보 패치를 찾습니다. 가까운 후보와 전역 후보를 비교하지만, 학습된 생성형 모델이 새로운 물체를 상상하는 경로는 아닙니다.

패치 후보에는 선택된 픽셀이나 샘플에서 제외된 픽셀이 섞이면 안 됩니다. 채울 영역과 가져올 영역을 분리해야 같은 스티커가 다시 복제되는 일을 줄일 수 있습니다. 적절한 주변 질감이 없다면 자연스러운 결과도 기대하기 어렵습니다.

새 레이어 출력은 원본과 결과를 분리한다

2026년 10월 22일 스티커 주변을 사각 선택하고 Feather 4를 사용했습니다. Fill의 Patch Size는 4, Sampling Layers는 All Layers, Sampling Area는 Auto — outside selection, Output To는 New Layer로 두었습니다. 미리보기에서 스티커 대신 밝은 벽 질감이 보인 뒤 적용했습니다.

프로젝트에는 original과 Content-Aware Fill 두 레이어가 남았습니다. 원본 src의 PNG 바이트는 입력 파일과 정확히 같았습니다. 새 레이어의 불투명 영역은 선택 주변으로 한정됐으며 경계에는 부드러운 알파 값이 남아 있습니다.

app/studio/content-aware.ts · SHA-256 3f1bafefa260 · L7–15포토샷 구현 코드 ↗
/** Exemplar patch synthesis. Donor patches never include selected or excluded pixels. */
export async function contentAwarePixels(image:{width:number;height:number;data:Uint8ClampedArray|Float32Array},mask:ArrayLike<number>,options:Pick<FillOptions,'radius'|'sampling'|'signal'|'progress'>){
 check(options.signal);const {width:w,height:h}=image,n=w*h,r=Math.round(options.radius);if(!supportedSize(w,h)||!Number.isInteger(w)||!Number.isInteger(h)||image.data.length!==n*4||mask.length!==n||!Number.isFinite(r)||r<1||r>15)throw Error('Invalid fill image or patch size.');if(options.sampling&&(options.sampling.length!==n||options.sampling.some(v=>!Number.isFinite(v)||v<0||v>255)))throw Error('Invalid sampling area.');
 const source=image.data,out=source.slice(),missing=new Uint8Array(n),queue=new Int32Array(n),seen=new Uint8Array(n),integral=new Int32Array((w+1)*(h+1));let total=0,tail=0;
 for(let y=0;y<h;y++){if(y%64===0){check(options.signal);await new Promise(r=>setTimeout(r,0));}let row=0;for(let x=0;x<w;x++){const i=y*w+x;if(!Number.isFinite(mask[i])||mask[i]<0||mask[i]>255)throw Error('Invalid fill selection.');missing[i]=mask[i]>0?1:0;total+=missing[i];row+=missing[i]||source[i*4+3]<250||options.sampling&&options.sampling[i]<128?1:0;integral[(y+1)*(w+1)+x+1]=integral[y*(w+1)+x+1]+row;}}
 if(!total)throw Error('Select an area to fill.');
 const candidates:number[]=[],valid=new Uint8Array(n),stride=w+1;
 for(let y=r;y<h-r;y++){if(y%64===0){check(options.signal);await new Promise(r=>setTimeout(r,0));}for(let x=r;x<w-r;x++){const x0=x-r,y0=y-r,x1=x+r+1,y1=y+r+1;if(integral[y1*stride+x1]-integral[y0*stride+x1]-integral[y1*stride+x0]+integral[y0*stride+x0]===0){valid[y*w+x]=1;candidates.push(y*w+x);}}}
 if(!candidates.length)throw Error('No clean sampling patch. Expand the sampling area or reduce Patch Size.');
Photoshot무료 포토샵 개발기 / 28IMPLEMENTATION

계산의
안쪽으로.

선택 범위를 바로잡아 채우기를 다시 완료했다

전체 선택 상태에서 실패한 이유는 선택 밖의 샘플이 하나도 남지 않았기 때문입니다. 오류를 결과로 남기는 대신 전체 선택을 해제하고, 원본을 다시 열어 스티커와 그 주변만 사각형으로 선택했습니다. 깨끗한 벽을 선택 밖에 남기자 같은 Patch Size 4로 채우기가 정상 완료됐습니다.

새 레이어 출력을 적용해 원본은 그대로 보존했습니다. 즉, 이번 문제의 해결은 채우기 알고리즘을 무조건 통과시키는 것이 아니라 대상과 소스 영역을 올바르게 나누는 것이었습니다. 재실행 결과를 저장해 원본 바이트와 출력 레이어를 다시 확인했습니다.

인물 보호 채널을 지정하고 1536px를 1200px로 줄였다

원본의 인물 쪽 영역을 선택해 Portrait protect 채널로 저장했습니다. Content-Aware Scale에서 Width 1200, Height 1024, Protect Portrait protect, Reference Point Middle Right를 지정하고 미리보기를 갱신한 뒤 적용했습니다.

문서는 1536×1024를 유지하고 레이어가 1200×1024, X 336, Y 0이 됐습니다. 오른쪽 기준점이 유지돼 왼쪽 336px는 투명하게 남습니다. 저장된 PNG에서 보호 영역 757,020개 픽셀을 위치 이동을 반영해 비교한 결과, 모든 RGBA 값의 차이가 0이었습니다.

PHOTOSHOT / SOURCE NOTES28

코드를 읽다.

app/studio/content-aware.ts · SHA-256 3f1bafefa260 · L33–37포토샷 구현 코드 ↗
export async function contentAwareFill(doc:StudioDoc,layer:Layer,options:FillOptions):Promise<StudioDoc>{

 if(!doc.selection)throw Error('Make a selection first.');if(options.output==='new'&&doc.layers.length>=256)throw Error('Up to 256 layers.');if(options.output==='current'&&(layerLocked(doc,layer,'pixels')||layer.kind==='text'||layer.kind==='adjustment'||layer.shape||layer.smartObject||layer.gridWarp||layer.puppet))throw Error('Choose New Layer or an unlocked raster layer.');
 check(options.signal);const edit=await openPixelEdit(options.sample==='all'?doc:{...doc,layers:[layer]}),pixels=edit.image,result=await contentAwarePixels(pixels,doc.selection.alpha,options);check(options.signal);
 if(options.output==='new'){for(let i=0;i<doc.width*doc.height;i++)result[i*4+3]*=doc.selection.alpha[i]/255;const output=await newPixelEditLayer(edit,'Content-Aware Fill',result);check(options.signal);return {...doc,layers:[...doc.layers,output]};}
app/studio/content-scale.ts · SHA-256 ff2884f5e7a4 · L9–12포토샷 구현 코드 ↗
/** Dynamic-programming seams, including masks, follow the same pixel correspondence. */
export async function carveImage(input:ImageBuffer,width:number,height:number,options:{signal?:AbortSignal;progress?:(n:number)=>void}={}){
 if(!supportedSize(width,height)||!Number.isInteger(width)||!Number.isInteger(height)||!supportedSize(input.width,input.height)||input.data.length!==input.width*input.height*4||input.channels.some(c=>c.length!==input.width*input.height||c.some(v=>!Number.isFinite(v)||v<0||v>255)))throw Error('Invalid content-aware size or channels.');let image:ImageBuffer={...input,data:input.data.slice(),channels:input.channels.map(c=>c.slice())},done=0,total=Math.abs(width-input.width)+Math.abs(height-input.height),yieldAt=performance.now();
 const checkpoint=async()=>{if(options.signal?.aborted)throw new DOMException('Scale canceled.','AbortError');if(performance.now()-yieldAt>16){options.progress?.(total?done/total:1);await new Promise(r=>setTimeout(r,0));yieldAt=performance.now();if(options.signal?.aborted)throw new DOMException('Scale canceled.','AbortError');}};
app/studio/content-scale.ts · SHA-256 ff2884f5e7a4 · L20–24포토샷 구현 코드 ↗
export async function contentAwareScale(doc:StudioDoc,layer:Layer,options:ScaleOptions):Promise<StudioDoc>{

 if(layerLocked(doc,layer,'pixels')||layerLocked(doc,layer,'position')||layer.kind==='adjustment'||layer.kind==='text'||layer.smartObject||layer.shape||layer.gridWarp||layer.puppet)throw Error('Select an unlocked raster layer. Rasterize type, shapes, Smart Objects or existing warps first.');if(!Number.isInteger(options.anchor)||options.anchor<0||options.anchor>8)throw Error('Invalid scale reference point.');
 const edit=await openPixelEdit(doc,layer),canvas=edit.canvas,pixels=edit.image,selected=options.protect==='$selection'?doc.selection:options.protect?doc.savedSelections?.[options.protect]:undefined;if(options.protect&&!selected)throw Error('Protection selection is unavailable.');const channels=[selected?Float32Array.from(selectionForLayer(selected,layer).alpha):new Float32Array(canvas.width*canvas.height)],maskKeys=(['mask','vectorMask'] as const).filter(k=>layer[k]);for(const key of maskKeys){const m=maskSurface({...layer[key]!,enabled:true},canvas.width,canvas.height),p=m.getContext('2d')!.getImageData(0,0,m.width,m.height).data;channels.push(Float32Array.from({length:m.width*m.height},(_,i)=>p[i*4+3]));}
 const result=await carveImage({width:pixels.width,height:pixels.height,data:pixels.data,channels},options.width,options.height,options);const raster=await closePixelEdit(edit,layer,result.data,result.width,result.height),masks:Partial<Layer>={};if(options.signal?.aborted)throw new DOMException('Scale canceled.','AbortError');maskKeys.forEach((key,i)=>{masks[key]={width:result.width,height:result.height,enabled:layer[key]!.enabled,inverted:false,strokes:[],raster:{width:result.width,height:result.height,alpha:Array.from(result.channels[i+1],Math.round)}};});if(layer.vectorMask){const enabled=maskKeys.filter(key=>layer[key]!.enabled),merge=enabled.length?enabled:maskKeys;masks.mask={width:result.width,height:result.height,enabled:enabled.length>0,inverted:false,strokes:[],raster:{width:result.width,height:result.height,alpha:Array.from({length:result.width*result.height},(_,n)=>Math.round(merge.reduce((a,key)=>a*result.channels[maskKeys.indexOf(key)+1][n]/255,255)))}};masks.vectorMask=undefined;}const next=aroundAnchor(layer,{...raster,...masks,width:result.width,height:result.height},options.anchor);return {...doc,layers:doc.layers.map(l=>l.id===layer.id?next:l)};
Photoshot무료 포토샵 개발기 / 28IN PRACTICE

편집 화면에서.

채울 곳과 지킬 곳 / workspace
선택을 수정한 뒤 채우기가 정상 완료된 실제 미리보기.

보호를 끈 비교로 설정의 효과를 검증했다

같은 원본을 새로 열어 보호만 None으로 바꾸고 크기와 오른쪽 기준점은 동일하게 적용했습니다. 앞서 보호했던 영역을 같은 좌표 기준으로 비교하자 522,490개 픽셀에 변화가 있었습니다. 보호한 경우는 0개였습니다. 이는 이번 사진과 1200px 조건에서 측정한 결과이며, 픽셀 차이 자체를 미관 점수로 해석하지는 않습니다.

content-scale.ts는 낮은 비용의 심을 제거하고 보호 채널에 큰 가중치를 더합니다. 마스크도 같은 픽셀 대응을 따릅니다. 이번 개발기에는 채우기 재실행, 보호 적용·해제 비교, 저장 결과 확인까지 실제 수행한 과정과 수치를 담았습니다. 인물은 AI 생성 성인 가상 모델이며 29편은 정렬·HDR·파노라마입니다.

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

원본과 새 레이어 채우기.

채울 곳과 지킬 곳 / original
01 / AI 생성 시연 원본
채울 곳과 지킬 곳 / result
02 / 작은 스티커 제거 · 원본 레이어 보존

인물 보호를 켜고 비교하다.

03 / 보호 적용 · 인물 영역의 픽셀 차이 0
03 / 보호 적용 · 인물 영역의 픽셀 차이 0
04 / 보호 해제 · 같은 크기와 오른쪽 기준점
04 / 보호 해제 · 같은 크기와 오른쪽 기준점

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