
無料Photoshop代替の開発記
周囲の画素で修復する
AI人物写真の壁にある小さな汚れをコピースタンプで消しました。複製レイヤーで作業し、実際の画面で取り消しとやり直しまで確認します。
AI生成の動作例用写真。編集画面と出力画像は実際のPhotoshotで作成しました。
ソースから始める。
原本と編集するレイヤーを分ける
複製・修復は対象画素を変えます。originalを残し、Clone wall repairという複製を作りました。原本保持とUndoは別の安全策です。
retouch.tsは画素ロックを検査し、文字・調整・図形・スマートオブジェクトなどの直接修正を拒否します。サイズ、強さ、経路、サンプル範囲も検査します。ツール表示と対象の編集可否は別の条件です。
コピースタンプは指定したソースを読む
Clone StampにはAltクリックのソース指定が必要です。対象の移動量に合わせて採取位置を動かし、Clone Sourceの拡大率・回転・反転を適用します。現在レイヤーと他レイヤーの合成を読む設定も区別します。
きれいな壁を指定し、汚れに一度押しました。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生成の動作例用写真。編集画面と出力画像は実際のPhotoshotで作成しました。