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:
+109
-10
@@ -1140,6 +1140,87 @@ commands.icon = async ({ flags, positional }) => {
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (action === "search") {
|
||||
// 找圖第一步:從 Fandom wiki 撈這個角色的官方圖,依「解析度 + 是不是官方設定稿」排序
|
||||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||||
const wiki = str(flags.wiki);
|
||||
const page = str(flags.page);
|
||||
if (!wiki || !page) die("需要 `--wiki <fandom 子網域>` 與 `--page <角色頁名>`(例:--wiki swordartonline --page Yui)。");
|
||||
let rows;
|
||||
try {
|
||||
rows = await ic.wikiImageCandidates(wiki, page, { limit: num(flags.limit, 60) });
|
||||
} catch (err) {
|
||||
die(err.message);
|
||||
}
|
||||
const top = rows.slice(0, num(flags.top, 12));
|
||||
emit({ wiki, page, candidates: top }, flags.json, [
|
||||
`\`${page}\` 在 ${wiki}.fandom.com 的圖片候選(依解析度與是否官方設定稿排序):`,
|
||||
...top.map((r, i) =>
|
||||
` #${i} ${String(r.width).padStart(5)}×${String(r.height).padEnd(5)}` +
|
||||
`${r.official_sheet ? " 📐官方設定稿" : " "} ${r.title}\n ${r.url}`),
|
||||
" 官方設定稿(Full Body/Character Design)通常是透明底或白底,去背幾乎免費,優先選它。",
|
||||
" 選好之後:`icon measure --photo <網址>` 看臉夠不夠大,再 `icon cutout --photo <網址>`。",
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (action === "measure") {
|
||||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||||
const want = str(flags.photo);
|
||||
if (!want) die("需要 `--photo <圖片路徑或網址>`。");
|
||||
let file;
|
||||
try {
|
||||
file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
|
||||
} catch (err) {
|
||||
die(err.message);
|
||||
}
|
||||
const res = ic.measureImage(file);
|
||||
if (!res.ok) {
|
||||
emit(res, flags.json, [`✖ 量測失敗:${res.reason || "工具不足"}`,
|
||||
...ic.installHintLines(res.report || ic.toolReport())]);
|
||||
process.exit(1);
|
||||
}
|
||||
emit(res, flags.json, [
|
||||
`解析度 ${res.size.join("×")}(${(res.pixels / 1e6).toFixed(2)} MP)|找到 ${res.faces_found} 張臉` +
|
||||
`|臉佔長邊 ${(res.face_ratio * 100).toFixed(0)}%`,
|
||||
`背景:${res.transparent ? "透明底(最佳)" : res.background.plain ? "單色底(好去背)" : "有場景(要靠 GrabCut,可能不乾淨)"}` +
|
||||
`|去背難度:${res.cutout_easy ? "容易" : "偏難"}`,
|
||||
res.cutout_easy
|
||||
? " → 這張可以用。`icon cutout --photo <同一張>`"
|
||||
: " → 建議換一張官方設定稿(`icon search` 裡標 📐 的),去背會乾淨很多。",
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (action === "cutout") {
|
||||
requireOwner(slug, session);
|
||||
const want = str(flags.photo);
|
||||
if (!want) die("需要 `--photo <圖片路徑或網址>`。");
|
||||
let file;
|
||||
try {
|
||||
file = await ic.fetchPhoto(want, path.join(pl.personaDir(slug), ".sync"));
|
||||
} catch (err) {
|
||||
die(err.message);
|
||||
}
|
||||
const out = ic.cutoutPath(slug);
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
const res = ic.cutoutImage(file, out, {
|
||||
pick: str(flags.pick) || null,
|
||||
face: str(flags.face) || null,
|
||||
});
|
||||
if (!res.ok) {
|
||||
emit(res, flags.json, [`✖ 去背失敗:${res.reason || "工具不足"}`,
|
||||
...ic.installHintLines(res.report || ic.toolReport())]);
|
||||
process.exit(1);
|
||||
}
|
||||
const method = { "source-alpha": "原圖本來就是透明底", "plain-background": "單色底去除",
|
||||
grabcut: "GrabCut(有場景,邊緣可能不完美)" }[res.method] || res.method;
|
||||
emit({ persona: slug, ...res }, flags.json, [
|
||||
`✔ 去背完成:${out}`,
|
||||
` 方式:${method}|原圖 ${res.source_size.join("×")} → 去背後 ${res.output_size.join("×")}` +
|
||||
`|不透明佔比 ${(res.opaque_ratio * 100).toFixed(0)}%`,
|
||||
" 請用 Read 打開確認邊緣乾不乾淨,再 `icon generate --from-cutout --force`。",
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (action === "headshot") {
|
||||
// 裁出「參考用大頭照」。這張不是圖示,也不會同步出去——它是給 AI 看的底稿。
|
||||
requireOwner(slug, session);
|
||||
@@ -1197,7 +1278,7 @@ commands.icon = async ({ flags, positional }) => {
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (action !== "generate") die(`未知 action:${action}(可用 generate/show/faces/headshot)`);
|
||||
if (action !== "generate") die(`未知 action:${action}(可用 search/measure/faces/headshot/cutout/generate/show)`);
|
||||
requireOwner(slug, session);
|
||||
if (ic.hasIcon(slug) && !flags.force) {
|
||||
die(`人格 \`${slug}\` 已經有圖示了。改過身分或換了參考照片要重畫請加 --force。`);
|
||||
@@ -1227,19 +1308,33 @@ commands.icon = async ({ flags, positional }) => {
|
||||
const style = str(flags.style) || null;
|
||||
if (style && !ic.STYLES.includes(style)) die(`--style 只能是 ${ic.STYLES.join("/")}。`);
|
||||
const features = flags.features ? ic.parseFeatures(str(flags.features)) : null;
|
||||
const res = ic.generateIcon(slug, { size, palette, source, style, features });
|
||||
// --from-cutout:用去背好的官方原圖合成(解析度高、忠於原作)
|
||||
const cutout = flags["from-cutout"] && fs.existsSync(ic.cutoutPath(slug)) ? ic.cutoutPath(slug) : null;
|
||||
if (flags["from-cutout"] && !cutout) {
|
||||
die(`還沒有去背圖。先跑 \`icon search\` 找官方設定稿 → \`icon cutout --photo <網址>\`。`);
|
||||
}
|
||||
const res = ic.generateIcon(slug, {
|
||||
size, palette, source, style, features, cutout,
|
||||
pick: str(flags.pick) || null,
|
||||
face: str(flags.face) || null,
|
||||
zoom: num(flags.zoom, 2.15),
|
||||
});
|
||||
const lines = [
|
||||
`✔ 人格 \`${slug}\` 的圖示已產生(${size}×${size})。`,
|
||||
` ${res.svg}(${(res.bytes.svg / 1024).toFixed(1)} KB)`,
|
||||
` ${res.png}(${(res.bytes.png / 1024).toFixed(1)} KB)`,
|
||||
` icon/:${res.renders.map((r) => `${r.name} ${(r.bytes / 1024).toFixed(0)}KB`).join("、")}`,
|
||||
res.spec.style === "portrait"
|
||||
? ` 形象圖:依人格資料重新繪製的人物頭像(有臉)`
|
||||
: ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`,
|
||||
res.spec.style === "cutout"
|
||||
? ` 形象圖:官方原圖去背後合成(頭肩構圖,解析度取自原圖)`
|
||||
: res.spec.style === "portrait"
|
||||
? ` 形象圖:依人格資料重新繪製的人物頭像(有臉)`
|
||||
: ` 徽章:字母 ${res.spec.letters}|配色由編號 \`${res.spec.code}\`、名字與 emoji 決定`,
|
||||
...(res.spec.style === "portrait"
|
||||
? [` 配色:${ic.paletteToString(res.spec.palette)}`,
|
||||
` 特徵:${ic.featuresToString(res.spec.features)}`]
|
||||
: []),
|
||||
: res.spec.style === "cutout" && res.spec.palette
|
||||
? [` 底色:取自 ${ic.paletteToString(res.spec.palette)}`]
|
||||
: []),
|
||||
...(source?.url || res.spec.source?.url
|
||||
? [` 參考來源:${(source || res.spec.source).url}${(source || res.spec.source).note ? `\n ${(source || res.spec.source).note}` : ""}`]
|
||||
: []),
|
||||
@@ -1435,16 +1530,20 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
||||
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force)
|
||||
|
||||
圖示(建立人格並補齊資料後跑):
|
||||
icon faces|headshot|generate|show --session <id>
|
||||
faces --photo <圖片路徑或網址> 列出圖裡偵測到的臉(多角色務必先看)
|
||||
icon search|measure|faces|headshot|cutout|generate|show --session <id>
|
||||
search --wiki <fandom 子網域> --page <角色頁> 找官方圖,依解析度/是否設定稿排序
|
||||
measure --photo <網址或路徑> 解析度、臉多大、背景好不好去
|
||||
faces --photo <圖片路徑或網址> 列出圖裡偵測到的臉(多角色務必先看)
|
||||
headshot --photo <...> [--pick <索引>|--face x,y,w,h] [--size 384]
|
||||
裁出**參考用大頭照**到 .sync/headshot.png(不是圖示、不同步)
|
||||
cutout --photo <...> [--pick <索引>] 去背 → icon/portrait-cutout.png
|
||||
generate [--size 512 --force --no-gitea --style portrait|badge]
|
||||
[--palette "hair=#..,eye=#..,accent=#..,secondary=#..,light=#..,skin=#.."]
|
||||
[--features "hairstyle=..,length=..,fringe=..,eyes=..,expression=..,accessory=..,side=..,collar=..,ahoge=.."]
|
||||
[--source-url <來源網址> --source-note <說明> --source-date <YYYY-MM-DD>]
|
||||
**依人格資料重新繪製**人物頭像;不會把來源圖放進圖示。
|
||||
沒帶 --palette → 徽章樣式(配色由編號/名字/emoji 雜湊)。
|
||||
[--from-cutout] [--zoom 2.15] [--pick <索引>]
|
||||
帶 --from-cutout → 用去背好的官方原圖合成(頭肩構圖,最忠於原作)。
|
||||
否則依人格資料重新繪製;沒帶 --palette → 徽章樣式。
|
||||
show 看目前的樣式、配色、特徵與來源
|
||||
產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user