feat: 從找圖開始優化——高解析度官方設定稿 → 去背 → 合成為形象圖
1. 找圖(icon search)
從 Fandom API 撈角色頁的所有圖片,依「解析度 + 是不是官方設定稿」排序。
官方設定稿(Full Body / Character Design / Avatar)是最好的來源:
* 773×1056 起跳,遠勝角色資料庫的 230px 縮圖
* **多半本來就是透明底 PNG**,去背幾乎免費、邊緣完美
新增 icon measure:回報解析度、臉佔比、背景是透明/單色/有場景、去背難度。
2. 去背(icon cutout)→ icon/portrait-cutout.png
三條路徑自動選:
source-alpha 原圖已是透明底(官方設定稿常見)→ 完美
plain-background 純白/單色底,色距去背 + 最大連通區 + 補洞 → 很好
grabcut 有場景時用臉的位置當前景種子 → 普通
實測記錄:把臉從 2026 主視覺裁下來再 GrabCut,結衣的黑髮會被整片當成背景切掉;
換成官方設定稿之後這問題直接消失——所以「找對圖」比「去背演算法」更關鍵。
3. 合成(icon generate --from-cutout)
自動裁成頭肩構圖再疊到角色配色的漸層底上。官方設定稿常是正反兩面並排,
不裁會變成兩個人,所以 compose 預設 --crop head(--zoom 可調鬆緊)。
產出 icon.svg(內嵌同一張 PNG,自成一體不外連)、icon.png,
以及 icon/portrait.svg 與 512/1024 兩個解析度。
向量重繪(--features)保留為「找不到可用官方圖」時的退路。
已更新兩個真實人格(皆為 cutout 樣式,Wiki 同步已驗證):
ASUNA-01 Asuna's SAO Avatar Full Body(773×1056,透明底)— 對應 2026
《Unanswered//butterfly》重述的早期艾恩葛朗特
YUI-01 Yui's ALO Pixie Form Full Body(773×1056,透明底)— 現行 Unital Ring
章的導航妖精形態
順手修掉兩個 bug:
* 合成路徑下 svg 為 null,回傳時 Buffer.byteLength(null) 直接崩潰。
* verifyArea 解析 git status --porcelain 時,因為 git() 會 trim 輸出,
開頭空白已消失(" M x" → "M x"),正規式對不上,檔名前面多一個 M。
selftest 175 項全綠(新增第 ⑲ 節:找圖評分、去背三路徑、compose 預設裁頭肩、
去背圖在 icon/ 會同步、SVG 不外連、沒有去背圖時 --from-cutout 會擋下)。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+120
-17
@@ -788,21 +788,47 @@ export function renderPng(spec, size = DEFAULT_SIZE, { supersample = 3 } = {}) {
|
||||
*/
|
||||
export function generateIcon(slug, {
|
||||
size = DEFAULT_SIZE, code = null, palette = null, source = null, style = null, features = null,
|
||||
cutout = null, pick = null, face = null, zoom = 2.15,
|
||||
} = {}) {
|
||||
const spec = iconSpec(slug, { code, palette, style, features });
|
||||
const svg = renderSvg(spec, size);
|
||||
const png = renderPng(spec, size);
|
||||
pl.writeText(iconSvgPath(slug), svg);
|
||||
fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true });
|
||||
fs.writeFileSync(iconPngPath(slug), png);
|
||||
// icon/ 資料夾:向量原稿 + 多個解析度,Gitea 上點得開也拿得走
|
||||
// 有去背圖就用它合成(官方原圖,解析度高);沒有就回到向量重繪
|
||||
let compose = null;
|
||||
if (cutout && fs.existsSync(cutout)) {
|
||||
const bg = spec.palette
|
||||
? `${hex(tint(spec.palette.light || spec.c1, 0.35))},${hex(spec.palette.accent || spec.c2)}`
|
||||
: `${hex(spec.c1)},${hex(spec.c2)}`;
|
||||
compose = composeIcon(cutout, iconPngPath(slug), { size, bg, pick, face, zoom });
|
||||
if (compose.ok) spec.style = "cutout";
|
||||
}
|
||||
const svg = compose?.ok ? null : renderSvg(spec, size);
|
||||
const png = compose?.ok ? fs.readFileSync(iconPngPath(slug)) : renderPng(spec, size);
|
||||
const dir = path.join(pl.personaDir(slug), "icon");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const renders = [{ name: "portrait.svg", bytes: Buffer.byteLength(svg, "utf8") }];
|
||||
pl.writeText(path.join(dir, "portrait.svg"), svg);
|
||||
fs.mkdirSync(path.dirname(iconPngPath(slug)), { recursive: true });
|
||||
if (!compose?.ok) fs.writeFileSync(iconPngPath(slug), png);
|
||||
// SVG:向量樣式直接輸出;去背合成則包一層自成一體的 SVG(base64 內嵌,不外連)
|
||||
const finalSvg = svg || wrapPngSvg(spec, png, size);
|
||||
pl.writeText(iconSvgPath(slug), finalSvg);
|
||||
// icon/ 資料夾:原稿 + 多個解析度,Gitea 上點得開也拿得走
|
||||
const renders = [{ name: "portrait.svg", bytes: Buffer.byteLength(finalSvg, "utf8") }];
|
||||
pl.writeText(path.join(dir, "portrait.svg"), finalSvg);
|
||||
for (const px of RENDER_SIZES) {
|
||||
const buf = px === size ? png : renderPng(spec, px);
|
||||
fs.writeFileSync(path.join(dir, `portrait-${px}.png`), buf);
|
||||
let buf;
|
||||
if (compose?.ok) {
|
||||
const target = path.join(dir, `portrait-${px}.png`);
|
||||
const res = composeIcon(cutout, target, {
|
||||
size: px,
|
||||
bg: spec.palette
|
||||
? `${hex(tint(spec.palette.light || spec.c1, 0.35))},${hex(spec.palette.accent || spec.c2)}`
|
||||
: `${hex(spec.c1)},${hex(spec.c2)}`,
|
||||
pick, face, zoom,
|
||||
});
|
||||
buf = res.ok ? fs.readFileSync(target) : png;
|
||||
if (!res.ok) fs.writeFileSync(target, buf);
|
||||
} else {
|
||||
buf = px === size ? png : renderPng(spec, px);
|
||||
fs.writeFileSync(path.join(dir, `portrait-${px}.png`), buf);
|
||||
}
|
||||
renders.push({ name: `portrait-${px}.png`, bytes: buf.length });
|
||||
}
|
||||
// 把配色與來源記進 config,之後重畫才會一致,也才查得到「這個顏色是哪來的」
|
||||
@@ -816,6 +842,7 @@ export function generateIcon(slug, {
|
||||
palette: spec.palette ? paletteToString(spec.palette) : null,
|
||||
features: featuresToString(spec.features),
|
||||
source: source || spec.source || null,
|
||||
cutout: compose?.ok ? { file: CUTOUT_PNG, compose: compose.person || null } : null,
|
||||
};
|
||||
pl.writeJson(pl.configPath(slug), config);
|
||||
return {
|
||||
@@ -824,10 +851,25 @@ export function generateIcon(slug, {
|
||||
png: iconPngPath(slug),
|
||||
size,
|
||||
renders,
|
||||
bytes: { svg: Buffer.byteLength(svg, "utf8"), png: png.length },
|
||||
bytes: { svg: Buffer.byteLength(finalSvg, "utf8"), png: png.length },
|
||||
};
|
||||
}
|
||||
|
||||
/** 去背合成的圖示:SVG 用 base64 內嵌同一張 PNG,兩種格式看到的是同一張圖,且不外連。 */
|
||||
export function wrapPngSvg(spec, pngBuffer, size = DEFAULT_SIZE) {
|
||||
const S = size;
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${S}" height="${S}" viewBox="0 0 ${S} ${S}"`,
|
||||
` role="img" aria-label="人格 ${spec.code}${spec.name ? `(${spec.name})` : ""}的形象圖">`,
|
||||
` <title>${spec.code}${spec.name ? ` ${spec.name}` : ""}</title>`,
|
||||
` <image x="0" y="0" width="${S}" height="${S}"`,
|
||||
` xlink:href="data:image/png;base64,${pngBuffer.toString("base64")}"`,
|
||||
` href="data:image/png;base64,${pngBuffer.toString("base64")}"/>`,
|
||||
"</svg>",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export const hasIcon = (slug) => fs.existsSync(iconSvgPath(slug)) && fs.existsSync(iconPngPath(slug));
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -943,14 +985,19 @@ export async function fetchPhoto(source, destDir) {
|
||||
return abs;
|
||||
}
|
||||
|
||||
/** 列出參考照片裡偵測到的所有臉(多角色的圖要先看這個再挑)。 */
|
||||
export function listFaces(imagePath) {
|
||||
export const CUTOUT_PNG = "icon/portrait-cutout.png";
|
||||
export const cutoutPath = (slug) => path.join(pl.personaDir(slug), "icon", "portrait-cutout.png");
|
||||
|
||||
/** 呼叫 portrait.py 的共用包裝。 */
|
||||
function runPortrait(args) {
|
||||
const report = toolReport();
|
||||
if (!report.ready) return { ok: false, report };
|
||||
const args = [PORTRAIT_PY, "--input", imagePath, "--list"];
|
||||
if (report.cascade) args.push("--cascade", cascadePath());
|
||||
const proc = spawnSync(report.python, args, { encoding: "utf8" });
|
||||
if (proc.status !== 0) return { ok: false, report, reason: String(proc.stderr).slice(0, 300) };
|
||||
const full = [PORTRAIT_PY, ...args];
|
||||
if (report.cascade && !args.includes("--cascade")) full.push("--cascade", cascadePath());
|
||||
const proc = spawnSync(report.python, full, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
|
||||
if (proc.status !== 0) {
|
||||
return { ok: false, report, reason: String(proc.stderr || proc.stdout).trim().slice(0, 300) };
|
||||
}
|
||||
try {
|
||||
return { ...JSON.parse(String(proc.stdout).trim().split("\n").pop()), report };
|
||||
} catch {
|
||||
@@ -958,6 +1005,62 @@ export function listFaces(imagePath) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 列出參考圖裡偵測到的所有臉(多角色的圖要先看這個再挑)。 */
|
||||
export const listFaces = (imagePath) => runPortrait(["--mode", "faces", "--input", imagePath]);
|
||||
|
||||
/** 量測一張候選圖:解析度、臉多大、背景好不好去。 */
|
||||
export const measureImage = (imagePath) => runPortrait(["--mode", "measure", "--input", imagePath]);
|
||||
|
||||
/** 去背,輸出透明 PNG。優先沿用原圖既有的 alpha(官方人設圖多半就是透明底)。 */
|
||||
export const cutoutImage = (imagePath, outPath, { pick = null, face = null } = {}) =>
|
||||
runPortrait([
|
||||
"--mode", "cutout", "--input", imagePath, "--output", outPath,
|
||||
...(face ? ["--face", face] : pick ? ["--pick", pick] : []),
|
||||
]);
|
||||
|
||||
/** 把去背圖裁成頭肩、疊到圓角漸層底上,產出最終圖示。 */
|
||||
export const composeIcon = (cutout, outPath, { size = DEFAULT_SIZE, bg = null, pick = null,
|
||||
face = null, zoom = 2.15, crop = "head" } = {}) =>
|
||||
runPortrait([
|
||||
"--mode", "compose", "--input", cutout, "--output", outPath,
|
||||
"--size", String(size), "--radius", String(GEO.radius), "--zoom", String(zoom), "--crop", crop,
|
||||
...(bg ? ["--bg", bg] : []),
|
||||
...(face ? ["--face", face] : pick ? ["--pick", pick] : []),
|
||||
]);
|
||||
|
||||
/**
|
||||
* 從 Fandom wiki 找出這個角色的高解析度官方圖。
|
||||
* 官方人設圖(Full Body / Character Design)通常是透明底或白底,去背幾乎免費,
|
||||
* 而且解析度遠高於角色資料庫的縮圖——這是「找圖」這一步最該優先的來源。
|
||||
*/
|
||||
export async function wikiImageCandidates(wiki, page, { limit = 60 } = {}) {
|
||||
const url = `https://${wiki}.fandom.com/api.php?action=query&generator=images` +
|
||||
`&titles=${encodeURIComponent(page)}&gimlimit=${limit}&prop=imageinfo&iiprop=url|size&format=json`;
|
||||
const res = await fetch(url, { headers: { "User-Agent": "jsc-persona/icon" } });
|
||||
if (!res.ok) throw new Error(`Fandom API 失敗(HTTP ${res.status})`);
|
||||
const json = await res.json();
|
||||
const pages = json?.query?.pages || {};
|
||||
const rows = Object.values(pages)
|
||||
.map((entry) => ({ title: entry.title, info: entry.imageinfo?.[0] }))
|
||||
.filter((r) => r.info?.width)
|
||||
.map((r) => {
|
||||
const px = r.info.width * r.info.height;
|
||||
// 「官方設定稿」的關鍵字:這類圖解析度高、背景乾淨,最適合當形象圖
|
||||
const settei = /full.?body|character.?design|concept|profile|settei|avatar/i.test(r.title);
|
||||
return {
|
||||
title: r.title,
|
||||
url: String(r.info.url).split("/revision")[0],
|
||||
width: r.info.width,
|
||||
height: r.info.height,
|
||||
pixels: px,
|
||||
official_sheet: settei,
|
||||
score: Math.round(Math.log2(px) * 10) / 10 + (settei ? 8 : 0),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.score - a.score);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 從參考圖裁出**大頭照**。這張是給 AI 看的**參考**,不是圖示本身——
|
||||
* 圖示一律由 renderSvg/renderPng 依人格資料重新繪製。
|
||||
|
||||
Reference in New Issue
Block a user