feat(edutainment): refresh baby object match flow

This commit is contained in:
2026-05-16 11:29:28 +08:00
parent 49ffa6b901
commit 45daca3647
24 changed files with 6616 additions and 659 deletions

View File

@@ -158,6 +158,26 @@ const assetDefinitions = [
chromaKeyNote,
].join(''),
},
{
id: 'character-outline-only-v3',
output: 'picture-book-character-outline-v3.png',
sourceOutput: 'picture-book-character-outline-v2.png',
sourceDirectory: 'asset',
transparent: true,
localPostprocess: 'character-outline-only',
prompt:
'本地后处理资源:基于 character-outline-v2 提取用户角色外轮廓,只保留浅青白描边,中间完全透明,不保留原有半透明材质、填充和明暗变化。',
},
{
id: 'character-outline-white-v4',
output: 'picture-book-character-outline-v4.png',
sourceOutput: 'picture-book-character-outline-v2.png',
sourceDirectory: 'asset',
transparent: true,
localPostprocess: 'character-outline-white-thin',
prompt:
'本地后处理资源:基于 character-outline-v2 提取用户角色外轮廓,先弱化耳朵、手指、脚趾等细碎凸起,再输出更细的白色描边,中间完全透明。',
},
{
id: 'hud-strip',
output: 'picture-book-hud-strip-v2.png',
@@ -601,6 +621,16 @@ const assetDefinitions = [
chromaKeyNote,
].join(''),
},
{
id: 'wave-cat-body-guide-v7',
output: 'picture-book-wave-cat-body-guide-v7.png',
sourceOutput: 'picture-book-wave-cat-body-guide-v6.png',
sourceDirectory: 'asset',
transparent: true,
localPostprocess: 'remove-cat-body-shoulder-dots',
prompt:
'本地后处理资源:基于 wave-cat-body-guide-v6 去除身体左右两侧不协调的小圆点,保留猫头、身体、透明边界和整体水彩风格。',
},
{
id: 'wave-cat-arm-guide-v6',
output: 'picture-book-wave-cat-arm-guide-v6.png',
@@ -632,6 +662,37 @@ const assetDefinitions = [
chromaKeyNote,
].join(''),
},
{
id: 'wave-cat-arm-guide-v7',
output: 'picture-book-wave-cat-arm-guide-v7.png',
sourceOutput: 'picture-book-wave-cat-arm-guide-v7-source.png',
size: '1024x1024',
transparent: true,
transparencyCleanup: 'cat-guide',
useWaveCatHeadReference: true,
useWaveCatArmReference: true,
layoutNormalization: {
canvasWidth: 1024,
canvasHeight: 1024,
fit: 'contain',
fillWidth: 0.74,
fillHeight: 0.88,
anchorY: 'bottom',
padding: 20,
},
prompt: [
'请在参考手臂资源的基础上重新绘制儿童动作互动游戏猫咪挥手动画用的单侧手臂资源,严格作为可动纸偶拆件。只画一条橘白猫手臂:底部是肩膀连接端,向左上方弯曲,末端是一只简化圆猫手。',
'关键修改:末端圆猫爪必须正面对镜头,像在对观众挥手。圆爪正面轮廓要清楚可见,不要转成侧面,不要转向画面内侧或角色中心,不要画成握拳或背面。可以用浅奶油白圆形爪面、柔和高光和非常淡的短弧线表现正面对镜头。',
'猫手必须像多啦A梦式圆手或软玩具圆爪一个完整圆润圆爪不画分开的手指不画尖爪不画黑色或深色爪垫。若需要爪面细节只允许非常浅的桃色小圆面或柔和弧线不能变成真实动物爪垫。',
'手臂短而厚实,像小猫上肢,不要成人类长手臂。资源必须适合网页左右镜像复用和围绕肩部连接点旋转:肩膀连接端在画面底部偏内侧,圆手在画面上方,四周留透明空白。',
'颜色参考输入猫猫头和参考手臂:浅奶油白和淡橘色为主体,少量浅橘斑纹,柔和暖棕或浅橘棕描边;不要纯黑粗描边。',
'请避免粉色大背景、避免主体外侧彩色光晕,主体贴纸外轮廓之外必须直接是纯色背景。资源自身保持清晰不透明,半透明效果由网页 CSS 控制。',
'不要画猫头、躯干、另一只手臂、完整动物、腿、脚、文字、数字、按钮、面板、水印、真实照片质感、黑色、灰色、黑白毛、黑灰重阴影或深色大面积毛。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
];
const args = new Map();
@@ -811,6 +872,12 @@ function buildRequestBody(asset, size) {
path.join(assetDir, 'picture-book-wave-cat-head-guide-v2.png'),
);
}
if (asset.useWaveCatArmReference) {
pushReferenceImage(
body,
path.join(assetDir, 'picture-book-wave-cat-arm-guide-v6.png'),
);
}
return body;
}
@@ -864,6 +931,9 @@ function outputPathFor(asset) {
}
function sourceOutputPathFor(asset) {
if (asset.sourceDirectory === 'asset') {
return path.join(assetDir, asset.sourceOutput || asset.output);
}
return path.join(intermediateDir, asset.sourceOutput || asset.output);
}
@@ -1038,6 +1108,92 @@ function removeCharacterOutlineChromaKey(sourcePath, finalPath) {
}
}
function createCharacterOutlineOnlyIndicator(sourcePath, finalPath) {
const script = [
'from PIL import Image, ImageChops, ImageFilter',
'import sys',
'source, out = sys.argv[1], sys.argv[2]',
'im = Image.open(source).convert("RGBA")',
'alpha = im.getchannel("A")',
'mask = alpha.point(lambda v: 255 if v > 24 else 0)',
'mask = mask.filter(ImageFilter.MaxFilter(5)).filter(ImageFilter.MinFilter(5))',
'outer = mask.filter(ImageFilter.MaxFilter(47))',
'inner = mask.filter(ImageFilter.MinFilter(47))',
'stroke = ImageChops.subtract(outer, inner)',
'stroke = stroke.filter(ImageFilter.GaussianBlur(0.45))',
'glow = stroke.filter(ImageFilter.GaussianBlur(3.0)).point(lambda v: int(v * 0.34))',
'result = Image.new("RGBA", im.size, (0, 0, 0, 0))',
'glow_layer = Image.new("RGBA", im.size, (91, 205, 197, 0))',
'glow_layer.putalpha(glow)',
'line_layer = Image.new("RGBA", im.size, (224, 255, 247, 0))',
'line_layer.putalpha(stroke.point(lambda v: min(235, int(v * 0.92))))',
'result.alpha_composite(glow_layer)',
'result.alpha_composite(line_layer)',
'result.save(out)',
].join('\n');
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to create outline-only character indicator: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function createWhiteCharacterOutlineIndicator(sourcePath, finalPath) {
const script = [
'from pathlib import Path',
'import cv2',
'import numpy as np',
'from PIL import Image',
'import sys',
'source, out = Path(sys.argv[1]), Path(sys.argv[2])',
'rgba = np.array(Image.open(source).convert("RGBA"))',
'alpha = rgba[:, :, 3]',
'mask = (alpha > 24).astype(np.uint8) * 255',
'contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)',
'body = np.zeros_like(mask)',
'if contours:',
' largest = max(contours, key=cv2.contourArea)',
' cv2.drawContours(body, [largest], -1, 255, thickness=cv2.FILLED)',
'open_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (25, 25))',
'close_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (35, 35))',
'body = cv2.morphologyEx(body, cv2.MORPH_OPEN, open_kernel, iterations=1)',
'body = cv2.morphologyEx(body, cv2.MORPH_CLOSE, close_kernel, iterations=1)',
'body = cv2.GaussianBlur(body, (0, 0), 7.0)',
'_, body = cv2.threshold(body, 92, 255, cv2.THRESH_BINARY)',
'body = cv2.GaussianBlur(body, (0, 0), 1.4)',
'_, body = cv2.threshold(body, 64, 255, cv2.THRESH_BINARY)',
'line_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))',
'outer = cv2.dilate(body, line_kernel, iterations=1)',
'inner = cv2.erode(body, line_kernel, iterations=1)',
'stroke = cv2.subtract(outer, inner)',
'stroke = cv2.GaussianBlur(stroke, (0, 0), 0.55)',
'glow = cv2.GaussianBlur(stroke, (0, 0), 2.2)',
'result = np.zeros((mask.shape[0], mask.shape[1], 4), dtype=np.uint8)',
'glow_alpha = np.clip(glow.astype(np.float32) * 0.22, 0, 70).astype(np.uint8)',
'line_alpha = np.clip(stroke.astype(np.float32) * 0.78, 0, 205).astype(np.uint8)',
'result[:, :, 0:3] = 255',
'result[:, :, 3] = np.maximum(glow_alpha, line_alpha)',
'Image.fromarray(result, "RGBA").save(out)',
].join('\n');
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to create thin white character indicator: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function removeCatGuideChromaKey(sourcePath, finalPath) {
const script = [
'from collections import deque',
@@ -1253,6 +1409,50 @@ function scrubChromaFringe(finalPath) {
}
}
function removeCatBodyShoulderDots(sourcePath, finalPath) {
const script = [
'from pathlib import Path',
'import cv2',
'import numpy as np',
'from PIL import Image',
'source, out = Path(__import__("sys").argv[1]), Path(__import__("sys").argv[2])',
'rgba = np.array(Image.open(source).convert("RGBA"))',
'rgb = rgba[:, :, :3].copy()',
'alpha = rgba[:, :, 3]',
'opaque = alpha > 10',
'known = opaque.astype(np.uint8)',
'unknown = (1 - known).astype(np.uint8)',
'_, labels = cv2.distanceTransformWithLabels(unknown, cv2.DIST_L2, 5, labelType=cv2.DIST_LABEL_PIXEL)',
'flat_known_indices = np.flatnonzero(known.reshape(-1))',
'filled_rgb = rgb.copy().reshape(-1, 3)',
'labels_flat = labels.reshape(-1)',
'unknown_flat = unknown.reshape(-1).astype(bool)',
'if flat_known_indices.size > 0 and unknown_flat.any():',
' nearest_known_flat_index = flat_known_indices[np.maximum(labels_flat[unknown_flat] - 1, 0)]',
' filled_rgb[unknown_flat] = filled_rgb[nearest_known_flat_index]',
'filled_rgb = filled_rgb.reshape(rgb.shape)',
'bgr = cv2.cvtColor(filled_rgb, cv2.COLOR_RGB2BGR)',
'mask = np.zeros(alpha.shape, dtype=np.uint8)',
'cv2.ellipse(mask, (383, 763), (23, 26), 0, 0, 360, 255, -1)',
'cv2.ellipse(mask, (648, 762), (23, 26), 0, 0, 360, 255, -1)',
'mask = cv2.bitwise_and(mask, opaque.astype(np.uint8) * 255)',
'repaired = cv2.inpaint(bgr, mask, 7, cv2.INPAINT_TELEA)',
'repaired_rgb = cv2.cvtColor(repaired, cv2.COLOR_BGR2RGB)',
'Image.fromarray(np.dstack([repaired_rgb, alpha]), "RGBA").save(out)',
].join('\n');
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to remove cat body shoulder dots: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function writeOpaquePng(sourcePath, outputPath) {
const result = spawnSync(
'python',
@@ -1291,6 +1491,54 @@ async function generateAsset(asset, env, size, force) {
}
if (args.has('--postprocess-only')) {
if (asset.localPostprocess === 'character-outline-white-thin') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
createWhiteCharacterOutlineIndicator(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
if (asset.localPostprocess === 'character-outline-only') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
createCharacterOutlineOnlyIndicator(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
if (asset.localPostprocess === 'remove-cat-body-shoulder-dots') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
removeCatBodyShoulderDots(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
if (!asset.transparent) {
return {
id: asset.id,
@@ -1328,6 +1576,54 @@ async function generateAsset(asset, env, size, force) {
};
}
if (asset.localPostprocess === 'character-outline-white-thin') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for local postprocess: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
createWhiteCharacterOutlineIndicator(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
if (asset.localPostprocess === 'character-outline-only') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for local postprocess: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
createCharacterOutlineOnlyIndicator(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
if (asset.localPostprocess === 'remove-cat-body-shoulder-dots') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for local postprocess: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
removeCatBodyShoulderDots(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
const requestBody = buildRequestBody(asset, size);
const payloadText = await fetchWithTimeout(
buildVectorEngineImagesGenerationUrl(env.baseUrl),
@@ -1427,6 +1723,7 @@ function dryRun(selectedAssets, size) {
? sourceOutputPathFor(asset)
: undefined,
transparent: asset.transparent,
localPostprocess: asset.localPostprocess,
body: {
...body,
image: body.image ? ['<local style reference image>'] : undefined,