face-align.js 14.1 KB
#!/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);
});