
無料Photoshop代替の開発記
顔の上にメッシュを置く
スライダーがあることと、顔の位置を検出して変形することは別です。公開版のDetect Facesで特徴点を検出し、Eye SizeとSmileを調整しました。メッシュ保存、適用、取消、復帰を確認します。
AI生成の動作例用写真。編集画面と出力画像は実際のPhotoshotで作成しました。
ソースから始める。
画素の描画ではなく変位を重ねる
Liquifyは各メッシュ点に横と縦の変位、保護領域を記録します。既定サイズは129で、検証関数は9から129の正方格子を許可します。ブラシ操作では逆方向の変形を合成し、最終適用時に画像を再サンプルします。
道具はForward Warp、Pucker、Bloat、Twirl Clockwise、Reconstruct、Smooth、Freeze Mask、Thaw Maskの八つです。保護領域も同じメッシュ内にあるため変形計算で読みます。格子を表示するだけの機能とはデータ構造が異なります。
ローカルモデルで顔の位置を探す
face-liquifyはローカル配信のモデルとMediaPipe FaceLandmarkerを使用します。IMAGEモードで最大五つの顔を検出し、座標をメッシュ変形関数に渡します。顔が見つからない場合は明示的にエラーを出し、通常ブラシを案内します。
変形関数は最低468点と有効な座標、縦横比、調整範囲を確認します。目の向きから顔の回転座標系を作り、目、鼻、口、顎の周辺を局所変形します。これは特徴の位置検出であり、人物の身元を判定する手順ではありません。
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. */計算の
内側へ。
新しい作例で二つの調整を適用する
公開版のLiquifyでDetect Facesを実行し、実際の検出結果を確認しました。Eye Sizeを25、Smileを15にしてAdd Face Shapeで変形を加えました。作業画面はShow Meshを有効にした状態です。
この数値は機能経路を試すための例です。顔をこの形に直すべきだという基準ではありません。前後画像は生成原本と、その原本をPhotoshotで実際に変形した結果です。
メッシュ保存と履歴を分けて確認する
Save Meshで保存したファイルの変位が変化していることを確かめ、Apply Liquifyを実行しました。適用後のsrcは原本と異なり、Undoは適用前、Redoは適用後のsrcと完全一致しました。
別途再実行した描画検査は八つの調整、中立値の維持、目の拡大の逆写像、Freeze保護、入力の不変性、不正設定を確認します。固定テスト入力の検査と、新しい写真のUI検査は別々に記録しています。
コードを読む。
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})));
}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});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;
}エディターで見る。

専用画面はそれぞれの構造を持つ
同じFilterメニューでもすべてがLiquifyメッシュを使うわけではありません。元の計画にあるAdaptive Wide Angleは線制約、Blur Galleryはピンとぼかし領域を扱います。現在のコードで五つのBlur Galleryモードと4MP上限も確認しました。Liquifyは通常フィルターの16MP検査を使います。
今回の新しい写真の実演はLiquifyです。広角やぼかし画面もすべてこの写真で新しく実演したとは書いていません。データ構造と検査範囲を分けて根拠を残しました。次回はセンサー原本の保存とRAW再現像です。
原本と編集結果。


AI生成の動作例用写真。編集画面と出力画像は実際のPhotoshotで作成しました。