face-align.js
14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
#!/usr/bin/env node
/**
* face-align.js — 角色表情对齐引擎(Agentic 接口)
*
* 从项目 server/modules/face-aligner/ 提取的核心管线:
* - pixel-fallback: HSV 肤色检测 → BFS 连通域 → 形状评分 → 颈部熔断 → 质心
* - image-composite: 表情叠加 + Set-of-Mark 标尺图
* - CSS 偏移量计算(含 object-fit:contain 视口纠偏)
*
* 设计为供 Claude(skill 执行者)作为 VLM 进行多轮审阅:
* 第 1 轮: node face-align.js --base char.png --emoji normal.png --out /tmp/align
* Claude: Read /tmp/align_marked.png → 判断对齐质量
* 第 N 轮: node face-align.js --base char.png --emoji normal.png --out /tmp/align --dx -5 --dy 3 --ds 0.95
* Claude: Read → OK → 采用输出的 faceOffset
*
* 输出:
* - {out}.png 纯净合成图
* - {out}_marked.png 带标尺线的调试图(供 Claude 审阅)
* - stdout JSON: { faceOffset: { top, left, width }, renderedX, renderedY, renderedW }
*
* 依赖: sharp
*/
const path = require('path');
const fs = require('fs');
const sharp = require('sharp');
// ==================== 像素引擎(对齐 pixel-fallback.js) ====================
const SKIN = {
ALPHA_MIN: 10,
H_MIN: 0, H_MAX: 45,
S_MIN: 0.08, S_MAX: 0.65,
V_MIN: 0.35,
};
const SCAN_HEIGHT_RATIO = 0.85;
const BFS_RADIUS = 2;
const MIN_BLOB_RATIO = 0.001;
const NECK_GAP_RATIO = 0.02;
const ROW_MIN_SKIN_RATIO = 0.02;
const FALLBACK_BOX = [0.35, 0.15, 0.3, 0.3, 0.5, 0.3, 0.3];
function rgbToHsv(r, g, b) {
const rr = r / 255, gg = g / 255, bb = b / 255;
const cMax = Math.max(rr, gg, bb);
const cMin = Math.min(rr, gg, bb);
const delta = cMax - cMin;
let h = 0;
if (delta > 0) {
if (cMax === rr) h = 60 * (((gg - bb) / delta) % 6);
else if (cMax === gg) h = 60 * ((bb - rr) / delta + 2);
else h = 60 * ((rr - gg) / delta + 4);
if (h < 0) h += 360;
}
const s = cMax === 0 ? 0 : delta / cMax;
return [h, s, cMax];
}
function buildSkinMap(data, width, height) {
const isSkin = new Uint8Array(width * height);
const scanH = Math.floor(height * SCAN_HEIGHT_RATIO);
for (let y = 0; y < scanH; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
if (data[idx + 3] <= SKIN.ALPHA_MIN) continue;
const [h, s, v] = rgbToHsv(data[idx], data[idx + 1], data[idx + 2]);
if (h >= SKIN.H_MIN && h <= SKIN.H_MAX && s >= SKIN.S_MIN && s <= SKIN.S_MAX && v >= SKIN.V_MIN) {
isSkin[y * width + x] = 1;
}
}
}
return isSkin;
}
function labelConnectedComponents(isSkin, width, height) {
const blobMap = new Int32Array(width * height).fill(-1);
const blobs = [];
const queue = new Int32Array(width * height);
const dirs = [];
for (let dy = -BFS_RADIUS; dy <= BFS_RADIUS; dy++)
for (let dx = -BFS_RADIUS; dx <= BFS_RADIUS; dx++)
if (dx !== 0 || dy !== 0) dirs.push(dx, dy);
const dirCount = dirs.length / 2;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const si = y * width + x;
if (!isSkin[si] || blobMap[si] !== -1) continue;
const bi = blobs.length;
let head = 0, tail = 0;
queue[tail++] = si;
blobMap[si] = bi;
let minX = x, maxX = x, minY = y, maxY = y, count = 0;
while (head < tail) {
const curr = queue[head++];
const cx = curr % width, cy = (curr - cx) / width;
if (cx < minX) minX = cx; if (cx > maxX) maxX = cx;
if (cy < minY) minY = cy; if (cy > maxY) maxY = cy;
count++;
for (let i = 0; i < dirCount; i++) {
const nx = cx + dirs[i * 2], ny = cy + dirs[i * 2 + 1];
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
const nIdx = ny * width + nx;
if (isSkin[nIdx] && blobMap[nIdx] === -1) { blobMap[nIdx] = bi; queue[tail++] = nIdx; }
}
}
}
blobs.push({ index: bi, minX, maxX, minY, maxY, count });
}
}
return { blobMap, blobs };
}
function selectBestBlob(blobs, width, height) {
const minSize = width * height * MIN_BLOB_RATIO;
let best = null, bestScore = -1;
for (const b of blobs) {
if (b.count <= minSize) continue;
const bw = b.maxX - b.minX + 1, bh = b.maxY - b.minY + 1;
const solidity = b.count / (bw * bh);
const posWeight = 1.0 - 0.7 * ((b.minY + b.maxY) / 2 / height);
const score = b.count * solidity * solidity * posWeight;
if (score > bestScore) { bestScore = score; best = b; }
}
return best;
}
function extractFaceMetrics(bestBlob, blobMap, width, height) {
const gapLimit = Math.round(height * NECK_GAP_RATIO);
const minRowSkin = width * ROW_MIN_SKIN_RATIO;
let fMinY = -1, fMaxY = -1, fMinX = width, fMaxX = 0;
let sumX = 0, sumY = 0, pxCount = 0, maxRun = 0, gapCount = 0;
for (let y = bestBlob.minY; y <= bestBlob.maxY; y++) {
let rowCount = 0, rowMinX = width, rowMaxX = 0, rowSumX = 0, longestRun = 0, currentRun = 0;
for (let x = bestBlob.minX; x <= bestBlob.maxX; x++) {
if (blobMap[y * width + x] === bestBlob.index) {
rowCount++; if (x < rowMinX) rowMinX = x; if (x > rowMaxX) rowMaxX = x;
rowSumX += x; currentRun++;
if (currentRun > longestRun) longestRun = currentRun;
} else { currentRun = 0; }
}
if (rowCount > minRowSkin) {
if (fMinY === -1) fMinY = y; fMaxY = y;
if (rowMinX < fMinX) fMinX = rowMinX; if (rowMaxX > fMaxX) fMaxX = rowMaxX;
gapCount = 0; pxCount += rowCount; sumX += rowSumX; sumY += y * rowCount;
if (longestRun > maxRun) maxRun = longestRun;
} else if (fMinY !== -1) { gapCount++; if (gapCount >= gapLimit) break; }
}
if (fMinY === -1 || pxCount === 0) return null;
return {
x: fMinX / width, y: fMinY / height,
w: (fMaxX - fMinX) / width, h: (fMaxY - fMinY) / height,
comX: (sumX / pxCount) / width, comY: (sumY / pxCount) / height,
faceW: (maxRun > 0 ? maxRun : (fMaxX - fMinX)) / width,
};
}
async function detectFaceBox(imageInput) {
const img = sharp(imageInput);
const { width, height } = await img.metadata();
const { data } = await img.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const isSkin = buildSkinMap(data, width, height);
const { blobMap, blobs } = labelConnectedComponents(isSkin, width, height);
const best = selectBestBlob(blobs, width, height);
if (!best) return FALLBACK_BOX;
const result = extractFaceMetrics(best, blobMap, width, height);
if (!result) return FALLBACK_BOX;
return [result.x, result.y, result.w, result.h, result.comX, result.comY, result.faceW];
}
// ==================== 合成引擎(对齐 image-composite.js) ====================
async function renderOverlay(baseBuffer, emojiImgPath, outPath, params, metadata) {
const { absX, absY, absW, absH, scale, dx, dy } = params;
let trueFaceWidth = absW;
if (params.faceW != null) {
trueFaceWidth = params.faceW * 1.15;
} else if (params.comX != null) {
const geometricCX = absX + (absW / 2);
trueFaceWidth = params.comX < geometricCX
? (params.comX - absX) * 2
: (absX + absW - params.comX) * 2;
trueFaceWidth = Math.max(trueFaceWidth, absW * 0.4);
}
const emojiW = Math.max(10, Math.round(trueFaceWidth * scale));
const emojiBuffer = await sharp(emojiImgPath)
.trim().resize({ width: emojiW, fit: 'contain' }).png()
.toBuffer({ resolveWithObject: true });
let targetCX = params.comX != null ? params.comX : absX + absW / 2;
let targetCY = (params.comY != null ? params.comY : absY + absH / 2) + absH * 0.05;
let offsetX = Math.round(targetCX - emojiW / 2) + dx;
let offsetY = Math.round(targetCY - emojiBuffer.info.height / 2) + dy;
// 纯净合成图
await sharp(baseBuffer)
.composite([{ input: emojiBuffer.data, left: offsetX, top: offsetY }])
.png().toFile(outPath);
// Set-of-Mark 标尺图
const rulerSvg = Buffer.from(`
<svg width="${metadata.width}" height="${metadata.height}" xmlns="http://www.w3.org/2000/svg">
<rect x="${absX}" y="${absY}" width="${absW}" height="${absH}" fill="none" stroke="red" stroke-width="2" />
<line x1="${absX + absW/2}" y1="${absY}" x2="${absX + absW/2}" y2="${absY + absH}" stroke="rgba(255,0,0,0.6)" stroke-width="1" stroke-dasharray="4" />
<line x1="${absX}" y1="${absY + absH/2}" x2="${absX + absW}" y2="${absY + absH/2}" stroke="rgba(255,0,0,0.6)" stroke-width="1" stroke-dasharray="4" />
${params.comX != null ? `<circle cx="${params.comX}" cy="${params.comY}" r="8" fill="purple" />
<line x1="${absX + absW/2}" y1="${absY + absH/2}" x2="${params.comX}" y2="${params.comY}" stroke="yellow" stroke-width="2" />` : ''}
<rect x="${offsetX}" y="${offsetY}" width="${emojiW}" height="2" fill="green" />
<rect x="${offsetX}" y="${offsetY}" width="2" height="${emojiBuffer.info.height}" fill="green" />
</svg>`);
const markedOutPath = outPath.replace(/\.png$/, '_marked.png');
await sharp(baseBuffer)
.composite([
{ input: emojiBuffer.data, left: offsetX, top: offsetY },
{ input: rulerSvg, top: 0, left: 0 },
])
.png().toFile(markedOutPath);
return { renderedX: offsetX, renderedY: offsetY, renderedW: emojiW, renderedH: emojiBuffer.info.height, markedOutPath };
}
// ==================== CSS 偏移量(对齐 index.js 视口纠偏) ====================
function computeFaceOffset(renderInfo, metadata) {
let rawX = (renderInfo.renderedX + renderInfo.renderedW / 2) / metadata.width;
let rawY = renderInfo.renderedY / metadata.height;
let rawW = renderInfo.renderedW / metadata.width;
const aspect = metadata.width / metadata.height;
let fX = rawX, fY = rawY, fW = rawW;
if (aspect <= 1) {
const xm = (1 - aspect) / 2;
fX = xm + rawX * aspect;
fW = rawW * aspect;
} else {
const hr = 1 / aspect;
fY = (1 - hr) + rawY * hr;
}
return {
top: (fY * 100).toFixed(2) + '%',
left: (fX * 100).toFixed(2) + '%',
width: (fW * 100).toFixed(2) + '%',
};
}
// ==================== 默认表情图下载 ====================
const DEFAULT_EMOJI_URL = 'https://shadiao-drama.oss-cn-beijing.aliyuncs.com/dramas/zhenhuan_chuan/emojis/%E6%AD%A3%E5%B8%B8.png';
async function downloadDefaultEmoji(destPath) {
if (fs.existsSync(destPath)) return destPath;
const https = require('https');
const http = require('http');
return new Promise((resolve, reject) => {
const get = (url) => {
const lib = url.startsWith('https') ? https : http;
lib.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return get(res.headers.location);
}
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => { fs.writeFileSync(destPath, Buffer.concat(chunks)); resolve(destPath); });
res.on('error', reject);
}).on('error', reject);
};
get(DEFAULT_EMOJI_URL);
});
}
// ==================== 主入口 ====================
async function main() {
const args = process.argv.slice(2);
const getArg = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : null; };
const basePath = getArg('--base');
const emojiPath = getArg('--emoji');
const outBase = getArg('--out') || '/tmp/face_align';
const dx = parseInt(getArg('--dx') || '0', 10);
const dy = parseInt(getArg('--dy') || '0', 10);
const ds = parseFloat(getArg('--ds') || '1.0');
if (!basePath) {
console.error('用法: node face-align.js --base <角色图> [--emoji <表情图>] [--out <输出路径前缀>] [--dx N] [--dy N] [--ds N]');
console.error(' --emoji 可省略,自动下载默认表情(normal.png)');
process.exit(1);
}
if (!fs.existsSync(basePath)) {
console.error(`文件不存在: ${basePath}`);
process.exit(1);
}
// 准备表情图
let finalEmojiPath = emojiPath;
if (!finalEmojiPath || !fs.existsSync(finalEmojiPath)) {
finalEmojiPath = path.join(require('os').tmpdir(), 'default_emoji_normal.png');
console.log('下载默认表情图...');
await downloadDefaultEmoji(finalEmojiPath);
}
// 1. 像素引擎检测
const baseBuffer = fs.readFileSync(basePath);
const metadata = await sharp(baseBuffer).metadata();
const boxRaw = await detectFaceBox(baseBuffer);
const state = {
absX: Math.round(boxRaw[0] * metadata.width),
absY: Math.round(boxRaw[1] * metadata.height),
absW: Math.round(boxRaw[2] * metadata.width),
absH: Math.round(boxRaw[3] * metadata.height),
comX: boxRaw[4] != null ? Math.round(boxRaw[4] * metadata.width) : null,
comY: boxRaw[5] != null ? Math.round(boxRaw[5] * metadata.height) : null,
faceW: boxRaw[6] != null ? Math.round(boxRaw[6] * metadata.width) : null,
scale: 0.85 * ds,
dx,
dy,
};
// 2. 合成 + 标尺图
const outPath = outBase.endsWith('.png') ? outBase : outBase + '.png';
const renderInfo = await renderOverlay(baseBuffer, finalEmojiPath, outPath, state, metadata);
// 3. 计算 CSS 偏移量
const faceOffset = computeFaceOffset(renderInfo, metadata);
// 4. 输出结果
const result = {
faceOffset,
renderedX: renderInfo.renderedX,
renderedY: renderInfo.renderedY,
renderedW: renderInfo.renderedW,
markedImage: renderInfo.markedOutPath,
compositeImage: outPath,
};
console.log(`\n__RESULT__${JSON.stringify(result)}__END__`);
}
main().catch(err => {
console.error('执行失败:', err);
process.exit(1);
});